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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #74.

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

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

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

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

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

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

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

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

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

Closes #53.

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

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

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

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

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

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

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

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

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

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

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

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

Closes #52.

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

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

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

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

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

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

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

Refs #54.

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

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

Now:

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

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

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

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

Refs #72.

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

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

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

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

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

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

Closes #78.

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

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

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

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

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

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

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

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

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

Closes #73.

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

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

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

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

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

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

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

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

Closes #71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 23:13:10 +02:00
60 changed files with 2563 additions and 863 deletions
+91
View File
@@ -0,0 +1,91 @@
# Measure the CI runner's own benchmark variance.
#
# #54 asks whether benchmark regressions can be gated. The threshold is the
# whole problem: too tight and CI goes red on noise, which trains people to
# re-run until green; too loose and it never fires. Which of those is possible
# depends on a number nobody has measured — how much this runner's results move
# between identical runs.
#
# So: run one unchanged benchmark ten times and report the spread. If it is
# ~15%, a fixed-threshold gate is dead and the answer is a tracker; if it is
# ~2%, a gate at 10% is meaningful.
#
# Manual only. It takes ten benchmark runs and answers a question that is asked
# once, not every push.
name: Benchmark variance
on:
workflow_dispatch:
inputs:
runs:
description: How many repeats
required: false
default: "10"
jobs:
variance:
name: runner variance on one benchmark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# `joint_factorise_480_appearances` is the right probe: ~9 ms, so it is
# long enough not to be dominated by timer overhead, and it is the
# measurement this crate most wants protected — the dense factorisation
# #52 is about replacing.
- name: Warm up
run: cargo bench --bench joint -- joint_factorise_480_appearances --warm-up-time 1 --measurement-time 3
- name: Repeat the same benchmark
run: |
set -euo pipefail
for i in $(seq 1 "${{ inputs.runs || '10' }}"); do
echo "== run $i =="
cargo bench --bench joint -- \
joint_factorise_480_appearances --warm-up-time 1 --measurement-time 3 \
2>&1 | tee -a raw.txt
done
- name: Report the spread
run: |
set -euo pipefail
# Criterion prints `time: [lo mid hi]` with a unit after each. Take
# the midpoints. `sort -n` rather than awk's `asort`, which is a gawk
# extension the runner's mawk does not have — that failed on the
# first try here.
grep -oE 'time:[[:space:]]+\[[^]]+\]' raw.txt \
| sed -E 's/.*\[[^ ]+ [^ ]+ ([0-9.]+) ([^ ]+).*/\1 \2/' > mids.txt
echo "--- midpoints ---"
cat mids.txt
# Criterion picks a unit per run, so mixed units would have us
# comparing 9 ms against 9 us as if they were the same number — the
# plausible-looking wrong answer this crate keeps removing. Refuse.
if [ "$(cut -d' ' -f2 mids.txt | sort -u | wc -l)" -ne 1 ]; then
echo "runs reported different units; the spread would be meaningless"
cut -d' ' -f2 mids.txt | sort | uniq -c
exit 1
fi
sort -n mids.txt | awk '{ v[NR]=$1; u=$2; s+=$1 }
END {
if (NR == 0) { print "no samples parsed - see the raw.txt artifact"; exit 1 }
printf "n = %d\n", NR
printf "min = %.4f %s\n", v[1], u
printf "median = %.4f %s\n", v[int((NR+1)/2)], u
printf "max = %.4f %s\n", v[NR], u
printf "mean = %.4f %s\n", s/NR, u
printf "spread = %.2f%% (max-min)/min\n", 100*(v[NR]-v[1])/v[1]
print ""
print "Read it against #54: a spread near 15% kills both"
print "fixed-threshold options and the answer is a tracker;"
print "a spread near 2% makes a gate at 10% meaningful."
}'
- uses: actions/upload-artifact@v4
if: always()
with:
name: bench-variance-raw
path: |
raw.txt
mids.txt
+61 -10
View File
@@ -2,6 +2,65 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 0.9.0 - 2026-09-10
### Breaking Changes
- fix!: propagate NaN through the convergence reduction
- fix!: collapse a drift too small to represent, on a relative threshold
- fix!: report an unresolvable prediction grid instead of clamping
- fix!: validate the constructors below HistoryBuilder
- fix!: seal ConstantDrift's field so gamma can be validated
- fix!: make the Time generic reachable
- refactor!: un-export six types that no caller could reach
- fix!: correct eight wrong `# Errors` sections and seal the error variants
- fix!: per-key queries report unknown keys instead of a plausible constant
- fix!: no prediction path answers from a fit it cannot answer from
- docs!: one name for score noise, and say which of beta/sigma to turn
- fix!: non_exhaustive on ConvergenceReport, and not on the options structs
- feat!: prediction and joint queries take borrowed keys
- feat!: Game is the type you get, and one_v_one returns one
- feat!: Gaussian's EP operations stop wearing arithmetic's clothes
- refactor!: retire Index, intern and lookup
- refactor!: scores_with_noise, and History::quality
- refactor!: the joint is reached through Joint, not mirrored on History
- refactor!: K comes first in History, HistoryBuilder and Joint
- perf!: sparse Cholesky with an AMD ordering for the joint
- refactor!: typed discriminators for InferenceError
### Bug Fixes
- fix: take quality's determinant ratio in log space
- fix: keep the truncated variance representable in the far tail
- fix: route the last three transcendentals through libm, and enforce it
- fix: make posterior_of reproducible across processes
- fix: warn on dropped builders and values; stop exporting EP internals
### CI
- ci: measure the runner's own benchmark variance, and fix the joint bench
### Documentation
- docs: document the whole public surface and deny(missing_docs)
- docs: add a migration guide for 0.9.0, and drop merge noise from the changelog
### Features
- feat: add the missing trait impls and make `#[must_use]` consistent
- feat: complete the evidence matrix and add current_skills
- feat: PartialEq on the config types, and pin the public trait impls
- feat: HistoryBuilder::default_rating_for, a rule instead of a roll call
### Refactor
- refactor: one word per concept
### Testing
- test: scale the ceiling sweep by build profile
- test: make the determinism test exercise the parallel sweep
## 0.8.0 - 2026-09-08 ## 0.8.0 - 2026-09-08
### Breaking Changes ### Breaking Changes
@@ -25,13 +84,9 @@ All notable changes to this project will be documented in this file.
- feat: add EventBuilder::members for per-member configuration - feat: add EventBuilder::members for per-member configuration
### Other (unconventional) ### Miscellaneous Tasks
- Merge branch 'fix/ingestion-shape' - chore: Release trueskill-tt version 0.8.0
- Merge branch 'feat/convergence-strictness'
- Merge branch 'fix/non-finite-weights'
- Merge branch 'test/close-coverage-gaps'
- Merge branch 'fix/game-boundary'
### Testing ### Testing
@@ -47,10 +102,6 @@ All notable changes to this project will be documented in this file.
- chore: Release trueskill-tt version 0.7.0 - chore: Release trueskill-tt version 0.7.0
### Other (unconventional)
- Merge branch 'feat/joint-handle'
## 0.6.0 - 2026-09-08 ## 0.6.0 - 2026-09-08
### Breaking Changes ### Breaking Changes
+4 -5
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "trueskill-tt" name = "trueskill-tt"
version = "0.8.0" version = "0.9.0"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing" description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
@@ -33,10 +33,6 @@ bench = false
name = "batch" name = "batch"
harness = false harness = false
[[bench]]
name = "gaussian"
harness = false
[[bench]] [[bench]]
name = "history_converge" name = "history_converge"
harness = false harness = false
@@ -51,12 +47,15 @@ harness = false
[dependencies] [dependencies]
approx = { version = "0.5.1", optional = true } approx = { version = "0.5.1", optional = true }
feral-amd = "0.2"
libm = "0.2.16" libm = "0.2.16"
rayon = { version = "1", optional = true } rayon = { version = "1", optional = true }
smallvec = "1" smallvec = "1"
[features] [features]
approx = ["dep:approx"] approx = ["dep:approx"]
# Exposes the joint sparsity pattern for the #52 measurement. Test-only.
measure-sparsity = []
rayon = ["dep:rayon"] rayon = ["dep:rayon"]
[dev-dependencies] [dev-dependencies]
+204
View File
@@ -0,0 +1,204 @@
# Migrating
## 0.8.0 → 0.9.0
Twenty-one breaking changes. Nearly all of them are mechanical, and the
compiler finds every one — nothing here changes behaviour silently.
Three exceptions are worth reading before you start, because they change
what an existing, compiling call *returns*: [unknown keys](#unknown-keys-are-reported-not-skipped),
[predictions from a broken fit](#predictions-refuse-a-fit-they-cannot-answer-from),
and [`Gaussian`'s operators](#gaussians-operators-are-gone).
### Type parameters: `K` comes first
`K` was last, so naming a history meant writing all four parameters to change
the one that matters.
```rust
// before
struct Ladder { history: History<i64, ConstantDrift, NullObserver, String> }
struct Analysis<'h> { joint: Joint<'h, i64, ConstantDrift, NullObserver, &'static str> }
// after
struct Ladder { history: History<String> }
struct Analysis<'h> { joint: Joint<'h> }
```
`History<K, T, D, O, R>` — key, time, drift, observer, rating rule — all
defaulted. `HistoryBuilder` matches. There is a fifth parameter now (`R`), and
you will never write it unless you use `default_rating_for`.
`HistoryBuilder::<Untimed, _, _, String>::new()` becomes
`HistoryBuilder::<String, Untimed>::new()`.
### Predictions and joint queries take borrowed keys
At `K = String` a string literal used to be impossible, and asking "who wins"
cost four allocations of temporaries that all had to outlive the call.
```rust
// before, at K = String
let ta = vec![a.to_string()];
let ra: Vec<&String> = ta.iter().collect();
let tb = vec![b.to_string()];
let rb: Vec<&String> = tb.iter().collect();
let teams: Vec<&[&String]> = vec![&ra, &rb];
h.predict_win_probabilities(&teams)?;
// after, at either key type
h.predict_win_probabilities(&[&["alice"], &["bob"]])?;
h.posterior_of(&[("alice", 1.0), ("bob", -1.0)])?;
```
At `K = &'static str` the old `&[&[&"a"]]` spelling still compiles — `Q` infers
to `&str` and the two shapes coincide — so this is only a break for owned keys,
where nothing compiled before.
One cost: `predict_outcome(&[])` can no longer infer the key type. Annotate it,
`let none: &[&[&str]] = &[];`. It bites only on that degenerate call.
### Unknown keys are reported, not skipped
**Read this one.** `log_evidence_for` used to `filter_map` unknown keys away,
and an empty target list means *no restriction* downstream — so a list of
entirely unknown keys returned the **whole-history** value. Measured:
`log_evidence_for(["typo"])` returned exactly `log_evidence()`. On the one
workload it is documented for, leave-one-out cross-validation, that is the
un-held-out score.
```rust
let e = h.log_evidence_for(&["alice"])?; // now Result
let curve = h.learning_curve("alice"); // now Option
```
Note `&["alice"]`, not `&[&"alice"]`. These take borrowed keys like the
prediction methods, so one spelling works at both key types.
`learning_curve` and `filtered_learning_curve` return `Option`: `None` is "never
heard of this key", `Some(vec![])` is "known, has not played". They used to be
the same empty `Vec`.
### Predictions refuse a fit they cannot answer from
**Read this one too.** `converge` already refused to report a NaN fit, but
nothing stopped a caller ignoring that error and predicting anyway. On a
NaN-poisoned fit, `quality` returned `Ok(NaN)`, `predict_outcome().total()` was
`NaN`, and `predict_win_probabilities` returned `Ok([0.0, 0.0])` — finite,
plausible, and summing to zero against a doc promising one.
Every `predict_*` path now returns `Err(NonFiniteSkill { .. })` there, and
`Err(NoPerformanceVariance)` when `beta` is zero and every skill is a point
mass. If you were ignoring `converge`'s error, you will start seeing these.
### `Gaussian`'s operators are gone
`Mul`, `Div`, `Add` and `Sub` were the EP product, cavity and variance-space
convolutions, not arithmetic — `N(10,2) * N(4,3)` is `N(8.15, 1.66)`, and
`a / c` could leave a negative precision whose `mu()` printed a confident `0`.
They are `pub(crate)` inherent methods now. The public surface is `from_ms`,
`from_mv`, `mu`, `sigma`, `variance`, `probability_below`, `probability_above`;
`pi()` and `tau()` are internal. If you compared fits bit-for-bit on
`(pi, tau)`, compare `(mu, variance)` — same information, still exact.
### `Game` is the type you get
`Game::ranked` returned an `OwnedGame`, so `let g: Game = Game::ranked(..)?` did
not compile. Names swapped: `Game<T, D>` is public, `OwnedGame` is gone.
`one_v_one` returns a `Game` rather than `(Gaussian, Gaussian)`, so it can be
asked for `log_evidence()` like its siblings. For the old shape:
```rust
let post = Game::one_v_one(&a, &b, outcome, &opts)?.posteriors();
let (a_post, b_post) = (post[0][0], post[1][0]);
```
### The joint is reached through `Joint`
`History::posterior_of`, `posterior_of_at` and `expected_variance_reduction`
were one-shot wrappers that re-factorised on every call. They are gone.
```rust
// before — pays for the factorisation twice
let a = h.posterior_of(&terms)?;
let b = h.posterior_of(&other)?;
// after — pays once, and the borrow says so
let joint = h.joint()?;
let a = joint.posterior_of(&terms)?;
let b = joint.posterior_of(&other)?;
```
### `InferenceError` is typed
Six variants carried `&'static str` discriminators. Four enums replace them:
`Parameter`, `Shape`, `OutcomeKind`, `CompetitorField`.
```rust
// before
InferenceError::InvalidParameter { name: "drift_scale", value }
InferenceError::MismatchedShape { kind: "ranks vs teams", .. }
InferenceError::WrongOutcomeKind { context, expected, got } // three &str
// after
InferenceError::InvalidParameter { parameter: Parameter::DriftScale, value }
InferenceError::MismatchedShape { shape: Shape::OutcomeVsTeams, .. }
InferenceError::WrongOutcomeKind { expected: OutcomeKind::Ranked, got }
```
Variants that split or merged:
| before | after |
|---|---|
| `InvalidProbability { value }` | `InvalidParameter { parameter: Parameter::PDraw, value }` |
| `JointUnavailable { reason }` | `EmptyHistory`, `JointRequiresScoredEvents`, `NotPositiveDefinite` |
| `NonFiniteResult { context, step }` | `NonFiniteStep { context, step }` (convergence), `NonFiniteSkill { mu, sigma }` (prediction) |
Every struct variant is `#[non_exhaustive]`, so `match` with a `..` and
construct through the library.
### Renames
| before | after |
|---|---|
| `History::predict_quality` | `History::quality` |
| `Outcome::scores_with_sigma` | `Outcome::scores_with_noise` |
| `EventBuilder::scores_with_sigma` | `EventBuilder::scores_with_noise` |
| `Outcome::Scored { sigma }` | `Outcome::Scored { score_sigma }` |
| `OwnedGame` | `Game` |
### Removed
`History::intern`, `History::lookup` and `Index`. Nothing public ever accepted
an `Index`, so there was nothing to do with one. `current_skill`, `rating` and
`learning_curve` answer "does this history know this key" and all take a
borrowed key.
`TimeSlice`, `EventKind`, `KeyTable`, `CompetitorStore`, `Competitor` and `N01`
are no longer exported. None was obtainable from a `History`.
### Warnings, not errors
`#[must_use]` now sits on the value types, so a dropped `EventBuilder` — an
event you forgot to `.commit()`, previously a silent no-op — warns. So do
dropped `Team`, `Member`, `Outcome` and `Joint` values. A `-D warnings` build
will need updating.
### Nothing to do, but worth knowing
The joint factorisation is sparse with an AMD fill-reducing ordering:
**745 ms → 1.11 ms** on a 1976-appearance fixture, and near-linear scaling where
it was cubic. Results are unchanged; `feral-amd` is a new dependency (two
crates, both `#![forbid(unsafe_code)]`).
`HistoryBuilder::gamma(f64)` is shorthand for
`.drift(ConstantDrift::new(gamma))`.
`History::current_skills()` is the leaderboard query — every competitor's latest
posterior in one pass, rather than a full smoothed curve each.
`History::filtered_log_evidence_for(&["alice"])` completes the evidence matrix:
forward-only *and* key-restricted, which is what per-competitor prequential
scoring needs.
+4 -3
View File
@@ -121,7 +121,7 @@ for everything that accumulates.
## `converge` is strict ## `converge` is strict
`converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the `converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the
step still above `epsilon`, and `Err(NonFiniteResult)` if a sweep produces NaN. 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 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, shape. A fit that stops short is *wrong by a little*: every posterior is finite,
@@ -330,7 +330,7 @@ stay available at any size:
Unknown keys are an error by default, not a silent omission: a team the history Unknown keys are an error by default, not a silent omission: a team the history
has never seen cannot produce a confident-looking probability. The error names has never seen cannot produce a confident-looking probability. The error names
the key, and every key must already be known — pre-filter with `lookup` or the key, and every key must already be known — pre-filter with
`current_skill` if your caller cannot guarantee that. `current_skill` if your caller cannot guarantee that.
If predicting for competitors you have never seen is the point rather than a If predicting for competitors you have never seen is the point rather than a
@@ -419,7 +419,8 @@ expensive than `quality()`. Scoring every pairing among `n` competitors is
Every box on the old todo list is ticked, so it has been retired; open work 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 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 moving — breaking changes are batched into minor releases rather than dribbled
out, and `CHANGELOG.md` records them. out. `CHANGELOG.md` lists them and [`MIGRATING.md`](MIGRATING.md) explains what
to do about them.
## License ## License
-53
View File
@@ -1,53 +0,0 @@
use criterion::{Criterion, criterion_group, criterion_main};
use trueskill_tt::gaussian::Gaussian;
fn benchmark_gaussian_arithmetic(criterion: &mut Criterion) {
// Define test Gaussians
let g1 = Gaussian::from_ms(25.0, 25.0 / 3.0);
let g2 = Gaussian::from_ms(0.0, 1.0);
let g3 = Gaussian::from_ms(1.0, 1.0);
// Benchmark addition
criterion.bench_function("Gaussian::add", |bencher| {
bencher.iter(|| g1 + g2);
});
// Benchmark subtraction
criterion.bench_function("Gaussian::sub", |bencher| {
bencher.iter(|| g1 - g3);
});
// Benchmark multiplication
criterion.bench_function("Gaussian::mul", |bencher| {
bencher.iter(|| g1 * g2);
});
// Benchmark division
// NOTE: numerator must have higher precision (smaller sigma) than the
// denominator in this representation; g2 (sigma=1) / g1 (sigma=8.33) is
// well-defined, whereas g1 / g2 underflows and panics in mu_sigma.
criterion.bench_function("Gaussian::div", |bencher| {
bencher.iter(|| g2 / g1);
});
// Benchmark natural parameter conversions
criterion.bench_function("Gaussian::pi", |bencher| {
bencher.iter(|| g1.pi());
});
criterion.bench_function("Gaussian::tau", |bencher| {
bencher.iter(|| g1.tau());
});
// Benchmark combined pi/tau operations (used in mul/div)
criterion.bench_function("Gaussian::pi_tau_combined", |bencher| {
bencher.iter(|| {
let pi = g1.pi();
let tau = g1.tau();
(pi, tau)
});
});
}
criterion_group!(benches, benchmark_gaussian_arithmetic);
criterion_main!(benches);
+2 -4
View File
@@ -25,16 +25,14 @@
use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
use smallvec::smallvec; use smallvec::smallvec;
use trueskill_tt::{ use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
};
fn build_history_1v1( fn build_history_1v1(
n_events: usize, n_events: usize,
n_competitors: usize, n_competitors: usize,
events_per_slice: usize, events_per_slice: usize,
seed: u64, seed: u64,
) -> History<i64, ConstantDrift, NullObserver, String> { ) -> History<String> {
let mut rng = seed; let mut rng = seed;
let mut next = || { let mut next = || {
rng = rng rng = rng
+2 -4
View File
@@ -32,8 +32,7 @@ fn bench_ingest(c: &mut Criterion) {
b.iter_batched( b.iter_batched(
|| events(n, 0), || events(n, 0),
|evs| { |evs| {
let mut h: History<i64, _, _, String> = let mut h: History<String> = History::builder().key_type::<String>().build();
History::builder().key_type::<String>().build();
for ev in evs { for ev in evs {
h.add_events(std::iter::once(ev)).unwrap(); h.add_events(std::iter::once(ev)).unwrap();
} }
@@ -47,8 +46,7 @@ fn bench_ingest(c: &mut Criterion) {
b.iter_batched( b.iter_batched(
|| events(n, 0), || events(n, 0),
|evs| { |evs| {
let mut h: History<i64, _, _, String> = let mut h: History<String> = History::builder().key_type::<String>().build();
History::builder().key_type::<String>().build();
h.add_events(evs).unwrap(); h.add_events(evs).unwrap();
black_box(h.time_slices_len()) black_box(h.time_slices_len())
}, },
+13 -4
View File
@@ -10,16 +10,22 @@ use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team}; use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
/// 30 slices of 8 duels: 480 appearances over 100 competitors. /// 30 slices of 8 duels: 480 appearances over 100 competitors.
fn fitted() -> History<i64, ConstantDrift, trueskill_tt::NullObserver, String> { fn fitted() -> History<String> {
let mut h: History<i64, ConstantDrift, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.mu(0.0) .mu(0.0)
.sigma(6.0) .sigma(6.0)
.beta(1.0) .beta(1.0)
.score_sigma(2.0) .score_sigma(2.0)
.drift(ConstantDrift::new(0.05)) .drift(ConstantDrift::new(0.05))
// `max_iter: 30` was here, and this fixture needs more: `converge`
// reported `NotConverged { iterations: 30, final_step: (4.5e-4, 0.0) }`
// once it stopped returning short fits silently. The benchmark measures
// the factorisation, whose cost depends on the fit's *shape* rather
// than its exactness — but measuring it on an unconverged fit is still
// measuring something nobody would run.
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: 30, max_iter: trueskill_tt::ITERATIONS,
epsilon: 1e-10, epsilon: 1e-10,
alpha: 1.0, alpha: 1.0,
}) })
@@ -58,8 +64,11 @@ fn bench_joint(c: &mut Criterion) {
bencher.iter(|| std::hint::black_box(h.joint().unwrap().variables())); bencher.iter(|| std::hint::black_box(h.joint().unwrap().variables()));
}); });
// Factorise-and-query, the cost the deleted `History::posterior_of`
// wrapper paid on every call. Kept as the baseline the cached query below
// is measured against.
c.bench_function("posterior_of_one_shot_480_appearances", |bencher| { c.bench_function("posterior_of_one_shot_480_appearances", |bencher| {
bencher.iter(|| std::hint::black_box(h.posterior_of(&terms).unwrap())); bencher.iter(|| std::hint::black_box(h.joint().unwrap().posterior_of(&terms).unwrap()));
}); });
let joint = h.joint().unwrap(); let joint = h.joint().unwrap();
+1 -1
View File
@@ -5,7 +5,7 @@ use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
fn bench_scored_history(c: &mut Criterion) { fn bench_scored_history(c: &mut Criterion) {
c.bench_function("scored_history_60_events_30_iter", |bencher| { c.bench_function("scored_history_60_events_30_iter", |bencher| {
bencher.iter(|| { bencher.iter(|| {
let mut h: History<i64, ConstantDrift, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.mu(25.0) .mu(25.0)
.sigma(25.0 / 3.0) .sigma(25.0 / 3.0)
+5
View File
@@ -58,6 +58,11 @@ commit_parsers = [
{ message = "^test", group = "Testing" }, { message = "^test", group = "Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true }, { message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore", group = "Miscellaneous Tasks" }, { message = "^chore", group = "Miscellaneous Tasks" },
{ message = "^ci", group = "CI" },
# Every branch lands with `--no-ff`, so a release's merge commits outnumber
# its real ones and say nothing the merged commits do not. They were
# filling an "Other (unconventional)" section with 14 lines of noise.
{ message = "^Merge ", skip = true },
{ body = ".*security", group = "Security" }, { body = ".*security", group = "Security" },
{ body = ".*", group = "Other (unconventional)" }, { body = ".*", group = "Other (unconventional)" },
] ]
+1 -1
View File
@@ -43,7 +43,7 @@ fn main() {
} }
} }
let mut hist: History<i64, _, _, String> = History::builder() let mut hist: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.sigma(1.6) .sigma(1.6)
.drift(ConstantDrift::new(0.036)) .drift(ConstantDrift::new(0.036))
+8 -4
View File
@@ -123,7 +123,7 @@ fn u_minus_ln1p(u: f64) -> f64 {
/// - `EmptyTeam` if any team has no members. /// - `EmptyTeam` if any team has no members.
/// - `TooManyTeams` if the outcome space is too large to enumerate; see /// - `TooManyTeams` if the outcome space is too large to enumerate; see
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS). /// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`. /// - `InvalidParameter` for a `p_draw` outside `[0.0, 1.0)`.
/// - `GridTooCoarse` when the performance sigmas are too far apart to /// - `GridTooCoarse` when the performance sigmas are too far apart to
/// integrate on one grid. This comes from `outcome_distribution`, which runs /// integrate on one grid. This comes from `outcome_distribution`, which runs
/// before any inference — so it is not covered by "anything `Game::ranked` /// before any inference — so it is not covered by "anything `Game::ranked`
@@ -144,7 +144,8 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
}); });
} }
if !(0.0..1.0).contains(&options.p_draw) { if !(0.0..1.0).contains(&options.p_draw) {
return Err(InferenceError::InvalidProbability { return Err(InferenceError::InvalidParameter {
parameter: crate::Parameter::PDraw,
value: options.p_draw, value: options.p_draw,
}); });
} }
@@ -159,7 +160,7 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
.iter() .iter()
.map(|team| { .map(|team| {
team.iter() team.iter()
.fold(crate::N00, |acc, rating| acc + rating.performance()) .fold(crate::N00, |acc, rating| acc.convolve(rating.performance()))
}) })
.collect(); .collect();
@@ -367,7 +368,10 @@ mod tests {
)); ));
assert!(matches!( assert!(matches!(
expected_information_gain(&[&a, &a], &options(1.5)), expected_information_gain(&[&a, &a], &options(1.5)),
Err(InferenceError::InvalidProbability { .. }) Err(InferenceError::InvalidParameter {
parameter: crate::Parameter::PDraw,
..
})
)); ));
} }
+2 -2
View File
@@ -66,13 +66,13 @@ impl ConvergenceOptions {
pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> { pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> {
if !(self.alpha > 0.0 && self.alpha <= 1.0) { if !(self.alpha > 0.0 && self.alpha <= 1.0) {
return Err(crate::InferenceError::InvalidParameter { return Err(crate::InferenceError::InvalidParameter {
name: "alpha", parameter: crate::Parameter::Alpha,
value: self.alpha, value: self.alpha,
}); });
} }
if self.epsilon.is_nan() || self.epsilon < 0.0 { if self.epsilon.is_nan() || self.epsilon < 0.0 {
return Err(crate::InferenceError::InvalidParameter { return Err(crate::InferenceError::InvalidParameter {
name: "epsilon", parameter: crate::Parameter::Epsilon,
value: self.epsilon, value: self.epsilon,
}); });
} }
+1 -1
View File
@@ -35,7 +35,7 @@ pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
/// `variance_for_elapsed` would have been worse: it runs inside the sweep, so a /// `variance_for_elapsed` would have been worse: it runs inside the sweep, so a
/// construction-time mistake would panic mid-inference — and `Gaussian::from_ms` /// construction-time mistake would panic mid-inference — and `Gaussian::from_ms`
/// is a worked example of why that is the wrong place for a guard, where /// is a worked example of why that is the wrong place for a guard, where
/// rejecting NaN turned the `NonFiniteResult` reporting path into a crash. /// rejecting NaN turned the `NonFiniteStep` reporting path into a crash.
/// ///
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value /// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
/// back with [`ConstantDrift::gamma`]. /// back with [`ConstantDrift::gamma`].
+367 -60
View File
@@ -39,6 +39,182 @@ pub enum UnknownKeys {
Prior, Prior,
} }
/// Which scalar an [`InferenceError::InvalidParameter`] is about.
///
/// A typed discriminator rather than a `&'static str`, so a caller can branch
/// on it and `Display` can state each parameter's actual valid range. Nine
/// distinct strings used to flow through this position, and the only thing a
/// caller could do with one was print it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Parameter {
/// Prior mean skill. Must be finite.
Mu,
/// Prior standard deviation. Must be finite and strictly positive.
Sigma,
/// Performance noise. Must be finite and non-negative.
Beta,
/// Draw probability. Must be in `[0.0, 1.0)`.
PDraw,
/// Observation noise on a score margin. Must be finite and strictly
/// positive.
ScoreSigma,
/// EP damping factor. Must be in `(0.0, 1.0]`.
Alpha,
/// Convergence threshold. Must be non-negative and not NaN.
Epsilon,
/// A competitor's multiplier on the drift variance. Must be finite and
/// non-negative.
DriftScale,
/// The variance a [`Drift`](crate::Drift) implementation actually produced
/// for a span. Must be finite and non-negative — checked because a custom
/// implementation is the one thing no constructor can validate up front.
DriftVariance,
/// A per-member weight on an event. Must be finite.
Weight,
/// A team's score on a scored event. Must be finite.
Score,
/// A team's rank on a ranked event. Must be finite.
Rank,
/// The winning team's index, as given to `Outcome::winner`. Must be less
/// than the team count.
WinnerIndex,
}
impl Parameter {
/// The range this parameter must lie in, for the `Display` message.
fn range(self) -> &'static str {
match self {
Self::Mu => "must be finite",
Self::Sigma => "must be finite and strictly positive",
Self::Beta => "must be finite and non-negative",
Self::PDraw => "must be in [0.0, 1.0)",
Self::ScoreSigma => "must be finite and strictly positive",
Self::Alpha => "must be in (0.0, 1.0]",
Self::Epsilon => "must be non-negative and not NaN",
Self::DriftScale => "must be finite and non-negative",
Self::DriftVariance => "must be finite and non-negative",
Self::Weight => "must be finite",
Self::Score => "must be finite",
Self::Rank => "must be finite",
Self::WinnerIndex => "must be less than the number of teams",
}
}
}
impl std::fmt::Display for Parameter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
Self::Mu => "mu",
Self::Sigma => "sigma",
Self::Beta => "beta",
Self::PDraw => "p_draw",
Self::ScoreSigma => "score_sigma",
Self::Alpha => "alpha",
Self::Epsilon => "epsilon",
Self::DriftScale => "drift_scale",
Self::DriftVariance => "drift variance",
Self::Weight => "weight",
Self::Score => "score",
Self::Rank => "rank",
Self::WinnerIndex => "winner index",
};
f.write_str(name)
}
}
/// Which two lengths an [`InferenceError::MismatchedShape`] found disagreeing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Shape {
/// The outcome describes a different number of teams than the event has.
OutcomeVsTeams,
/// A per-member weight list does not match the team's membership.
Weights,
/// A call that takes a fixed number of teams got a different number.
Teams,
/// One of `add_events_with_prior`'s parallel arrays disagreed with the
/// others.
///
/// Not reachable through the public API — the arrays are built together at
/// the ingestion chokepoint. Kept as a checked error rather than a
/// `debug_assert!` so it also holds in release, which is where this
/// crate's defects have tended to hide.
Internal,
}
impl std::fmt::Display for Shape {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let what = match self {
Self::OutcomeVsTeams => {
"the outcome describes a different number of teams than the event has"
}
Self::Weights => "the weight list does not match the team's membership",
Self::Teams => "this call takes a fixed number of teams",
Self::Internal => {
"an internal array disagreed with its siblings (this is a bug in trueskill-tt)"
}
};
f.write_str(what)
}
}
/// Which [`Outcome`](crate::Outcome) variant a call found or wanted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum OutcomeKind {
/// [`Outcome::Ranked`](crate::Outcome::Ranked): an ordinal finish.
Ranked,
/// [`Outcome::Scored`](crate::Outcome::Scored): continuous scores.
Scored,
}
impl OutcomeKind {
/// The call that takes this kind, for the `Display` message.
fn constructor(self) -> &'static str {
match self {
Self::Ranked => "Game::ranked",
Self::Scored => "Game::scored",
}
}
/// The adjective form, for prose.
fn adjective(self) -> &'static str {
match self {
Self::Ranked => "ranked",
Self::Scored => "scored",
}
}
}
impl std::fmt::Display for OutcomeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Ranked => "Outcome::Ranked",
Self::Scored => "Outcome::Scored",
})
}
}
/// Which piece of per-competitor configuration was declared twice.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CompetitorField {
/// The starting skill distribution.
Prior,
/// The multiplier on the drift variance.
DriftScale,
}
impl std::fmt::Display for CompetitorField {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Prior => "prior",
Self::DriftScale => "drift_scale",
})
}
}
/// Every way ingestion, inference or prediction can refuse to answer. /// Every way ingestion, inference or prediction can refuse to answer.
/// ///
/// The crate reports rather than repairs. An input it cannot represent, a fit /// The crate reports rather than repairs. An input it cannot represent, a fit
@@ -56,9 +232,8 @@ 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] #[non_exhaustive]
MismatchedShape { MismatchedShape {
/// Which input disagreed, as a short label — `"ranks vs teams"`, /// Which pair of lengths disagreed.
/// `"weights"`, `"times"`. shape: Shape,
kind: &'static str,
/// The length it had to have, taken from whatever it must line up with /// The length it had to have, taken from whatever it must line up with
/// (usually the event's team count). /// (usually the event's team count).
expected: usize, expected: usize,
@@ -68,28 +243,18 @@ pub enum InferenceError {
/// An `Outcome` of the wrong variant was supplied for the requested inference. /// An `Outcome` of the wrong variant was supplied for the requested inference.
#[non_exhaustive] #[non_exhaustive]
WrongOutcomeKind { WrongOutcomeKind {
/// The call that rejected the outcome, e.g. `"Game::ranked"`. /// The variant the call needs.
context: &'static str, expected: OutcomeKind,
/// The [`Outcome`](crate::Outcome) variant that call needs, by name. /// The variant actually supplied.
expected: &'static str, got: OutcomeKind,
/// The variant actually supplied, by name.
got: &'static str,
},
/// A probability value is outside `[0, 1]`.
#[non_exhaustive]
InvalidProbability {
/// The value supplied, as it fell outside `[0, 1]`. Today only
/// `p_draw` reaches here.
value: f64,
}, },
/// A scalar parameter is outside its valid range. /// A scalar parameter is outside its valid range.
#[non_exhaustive] #[non_exhaustive]
InvalidParameter { InvalidParameter {
/// The parameter, spelled as the API spells it — `"alpha"`, /// Which parameter. `Display` states its valid range.
/// `"epsilon"`, `"score_sigma"`, `"drift_scale"`, `"drift variance"`. parameter: Parameter,
name: &'static str, /// The value supplied for it: outside that range, or NaN, which fails
/// The value supplied for it. Out of that parameter's range, or NaN, /// every range comparison and is rejected on that basis.
/// which fails every range comparison and is rejected on that basis.
value: f64, value: f64,
}, },
/// An event contains tied teams, but the draw probability is zero. /// An event contains tied teams, but the draw probability is zero.
@@ -130,20 +295,44 @@ pub enum InferenceError {
/// The threshold both components of `final_step` had to reach. /// The threshold both components of `final_step` had to reach.
epsilon: f64, epsilon: f64,
}, },
/// Inference produced a non-finite value (NaN or infinity). /// A convergence sweep produced a non-finite step.
/// ///
/// Indicates numerical breakdown; the resulting skills are meaningless /// EP has broken down; the resulting skills are meaningless and must not
/// and must not be treated as a converged estimate. /// be treated as a converged estimate. Further iterations cannot recover,
/// so the loop stops rather than reporting a NaN step as convergence.
#[non_exhaustive] #[non_exhaustive]
NonFiniteResult { NonFiniteStep {
/// Where the breakdown was caught `"History::converge"` for a sweep, /// Where the breakdown was caught, e.g. `"History::converge"`.
/// or a phrase naming the prediction that read an unusable skill.
context: &'static str, context: &'static str,
/// The offending pair, at least one component of which is NaN or /// The offending step as `(|d mu|, |d sigma|)`, at least one component
/// infinite. From `converge` it is the sweep's step; from a prediction /// of which is NaN or infinite.
/// it is the skill's own `(mu, sigma)`.
step: (f64, f64), step: (f64, f64),
}, },
/// A prediction read a skill with no usable mean or variance.
///
/// Split from `NonFiniteStep` (#74), which used to carry both under one
/// `step: (f64, f64)` field — a sweep step from `converge` and a skill's
/// own moments from a prediction. One field name cannot be right for both.
///
/// Reaching this means a previous `converge` failed and its error was
/// ignored: predicting from a NaN fit produced `Ok(NaN)` on some paths and
/// a plausible-looking `Ok([0.0, 0.0])` on others.
#[non_exhaustive]
NonFiniteSkill {
/// The skill's mean, which may itself be finite while `sigma` is not.
mu: f64,
/// The skill's standard deviation.
sigma: f64,
},
/// Every skill in the matchup is a point mass and `beta` is zero, so
/// there is no performance distribution to predict from.
///
/// Not `InvalidParameter`: both values are individually in range, and it
/// is their combination that leaves nothing varying. Every prediction is a
/// statement about how performances vary, and in this configuration
/// nothing does — `quality` would divide by a singular contrast covariance
/// and `predict_win_probabilities` would report zeros that sum to zero.
NoPerformanceVariance,
/// One batch declared two different values for the same competitor's /// One batch declared two different values for the same competitor's
/// configuration. /// configuration.
/// ///
@@ -155,13 +344,12 @@ pub enum InferenceError {
/// when a competitor's configuration is a property of the domain. /// when a competitor's configuration is a property of the domain.
#[non_exhaustive] #[non_exhaustive]
ConflictingCompetitorConfig { ConflictingCompetitorConfig {
/// The competitor's interned [`Index`](crate::Index) as a raw `usize`, /// The competitor's interned slot as a raw `usize`,
/// not the user key — the batch is already flattened to indices by the /// not the user key — the batch is already flattened to indices by the
/// time the conflict is detectable. /// time the conflict is detectable.
competitor: usize, competitor: usize,
/// Which piece of configuration was declared twice: `"prior"` or /// Which piece of configuration was declared twice.
/// `"drift_scale"`. field: CompetitorField,
field: &'static str,
}, },
/// A prediction referenced a key the history has no skill for. /// A prediction referenced a key the history has no skill for.
/// ///
@@ -231,14 +419,32 @@ pub enum InferenceError {
/// Nodes the grid may hold. /// Nodes the grid may hold.
max: usize, max: usize,
}, },
/// A joint posterior was requested where one cannot be formed exactly. /// A joint posterior was requested from a history with no events.
#[non_exhaustive] ///
JointUnavailable { /// Split out of a single `JointUnavailable { reason: &str }` (#74): the
/// Why no exact joint exists here: the history has no events, it holds /// three reasons are conditions a caller branches on differently, and
/// ranked events whose EP factors are not retained past convergence, or /// distinguishing them used to mean matching on English prose. This one
/// the assembled precision matrix is not positive-definite. /// means "add events".
reason: &'static str, EmptyHistory,
}, /// A joint posterior was requested from a history containing ranked
/// events.
///
/// Exact only for an all-scored history: a scored likelihood is Gaussian
/// and its factor can be rebuilt exactly, while a ranked outcome's
/// truncation is approximated by EP and reconstructing those factors needs
/// the converged messages, which inference does not retain.
///
/// [`History::predict_win_probabilities`](crate::History::predict_win_probabilities)
/// answers the comparable question on a ranked history.
JointRequiresScoredEvents,
/// The assembled precision matrix is not positive-definite.
///
/// The usual cause is a competitor with neither a proper prior nor any
/// evidence, but an extreme prior or drift can also make the assembled
/// matrix indefinite in floating point. Unlike its two siblings this one
/// is numerical rather than structural — the same history may factorise
/// under different parameters.
NotPositiveDefinite,
/// Fewer than two teams were supplied to a prediction. /// Fewer than two teams were supplied to a prediction.
#[non_exhaustive] #[non_exhaustive]
NotEnoughTeams { NotEnoughTeams {
@@ -268,21 +474,19 @@ impl fmt::Display for InferenceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::MismatchedShape { Self::MismatchedShape {
kind, shape,
expected, expected,
got, got,
} => { } => {
write!(f, "{kind}: expected length {expected}, got {got}") write!(f, "{shape}: expected {expected}, got {got}")
} }
Self::WrongOutcomeKind { Self::WrongOutcomeKind { expected, got } => {
context, write!(
expected, f,
got, "expected {expected}, got {got}; call {} for a {} outcome",
} => { got.constructor(),
write!(f, "{context}: expected {expected}, got {got}") got.adjective()
} )
Self::InvalidProbability { value } => {
write!(f, "probability must be in [0, 1]; got {value}")
} }
Self::TieWithoutDrawProbability { teams } => { Self::TieWithoutDrawProbability { teams } => {
write!( write!(
@@ -303,14 +507,23 @@ impl fmt::Display for InferenceError {
alpha < 1.0 if it is oscillating" alpha < 1.0 if it is oscillating"
) )
} }
Self::NonFiniteResult { context, step } => { Self::NonFiniteStep { context, step } => {
write!( write!(
f, f,
"{context}: inference produced a non-finite result (step = {step:?})" "{context}: inference produced a non-finite step {step:?}; EP has \
broken down and further iterations cannot recover"
) )
} }
Self::InvalidParameter { name, value } => { Self::NonFiniteSkill { mu, sigma } => {
write!(f, "{name} is invalid: {value}") write!(
f,
"a prediction read a skill with no usable mean or variance \
(mu = {mu}, sigma = {sigma}); the fit did not converge, and \
`converge` reports that"
)
}
Self::InvalidParameter { parameter, value } => {
write!(f, "{parameter} {} (got {value})", parameter.range())
} }
Self::ConflictingCompetitorConfig { competitor, field } => { Self::ConflictingCompetitorConfig { competitor, field } => {
write!( write!(
@@ -323,7 +536,7 @@ impl fmt::Display for InferenceError {
f, f,
"team {team}, member {member}: no skill recorded for key {key} \ "team {team}, member {member}: no skill recorded for key {key} \
(every key must already be known to the history; pre-filter \ (every key must already be known to the history; pre-filter \
with `lookup` or `current_skill` if that is not guaranteed)" with `current_skill` if that is not guaranteed)"
) )
} }
Self::AlreadyRegistered { key } => { Self::AlreadyRegistered { key } => {
@@ -346,9 +559,25 @@ impl fmt::Display for InferenceError {
one grid. Use predict_win_probabilities, which is accurate here" one grid. Use predict_win_probabilities, which is accurate here"
) )
} }
Self::JointUnavailable { reason } => { Self::EmptyHistory => {
write!(f, "no exact joint posterior is available: {reason}") f.write_str("no exact joint posterior is available: the history has no events")
} }
Self::JointRequiresScoredEvents => f.write_str(
"no exact joint posterior is available: the history contains ranked \
events, whose EP factors are not retained after convergence. Use \
predict_win_probabilities for a ranked history",
),
Self::NotPositiveDefinite => f.write_str(
"the joint precision matrix is not positive-definite; the usual cause \
is a competitor with neither a proper prior nor any evidence, but an \
extreme prior or drift can also make the assembled matrix indefinite \
in floating point",
),
Self::NoPerformanceVariance => f.write_str(
"beta is zero and every skill in this matchup is a point mass, so \
there is no performance distribution to predict from; give beta a \
positive value, or a competitor a prior with positive sigma",
),
Self::NotEnoughTeams { got } => { Self::NotEnoughTeams { got } => {
write!(f, "prediction needs at least 2 teams, got {got}") write!(f, "prediction needs at least 2 teams, got {got}")
} }
@@ -364,3 +593,81 @@ impl fmt::Display for InferenceError {
} }
impl std::error::Error for InferenceError {} impl std::error::Error for InferenceError {}
#[cfg(test)]
mod message_tests {
use super::*;
/// Every message must name the problem *and* what to do, which is the
/// standard the good ones set and the three #74 called out did not meet.
#[test]
fn messages_are_actionable() {
let cases = [
InferenceError::InvalidParameter {
parameter: Parameter::Alpha,
value: 0.0,
},
InferenceError::InvalidParameter {
parameter: Parameter::DriftVariance,
value: f64::NAN,
},
InferenceError::InvalidParameter {
parameter: Parameter::PDraw,
value: 1.5,
},
InferenceError::MismatchedShape {
shape: Shape::OutcomeVsTeams,
expected: 3,
got: 2,
},
InferenceError::WrongOutcomeKind {
expected: OutcomeKind::Ranked,
got: OutcomeKind::Scored,
},
InferenceError::EmptyHistory,
InferenceError::JointRequiresScoredEvents,
InferenceError::NotPositiveDefinite,
InferenceError::NoPerformanceVariance,
InferenceError::NonFiniteSkill {
mu: f64::NAN,
sigma: f64::NAN,
},
];
for case in &cases {
let rendered = case.to_string();
eprintln!("{rendered}");
// `InvalidParameter` used to render `drift variance is invalid: NaN`
// — no range, no remedy, no location. Every message must at least
// be a sentence.
assert!(
rendered.len() > 30,
"message is too terse to act on: {rendered}"
);
assert!(!rendered.contains("is invalid:"), "{rendered}");
}
// The three that #74 singled out now state a range or a next step.
assert!(
InferenceError::InvalidParameter {
parameter: Parameter::DriftVariance,
value: f64::NAN,
}
.to_string()
.contains("must be finite and non-negative")
);
assert!(
InferenceError::WrongOutcomeKind {
expected: OutcomeKind::Ranked,
got: OutcomeKind::Scored,
}
.to_string()
.contains("Game::scored")
);
assert!(
InferenceError::JointRequiresScoredEvents
.to_string()
.contains("predict_win_probabilities")
);
}
}
+22 -12
View File
@@ -38,14 +38,15 @@ use crate::{
/// ``` /// ```
#[must_use = "an event is only recorded by `.commit()`; a dropped builder \ #[must_use = "an event is only recorded by `.commit()`; a dropped builder \
silently ingests nothing"] silently ingests nothing"]
pub struct EventBuilder<'h, T, D, O, K> pub struct EventBuilder<'h, T, D, O, K, R>
where where
T: Time, T: Time,
D: Drift<T>, D: Drift<T>,
O: Observer<T>, O: Observer<T>,
K: Eq + std::hash::Hash + Clone, K: Eq + std::hash::Hash + Clone,
R: crate::RatingRule<K>,
{ {
history: &'h mut History<T, D, O, K>, history: &'h mut History<K, T, D, O, R>,
event: Event<T, K>, event: Event<T, K>,
current_team_idx: Option<usize>, current_team_idx: Option<usize>,
/// First validation failure seen while building, surfaced by `commit`. /// First validation failure seen while building, surfaced by `commit`.
@@ -58,14 +59,15 @@ where
error: Option<InferenceError>, error: Option<InferenceError>,
} }
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K> impl<'h, T, D, O, K, R> EventBuilder<'h, T, D, O, K, R>
where where
T: Time, T: Time,
D: Drift<T>, D: Drift<T>,
O: Observer<T>, O: Observer<T>,
K: Eq + std::hash::Hash + Clone, K: Eq + std::hash::Hash + Clone,
R: crate::RatingRule<K>,
{ {
pub(crate) fn new(history: &'h mut History<T, D, O, K>, time: T) -> Self { pub(crate) fn new(history: &'h mut History<K, T, D, O, R>, time: T) -> Self {
Self { Self {
history, history,
event: Event { event: Event {
@@ -142,7 +144,7 @@ where
if ws.len() != team.members.len() { if ws.len() != team.members.len() {
self.error.get_or_insert(InferenceError::MismatchedShape { self.error.get_or_insert(InferenceError::MismatchedShape {
kind: "weights", shape: crate::Shape::Weights,
expected: team.members.len(), expected: team.members.len(),
got: ws.len(), got: ws.len(),
}); });
@@ -171,13 +173,21 @@ where
/// Set explicit per-team continuous scores with a per-event noise override. /// Set explicit per-team continuous scores with a per-event noise override.
/// ///
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only. /// `score_sigma` is the observation noise on the *score margin*, not a
/// Must be `> 0.0`. Constructing the outcome with a non-positive or NaN /// skill sigma, and it overrides `HistoryBuilder::score_sigma` for this
/// sigma is allowed; the value is rejected with /// event only. A small value takes the margin near-literally; a large one
/// `InferenceError::InvalidParameter` when the event is ingested, so /// barely moves the ratings.
/// callers get an error from `commit` rather than a panic. ///
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self { /// Must be `> 0.0`. Building the outcome with a non-positive or NaN value
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma); /// is allowed; it is rejected with `InferenceError::InvalidParameter` when
/// the event is ingested, so callers get an error from `commit` rather
/// than a panic.
pub fn scores_with_noise<I: IntoIterator<Item = f64>>(
mut self,
scores: I,
score_sigma: f64,
) -> Self {
self.event.outcome = crate::Outcome::scores_with_noise(scores, score_sigma);
self self
} }
+2 -2
View File
@@ -39,7 +39,7 @@ impl MarginFactor {
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`. /// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) { pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
let marginal = vars.get(self.diff); let marginal = vars.get(self.diff);
let cavity = marginal / self.msg; let cavity = marginal.cavity(self.msg);
if self.log_evidence_cached.is_none() { if self.log_evidence_cached.is_none() {
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.m_obs, self.sigma)); self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.m_obs, self.sigma));
@@ -49,7 +49,7 @@ impl MarginFactor {
let damped = self.msg.damp_natural(new_msg, alpha); let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg; let old_msg = self.msg;
self.msg = damped; self.msg = damped;
vars.set(self.diff, cavity * damped); vars.set(self.diff, cavity.ep_product(damped));
old_msg.delta(damped) old_msg.delta(damped)
} }
+3 -3
View File
@@ -41,14 +41,14 @@ impl TruncFactor {
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`. /// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) { pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
let marginal = vars.get(self.diff); let marginal = vars.get(self.diff);
let cavity = marginal / self.msg; let cavity = marginal.cavity(self.msg);
if self.log_evidence_cached.is_none() { if self.log_evidence_cached.is_none() {
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.margin, self.tie)); self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.margin, self.tie));
} }
let trunc = approx(cavity, self.margin, self.tie); let trunc = approx(cavity, self.margin, self.tie);
let new_msg = trunc / cavity; let new_msg = trunc.cavity(cavity);
let damped = self.msg.damp_natural(new_msg, alpha); let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg; let old_msg = self.msg;
@@ -57,7 +57,7 @@ impl TruncFactor {
// marginal_new = cavity * stored_msg. With alpha = 1.0 this equals // marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
// `trunc` (since cavity * new_msg = trunc by construction); with // `trunc` (since cavity * new_msg = trunc by construction); with
// alpha < 1.0 it reflects the partially-applied update. // alpha < 1.0 it reflects the partially-applied update.
vars.set(self.diff, cavity * damped); vars.set(self.diff, cavity.ep_product(damped));
old_msg.delta(damped) old_msg.delta(damped)
} }
+43 -35
View File
@@ -205,7 +205,12 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
self.likelihoods self.likelihoods
.iter() .iter()
.zip(self.teams.iter()) .zip(self.teams.iter())
.map(|(l, t)| l.iter().zip(t.iter()).map(|(&l, r)| l * r.prior).collect()) .map(|(l, t)| {
l.iter()
.zip(t.iter())
.map(|(&l, r)| l.ep_product(r.prior))
.collect()
})
.collect() .collect()
} }
@@ -364,7 +369,7 @@ impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
.iter() .iter()
.zip(self.weights[t].iter()) .zip(self.weights[t].iter())
.fold(N00, |p, (competitor, &w)| { .fold(N00, |p, (competitor, &w)| {
p + (competitor.performance() * w) p.convolve(competitor.performance().scale(w))
}) })
})); }));
@@ -384,28 +389,28 @@ impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
step = (0.0_f64, 0.0_f64); step = (0.0_f64, 0.0_f64);
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() { for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
let pw = arena.team_prior[e] * arena.lhood_lose[e]; let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1]; let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
let raw = pw - pl; let raw = pw.convolve_diff(pl);
arena.vars.set(lf.diff(), raw * lf.msg()); arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
let d = lf.propagate(&mut arena.vars, alpha); let d = lf.propagate(&mut arena.vars, alpha);
step = tuple_max(step, d); step = tuple_max(step, d);
let new_ll = pw - lf.msg(); let new_ll = pw.convolve_diff(lf.msg());
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll)); step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
arena.lhood_lose[e + 1] = new_ll; arena.lhood_lose[e + 1] = new_ll;
} }
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() { for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
let e = n_diffs - 1 - rev_i; let e = n_diffs - 1 - rev_i;
let pw = arena.team_prior[e] * arena.lhood_lose[e]; let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1]; let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
let raw = pw - pl; let raw = pw.convolve_diff(pl);
arena.vars.set(lf.diff(), raw * lf.msg()); arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
let d = lf.propagate(&mut arena.vars, alpha); let d = lf.propagate(&mut arena.vars, alpha);
step = tuple_max(step, d); step = tuple_max(step, d);
let new_lw = pl + lf.msg(); let new_lw = pl.convolve(lf.msg());
step = tuple_max(step, arena.lhood_win[e].delta(new_lw)); step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
arena.lhood_win[e] = new_lw; arena.lhood_win[e] = new_lw;
} }
@@ -415,18 +420,21 @@ impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
// Special case: exactly 1 diff (2-team game); loop body was empty. // Special case: exactly 1 diff (2-team game); loop body was empty.
if n_diffs == 1 { if n_diffs == 1 {
let raw = (arena.team_prior[0] * arena.lhood_lose[0]) let raw = arena.team_prior[0]
- (arena.team_prior[1] * arena.lhood_win[1]); .ep_product(arena.lhood_lose[0])
arena.vars.set(links[0].diff(), raw * links[0].msg()); .convolve_diff(arena.team_prior[1].ep_product(arena.lhood_win[1]));
arena
.vars
.set(links[0].diff(), raw.ep_product(links[0].msg()));
links[0].propagate(&mut arena.vars, alpha); links[0].propagate(&mut arena.vars, alpha);
} }
// Boundary updates: close the chain at both ends. // Boundary updates: close the chain at both ends.
if n_diffs > 0 { if n_diffs > 0 {
let pl1 = arena.team_prior[1] * arena.lhood_win[1]; let pl1 = arena.team_prior[1].ep_product(arena.lhood_win[1]);
arena.lhood_win[0] = pl1 + links[0].msg(); arena.lhood_win[0] = pl1.convolve(links[0].msg());
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2]; let pw_last = arena.team_prior[n_teams - 2].ep_product(arena.lhood_lose[n_teams - 2]);
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg(); arena.lhood_lose[n_teams - 1] = pw_last.convolve_diff(links[n_diffs - 1].msg());
} }
let log_evidence: f64 = links.iter().map(DiffFactor::log_evidence).sum(); let log_evidence: f64 = links.iter().map(DiffFactor::log_evidence).sum();
@@ -444,7 +452,7 @@ impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
.enumerate() .enumerate()
.map(|(orig_i, (competitors, weights))| { .map(|(orig_i, (competitors, weights))| {
let si = arena.inv_buf[orig_i]; let si = arena.inv_buf[orig_i];
let m = arena.lhood_win[si] * arena.lhood_lose[si]; let m = arena.lhood_win[si].ep_product(arena.lhood_lose[si]);
// Already folded into `team_prior` at the top of the chain, // Already folded into `team_prior` at the top of the chain,
// indexed by sorted position. // indexed by sorted position.
let performance = arena.team_prior[si]; let performance = arena.team_prior[si];
@@ -452,7 +460,8 @@ impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
.iter() .iter()
.zip(weights.iter()) .zip(weights.iter())
.map(|(competitor, &w)| { .map(|(competitor, &w)| {
((m - performance.exclude(competitor.performance() * w)) * (1.0 / w)) m.convolve_diff(performance.exclude(competitor.performance().scale(w)))
.scale(1.0 / w)
.forget(competitor.beta.powi(2)) .forget(competitor.beta.powi(2))
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -504,7 +513,7 @@ impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
.map(|(l, t)| { .map(|(l, t)| {
l.iter() l.iter()
.zip(t.iter()) .zip(t.iter())
.map(|(&l, p)| l * p.prior) .map(|(&l, p)| l.ep_product(p.prior))
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -546,7 +555,7 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
/// - `InvalidParameter` if `options.convergence` is out of range — an /// - `InvalidParameter` if `options.convergence` is out of range — an
/// `alpha` of zero would leave every EP update unapplied and silently /// `alpha` of zero would leave every EP update unapplied and silently
/// return the priors. /// return the priors.
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`. /// - `InvalidParameter` for a `p_draw` outside `[0.0, 1.0)`.
/// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`. /// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`.
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`. /// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
/// - `TieWithoutDrawProbability` if the outcome ties two teams while /// - `TieWithoutDrawProbability` if the outcome ties two teams while
@@ -562,13 +571,14 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
options.convergence.validate()?; options.convergence.validate()?;
Self::validate_teams(teams)?; Self::validate_teams(teams)?;
if !(0.0..1.0).contains(&options.p_draw) { if !(0.0..1.0).contains(&options.p_draw) {
return Err(crate::InferenceError::InvalidProbability { return Err(crate::InferenceError::InvalidParameter {
parameter: crate::Parameter::PDraw,
value: options.p_draw, value: options.p_draw,
}); });
} }
if outcome.team_count() != teams.len() { if outcome.team_count() != teams.len() {
return Err(crate::InferenceError::MismatchedShape { return Err(crate::InferenceError::MismatchedShape {
kind: "outcome ranks vs teams", shape: crate::Shape::OutcomeVsTeams,
expected: teams.len(), expected: teams.len(),
got: outcome.team_count(), got: outcome.team_count(),
}); });
@@ -577,9 +587,8 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
let ranks = outcome let ranks = outcome
.as_ranks() .as_ranks()
.ok_or(crate::InferenceError::WrongOutcomeKind { .ok_or(crate::InferenceError::WrongOutcomeKind {
context: "Game::ranked", expected: crate::OutcomeKind::Ranked,
expected: "Outcome::Ranked", got: crate::OutcomeKind::Scored,
got: "Outcome::Scored",
})?; })?;
let tied = if options.p_draw == 0.0 { let tied = if options.p_draw == 0.0 {
@@ -629,13 +638,13 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
Self::validate_teams(teams)?; Self::validate_teams(teams)?;
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() { if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
return Err(crate::InferenceError::InvalidParameter { return Err(crate::InferenceError::InvalidParameter {
name: "score_sigma", parameter: crate::Parameter::ScoreSigma,
value: options.score_sigma, value: options.score_sigma,
}); });
} }
if outcome.team_count() != teams.len() { if outcome.team_count() != teams.len() {
return Err(crate::InferenceError::MismatchedShape { return Err(crate::InferenceError::MismatchedShape {
kind: "outcome scores vs teams", shape: crate::Shape::OutcomeVsTeams,
expected: teams.len(), expected: teams.len(),
got: outcome.team_count(), got: outcome.team_count(),
}); });
@@ -643,9 +652,8 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
let scores = outcome let scores = outcome
.as_scores() .as_scores()
.ok_or(crate::InferenceError::WrongOutcomeKind { .ok_or(crate::InferenceError::WrongOutcomeKind {
context: "Game::scored", expected: crate::OutcomeKind::Scored,
expected: "Outcome::Scored", got: crate::OutcomeKind::Ranked,
got: "Outcome::Ranked",
})? })?
.to_vec(); .to_vec();
// A non-finite score poisons the chain rather than failing it. Ranks // A non-finite score poisons the chain rather than failing it. Ranks
@@ -653,7 +661,7 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
for value in &scores { for value in &scores {
if !value.is_finite() { if !value.is_finite() {
return Err(crate::InferenceError::InvalidParameter { return Err(crate::InferenceError::InvalidParameter {
name: "score", parameter: crate::Parameter::Score,
value: *value, value: *value,
}); });
} }
@@ -1359,7 +1367,7 @@ mod tests {
assert!(matches!( assert!(matches!(
err, err,
crate::InferenceError::InvalidParameter { crate::InferenceError::InvalidParameter {
name: "score_sigma", parameter: crate::Parameter::ScoreSigma,
.. ..
} }
)); ));
+88 -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.
@@ -24,7 +22,7 @@ impl Gaussian {
/// ///
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a /// Panics if `sigma` is negative. NaN is deliberately allowed through: a
/// broken fit produces one, and `converge` reports that as /// broken fit produces one, and `converge` reports that as
/// `NonFiniteResult` rather than panicking mid-inference. /// `NonFiniteStep` rather than panicking mid-inference.
/// ///
/// A negative sigma used to be accepted and returned results **bit /// A negative sigma used to be accepted and returned results **bit
/// identical** to its absolute value, because sigma only ever enters as /// identical** to its absolute value, because sigma only ever enters as
@@ -48,7 +46,7 @@ impl Gaussian {
pub const fn from_ms(mu: f64, sigma: f64) -> Self { pub const fn from_ms(mu: f64, sigma: f64) -> Self {
// NaN is admitted on purpose. A broken fit legitimately produces a NaN // NaN is admitted on purpose. A broken fit legitimately produces a NaN
// sigma — `sqrt` of a negative truncated variance — and the design is // sigma — `sqrt` of a negative truncated variance — and the design is
// to propagate that to `converge`'s `NonFiniteResult` guard, not to // to propagate that to `converge`'s `NonFiniteStep` guard, not to
// panic inside inference. Rejecting it here turned that reporting path // panic inside inference. Rejecting it here turned that reporting path
// into a crash, which two tests caught immediately. // into a crash, which two tests caught immediately.
assert!( assert!(
@@ -75,11 +73,12 @@ impl Gaussian {
/// Construct from mean and *variance*, skipping the square-root round trip. /// Construct from mean and *variance*, skipping the square-root round trip.
/// ///
/// `from_ms(mu, var.sqrt())` immediately squares the root away again to /// `from_ms(mu, var.sqrt())` immediately squares the root away again to
/// recover `pi = 1/var`. Variance-combining operations (`Add`, `Sub`, /// recover `pi = 1/var`. Variance-combining operations work in variance
/// `exclude`, `forget`) work in variance space throughout, so they go /// space throughout, so they go through here instead and never take a
/// through here instead and never take a root. /// root. Use it whenever you already hold a variance —
/// [`variance`](Gaussian::variance) is its inverse.
#[inline] #[inline]
pub(crate) fn from_mv(mu: f64, var: f64) -> Self { pub fn from_mv(mu: f64, var: f64) -> Self {
if var == f64::INFINITY { if var == f64::INFINITY {
Self { pi: 0.0, tau: 0.0 } Self { pi: 0.0, tau: 0.0 }
} else if var == 0.0 { } else if var == 0.0 {
@@ -107,8 +106,7 @@ impl Gaussian {
/// means more certain; `0.0` is an improper, uninformative message and /// means more certain; `0.0` is an improper, uninformative message and
/// `inf` is a point mass. /// `inf` is a point mass.
#[inline] #[inline]
#[must_use] pub(crate) fn pi(&self) -> f64 {
pub fn pi(&self) -> f64 {
self.pi self.pi
} }
@@ -117,8 +115,7 @@ impl Gaussian {
/// Stored rather than derived, for the same reason as [`Gaussian::pi`]. /// Stored rather than derived, for the same reason as [`Gaussian::pi`].
/// Meaningful only alongside `pi`: on its own it is not a location. /// Meaningful only alongside `pi`: on its own it is not a location.
#[inline] #[inline]
#[must_use] pub(crate) fn tau(&self) -> f64 {
pub fn tau(&self) -> f64 {
self.tau self.tau
} }
@@ -142,12 +139,14 @@ impl Gaussian {
} }
} }
/// Variance, `1 / pi`, without the root-and-square of `sigma().powi(2)`. /// Variance, without the root-and-square of `sigma().powi(2)`.
/// ///
/// Mirrors `sigma()`'s treatment of the improper (`pi <= 0`) and point-mass /// Mirrors [`sigma`](Gaussian::sigma)'s treatment of the improper
/// (`pi == inf`) cases. /// (infinite) and point-mass (zero) cases, and is the inverse of
/// [`from_mv`](Gaussian::from_mv).
#[inline] #[inline]
pub(crate) fn variance(&self) -> f64 { #[must_use]
pub fn variance(&self) -> f64 {
if self.pi <= 0.0 { if self.pi <= 0.0 {
f64::INFINITY f64::INFINITY
} else if self.pi.is_infinite() { } else if self.pi.is_infinite() {
@@ -279,34 +278,65 @@ impl Default for Gaussian {
} }
} }
impl ops::Add<Gaussian> for Gaussian { impl Gaussian {
type Output = Gaussian; /// The EP factor **product**: multiply two messages about the same
/// Variance addition: (mu1 + mu2, sqrt(σ1² + σ2²)). /// variable.
/// Used for combining performance and noise; rare relative to mul/div. ///
fn add(self, rhs: Gaussian) -> Self::Output { /// Two natural-parameter additions and no square root, which is why the
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance()) /// type stores `pi` and `tau` rather than `mu` and `sigma`. This is the
} /// hot path.
} ///
/// Not arithmetic — `N(10, 2).ep_product(N(4, 3))` is `N(8.15, 1.66)`,
impl ops::Sub<Gaussian> for Gaussian { /// nowhere near 40. It used to be spelled `a * b`, on a public `Mul` impl,
type Output = Gaussian; /// where that was a trap rather than a shorthand.
/// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add. #[inline]
fn sub(self, rhs: Gaussian) -> Self::Output { pub(crate) fn ep_product(self, rhs: Gaussian) -> Gaussian {
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
}
}
impl ops::Mul<Gaussian> for Gaussian {
type Output = Gaussian;
/// Factor product: nat-param add. Hot path — two f64 additions, no sqrt.
fn mul(self, rhs: Gaussian) -> Self::Output {
Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau) Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau)
} }
}
impl ops::Mul<f64> for Gaussian { /// The EP **cavity**: divide out a message this belief already absorbed.
type Output = Gaussian; ///
fn mul(self, scalar: f64) -> Self::Output { /// The inverse of [`ep_product`](Gaussian::ep_product), and two
/// subtractions rather than two additions.
///
/// **May return an improper result.** Cancelling a message that carried
/// most of the precision leaves `pi <= 0`, which is not a distribution.
/// `mu()` reports `0.0` and `sigma()` reports `inf` for such a value —
/// both are the accessors' policy for "undefined", not answers. Measured:
/// `N(10, 2).cavity(N(1, 1))` has `pi = -0.75`, and its `mu()` prints a
/// confident `0`. That is why this is not a public operator.
#[inline]
pub(crate) fn cavity(self, rhs: Gaussian) -> Gaussian {
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
}
/// Convolve two independent Gaussians: `N(mu1 + mu2, sqrt(v1 + v2))`.
///
/// The distribution of a *sum* of independent variables, so the variances
/// add — the result is always wider than either input. Used to combine a
/// skill with performance noise. Goes through `from_mv` and takes no root.
#[inline]
pub(crate) fn convolve(self, rhs: Gaussian) -> Gaussian {
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
}
/// Convolve a *difference*: `N(mu1 - mu2, sqrt(v1 + v2))`.
///
/// The means subtract and the variances still **add**, because a
/// difference of independent variables is no more certain than a sum. That
/// is the half that made the old `Sub` impl misleading: `a - b` grew the
/// sigma from 2 to `sqrt(4 + 9)`.
#[inline]
pub(crate) fn convolve_diff(self, rhs: Gaussian) -> Gaussian {
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
}
/// Scale by a constant: `mu` by `scalar`, `sigma` by `|scalar|`.
///
/// The one operation that *is* ordinary arithmetic — it is the
/// distribution of `scalar * X`. Used for per-member weights.
#[inline]
pub(crate) fn scale(self, scalar: f64) -> Gaussian {
if !scalar.is_finite() { if !scalar.is_finite() {
return N_INF; return N_INF;
} }
@@ -321,14 +351,6 @@ impl ops::Mul<f64> for Gaussian {
} }
} }
impl ops::Div<Gaussian> for Gaussian {
type Output = Gaussian;
/// Cavity: nat-param sub. Hot path — two f64 subtractions, no sqrt.
fn div(self, rhs: Gaussian) -> Self::Output {
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// A message that did not change must report no change, even when it is /// A message that did not change must report no change, even when it is
@@ -386,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);
@@ -451,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);
+322 -322
View File
File diff suppressed because it is too large Load Diff
+406 -48
View File
@@ -1,4 +1,4 @@
//! Cholesky factorisation of a joint precision matrix. //! Sparse Cholesky factorisation of a joint precision matrix.
//! //!
//! Every question the joint answers is a *bilinear form* in the precision //! Every question the joint answers is a *bilinear form* in the precision
//! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the //! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the
@@ -18,72 +18,324 @@
//! negative number, where the same quantity as `|L^-1 c|^2` is a sum of //! negative number, where the same quantity as `|L^-1 c|^2` is a sum of
//! squares and cannot. //! squares and cannot.
//! //!
//! Factorising is `O(n^3)` and whitening is `O(n^2)`, so the split also //! # Why this is sparse (#52)
//! matters structurally: the expensive half depends only on the fit, and is //!
//! shared across every query a [`Joint`](crate::Joint) answers. //! A time-expanded joint is *extremely* sparse and gets sparser as the history
//! grows: a row couples only to its own previous and next appearance through
//! the drift link, and to whoever co-appeared in its slice. Measured on a
//! 76-slice, 988-duel, 200-competitor history: `n = 1976`, `nnz = 7504`,
//! **0.19% dense**.
//!
//! This used to store all `n^2` entries and run a dense `O(n^3)` factorisation
//! over them. Two measurements decided the replacement:
//!
//! - **Ordering alone does nothing to a dense factorisation.** Its inner loops
//! run over every `k` whether or not the entry is zero. A 700x700 banded
//! matrix at 0.43% density factorised in 30.196 ms in band order and
//! 29.544 ms under a scramble that destroyed the band — identical, as the
//! flop count says it must be. Fill-reducing order is worth nothing until
//! the factorisation skips zeros.
//! - **Together they are worth four orders of magnitude.** On that `n = 1976`
//! fixture, against `n^3/3 = 2.572e9` flops dense: sparse in the natural
//! order needs `5.597e7` (46x better), and sparse under an AMD fill-reducing
//! order needs `8.656e4` — **29,710x**. AMD is worth 646x *on top of*
//! sparsity and nothing without it.
//!
//! Natural ordering fills in badly here for the reason #52 predicted: a
//! competitor who appears in slice 0 and not again until slice 75 creates a
//! drift link spanning nearly the whole matrix. `nnz(L)` is 292,437 under the
//! natural order against 11,583 under AMD, from an `A` with 7,504.
//!
//! The ordering comes from `feral-amd`. The factorisation is the up-looking
//! sparse Cholesky of Davis's *Direct Methods for Sparse Linear Systems*,
//! written here rather than taken from a crate: the sparse solvers on
//! crates.io either pull SIMD dispatch (`faer`, and `feral` itself, both
//! through `pulp`), which would make results differ between an AVX-512 host
//! and an AVX2 one — the same class of drift the `libm`-over-`std` decision
//! was made to avoid — or are LGPL, or disclaim fill-reduction in their own
//! docs.
use std::collections::BTreeMap;
/// A symmetric matrix accumulated entry by entry, before factorisation.
///
/// A `BTreeMap` rather than a hash map because the iteration order becomes the
/// factorisation's summation order, and a hash map's order varies per process.
/// `tests/cross_process_determinism.rs` exists because that has bitten before.
#[derive(Default)]
pub(crate) struct SymmetricBuilder {
entries: BTreeMap<(usize, usize), f64>,
}
impl SymmetricBuilder {
pub(crate) fn new() -> Self {
Self::default()
}
/// Add `value` to entry `(row, col)`. Both triangles must be supplied.
pub(crate) fn add(&mut self, row: usize, col: usize, value: f64) {
*self.entries.entry((row, col)).or_insert(0.0) += value;
}
/// The `(row, col)` positions that hold a nonzero. For the #52 measurement.
#[cfg(feature = "measure-sparsity")]
pub(crate) fn pattern(&self) -> impl Iterator<Item = (usize, usize)> + '_ {
self.entries
.iter()
.filter(|(_, v)| **v != 0.0)
.map(|(&rc, _)| rc)
}
}
/// A factorised symmetric positive-definite matrix, reusable across queries. /// A factorised symmetric positive-definite matrix, reusable across queries.
pub(crate) struct Cholesky { pub(crate) struct Cholesky {
/// Lower triangle of `L`, row-major `n * n`. The upper triangle is
/// leftover scratch from the factorisation and is never read.
l: Vec<f64>,
n: usize, n: usize,
/// `inv[old] = new`: where each original row sits after the AMD reorder.
inv: Vec<usize>,
/// `L` in compressed-column form, permuted. Within a column the diagonal
/// is first and the rest ascend by row.
col_ptr: Vec<usize>,
row_idx: Vec<usize>,
val: Vec<f64>,
} }
impl Cholesky { impl Cholesky {
/// Factorise `a` (row-major, `n * n`, symmetric) into `L L^T`. /// Factorise the accumulated matrix into `L L^T`, under a fill-reducing
/// /// permutation.
/// `a` is consumed as scratch.
/// ///
/// Returns `None` if the matrix is not positive-definite, which for a /// Returns `None` if the matrix is not positive-definite, which for a
/// precision matrix means the model is improper — a competitor with /// precision matrix means the model is improper — a competitor with
/// neither a proper prior nor any evidence. /// neither a proper prior nor any evidence — or if the ordering fails.
pub(crate) fn factor(mut a: Vec<f64>, n: usize) -> Option<Self> { pub(crate) fn factor(built: SymmetricBuilder, n: usize) -> Option<Self> {
debug_assert_eq!(a.len(), n * n); if n == 0 {
return Some(Self {
for j in 0..n { n: 0,
let mut d = a[j * n + j]; inv: Vec::new(),
for k in 0..j { col_ptr: vec![0],
d -= a[j * n + k] * a[j * n + k]; row_idx: Vec::new(),
val: Vec::new(),
});
} }
let inv = Self::amd_permutation(n, &built)?;
// Upper triangle of the permuted matrix, column-major: column `c`
// holds the rows `r <= c`. Exactly one of a symmetric pair survives
// the `r <= c` filter, so nothing is double-counted.
let mut cols: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
for (&(old_r, old_c), &v) in &built.entries {
if v == 0.0 {
continue;
}
let (r, c) = (inv[old_r], inv[old_c]);
if r <= c {
cols[c].push((r, v));
}
}
let mut a_ptr = Vec::with_capacity(n + 1);
let mut a_row = Vec::new();
let mut a_val = Vec::new();
a_ptr.push(0usize);
for col in &mut cols {
col.sort_unstable_by_key(|&(r, _)| r);
for &(r, v) in col.iter() {
a_row.push(r);
a_val.push(v);
}
a_ptr.push(a_row.len());
}
let parent = Self::etree(n, &a_ptr, &a_row);
// Symbolic pass: how many entries each column of L will hold. Running
// `ereach` per column costs O(nnz(L)) in total, which is the same order
// as the numeric pass it sizes.
let mut counts = vec![0usize; n];
let mut stack = vec![0usize; n];
let mut mark = vec![false; n];
for k in 0..n {
let top = Self::ereach(k, &a_ptr, &a_row, &parent, &mut stack, &mut mark);
for &i in &stack[top..] {
counts[i] += 1;
}
counts[k] += 1; // the diagonal
}
let mut col_ptr = Vec::with_capacity(n + 1);
col_ptr.push(0usize);
for &c in &counts {
col_ptr.push(col_ptr[col_ptr.len() - 1] + c);
}
let nnz = col_ptr[n];
let mut row_idx = vec![0usize; nnz];
let mut val = vec![0.0f64; nnz];
// `next[i]` is the slot column `i` will fill next. Column `i`'s
// diagonal lands first, at `col_ptr[i]`, because nothing is written to
// a column before its own iteration.
let mut next: Vec<usize> = col_ptr[..n].to_vec();
let mut x = vec![0.0f64; n];
for k in 0..n {
let top = Self::ereach(k, &a_ptr, &a_row, &parent, &mut stack, &mut mark);
for p in a_ptr[k]..a_ptr[k + 1] {
if a_row[p] <= k {
x[a_row[p]] = a_val[p];
}
}
let mut d = x[k];
x[k] = 0.0;
for &i in &stack[top..] {
let lki = x[i] / val[col_ptr[i]];
x[i] = 0.0;
for p in col_ptr[i] + 1..next[i] {
x[row_idx[p]] -= val[p] * lki;
}
d -= lki * lki;
let p = next[i];
next[i] += 1;
row_idx[p] = k;
val[p] = lki;
}
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here // Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here
// too, and a negated comparison would let it through as "not // too, and a negated comparison would let it through as "not
// positive". // positive".
if d.is_nan() || d <= 0.0 { if d.is_nan() || d <= 0.0 {
return None; return None;
} }
let d = d.sqrt(); let p = next[k];
a[j * n + j] = d; next[k] += 1;
row_idx[p] = k;
for i in j + 1..n { val[p] = d.sqrt();
let mut s = a[i * n + j];
for k in 0..j {
s -= a[i * n + k] * a[j * n + k];
}
a[i * n + j] = s / d;
}
} }
Some(Self { l: a, n }) Some(Self {
n,
inv,
col_ptr,
row_idx,
val,
})
} }
/// Whiten a contrast: `y = L^-1 b`. /// AMD fill-reducing order, as `inv[old] = new`.
fn amd_permutation(n: usize, built: &SymmetricBuilder) -> Option<Vec<usize>> {
let mut cols: Vec<Vec<i32>> = vec![Vec::new(); n];
for (&(r, c), &v) in &built.entries {
if v != 0.0 {
cols[c].push(i32::try_from(r).ok()?);
}
}
let mut col_ptr = Vec::with_capacity(n + 1);
let mut row_idx = Vec::new();
col_ptr.push(0i32);
for (j, col) in cols.iter_mut().enumerate() {
col.push(i32::try_from(j).ok()?);
col.sort_unstable();
col.dedup();
row_idx.extend_from_slice(col);
col_ptr.push(i32::try_from(row_idx.len()).ok()?);
}
let pattern = feral_amd::CscPattern::new(n, &col_ptr, &row_idx)?;
// `perm[new] = old`; we want the inverse.
let perm = feral_amd::amd_order(&pattern).ok()?;
let mut inv = vec![0usize; n];
for (new, &old) in perm.iter().enumerate() {
inv[usize::try_from(old).ok()?] = new;
}
Some(inv)
}
/// Elimination tree of the upper-triangular pattern. `usize::MAX` is "no
/// parent", i.e. a root.
fn etree(n: usize, col_ptr: &[usize], row_idx: &[usize]) -> Vec<usize> {
let mut parent = vec![usize::MAX; n];
let mut ancestor = vec![usize::MAX; n];
for k in 0..n {
for &row in &row_idx[col_ptr[k]..col_ptr[k + 1]] {
let mut i = row;
while i != usize::MAX && i < k {
let next = ancestor[i];
ancestor[i] = k;
if next == usize::MAX {
parent[i] = k;
}
i = next;
}
}
}
parent
}
/// Nonzero pattern of row `k` of `L`, written into `stack[top..n]` in
/// topological order. Returns `top`.
///
/// `stack` is used from both ends — a scratch region from `0` while walking
/// each path up the tree, and the result from `n` downwards. They cannot
/// collide because every node is pushed at most once across the whole call.
fn ereach(
k: usize,
col_ptr: &[usize],
row_idx: &[usize],
parent: &[usize],
stack: &mut [usize],
mark: &mut [bool],
) -> usize {
let n = mark.len();
let mut top = n;
mark[k] = true;
for &row in &row_idx[col_ptr[k]..col_ptr[k + 1]] {
let mut i = row;
if i > k {
continue;
}
let mut len = 0usize;
while i != usize::MAX && !mark[i] {
stack[len] = i;
len += 1;
mark[i] = true;
i = parent[i];
}
// Reverse the path onto the output end, so the result stays in
// topological order overall.
while len > 0 {
len -= 1;
top -= 1;
stack[top] = stack[len];
}
}
for &i in &stack[top..] {
mark[i] = false;
}
mark[k] = false;
top
}
/// Whiten a contrast: `y = L^-1 P b`.
/// ///
/// The point of the result is the dot product, not the vector: for two /// The point of the result is the dot product, not the vector: for two
/// contrasts `b` and `b'`, `y . y'` is `b^T A^-1 b'`. See the module docs. /// contrasts `b` and `b'`, `y . y'` is `b^T A^-1 b'`. See the module docs.
///
/// The result is in the permuted order, and stays there — a dot product
/// does not care, as long as both operands were permuted the same way.
pub(crate) fn whiten(&self, b: &[f64]) -> Vec<f64> { pub(crate) fn whiten(&self, b: &[f64]) -> Vec<f64> {
debug_assert_eq!(b.len(), self.n); debug_assert_eq!(b.len(), self.n);
let n = self.n; let n = self.n;
let mut y = b.to_vec(); let mut y = vec![0.0f64; n];
for i in 0..n { for (old, &v) in b.iter().enumerate() {
// Folded from `y[i]` rather than summed and subtracted once, so the y[self.inv[old]] = v;
// accumulation order matches a plain substitution loop exactly. }
let row = &self.l[i * n..i * n + i]; for j in 0..n {
let s = row y[j] /= self.val[self.col_ptr[j]];
.iter() let yj = y[j];
.zip(&y[..i]) for p in self.col_ptr[j] + 1..self.col_ptr[j + 1] {
.fold(y[i], |acc, (l, v)| acc - l * v); y[self.row_idx[p]] -= self.val[p] * yj;
y[i] = s / self.l[i * n + i]; }
} }
y y
} }
@@ -98,11 +350,24 @@ pub(crate) fn bilinear(y: &[f64], y_prime: &[f64]) -> f64 {
mod tests { mod tests {
use super::*; use super::*;
/// Factorise a dense row-major matrix, for the goldens below.
fn dense(a: &[f64], n: usize) -> Option<Cholesky> {
let mut b = SymmetricBuilder::new();
for i in 0..n {
for j in 0..n {
if a[i * n + j] != 0.0 {
b.add(i, j, a[i * n + j]);
}
}
}
Cholesky::factor(b, n)
}
/// `[[4, 1], [1, 3]] z = [1, 2]` has `z = [1/11, 7/11]`, so the quadratic /// `[[4, 1], [1, 3]] z = [1, 2]` has `z = [1/11, 7/11]`, so the quadratic
/// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`. /// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`.
#[test] #[test]
fn reproduces_a_known_quadratic_form() { fn reproduces_a_known_quadratic_form() {
let c = Cholesky::factor(vec![4.0, 1.0, 1.0, 3.0], 2).unwrap(); let c = dense(&[4.0, 1.0, 1.0, 3.0], 2).unwrap();
let y = c.whiten(&[1.0, 2.0]); let y = c.whiten(&[1.0, 2.0]);
assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12); assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12);
} }
@@ -113,8 +378,8 @@ mod tests {
fn recovers_the_inverse_diagonal() { fn recovers_the_inverse_diagonal() {
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is // A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
// [0.75, 1.0, 0.75]. // [0.75, 1.0, 0.75].
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0]; let a = [2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
let c = Cholesky::factor(a, 3).unwrap(); let c = dense(&a, 3).unwrap();
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() { for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
let mut e = vec![0.0; 3]; let mut e = vec![0.0; 3];
e[i] = 1.0; e[i] = 1.0;
@@ -127,8 +392,8 @@ mod tests {
#[test] #[test]
fn recovers_an_off_diagonal_covariance() { fn recovers_an_off_diagonal_covariance() {
// Same A; (A^-1)_{0,1} = 0.5. // Same A; (A^-1)_{0,1} = 0.5.
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0]; let a = [2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
let c = Cholesky::factor(a, 3).unwrap(); let c = dense(&a, 3).unwrap();
let y0 = c.whiten(&[1.0, 0.0, 0.0]); let y0 = c.whiten(&[1.0, 0.0, 0.0]);
let y1 = c.whiten(&[0.0, 1.0, 0.0]); let y1 = c.whiten(&[0.0, 1.0, 0.0]);
assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12); assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12);
@@ -138,15 +403,108 @@ mod tests {
/// A variance can never come out negative, because it is a sum of squares. /// A variance can never come out negative, because it is a sum of squares.
#[test] #[test]
fn a_quadratic_form_is_never_negative() { fn a_quadratic_form_is_never_negative() {
let a = vec![1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12]; let a = [1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
let c = Cholesky::factor(a, 2).unwrap(); let c = dense(&a, 2).unwrap();
let y = c.whiten(&[1.0, -1.0]); let y = c.whiten(&[1.0, -1.0]);
assert!(bilinear(&y, &y) >= 0.0); assert!(bilinear(&y, &y) >= 0.0);
} }
/// Against an independent dense reference, on random sparse SPD matrices.
///
/// The goldens above are 2x2 and 3x3 — small enough that AMD does nothing
/// and no fill-in occurs, so they cannot catch a symbolic-pass bug. This
/// builds matrices big enough to permute and fill in, and checks every
/// bilinear form against a textbook dense factorisation of the *same*
/// matrix in its original order.
#[test]
fn agrees_with_a_dense_reference_on_random_sparse_systems() {
/// Dense Cholesky and quadratic form, deliberately naive: this is the
/// reference, so it must not share code with what it is checking.
fn dense_quadratic_form(a: &[f64], n: usize, b: &[f64], c: &[f64]) -> f64 {
let mut l = a.to_vec();
for j in 0..n {
let mut d = l[j * n + j];
for k in 0..j {
d -= l[j * n + k] * l[j * n + k];
}
let d = d.sqrt();
l[j * n + j] = d;
for i in j + 1..n {
let mut sum = l[i * n + j];
for k in 0..j {
sum -= l[i * n + k] * l[j * n + k];
}
l[i * n + j] = sum / d;
}
}
let solve = |rhs: &[f64]| -> Vec<f64> {
let mut y = rhs.to_vec();
for i in 0..n {
for k in 0..i {
y[i] -= l[i * n + k] * y[k];
}
y[i] /= l[i * n + i];
}
y
};
let (yb, yc) = (solve(b), solve(c));
yb.iter().zip(&yc).map(|(x, y)| x * y).sum()
}
// A cheap deterministic generator; no dependency, and reproducible.
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut rand = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
for n in [7usize, 23, 60] {
let mut a = vec![0.0f64; n * n];
// A chain plus scattered long-range couplings: the shape of a
// time-expanded joint, where a competitor's drift link can span
// the whole matrix.
for i in 0..n {
a[i * n + i] = 4.0 + rand();
if i + 1 < n {
let v = -(0.5 + rand() * 0.5);
a[i * n + i + 1] = v;
a[(i + 1) * n + i] = v;
}
}
for step in 0..n / 3 {
let i = (step * 7) % n;
let j = (step * 29 + 3) % n;
if i != j {
let v = -(0.1 + rand() * 0.2);
a[i * n + j] = v;
a[j * n + i] = v;
// Keep it diagonally dominant, hence positive-definite.
a[i * n + i] += 0.6;
a[j * n + j] += 0.6;
}
}
let sparse = dense(&a, n).expect("spd");
for trial in 0..8 {
let b: Vec<f64> = (0..n).map(|_| rand() * 2.0 - 1.0).collect();
let c: Vec<f64> = (0..n).map(|_| rand() * 2.0 - 1.0).collect();
let got = bilinear(&sparse.whiten(&b), &sparse.whiten(&c));
let want = dense_quadratic_form(&a, n, &b, &c);
assert!(
(got - want).abs() <= 1e-10 * want.abs().max(1.0),
"n={n} trial={trial}: sparse {got} vs dense {want}"
);
}
}
}
/// A permutation must not change which matrices are rejected.
#[test] #[test]
fn rejects_a_non_positive_definite_matrix() { fn rejects_a_non_positive_definite_matrix() {
// Singular: the second row is a multiple of the first. // Singular: the second row is a multiple of the first.
assert!(Cholesky::factor(vec![1.0, 2.0, 2.0, 4.0], 2).is_none()); assert!(dense(&[1.0, 2.0, 2.0, 4.0], 2).is_none());
} }
} }
+18 -13
View File
@@ -9,6 +9,10 @@
//! This is a Rust port of //! This is a Rust port of
//! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py). //! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
//! //!
//! Upgrading? `MIGRATING.md` in the repository root covers every breaking
//! change, with the ones that alter what an existing call *returns* called out
//! first.
//!
//! # Getting started //! # Getting started
//! //!
//! Record results, converge, then read off skills: //! Record results, converge, then read off skills:
@@ -144,6 +148,7 @@ mod outcome;
mod predict; mod predict;
pub(crate) mod quadrature; pub(crate) mod quadrature;
mod rating; mod rating;
pub mod rating_rule;
pub(crate) mod storage; pub(crate) mod storage;
mod time; mod time;
mod time_slice; mod time_slice;
@@ -151,7 +156,7 @@ mod time_slice;
pub use acquisition::expected_information_gain; pub use acquisition::expected_information_gain;
pub use convergence::{ConvergenceOptions, ConvergenceReport}; pub use convergence::{ConvergenceOptions, ConvergenceReport};
pub use drift::{ConstantDrift, Drift}; pub use drift::{ConstantDrift, Drift};
pub use error::{InferenceError, UnknownKeys}; pub use error::{CompetitorField, InferenceError, OutcomeKind, Parameter, Shape, UnknownKeys};
pub use event::{Event, Member, Team}; pub use event::{Event, Member, Team};
pub use event_builder::EventBuilder; pub use event_builder::EventBuilder;
pub use game::{Game, GameOptions}; pub use game::{Game, GameOptions};
@@ -162,6 +167,7 @@ pub use observer::{NullObserver, Observer};
pub use outcome::Outcome; pub use outcome::Outcome;
pub use predict::Prediction; pub use predict::Prediction;
pub use rating::Rating; pub use rating::Rating;
pub use rating_rule::{FnRule, NoRule, RatingRule, StartingPoint};
/// The `smallvec` crate, re-exported. /// The `smallvec` crate, re-exported.
/// ///
/// Four public items name `SmallVec` in their signatures: [`Event::teams`], /// Four public items name `SmallVec` in their signatures: [`Event::teams`],
@@ -290,23 +296,22 @@ pub(crate) const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
/// An interned competitor handle: a dense slot number, not a user key. /// An interned competitor handle: a dense slot number, not a user key.
/// ///
/// [`History`] stores skills and messages by `Index` rather than by `K`, so /// `History` stores skills and messages by `Index` rather than by `K`, so the
/// the hot path never hashes a key. [`History::intern`] promotes a key to one /// hot path never hashes a key. Indices are assigned in interning order and
/// and [`History::lookup`] resolves an existing key without creating. /// are stable for the life of a history; they are not portable between
/// histories, since the same key interns to a different slot under a different
/// ingestion order.
/// ///
/// Indices are assigned in interning order and are stable for the life of a /// Crate-internal. It was public, along with `History::intern` and
/// history. They are **not** portable between histories: the same key interns /// `History::lookup` that produced one — and **nothing public ever accepted
/// to different slots depending on ingestion order. /// one**, so it was a handle with nowhere to go. It also shadowed
/// `std::ops::Index`, which `CompetitorStore` implements. See #73.
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)] #[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
pub struct Index(usize); pub(crate) struct Index(usize);
impl Index { impl Index {
/// The underlying slot number. /// The underlying slot number.
/// pub(crate) fn get(self) -> usize {
/// Indices are dense and assigned in interning order, so this is usable as
/// a key into a caller-side side table.
#[must_use]
pub fn get(self) -> usize {
self.0 self.0
} }
} }
+10 -5
View File
@@ -84,7 +84,7 @@ impl Outcome {
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> { pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
if winner >= n { if winner >= n {
return Err(crate::InferenceError::InvalidParameter { return Err(crate::InferenceError::InvalidParameter {
name: "winner", parameter: crate::Parameter::WinnerIndex,
value: f64::from(winner), value: f64::from(winner),
}); });
} }
@@ -113,11 +113,16 @@ impl Outcome {
/// Explicit per-team continuous scores with a per-event noise override. /// Explicit per-team continuous scores with a per-event noise override.
/// ///
/// The noise is on the *observed score margin*, in the units of the scores
/// themselves — it is not a skill sigma, which is what the old name
/// `scores_with_sigma` read as. It overrides `HistoryBuilder::score_sigma`
/// for this event only.
///
/// `score_sigma` must be `> 0.0`. Constructing an `Outcome` with a /// `score_sigma` must be `> 0.0`. Constructing an `Outcome` with a
/// non-positive or NaN value is allowed; the value is rejected with /// non-positive or NaN value is allowed; the value is rejected with
/// `InferenceError::InvalidParameter` when the event is ingested, so /// `InferenceError::InvalidParameter` when the event is ingested, so
/// callers get an error rather than a panic. /// callers get an error rather than a panic.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, score_sigma: f64) -> Self { pub fn scores_with_noise<I: IntoIterator<Item = f64>>(scores: I, score_sigma: f64) -> Self {
Self::Scored { Self::Scored {
scores: scores.into_iter().collect(), scores: scores.into_iter().collect(),
score_sigma: Some(score_sigma), score_sigma: Some(score_sigma),
@@ -211,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][..]));
} }
@@ -227,7 +232,7 @@ mod tests {
#[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 { score_sigma, .. } => assert_eq!(score_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"),
@@ -239,7 +244,7 @@ mod tests {
/// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`. /// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`.
#[test] #[test]
fn scores_with_sigma_defers_validation_to_ingestion() { fn scores_with_sigma_defers_validation_to_ingestion() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 0.0); let o = Outcome::scores_with_noise([3.0, 1.0], 0.0);
match o { match o {
Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(0.0)), Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(0.0)),
Outcome::Ranked(_) => panic!("expected Scored variant"), Outcome::Ranked(_) => panic!("expected Scored variant"),
+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)
}
}
+10 -7
View File
@@ -26,7 +26,9 @@ pub(crate) struct Skill {
impl Skill { impl Skill {
pub(crate) fn posterior(&self) -> Gaussian { pub(crate) fn posterior(&self) -> Gaussian {
self.likelihood * self.backward * self.forward self.likelihood
.ep_product(self.backward)
.ep_product(self.forward)
} }
} }
@@ -74,7 +76,7 @@ impl Item {
if forward { if forward {
Rating::new(skill.forward, r.beta, r.drift).with_drift_scale(r.drift_scale) Rating::new(skill.forward, r.beta, r.drift).with_drift_scale(r.drift_scale)
} else { } else {
Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift) Rating::new(skill.posterior().cavity(self.likelihood), r.beta, r.drift)
.with_drift_scale(r.drift_scale) .with_drift_scale(r.drift_scale)
} }
} }
@@ -171,7 +173,7 @@ impl Event {
for (i, item) in team.items.iter_mut().enumerate() { for (i, item) in team.items.iter_mut().enumerate() {
let fresh = update.likelihoods[t][i]; let fresh = update.likelihoods[t][i];
let old_likelihood = skills.at(item.slot).likelihood; let old_likelihood = skills.at(item.slot).likelihood;
let new_likelihood = (old_likelihood / item.likelihood) * fresh; let new_likelihood = old_likelihood.cavity(item.likelihood).ep_product(fresh);
skills.at_mut(item.slot).likelihood = new_likelihood; skills.at_mut(item.slot).likelihood = new_likelihood;
item.likelihood = fresh; item.likelihood = fresh;
} }
@@ -435,8 +437,9 @@ impl<T: Time> TimeSlice<T> {
for (t, team) in event.teams.iter_mut().enumerate() { for (t, team) in event.teams.iter_mut().enumerate() {
for (i, item) in team.items.iter_mut().enumerate() { for (i, item) in team.items.iter_mut().enumerate() {
let old_likelihood = self.skills.at(item.slot).likelihood; let old_likelihood = self.skills.at(item.slot).likelihood;
let new_likelihood = let new_likelihood = old_likelihood
(old_likelihood / item.likelihood) * g.likelihoods[t][i]; .cavity(item.likelihood)
.ep_product(g.likelihoods[t][i]);
self.skills.at_mut(item.slot).likelihood = new_likelihood; self.skills.at_mut(item.slot).likelihood = new_likelihood;
item.likelihood = g.likelihoods[t][i]; item.likelihood = g.likelihoods[t][i];
} }
@@ -586,7 +589,7 @@ impl<T: Time> TimeSlice<T> {
pub(crate) fn forward_prior_out(&self, competitor: &Index) -> Gaussian { pub(crate) fn forward_prior_out(&self, competitor: &Index) -> Gaussian {
let skill = self.skills.get(*competitor).unwrap(); let skill = self.skills.get(*competitor).unwrap();
skill.forward * skill.likelihood skill.forward.ep_product(skill.likelihood)
} }
pub(crate) fn backward_prior_out<D: Drift<T>>( pub(crate) fn backward_prior_out<D: Drift<T>>(
@@ -595,7 +598,7 @@ impl<T: Time> TimeSlice<T> {
competitors: &CompetitorStore<T, D>, competitors: &CompetitorStore<T, D>,
) -> Gaussian { ) -> Gaussian {
let skill = self.skills.get(*competitor).unwrap(); let skill = self.skills.get(*competitor).unwrap();
let n = skill.likelihood * skill.backward; let n = skill.likelihood.ep_product(skill.backward);
n.forget( n.forget(
competitors[*competitor] competitors[*competitor]
.rating .rating
+9 -4
View File
@@ -29,7 +29,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
let players = ["p0", "p1", "p2"]; let players = ["p0", "p1", "p2"];
let holes = ["h0", "h1"]; let holes = ["h0", "h1"];
let mut h: History<i64, _, _, &'static str> = History::builder() let mut h: History = History::builder()
.mu(0.0) .mu(0.0)
.sigma(6.0) .sigma(6.0)
.beta(1.0) .beta(1.0)
@@ -80,7 +80,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
println!("\n== the same nodes via posterior_of (exact marginal) =="); println!("\n== the same nodes via posterior_of (exact marginal) ==");
for k in players.iter().chain(holes.iter()) { for k in players.iter().chain(holes.iter()) {
let g = h.posterior_of(&[(k, 1.0)]).unwrap(); let g = h.joint().unwrap().posterior_of(&[(k, 1.0)]).unwrap();
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma()); println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
} }
@@ -97,7 +97,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
vec![(&"h0", 1.0), (&"h1", -1.0)], vec![(&"h0", 1.0), (&"h1", -1.0)],
), ),
] { ] {
let joint = h.posterior_of(&terms).unwrap(); let joint = h.joint().unwrap().posterior_of(&terms).unwrap();
// what a consumer gets today by adding marginals // what a consumer gets today by adding marginals
let naive: f64 = terms let naive: f64 = terms
.iter() .iter()
@@ -132,7 +132,12 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
// shares with its partners is pinned only by the prior. // shares with its partners is pinned only by the prior.
for k in players.iter().chain(holes.iter()) { for k in players.iter().chain(holes.iter()) {
let bp = h.current_skill(k).unwrap().sigma(); let bp = h.current_skill(k).unwrap().sigma();
let exact = h.posterior_of(&[(k, 1.0)]).unwrap().sigma(); let exact = h
.joint()
.unwrap()
.posterior_of(&[(k, 1.0)])
.unwrap()
.sigma();
assert!( assert!(
exact > 3.0 * bp, exact > 3.0 * bp,
"{k}: exact marginal {exact} should be much wider than the reported \ "{k}: exact marginal {exact} should be much wider than the reported \
+7 -7
View File
@@ -41,9 +41,9 @@ fn add_events_bulk_via_iter() {
h.add_events(events).unwrap(); h.add_events(events).unwrap();
let report = h.converge().unwrap(); let report = h.converge().unwrap();
assert!(report.converged); assert!(report.converged);
assert!(h.lookup(&"a").is_some()); assert!(h.current_skill("a").is_some());
assert!(h.lookup(&"b").is_some()); assert!(h.current_skill("b").is_some());
assert!(h.lookup(&"c").is_some()); assert!(h.current_skill("c").is_some());
} }
#[test] #[test]
@@ -103,9 +103,9 @@ fn fluent_event_builder_basic() {
let report = h.converge().unwrap(); let report = h.converge().unwrap();
assert!(report.converged); assert!(report.converged);
assert!(h.lookup(&"alice").is_some()); assert!(h.current_skill("alice").is_some());
assert!(h.lookup(&"bob").is_some()); assert!(h.current_skill("bob").is_some());
assert!(h.lookup(&"carol").is_some()); assert!(h.current_skill("carol").is_some());
} }
#[test] #[test]
@@ -203,7 +203,7 @@ fn predict_quality_two_teams() {
h.record_winner(&"a", &"b", 1).unwrap(); h.record_winner(&"a", &"b", 1).unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
let q = h.predict_quality(&[&[&"a"], &[&"b"]]).unwrap(); let q = h.quality(&[&[&"a"], &[&"b"]]).unwrap();
assert!(q > 0.0 && q <= 1.0); assert!(q > 0.0 && q <= 1.0);
} }
+4 -1
View File
@@ -184,7 +184,10 @@ fn a_batch_declaring_two_different_priors_is_rejected() {
assert!( assert!(
matches!( matches!(
err, err,
InferenceError::ConflictingCompetitorConfig { field: "prior", .. } InferenceError::ConflictingCompetitorConfig {
field: trueskill_tt::CompetitorField::Prior,
..
}
), ),
"got {err:?}" "got {err:?}"
); );
+2 -2
View File
@@ -102,7 +102,7 @@ fn every_magnitude_parameter_rejects_a_negative_value() {
}), }),
), ),
( (
"Outcome::scores_with_sigma (at ingestion)", "Outcome::scores_with_noise (at ingestion)",
Box::new(|v| { Box::new(|v| {
let mut h = History::builder().build(); let mut h = History::builder().build();
h.add_events(vec![trueskill_tt::Event { h.add_events(vec![trueskill_tt::Event {
@@ -111,7 +111,7 @@ fn every_magnitude_parameter_rejects_a_negative_value() {
trueskill_tt::Team::with_members([Member::new("a")]), trueskill_tt::Team::with_members([Member::new("a")]),
trueskill_tt::Team::with_members([Member::new("b")]), trueskill_tt::Team::with_members([Member::new("b")]),
], ],
outcome: Outcome::scores_with_sigma([3.0, 1.0], v), outcome: Outcome::scores_with_noise([3.0, 1.0], v),
}]) }])
.is_err() .is_err()
}), }),
+2 -2
View File
@@ -11,7 +11,7 @@ use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team, ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
}; };
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>; type H = History;
fn duel(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> { fn duel(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
Event { Event {
@@ -108,7 +108,7 @@ fn the_two_agree_on_a_converged_fit() {
/// At the old value of 30 this history stopped short and said nothing. /// At the old value of 30 this history stopped short and said nothing.
#[test] #[test]
fn the_default_cap_clears_an_ordinary_history() { fn the_default_cap_clears_an_ordinary_history() {
let mut h: History<i64, ConstantDrift, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.mu(0.0) .mu(0.0)
.sigma(6.0) .sigma(6.0)
+8 -5
View File
@@ -15,8 +15,7 @@ use std::{env, process::Command};
use smallvec::smallvec; use smallvec::smallvec;
use trueskill_tt::{ use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team, ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team, UnknownKeys,
UnknownKeys,
}; };
/// Set in the child so it reports instead of re-spawning. /// Set in the child so it reports instead of re-spawning.
@@ -24,7 +23,7 @@ const CHILD: &str = "TSTT_DETERMINISM_CHILD";
const RUNS: usize = 40; const RUNS: usize = 40;
type H = History<i64, ConstantDrift, NullObserver, String>; type H = History<String>;
fn fitted() -> H { fn fitted() -> H {
let mut h: H = History::builder() let mut h: H = History::builder()
@@ -84,13 +83,17 @@ fn fingerprint() -> String {
let known = "p0".to_string(); let known = "p0".to_string();
terms.push((&known, -1.0)); terms.push((&known, -1.0));
let posterior = h.posterior_of(&terms).unwrap(); let posterior = h.joint().unwrap().posterior_of(&terms).unwrap();
let a = "p0".to_string(); let a = "p0".to_string();
let b = "p1".to_string(); let b = "p1".to_string();
let target = [(&a, 1.0), (&b, -1.0)]; let target = [(&a, 1.0), (&b, -1.0)];
let teams: [&[&String]; 2] = [&[&a], &[&b]]; let teams: [&[&String]; 2] = [&[&a], &[&b]];
let evr = h.expected_variance_reduction(&teams, &target).unwrap(); let evr = h
.joint()
.unwrap()
.expected_variance_reduction(&teams, &target)
.unwrap();
let curves = h.learning_curves(); let curves = h.learning_curves();
let mut curve_bits: u64 = 0; let mut curve_bits: u64 = 0;
+5 -5
View File
@@ -8,7 +8,7 @@ mod common;
use common::assert_finite; use common::assert_finite;
use trueskill_tt::{ use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError, ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
NullObserver, Outcome, Rating, Outcome, Rating,
}; };
type R = Rating<i64, ConstantDrift>; type R = Rating<i64, ConstantDrift>;
@@ -126,7 +126,7 @@ fn empty_history_converges_trivially() {
/// indexed out of bounds in release, so this must run in both profiles. /// indexed out of bounds in release, so this must run in both profiles.
#[test] #[test]
fn converge_on_an_empty_history_with_owned_keys() { fn converge_on_an_empty_history_with_owned_keys() {
let mut history: History<i64, ConstantDrift, NullObserver, String> = History::builder() let mut history: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.score_sigma(5.0) .score_sigma(5.0)
.build(); .build();
@@ -157,7 +157,7 @@ fn event_builder_rejects_a_weights_length_mismatch() {
matches!( matches!(
err, err,
InferenceError::MismatchedShape { InferenceError::MismatchedShape {
kind: "weights", shape: trueskill_tt::Shape::Weights,
expected: 1, expected: 1,
got: 2, got: 2,
.. ..
@@ -222,13 +222,13 @@ fn scored_event_rejects_non_positive_sigma() {
.event(1) .event(1)
.team(["a"]) .team(["a"])
.team(["b"]) .team(["b"])
.scores_with_sigma([3.0, 1.0], f64::NAN) .scores_with_noise([3.0, 1.0], f64::NAN)
.commit() .commit()
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
InferenceError::InvalidParameter { InferenceError::InvalidParameter {
name: "score_sigma", parameter: trueskill_tt::Parameter::ScoreSigma,
.. ..
} }
)); ));
+6 -6
View File
@@ -8,11 +8,11 @@
use smallvec::smallvec; use smallvec::smallvec;
use trueskill_tt::{ use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
NullObserver, Outcome, Team, Team,
}; };
type Fit = History<i64, ConstantDrift, NullObserver, &'static str>; type Fit = History;
const CONVERGENCE: ConvergenceOptions = ConvergenceOptions { const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
max_iter: 64, max_iter: 64,
@@ -279,7 +279,7 @@ fn reject(scale: f64) -> InferenceError {
fn negative_scale_is_rejected() { fn negative_scale_is_rejected() {
assert!(matches!( assert!(matches!(
reject(-1.0), reject(-1.0),
InferenceError::InvalidParameter { name: "drift_scale", value, .. } InferenceError::InvalidParameter { parameter: trueskill_tt::Parameter::DriftScale, value, .. }
if value == -1.0 if value == -1.0
)); ));
} }
@@ -291,7 +291,7 @@ fn non_finite_scale_is_rejected() {
matches!( matches!(
reject(scale), reject(scale),
InferenceError::InvalidParameter { InferenceError::InvalidParameter {
name: "drift_scale", parameter: trueskill_tt::Parameter::DriftScale,
.. ..
} }
), ),
@@ -488,7 +488,7 @@ fn a_batch_that_contradicts_itself_is_rejected() {
matches!( matches!(
err, err,
InferenceError::ConflictingCompetitorConfig { InferenceError::ConflictingCompetitorConfig {
field: "drift_scale", field: trueskill_tt::CompetitorField::DriftScale,
.. ..
} }
), ),
+8 -5
View File
@@ -11,7 +11,7 @@ use trueskill_tt::{
Team, Team,
}; };
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>; type H = History;
fn history() -> H { fn history() -> H {
History::builder() History::builder()
@@ -64,8 +64,11 @@ fn members_matches_the_typed_path_exactly() {
for key in ["player", "layout_7"] { for key in ["player", "layout_7"] {
let a = typed.current_skill(&key).unwrap(); let a = typed.current_skill(&key).unwrap();
let b = fluent.current_skill(&key).unwrap(); let b = fluent.current_skill(&key).unwrap();
assert_eq!(a.pi(), b.pi(), "{key} pi"); // Exact equality, on the public moments rather than the natural
assert_eq!(a.tau(), b.tau(), "{key} tau"); // parameters: `mu` and `variance` are `tau/pi` and `1/pi`, so
// bit-equal natural parameters give bit-equal moments.
assert_eq!(a.mu(), b.mu(), "{key} mu");
assert_eq!(a.variance(), b.variance(), "{key} variance");
} }
} }
@@ -133,7 +136,7 @@ fn weights_still_guards_a_members_team() {
matches!( matches!(
err, err,
InferenceError::MismatchedShape { InferenceError::MismatchedShape {
kind: "weights", shape: trueskill_tt::Shape::Weights,
expected: 2, expected: 2,
got: 1, got: 1,
.. ..
@@ -161,7 +164,7 @@ fn an_invalid_drift_scale_surfaces_from_commit() {
matches!( matches!(
err, err,
InferenceError::InvalidParameter { InferenceError::InvalidParameter {
name: "drift_scale", parameter: trueskill_tt::Parameter::DriftScale,
.. ..
} }
), ),
+2 -4
View File
@@ -4,11 +4,9 @@
//! `filtered_log_evidence_for` was the missing corner: the one a per-competitor //! `filtered_log_evidence_for` was the missing corner: the one a per-competitor
//! prequential score needs. //! prequential score needs.
use trueskill_tt::{ use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
ConstantDrift, Event, History, InferenceError, Member, NullObserver, Outcome, Team,
};
type H = History<i64, ConstantDrift, NullObserver, &'static str>; type H = History;
/// Two disjoint cohorts, so a key restriction is guaranteed to leave events out. /// Two disjoint cohorts, so a key restriction is guaranteed to leave events out.
fn two_cohorts() -> H { fn two_cohorts() -> H {
+8 -2
View File
@@ -57,7 +57,7 @@ fn game_ranked_rejects_bad_p_draw() {
}, },
) )
.unwrap_err(); .unwrap_err();
assert!(matches!(err, InferenceError::InvalidProbability { .. })); assert!(matches!(err, InferenceError::InvalidParameter { .. }));
} }
#[test] #[test]
@@ -227,7 +227,13 @@ mod malformed_games {
) )
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "score", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Score,
..
}
),
"{bad}: {err:?}" "{bad}: {err:?}"
); );
} }
+2 -4
View File
@@ -4,11 +4,9 @@
//! Each test carries a control: the same call on a key the history *does* know, //! 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. //! so it cannot pass merely because everything returns the same thing.
use trueskill_tt::{ use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
ConstantDrift, Event, History, InferenceError, Member, NullObserver, Outcome, Team,
};
type H = History<i64, ConstantDrift, NullObserver, &'static str>; type H = History;
fn history() -> H { fn history() -> H {
let mut h = H::default(); let mut h = H::default();
+1 -1
View File
@@ -47,7 +47,7 @@ fn configured_event(a: &str, b: &str, time: i64, scale: f64) -> Event<i64, Strin
} }
fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> { fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
let mut h: History<i64, _, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.convergence(tight()) .convergence(tight())
.build(); .build();
+15 -4
View File
@@ -14,8 +14,7 @@ use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
type Ev = Event<i64, &'static str>; type Ev = Event<i64, &'static str>;
fn history() -> History<i64, trueskill_tt::ConstantDrift, trueskill_tt::NullObserver, &'static str> fn history() -> History {
{
History::builder().score_sigma(1.0).build() History::builder().score_sigma(1.0).build()
} }
@@ -113,7 +112,13 @@ fn a_non_finite_score_is_rejected_at_ingestion() {
}]) }])
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "score", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Score,
..
}
),
"{bad}: {err:?}" "{bad}: {err:?}"
); );
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway"); assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
@@ -137,7 +142,13 @@ fn a_non_finite_weight_is_rejected_at_ingestion() {
.commit() .commit()
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Weight,
..
}
),
"{bad}: {err:?}" "{bad}: {err:?}"
); );
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history"); assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
+31 -18
View File
@@ -11,7 +11,7 @@ use trueskill_tt::{
UnknownKeys, UnknownKeys,
}; };
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>; type H = History;
fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> { fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> {
Event { Event {
@@ -78,17 +78,22 @@ const PAIRS: [(&str, &str); 6] = [
("c", "d"), ("c", "d"),
]; ];
/// A joint reused across questions answers exactly what a fresh one per
/// question does. That is the whole correctness claim behind caching the
/// factorisation (#51); it used to be checked against the `History` one-shot
/// wrappers, which were deleted in #78, so it is checked against a fresh
/// factorisation instead — the same comparison, without the wrapper.
#[test] #[test]
fn a_joint_answers_exactly_what_the_one_shot_call_does() { fn a_reused_joint_answers_exactly_what_a_fresh_one_does() {
let h = fitted(UnknownKeys::Reject); let h = fitted(UnknownKeys::Reject);
let joint = h.joint().unwrap(); let joint = h.joint().unwrap();
for (a, b) in PAIRS { for (a, b) in PAIRS {
let terms = [(&a, 1.0), (&b, -1.0)]; let terms = [(&a, 1.0), (&b, -1.0)];
let one_shot = h.posterior_of(&terms).unwrap(); let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
let cached = joint.posterior_of(&terms).unwrap(); let cached = joint.posterior_of(&terms).unwrap();
assert_eq!(one_shot.pi(), cached.pi(), "{a} - {b}"); assert_eq!(one_shot.mu(), cached.mu(), "{a} - {b}");
assert_eq!(one_shot.tau(), cached.tau(), "{a} - {b}"); assert_eq!(one_shot.variance(), cached.variance(), "{a} - {b}");
} }
} }
@@ -100,12 +105,12 @@ fn a_joint_agrees_at_a_pinned_time_too() {
for time in 1..=5 { for time in 1..=5 {
for (a, b) in PAIRS { for (a, b) in PAIRS {
let terms = [(&a, 1.0), (&b, -1.0)]; let terms = [(&a, 1.0), (&b, -1.0)];
let one_shot = h.posterior_of_at(time, &terms); let one_shot = h.joint().unwrap().posterior_of_at(time, &terms);
let cached = joint.posterior_of_at(time, &terms); let cached = joint.posterior_of_at(time, &terms);
match (one_shot, cached) { match (one_shot, cached) {
(Ok(x), Ok(y)) => { (Ok(x), Ok(y)) => {
assert_eq!(x.pi(), y.pi(), "t={time} {a} - {b}"); assert_eq!(x.mu(), y.mu(), "t={time} {a} - {b}");
assert_eq!(x.tau(), y.tau(), "t={time} {a} - {b}"); assert_eq!(x.variance(), y.variance(), "t={time} {a} - {b}");
} }
(Err(x), Err(y)) => assert_eq!(x, y, "t={time} {a} - {b}"), (Err(x), Err(y)) => assert_eq!(x, y, "t={time} {a} - {b}"),
(x, y) => panic!("t={time} {a} - {b}: disagreed on success: {x:?} vs {y:?}"), (x, y) => panic!("t={time} {a} - {b}: disagreed on success: {x:?} vs {y:?}"),
@@ -123,7 +128,11 @@ fn a_joint_scores_candidate_matchups_identically() {
for (x, y) in PAIRS { for (x, y) in PAIRS {
let teams: [&[&&str]; 2] = [&[&x], &[&y]]; let teams: [&[&&str]; 2] = [&[&x], &[&y]];
let one_shot = h.expected_variance_reduction(&teams, &target).unwrap(); let one_shot = h
.joint()
.unwrap()
.expected_variance_reduction(&teams, &target)
.unwrap();
let cached = joint.expected_variance_reduction(&teams, &target).unwrap(); let cached = joint.expected_variance_reduction(&teams, &target).unwrap();
assert_eq!(one_shot, cached, "{x} vs {y}"); assert_eq!(one_shot, cached, "{x} vs {y}");
} }
@@ -222,16 +231,20 @@ fn a_ranked_history_has_no_exact_joint() {
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
assert!(matches!( assert!(matches!(
h.joint().unwrap_err(), h.joint().unwrap_err(),
InferenceError::JointUnavailable { .. } InferenceError::JointRequiresScoredEvents
)); ));
} }
/// Distinguishable from the ranked case, which is the point of splitting
/// `JointUnavailable { reason: &str }` into three variants (#74): "add events"
/// and "use predict_win_probabilities" are different instructions, and telling
/// them apart used to mean matching on English prose.
#[test] #[test]
fn an_empty_history_has_no_joint() { fn an_empty_history_has_no_joint() {
let h = history(UnknownKeys::Reject); let h = history(UnknownKeys::Reject);
assert!(matches!( assert!(matches!(
h.joint().unwrap_err(), h.joint().unwrap_err(),
InferenceError::JointUnavailable { .. } InferenceError::EmptyHistory
)); ));
} }
@@ -252,18 +265,18 @@ fn unknown_keys_are_rejected_per_query() {
} }
/// Under `Prior`, an unseen competitor is independent of everything in the /// Under `Prior`, an unseen competitor is independent of everything in the
/// history, and the cached path must add the same prior variance the one-shot /// history, and a reused joint must add the same prior variance a fresh one
/// path does. /// does.
#[test] #[test]
fn unseen_competitors_match_the_one_shot_path() { fn unseen_competitors_match_a_fresh_factorisation() {
let h = fitted(UnknownKeys::Prior); let h = fitted(UnknownKeys::Prior);
let joint = h.joint().unwrap(); let joint = h.joint().unwrap();
let (a, z) = ("a", "nobody"); let (a, z) = ("a", "nobody");
let terms = [(&a, 1.0), (&z, -1.0)]; let terms = [(&a, 1.0), (&z, -1.0)];
let one_shot = h.posterior_of(&terms).unwrap(); let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
let cached = joint.posterior_of(&terms).unwrap(); let cached = joint.posterior_of(&terms).unwrap();
assert_eq!(one_shot.pi(), cached.pi()); assert_eq!(one_shot.mu(), cached.mu());
assert_eq!(one_shot.tau(), cached.tau()); assert_eq!(one_shot.variance(), cached.variance());
} }
/// A drift too small to represent must collapse, not corrupt the matrix. /// A drift too small to represent must collapse, not corrupt the matrix.
@@ -279,7 +292,7 @@ fn unseen_competitors_match_the_one_shot_path() {
#[test] #[test]
fn a_drift_too_small_to_represent_collapses_rather_than_corrupting() { fn a_drift_too_small_to_represent_collapses_rather_than_corrupting() {
fn variance(scale: f64) -> f64 { fn variance(scale: f64) -> f64 {
let mut h: History<i64, ConstantDrift, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.mu(0.0) .mu(0.0)
.sigma(6.0) .sigma(6.0)
+30 -22
View File
@@ -8,10 +8,10 @@
//! Both key types are exercised in every test, because the point is that the //! Both key types are exercised in every test, because the point is that the
//! spelling is the same. //! spelling is the same.
use trueskill_tt::{ConstantDrift, History, NullObserver}; use trueskill_tt::{ConstantDrift, History};
type Owned = History<i64, ConstantDrift, NullObserver, String>; type Owned = History<String>;
type Borrowed = History<i64, ConstantDrift, NullObserver, &'static str>; type Borrowed = History;
fn owned() -> Owned { fn owned() -> Owned {
let mut h: Owned = History::builder().key_type::<String>().build(); let mut h: Owned = History::builder().key_type::<String>().build();
@@ -52,7 +52,7 @@ fn every_team_shaped_query_accepts_the_same_slice() {
let h = owned(); let h = owned();
let teams: &[&[&str]] = &[&["alice"], &["bob"]]; let teams: &[&[&str]] = &[&["alice"], &["bob"]];
h.predict_quality(teams).expect("quality"); h.quality(teams).expect("quality");
let _ = h.predict_outcome(teams).expect("outcome"); let _ = h.predict_outcome(teams).expect("outcome");
h.predict_ranking(teams, &[0, 1]).expect("ranking"); h.predict_ranking(teams, &[0, 1]).expect("ranking");
h.expected_information_gain(teams) h.expected_information_gain(teams)
@@ -62,32 +62,40 @@ fn every_team_shaped_query_accepts_the_same_slice() {
#[test] #[test]
fn linear_combinations_take_bare_keys() { fn linear_combinations_take_bare_keys() {
// `&[(&K, f64)]` at `K = String` meant `&[(&String, f64)]` — no literals. // `&[(&K, f64)]` at `K = String` meant `&[(&String, f64)]` — no literals.
let h = owned(); // A scored history, because the joint needs one.
let terms: &[(&str, f64)] = &[("alice", 1.0), ("bob", -1.0)]; 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");
// Ranked history, so the joint is unavailable — but the *call* compiles, let terms: &[(&str, f64)] = &[("alice", 1.0), ("bob", -1.0)];
// which is what this pins. The error proves it reached the joint check let gap = h
// rather than failing to resolve a key. .joint()
let err = h .expect("scored history has a joint")
.posterior_of(terms) .posterior_of(terms)
.expect_err("ranked history has no joint"); .expect("both keys are known");
assert!(
format!("{err}").contains("ranked"), assert!(gap.mu() > 0.0, "alice outscored bob every round");
"expected the joint-unavailable path, got {err}"
);
} }
/// `lookup` is gone with `Index` (#73); the accessors that answer the same
/// question all take a borrowed key.
#[test] #[test]
fn lookup_accepts_a_borrowed_key_like_its_neighbours() { fn membership_queries_accept_a_borrowed_key() {
// `lookup` carried `ToOwned<Owned = K>`, copy-pasted from `intern`, which
// genuinely needs it to create the entry. `lookup` never creates.
let h = owned(); let h = owned();
assert!(h.lookup("alice").is_some());
assert!(h.lookup("nobody").is_none());
// Control: its neighbours already accepted this and must still.
assert!(h.current_skill("alice").is_some()); assert!(h.current_skill("alice").is_some());
assert!(h.rating("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] #[test]
+2 -2
View File
@@ -3,7 +3,7 @@
//! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the //! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the
//! moment-space `Sub` in the game chain propagated into every skill once the slice grew past //! moment-space `Sub` in the game chain propagated into every skill once the slice grew past
//! ~75 competitors (e.g. a real ranking dataset with hundreds of players). //! ~75 competitors (e.g. a real ranking dataset with hundreds of players).
use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS, NullObserver}; use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS};
/// Tiny deterministic LCG — avoids a dev-dependency on `rand`. /// Tiny deterministic LCG — avoids a dev-dependency on `rand`.
struct Lcg(u64); struct Lcg(u64);
@@ -24,7 +24,7 @@ impl Lcg {
} }
fn nan_after_fit(players: usize) -> usize { fn nan_after_fit(players: usize) -> usize {
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.beta(1.0) .beta(1.0)
.sigma(6.0) .sigma(6.0)
+8 -6
View File
@@ -132,10 +132,8 @@ fn key(i: usize) -> &'static str {
} }
/// Returns (worst mean error, worst sd ratio). /// Returns (worst mean error, worst sd ratio).
fn fitted( fn fitted(obs: &[(usize, usize, f64)]) -> History {
obs: &[(usize, usize, f64)], let mut h: History = History::builder()
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
let mut h: History<i64, _, _, &'static str> = History::builder()
.mu(MU0) .mu(MU0)
.sigma(SIGMA0) .sigma(SIGMA0)
.beta(BETA) .beta(BETA)
@@ -281,6 +279,8 @@ fn posterior_of_matches_the_exact_joint() {
for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] { for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] {
let got = h let got = h
.joint()
.unwrap()
.posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)]) .posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)])
.expect("scored slice should have a joint"); .expect("scored slice should have a joint");
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt(); let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
@@ -302,7 +302,7 @@ fn posterior_of_matches_the_exact_joint() {
// A single competitor: this is where the loopy marginal was 2x narrow. // A single competitor: this is where the loopy marginal was 2x narrow.
for (i, row) in cov.iter().enumerate() { for (i, row) in cov.iter().enumerate() {
let got = h.posterior_of(&[(&key(i), 1.0)]).unwrap(); let got = h.joint().unwrap().posterior_of(&[(&key(i), 1.0)]).unwrap();
let exact_sd = row[i].sqrt(); let exact_sd = row[i].sqrt();
assert!( assert!(
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9, (got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
@@ -322,7 +322,7 @@ fn cost_scaling() {
use std::time::Instant; use std::time::Instant;
for n in [50usize, 100, 200, 400, 800] { for n in [50usize, 100, 200, 400, 800] {
let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect(); let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
let mut h: History<i64, _, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.score_sigma(2.0) .score_sigma(2.0)
.drift(ConstantDrift::new(0.0)) .drift(ConstantDrift::new(0.0))
@@ -361,6 +361,8 @@ fn cost_scaling() {
let t = Instant::now(); let t = Instant::now();
let g = h let g = h
.joint()
.unwrap()
.posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)]) .posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)])
.unwrap(); .unwrap();
println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma()); println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma());
+4 -4
View File
@@ -56,7 +56,7 @@ fn overflow_during_inference_is_reported_not_hidden() {
for (name, sigma, beta, score_sigma, scores) in cases { for (name, sigma, beta, score_sigma, scores) in cases {
match scored_fit(sigma, beta, score_sigma, scores) { match scored_fit(sigma, beta, score_sigma, scores) {
Err(InferenceError::NonFiniteResult { context, step, .. }) => { Err(InferenceError::NonFiniteStep { context, step, .. }) => {
assert_eq!(context, "History::converge", "{name}"); assert_eq!(context, "History::converge", "{name}");
assert!( assert!(
!step.0.is_finite() || !step.1.is_finite(), !step.0.is_finite() || !step.1.is_finite(),
@@ -86,7 +86,7 @@ fn a_broken_fit_is_never_reported_as_converged() {
let err = h.converge().unwrap_err(); let err = h.converge().unwrap_err();
assert!( assert!(
matches!(err, InferenceError::NonFiniteResult { .. }), matches!(err, InferenceError::NonFiniteStep { .. }),
"a breakdown must not be reported as convergence: {err:?}" "a breakdown must not be reported as convergence: {err:?}"
); );
@@ -104,7 +104,7 @@ fn a_broken_fit_is_never_reported_as_converged() {
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
h2.converge_partial().unwrap_err(), h2.converge_partial().unwrap_err(),
InferenceError::NonFiniteResult { .. } InferenceError::NonFiniteStep { .. }
)); ));
} }
@@ -161,7 +161,7 @@ fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
.converge() .converge()
.expect_err("a NaN fit must never be reported as converged"); .expect_err("a NaN fit must never be reported as converged");
assert!( assert!(
matches!(err, InferenceError::NonFiniteResult { .. }), matches!(err, InferenceError::NonFiniteStep { .. }),
"{err:?}" "{err:?}"
); );
} }
+4 -6
View File
@@ -6,9 +6,7 @@ use trueskill_tt::{
UnknownKeys, UnknownKeys,
}; };
fn builder( fn builder(policy: UnknownKeys) -> History {
policy: UnknownKeys,
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
History::builder() History::builder()
.mu(0.0) .mu(0.0)
.sigma(6.0) .sigma(6.0)
@@ -37,9 +35,7 @@ fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'sta
/// A history where "veteran" and "regular" are well observed and "novice" /// A history where "veteran" and "regular" are well observed and "novice"
/// appears once. /// appears once.
fn fitted( fn fitted(policy: UnknownKeys) -> History {
policy: UnknownKeys,
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
let mut h = builder(policy); let mut h = builder(policy);
let mut events: Vec<_> = (0..40) let mut events: Vec<_> = (0..40)
.map(|t| round("veteran", "regular", 10.0 + f64::from(t % 3), 5.0)) .map(|t| round("veteran", "regular", 10.0 + f64::from(t % 3), 5.0))
@@ -120,6 +116,8 @@ fn swapping_the_teams_negates_the_margin() {
fn the_predictive_interval_exceeds_the_skill_uncertainty() { fn the_predictive_interval_exceeds_the_skill_uncertainty() {
let h = fitted(UnknownKeys::Prior); let h = fitted(UnknownKeys::Prior);
let skill_gap = h let skill_gap = h
.joint()
.unwrap()
.posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)]) .posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)])
.unwrap(); .unwrap();
let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap(); let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
+2 -2
View File
@@ -34,7 +34,7 @@ fn unknown_keys_are_reported_not_silently_dropped() {
h.predict_win_probabilities(&[&[&"a"], &[&"ghost"]]) h.predict_win_probabilities(&[&[&"a"], &[&"ghost"]])
.is_err() .is_err()
); );
assert!(h.predict_quality(&[&[&"a"], &[&"ghost"]]).is_err()); assert!(h.quality(&[&[&"a"], &[&"ghost"]]).is_err());
assert!(h.predict_ranking(&[&[&"a"], &[&"ghost"]], &[0, 1]).is_err()); assert!(h.predict_ranking(&[&[&"a"], &[&"ghost"]], &[0, 1]).is_err());
} }
@@ -407,7 +407,7 @@ fn prior_reaches_every_prediction_entry_point() {
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior); let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
let teams: &[&[&&str]] = &[&[&"a"], &[&"ghost"]]; let teams: &[&[&&str]] = &[&[&"a"], &[&"ghost"]];
assert!(h.predict_quality(teams).is_ok()); assert!(h.quality(teams).is_ok());
assert!(h.predict_win_probabilities(teams).is_ok()); assert!(h.predict_win_probabilities(teams).is_ok());
assert!(h.predict_outcome(teams).is_ok()); assert!(h.predict_outcome(teams).is_ok());
assert!(h.predict_ranking(teams, &[0, 1]).is_ok()); assert!(h.predict_ranking(teams, &[0, 1]).is_ok());
+7 -7
View File
@@ -10,10 +10,10 @@
//! returning `Err`. //! returning `Err`.
use trueskill_tt::{ use trueskill_tt::{
ConstantDrift, Event, Gaussian, History, InferenceError, Member, NullObserver, Outcome, Team, ConstantDrift, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
}; };
type H = History<i64, ConstantDrift, NullObserver, &'static str>; type H = History;
fn build(beta: f64, prior: Option<Gaussian>, outcome: Outcome) -> H { fn build(beta: f64, prior: Option<Gaussian>, outcome: Outcome) -> H {
let mut h: H = History::builder() let mut h: H = History::builder()
@@ -49,7 +49,7 @@ fn nan_poisoned() -> H {
); );
let err = h.converge().expect_err("this fixture must not converge"); let err = h.converge().expect_err("this fixture must not converge");
assert!( assert!(
matches!(err, InferenceError::NonFiniteResult { .. }), matches!(err, InferenceError::NonFiniteStep { .. }),
"{err:?}" "{err:?}"
); );
h h
@@ -78,7 +78,7 @@ macro_rules! all_predictions {
($h:ident, $f:expr) => {{ ($h:ident, $f:expr) => {{
let teams: &[&[&&'static str]] = &[&[&"a"], &[&"b"]]; let teams: &[&[&&'static str]] = &[&[&"a"], &[&"b"]];
let f = $f; let f = $f;
f("predict_quality", $h.predict_quality(teams).map(|_| ())); f("quality", $h.quality(teams).map(|_| ()));
f( f(
"predict_win_probabilities", "predict_win_probabilities",
$h.predict_win_probabilities(teams).map(|_| ()), $h.predict_win_probabilities(teams).map(|_| ()),
@@ -101,7 +101,7 @@ fn a_nan_poisoned_fit_is_refused_by_every_prediction_path() {
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| { all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
match r { match r {
Err(InferenceError::NonFiniteResult { .. }) => {} Err(InferenceError::NonFiniteSkill { .. }) => {}
other => panic!("{name} answered from a NaN fit: {other:?}"), other => panic!("{name} answered from a NaN fit: {other:?}"),
} }
}); });
@@ -116,12 +116,12 @@ fn degenerate_performances_are_refused_rather_than_answered_wrongly() {
assert_eq!(skill.sigma(), 0.0); assert_eq!(skill.sigma(), 0.0);
assert!(skill.mu().is_finite()); assert!(skill.mu().is_finite());
// `predict_quality` previously PANICKED here, out of a method that returns // `quality` previously PANICKED here, out of a method that returns
// `Result`: the contrast covariance is exactly singular when beta is zero // `Result`: the contrast covariance is exactly singular when beta is zero
// and every skill is a point mass. // and every skill is a point mass.
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| { all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
match r { match r {
Err(InferenceError::InvalidParameter { .. }) => {} Err(InferenceError::NoPerformanceVariance) => {}
other => panic!("{name} predicted from a degenerate fit: {other:?}"), other => panic!("{name} predicted from a degenerate fit: {other:?}"),
} }
}); });
+2 -5
View File
@@ -110,11 +110,8 @@ fn history_predict_quality_supports_three_teams() {
h.record_winner(&"b", &"c", 2).unwrap(); h.record_winner(&"b", &"c", 2).unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
let q = h.predict_quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap(); let q = h.quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap();
assert!( assert!(q.is_finite(), "3-team quality must be finite, got {q}");
q.is_finite(),
"3-team predict_quality must be finite, got {q}"
);
assert!((0.0..=1.0).contains(&q), "out of range: {q}"); assert!((0.0..=1.0).contains(&q), "out of range: {q}");
} }
+192
View File
@@ -0,0 +1,192 @@
//! `HistoryBuilder::default_rating_for`: configuring a *class* of competitors
//! rather than one at a time (#53).
//!
//! Every test carries a control — a key the rule does not match — so none can
//! pass by the rule firing for everybody, which would be indistinguishable
//! from changing the history defaults.
use trueskill_tt::{
ConstantDrift, Gaussian, History, HistoryBuilder, InferenceError, Member, NullObserver,
RatingRule, StartingPoint,
};
/// Pinned: no drift, and a tight prior at a known strength.
fn pinned() -> StartingPoint {
StartingPoint::new()
.prior(Gaussian::from_ms(5.0, 0.5))
.drift_scale(0.0)
}
fn play<R: RatingRule<&'static str>>(
h: &mut History<&'static str, i64, ConstantDrift, NullObserver, R>,
) {
for t in 1..=6 {
h.event(t)
.team(["layout_a"])
.team(["alice"])
.scores([3.0, 1.0])
.commit()
.expect("ingests");
}
h.converge().expect("converges");
}
#[test]
fn a_rule_configures_every_matching_key_without_naming_them() {
let mut ruled = History::builder()
.gamma(0.5)
.default_rating_for(|key: &&'static str| key.starts_with("layout_").then(pinned))
.build();
play(&mut ruled);
let mut plain = History::builder().gamma(0.5).build();
play(&mut plain);
let layout = ruled.current_skill("layout_a").expect("played");
// The rule pinned the layout: tight prior, no drift.
assert!(
layout.sigma() < 0.5,
"the layout should stay near its pinned prior, got sigma {}",
layout.sigma()
);
assert_ne!(
layout.sigma(),
plain.current_skill("layout_a").unwrap().sigma(),
"the rule must actually change the fit"
);
// The control is the *configuration*, not the posterior. Alice's posterior
// legitimately moves — she is playing a differently-configured opponent,
// and what she learns from beating it depends on how sure the model is
// about it. What must not move is what the rule was asked about.
let alice = ruled.rating("alice").expect("played");
assert_eq!(
alice.drift_scale(),
1.0,
"a non-matching key keeps the default drift"
);
assert_eq!(
(alice.prior().mu(), alice.prior().sigma()),
{
let p = plain.rating("alice").expect("played").prior();
(p.mu(), p.sigma())
},
"a non-matching key keeps the history's prior"
);
}
#[test]
fn a_rule_fires_for_a_competitor_first_seen_through_record_winner() {
// `record_winner` cannot carry configuration, which is the case a rule
// exists for.
let mut h = History::builder()
.default_rating_for(|key: &&'static str| key.starts_with("bot_").then(pinned))
.build();
h.record_winner(&"bot_1", &"human", 1).expect("ingests");
h.converge().expect("converges");
assert_eq!(h.rating("bot_1").expect("known").drift_scale(), 0.0);
assert_eq!(h.rating("human").expect("known").drift_scale(), 1.0);
}
#[test]
fn explicit_configuration_overrides_a_rule_field_by_field() {
let mut h = History::builder()
.default_rating_for(|_: &&'static str| Some(pinned()))
.build();
// Sets only the prior, so the rule's `drift_scale` must survive.
h.register(Member::new("a").with_prior(Gaussian::from_ms(-9.0, 2.0)))
.expect("new");
// Sets neither: the rule supplies both.
h.register(Member::new("b")).expect("new");
let a = h.rating("a").expect("registered");
assert_eq!(a.prior().mu(), -9.0, "explicit prior wins");
assert_eq!(a.drift_scale(), 0.0, "the rule's drift_scale survives");
let b = h.rating("b").expect("registered");
assert_eq!(b.prior().mu(), 5.0);
assert_eq!(b.drift_scale(), 0.0);
}
#[test]
fn two_explicit_declarations_that_disagree_are_still_an_error() {
// Precedence resolves rule-vs-explicit. It does not weaken the check
// between two explicit declarations, neither of which is more specific.
let mut h = History::builder()
.default_rating_for(|_: &&'static str| Some(pinned()))
.build();
let err = h
.add_events(vec![
event(1, "x", Gaussian::from_ms(1.0, 1.0)),
event(2, "x", Gaussian::from_ms(2.0, 1.0)),
])
.expect_err("two different priors for one competitor");
assert!(
matches!(err, InferenceError::ConflictingCompetitorConfig { .. }),
"{err:?}"
);
}
fn event(time: i64, key: &'static str, prior: Gaussian) -> trueskill_tt::Event<i64, &'static str> {
trueskill_tt::Event {
time,
teams: [
trueskill_tt::Team::with_members([Member::new(key).with_prior(prior)]),
trueskill_tt::Team::with_members([Member::new("opponent")]),
]
.into_iter()
.collect(),
outcome: trueskill_tt::Outcome::scores([2.0, 1.0]),
}
}
/// A named rule type, so the `History<..>` can be written down in a field.
struct StaticLayouts;
impl RatingRule<&'static str> for StaticLayouts {
fn starting_point(&self, key: &&'static str) -> Option<StartingPoint> {
key.starts_with("layout_").then(pinned)
}
}
/// The reason this is a trait rather than a bare `Fn` bound: a consumer holds
/// its history in application state and has to name the type.
struct Ladder {
history: History<&'static str, i64, ConstantDrift, NullObserver, StaticLayouts>,
}
#[test]
fn a_named_rule_type_can_be_stored_in_a_struct_field() {
let mut ladder = Ladder {
history: HistoryBuilder::default().rating_rule(StaticLayouts).build(),
};
play(&mut ladder.history);
assert!(
ladder
.history
.current_skill("layout_a")
.expect("played")
.sigma()
< 0.5
);
assert_eq!(
ladder
.history
.rating("alice")
.expect("played")
.drift_scale(),
1.0
);
}
#[test]
fn no_rule_is_the_default_and_costs_nothing_to_spell() {
// The whole point of defaulting the parameter: `History<K>` still works.
let h: History<String> = History::builder().key_type::<String>().build();
assert_eq!(h.competitor_count(), 0);
}
+2 -2
View File
@@ -35,7 +35,7 @@ fn ev(a: &str, b: &str, time: i64) -> Event<i64, String> {
/// Ingest each chunk in turn, converging fully after every one. /// Ingest each chunk in turn, converging fully after every one.
fn fit_in_chunks(chunks: Vec<Events>) -> Vec<(String, Gaussian)> { fn fit_in_chunks(chunks: Vec<Events>) -> Vec<(String, Gaussian)> {
let mut h: History<i64, _, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.convergence(tight()) .convergence(tight())
.build(); .build();
@@ -152,7 +152,7 @@ fn re_converging_an_unchanged_history_costs_one_iteration() {
let (early, late) = fixture(); let (early, late) = fixture();
let all: Vec<_> = early.into_iter().chain(late).collect(); let all: Vec<_> = early.into_iter().chain(late).collect();
let mut h: History<i64, _, _, String> = History::builder() let mut h: History<String> = History::builder()
.key_type::<String>() .key_type::<String>()
.convergence(tight()) .convergence(tight())
.build(); .build();
+20 -12
View File
@@ -17,24 +17,32 @@ fn record_winner_builds_history() {
h.record_winner(&"alice", &"bob", 1).unwrap(); h.record_winner(&"alice", &"bob", 1).unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
let a_idx = h.lookup(&"alice").unwrap(); // `lookup` returned an `Index` that nothing public accepted, so the
let b_idx = h.lookup(&"bob").unwrap(); // observable claim is the one worth making: two distinct competitors, each
// with their own posterior, and the winner ahead.
assert_ne!(a_idx, b_idx); assert_eq!(h.competitor_count(), 2);
let alice = h.current_skill("alice").expect("alice played");
let bob = h.current_skill("bob").expect("bob played");
assert!(alice.mu() > bob.mu());
} }
/// The same key names the same competitor across events, which is what
/// interning bought and the only part of it a caller can observe.
#[test] #[test]
fn intern_is_idempotent() { fn a_repeated_key_is_one_competitor() {
let mut h: History = History::builder().build(); let mut h: History = History::builder().build();
let a1 = h.intern(&"alice"); h.record_winner(&"alice", &"bob", 1).unwrap();
let a2 = h.intern(&"alice"); h.record_winner(&"alice", &"carol", 2).unwrap();
assert_eq!(a1, a2);
assert_eq!(h.competitor_count(), 3);
assert_eq!(h.learning_curve("alice").expect("known").len(), 2);
} }
#[test] #[test]
fn lookup_returns_none_for_missing() { fn an_unknown_key_is_unknown() {
let h: History = History::builder().build(); let h: History = History::builder().build();
assert!(h.lookup(&"nobody").is_none()); assert!(h.current_skill("nobody").is_none());
assert!(h.learning_curve("nobody").is_none());
} }
#[test] #[test]
@@ -50,6 +58,6 @@ fn record_draw_with_p_draw_set() {
h.record_draw(&"alice", &"bob", 1).unwrap(); h.record_draw(&"alice", &"bob", 1).unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
assert!(h.lookup(&"alice").is_some()); assert!(h.current_skill("alice").is_some());
assert!(h.lookup(&"bob").is_some()); assert!(h.current_skill("bob").is_some());
} }
+17 -11
View File
@@ -11,7 +11,7 @@ use trueskill_tt::{
Team, Team,
}; };
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>; type H = History;
const PINNED: Gaussian = Gaussian::from_ms(2.0, 0.5); const PINNED: Gaussian = Gaussian::from_ms(2.0, 0.5);
@@ -94,8 +94,8 @@ fn registering_matches_configuring_on_the_first_event() {
}; };
for ((k, a), (_, b)) in skills(&configured).into_iter().zip(skills(&registered)) { for ((k, a), (_, b)) in skills(&configured).into_iter().zip(skills(&registered)) {
assert_eq!(a.pi(), b.pi(), "{k} pi"); assert_eq!(a.mu(), b.mu(), "{k} mu");
assert_eq!(a.tau(), b.tau(), "{k} tau"); assert_eq!(a.variance(), b.variance(), "{k} variance");
} }
} }
@@ -172,7 +172,13 @@ fn a_weight_on_a_registration_is_rejected() {
.register(Member::new("layout").with_weight(0.5)) .register(Member::new("layout").with_weight(0.5))
.unwrap_err(); .unwrap_err();
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Weight,
..
}
),
"{err:?}" "{err:?}"
); );
} }
@@ -188,7 +194,7 @@ fn an_invalid_drift_scale_on_a_registration_is_rejected() {
matches!( matches!(
err, err,
InferenceError::InvalidParameter { InferenceError::InvalidParameter {
name: "drift_scale", parameter: trueskill_tt::Parameter::DriftScale,
.. ..
} }
), ),
@@ -225,8 +231,8 @@ fn registration_makes_the_fit_order_independent() {
let forward = build(false); let forward = build(false);
let backward = build(true); let backward = build(true);
for ((k, a), (_, b)) in skills(&forward).into_iter().zip(skills(&backward)) { for ((k, a), (_, b)) in skills(&forward).into_iter().zip(skills(&backward)) {
assert_eq!(a.pi(), b.pi(), "{k} pi"); assert_eq!(a.mu(), b.mu(), "{k} mu");
assert_eq!(a.tau(), b.tau(), "{k} tau"); assert_eq!(a.variance(), b.variance(), "{k} variance");
} }
} }
@@ -246,8 +252,8 @@ fn rating_reads_back_what_was_stored() {
.unwrap(); .unwrap();
let r = h.rating(&"layout").unwrap(); let r = h.rating(&"layout").unwrap();
assert_eq!(r.drift_scale(), 0.25); assert_eq!(r.drift_scale(), 0.25);
assert_eq!(r.prior().pi(), PINNED.pi()); assert_eq!(r.prior().mu(), PINNED.mu());
assert_eq!(r.prior().tau(), PINNED.tau()); assert_eq!(r.prior().variance(), PINNED.variance());
// A competitor created by an event reports the history defaults. // A competitor created by an event reports the history defaults.
h.record_winner(&"player", &"layout", 1).unwrap(); h.record_winner(&"player", &"layout", 1).unwrap();
@@ -280,7 +286,7 @@ mod conflicting_configuration {
matches!( matches!(
err, err,
InferenceError::ConflictingCompetitorConfig { InferenceError::ConflictingCompetitorConfig {
field: "drift_scale", field: trueskill_tt::CompetitorField::DriftScale,
.. ..
} }
), ),
@@ -297,7 +303,7 @@ mod conflicting_configuration {
matches!( matches!(
err, err,
InferenceError::ConflictingCompetitorConfig { InferenceError::ConflictingCompetitorConfig {
field: "drift_scale", field: trueskill_tt::CompetitorField::DriftScale,
.. ..
} }
), ),
+178
View File
@@ -0,0 +1,178 @@
//! What a sparse factorisation of the joint would actually buy (#52).
//!
//! Run explicitly:
//!
//! ```text
//! cargo test --release --features approx,measure-sparsity \
//! --test sparsity_measurement -- --ignored --nocapture
//! ```
//!
//! The whole file is gated: it reaches for the joint's sparsity pattern, which
//! is exposed only under `measure-sparsity`.
#![cfg(feature = "measure-sparsity")]
use std::collections::HashSet;
use trueskill_tt::{ConvergenceOptions, History};
/// A history shaped like the issue's fixture: many slices, scored duels,
/// competitors reappearing across slices so the drift links are long.
fn fitted(slices: i64, duels: usize, competitors: usize) -> History<String> {
let mut h: History<String> = History::builder()
.key_type::<String>()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.gamma(0.05)
.convergence(ConvergenceOptions {
max_iter: trueskill_tt::ITERATIONS,
epsilon: 1e-8,
alpha: 1.0,
})
.build();
let mut k = 0usize;
for t in 0..slices {
for _ in 0..duels {
k += 1;
h.event(t)
.team([format!("p{}", k % competitors)])
.team([format!("p{}", (k + 37) % competitors)])
.scores([
(k as f64 * 0.3).sin().abs() * 20.0,
(k as f64 * 0.3).cos().abs() * 20.0,
])
.commit()
.expect("ingests");
}
}
h.converge().expect("converges");
h
}
/// Symbolic Cholesky by row-merge: returns (nnz(L), flops).
///
/// Fill-in is simulated directly — for each column, the set of rows below the
/// diagonal that are nonzero — which is exact and easily checked, at the cost
/// of being O(n * nnz(L)) rather than the linear elimination-tree method.
fn symbolic(n: usize, adj: &[HashSet<usize>], perm_of: &[usize]) -> (usize, f64) {
// `perm_of[old] = new`. Build the permuted lower-triangle pattern.
let mut cols: Vec<HashSet<usize>> = vec![HashSet::new(); n];
for (old, nbrs) in adj.iter().enumerate() {
let i = perm_of[old];
for &old_j in nbrs {
let j = perm_of[old_j];
if j < i {
cols[j].insert(i);
}
}
}
let mut nnz = 0usize;
let mut flops = 0.0f64;
for j in 0..n {
// Column j's pattern is final once every earlier column has merged in.
let rows: Vec<usize> = cols[j].iter().copied().collect();
let c = rows.len();
nnz += c + 1; // below-diagonal entries plus the diagonal
// Cholesky work for this column: one outer product over its pattern.
flops += (c as f64 + 1.0) * (c as f64 + 1.0);
// Fill-in: every pair in column j becomes an edge in the remaining graph.
for (a_idx, &a) in rows.iter().enumerate() {
for &b in &rows[a_idx + 1..] {
let (lo, hi) = if a < b { (a, b) } else { (b, a) };
cols[lo].insert(hi);
}
}
}
(nnz, flops)
}
#[test]
#[ignore = "measurement, run explicitly"]
fn what_sparsity_would_buy() {
for (slices, duels, competitors) in [(30, 8, 100), (76, 13, 200)] {
let h = fitted(slices, duels, competitors);
let (n, pattern) = h.joint_pattern_for_measurement();
let nnz_a: usize = pattern.iter().map(HashSet::len).sum::<usize>() + n;
let dense_flops = (n as f64).powi(3) / 3.0;
let natural: Vec<usize> = (0..n).collect();
let (nnz_nat, flops_nat) = symbolic(n, &pattern, &natural);
// AMD returns `perm[new] = old`; invert it.
let (col_ptr, row_idx) = csc(n, &pattern);
let p = feral_amd::amd_order(
&feral_amd::CscPattern::new(n, &col_ptr, &row_idx).expect("valid pattern"),
)
.expect("amd");
let mut perm_of = vec![0usize; n];
for (new, &old) in p.iter().enumerate() {
perm_of[old as usize] = new;
}
let (nnz_amd, flops_amd) = symbolic(n, &pattern, &perm_of);
println!(
"\n=== {slices} slices x {duels} duels, {competitors} competitors ===\n\
n = {n}\n\
nnz(A) = {nnz_a} ({:.4}% dense)\n\
dense flops = {:.3e}\n\
nnz(L) natural = {nnz_nat} flops = {:.3e} ({:.1}x vs dense)\n\
nnz(L) AMD = {nnz_amd} flops = {:.3e} ({:.1}x vs dense)",
100.0 * nnz_a as f64 / (n * n) as f64,
dense_flops,
flops_nat,
dense_flops / flops_nat,
flops_amd,
dense_flops / flops_amd,
);
}
}
/// Full symmetric pattern to CSC, as `feral-amd` wants it.
fn csc(n: usize, adj: &[HashSet<usize>]) -> (Vec<i32>, Vec<i32>) {
let mut col_ptr = Vec::with_capacity(n + 1);
let mut row_idx = Vec::new();
col_ptr.push(0i32);
for (j, nbrs) in adj.iter().enumerate() {
let mut rows: Vec<i32> = nbrs.iter().map(|&i| i as i32).collect();
rows.push(j as i32);
rows.sort_unstable();
rows.dedup();
row_idx.extend_from_slice(&rows);
col_ptr.push(row_idx.len() as i32);
}
(col_ptr, row_idx)
}
/// End-to-end factorisation time at the scale #52 was opened about.
#[test]
#[ignore = "measurement, run explicitly"]
fn factorisation_time_at_scale() {
use std::time::Instant;
for (slices, duels, competitors) in [(30, 8, 100), (76, 13, 200), (150, 26, 400)] {
let h = fitted(slices, duels, competitors);
let (n, _) = h.joint_pattern_for_measurement();
// Warm, then time.
let _ = h.joint().expect("scored history");
let t = Instant::now();
let joint = h.joint().expect("scored history");
let factor = t.elapsed();
let a = "p0".to_string();
let b = "p1".to_string();
let t = Instant::now();
let _ = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).expect("known");
let query = t.elapsed();
println!(
"n = {n:5} factorise = {factor:>12?} query = {query:>10?} \
(dense was O(n^3): {:.3e} flops)",
(n as f64).powi(3) / 3.0
);
}
}
+1 -1
View File
@@ -144,7 +144,7 @@ fn key_type_replaces_builder_with_key() {
/// Both axes at once, via the explicit constructor rather than the setters. /// Both axes at once, via the explicit constructor rather than the setters.
#[test] #[test]
fn new_constructs_on_any_axis_directly() { fn new_constructs_on_any_axis_directly() {
let mut h = HistoryBuilder::<Season, _, _, String>::new().build(); let mut h = HistoryBuilder::<String, Season>::new().build();
h.record_winner(&"a".to_string(), &"b".to_string(), Season(7)) h.record_winner(&"a".to_string(), &"b".to_string(), Season(7))
.unwrap(); .unwrap();
assert!(h.converge().unwrap().converged); assert!(h.converge().unwrap().converged);
+54 -13
View File
@@ -16,7 +16,7 @@ const BETA: f64 = 1.0;
const SCORE_SIGMA: f64 = 2.0; const SCORE_SIGMA: f64 = 2.0;
const GAMMA: f64 = 0.5; const GAMMA: f64 = 0.5;
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>; type H = History;
fn history(gamma: f64) -> H { fn history(gamma: f64) -> H {
History::builder() History::builder()
@@ -120,7 +120,11 @@ fn a_two_slice_joint_matches_the_exact_posterior() {
// The crate reads each competitor at their latest appearance: a1, b1. // The crate reads each competitor at their latest appearance: a1, b1.
let exact_gap = (cov[2][2] + cov[3][3] - 2.0 * cov[2][3]).sqrt(); let exact_gap = (cov[2][2] + cov[3][3] - 2.0 * cov[2][3]).sqrt();
let got = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap(); let got = h
.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
assert!( assert!(
(got.sigma() - exact_gap).abs() / exact_gap < 1e-9, (got.sigma() - exact_gap).abs() / exact_gap < 1e-9,
"difference: got {} exact {exact_gap}", "difference: got {} exact {exact_gap}",
@@ -128,7 +132,7 @@ fn a_two_slice_joint_matches_the_exact_posterior() {
); );
let exact_single = cov[2][2].sqrt(); let exact_single = cov[2][2].sqrt();
let got_single = h.posterior_of(&[(&"a", 1.0)]).unwrap(); let got_single = h.joint().unwrap().posterior_of(&[(&"a", 1.0)]).unwrap();
assert!( assert!(
(got_single.sigma() - exact_single).abs() / exact_single < 1e-9, (got_single.sigma() - exact_single).abs() / exact_single < 1e-9,
"single node: got {} exact {exact_single}", "single node: got {} exact {exact_single}",
@@ -154,6 +158,8 @@ fn competitors_last_seen_in_different_slices_are_comparable() {
// b last appeared at time 0; a and c at time 20. All three must resolve. // b last appeared at time 0; a and c at time 20. All three must resolve.
for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] { for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] {
let g = h let g = h
.joint()
.unwrap()
.posterior_of(&[(&x, 1.0), (&y, -1.0)]) .posterior_of(&[(&x, 1.0), (&y, -1.0)])
.unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}")); .unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}"));
assert!(g.sigma() > 0.0 && g.sigma().is_finite()); assert!(g.sigma() > 0.0 && g.sigma().is_finite());
@@ -175,7 +181,7 @@ fn means_agree_with_the_marginals() {
for k in ["a", "b", "c"] { for k in ["a", "b", "c"] {
let marginal = h.current_skill(&k).unwrap().mu(); let marginal = h.current_skill(&k).unwrap().mu();
let joint = h.posterior_of(&[(&k, 1.0)]).unwrap().mu(); let joint = h.joint().unwrap().posterior_of(&[(&k, 1.0)]).unwrap().mu();
assert!( assert!(
(marginal - joint).abs() < 1e-9, (marginal - joint).abs() < 1e-9,
"{k}: marginal {marginal}, joint {joint}" "{k}: marginal {marginal}, joint {joint}"
@@ -197,7 +203,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
]) ])
.unwrap(); .unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap() h.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap()
}; };
let together = { let together = {
let mut h = history(0.0); let mut h = history(0.0);
@@ -208,7 +217,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
]) ])
.unwrap(); .unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap() h.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap()
}; };
assert!( assert!(
@@ -234,7 +246,11 @@ fn drift_widens_a_comparison_across_time() {
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
// b was last seen at time 0; a at time 100. // b was last seen at time 0; a at time 100.
let g = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap(); let g = h
.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
assert!( assert!(
g.sigma() > previous, g.sigma() > previous,
"gamma={gamma}: sigma {} did not exceed {previous}", "gamma={gamma}: sigma {} did not exceed {previous}",
@@ -257,9 +273,21 @@ fn posterior_of_at_reads_as_of_a_time() {
.unwrap(); .unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
let early = h.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)]).unwrap(); let early = h
let late = h.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)]).unwrap(); .joint()
let latest = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap(); .unwrap()
.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
let late = h
.joint()
.unwrap()
.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
let latest = h
.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
// Asking as of the final slice is the same as asking for the latest. // Asking as of the final slice is the same as asking for the latest.
assert!((late.mu() - latest.mu()).abs() < 1e-9); assert!((late.mu() - latest.mu()).abs() < 1e-9);
@@ -275,7 +303,12 @@ fn posterior_of_at_reads_as_of_a_time() {
); );
// A time before any event has nothing to read. // A time before any event has nothing to read.
assert!(h.posterior_of_at(-1, &[(&"a", 1.0)]).is_err()); assert!(
h.joint()
.unwrap()
.posterior_of_at(-1, &[(&"a", 1.0)])
.is_err()
);
} }
/// Times between slices resolve to the latest appearance at or before them. /// Times between slices resolve to the latest appearance at or before them.
@@ -289,8 +322,16 @@ fn a_time_between_slices_reads_the_previous_appearance() {
.unwrap(); .unwrap();
let _ = h.converge().unwrap(); let _ = h.converge().unwrap();
let at_zero = h.posterior_of_at(0, &[(&"a", 1.0)]).unwrap(); let at_zero = h
let between = h.posterior_of_at(50, &[(&"a", 1.0)]).unwrap(); .joint()
.unwrap()
.posterior_of_at(0, &[(&"a", 1.0)])
.unwrap();
let between = h
.joint()
.unwrap()
.posterior_of_at(50, &[(&"a", 1.0)])
.unwrap();
assert!((at_zero.mu() - between.mu()).abs() < 1e-12); assert!((at_zero.mu() - between.mu()).abs() < 1e-12);
assert!((at_zero.sigma() - between.sigma()).abs() < 1e-12); assert!((at_zero.sigma() - between.sigma()).abs() < 1e-12);
} }
+1 -1
View File
@@ -42,7 +42,7 @@ fn a_struct_holding_a_history_can_derive_debug() {
#[test] #[test]
fn history_builder_is_debug_and_clone() { fn history_builder_is_debug_and_clone() {
let b: HistoryBuilder<i64, ConstantDrift, _, &'static str> = History::builder(); let b: HistoryBuilder = History::builder();
let cloned = b.clone(); let cloned = b.clone();
assert!(!format!("{cloned:?}").is_empty()); assert!(!format!("{cloned:?}").is_empty());
} }
+34 -9
View File
@@ -49,7 +49,13 @@ fn ranked_rejects_a_zero_damping_factor() {
) )
.expect_err("alpha = 0 must be rejected"); .expect_err("alpha = 0 must be rejected");
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"got {err:?}" "got {err:?}"
); );
} }
@@ -65,7 +71,13 @@ fn ranked_rejects_an_out_of_range_damping_factor() {
) )
.expect_err("alpha out of (0, 1] must be rejected"); .expect_err("alpha out of (0, 1] must be rejected");
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"alpha={alpha}: got {err:?}" "alpha={alpha}: got {err:?}"
); );
} }
@@ -81,7 +93,13 @@ fn scored_rejects_a_bad_damping_factor() {
) )
.expect_err("alpha = 0 must be rejected"); .expect_err("alpha = 0 must be rejected");
assert!( assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }), matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"got {err:?}" "got {err:?}"
); );
} }
@@ -139,7 +157,7 @@ fn ingestion_rejects_a_tie_without_a_draw_probability() {
); );
} }
/// `Outcome::scores_with_sigma` documents that a non-positive sigma is /// `Outcome::scores_with_noise` documents that a non-positive sigma is
/// accepted at construction and rejected at ingestion. /// accepted at construction and rejected at ingestion.
#[test] #[test]
fn ingestion_rejects_a_non_positive_per_event_score_sigma() { fn ingestion_rejects_a_non_positive_per_event_score_sigma() {
@@ -152,7 +170,7 @@ fn ingestion_rejects_a_non_positive_per_event_score_sigma() {
Team::with_members([Member::new("a")]), Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]), Team::with_members([Member::new("b")]),
], ],
outcome: Outcome::scores_with_sigma([21.0, 9.0], sigma), outcome: Outcome::scores_with_noise([21.0, 9.0], sigma),
}]) }])
.expect_err("a non-positive per-event sigma must be rejected"); .expect_err("a non-positive per-event sigma must be rejected");
assert!( assert!(
@@ -248,9 +266,13 @@ mod builder_parameters {
}; };
let zero = fit(0.0); let zero = fit(0.0);
let positive = fit(25.0 / 6.0); let positive = fit(25.0 / 6.0);
assert!(zero.pi().is_finite() && zero.pi() > 0.0); // `variance` rather than `pi`: the natural parameters are the crate's
// internal representation and no longer public. It is the same
// quantity inverted, so a finite positive precision is a finite
// positive variance.
assert!(zero.variance().is_finite() && zero.variance() > 0.0);
assert!( assert!(
(zero.pi() - positive.pi()).abs() > 1e-6, (zero.variance() - positive.variance()).abs() > 1e-6,
"zero beta must not merely be ignored: {zero:?} vs {positive:?}" "zero beta must not merely be ignored: {zero:?} vs {positive:?}"
); );
} }
@@ -280,7 +302,10 @@ mod constructor_parameters {
#[test] #[test]
fn a_nan_sigma_passes_through_from_ms() { fn a_nan_sigma_passes_through_from_ms() {
let g = Gaussian::from_ms(25.0, f64::NAN); let g = Gaussian::from_ms(25.0, f64::NAN);
assert!(g.sigma().is_nan() || g.pi().is_nan()); // `sigma()` is NaN exactly when the precision is: it guards `pi <= 0`
// (reporting `inf`) and `pi == inf` (reporting `0.0`), so NaN survives
// only from a NaN precision.
assert!(g.sigma().is_nan());
} }
#[test] #[test]
@@ -353,7 +378,7 @@ mod constructor_parameters {
matches!( matches!(
err, err,
InferenceError::InvalidParameter { InferenceError::InvalidParameter {
name: "drift variance", parameter: trueskill_tt::Parameter::DriftVariance,
.. ..
} }
), ),
+48 -8
View File
@@ -6,7 +6,7 @@ use trueskill_tt::{
UnknownKeys, UnknownKeys,
}; };
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>; type H = History;
fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> { fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> {
Event { Event {
@@ -30,7 +30,7 @@ fn base() -> Vec<Event<i64, &'static str>> {
} }
fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H { fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
let mut h: History<i64, _, _, &'static str> = History::builder() let mut h: History = History::builder()
.mu(0.0) .mu(0.0)
.sigma(6.0) .sigma(6.0)
.beta(1.0) .beta(1.0)
@@ -60,15 +60,30 @@ fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
fn the_closed_form_matches_an_actual_refit() { fn the_closed_form_matches_an_actual_refit() {
let h = fit(None, UnknownKeys::Reject); let h = fit(None, UnknownKeys::Reject);
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let before = h.posterior_of(&target).unwrap().sigma().powi(2); let before = h
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2);
for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] { for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] {
let predicted = h let predicted = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&x], &[&y]], &target) .expected_variance_reduction(&[&[&x], &[&y]], &target)
.unwrap(); .unwrap();
let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject); let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject);
let actual = before - after.posterior_of(&target).unwrap().sigma().powi(2); let actual = before
- after
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2);
assert!( assert!(
(predicted - actual).abs() / actual.abs() < 1e-9, (predicted - actual).abs() / actual.abs() < 1e-9,
@@ -84,12 +99,27 @@ fn the_closed_form_matches_an_actual_refit() {
fn the_outcome_does_not_change_the_reduction() { fn the_outcome_does_not_change_the_reduction() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let h = fit(None, UnknownKeys::Reject); let h = fit(None, UnknownKeys::Reject);
let before = h.posterior_of(&target).unwrap().sigma().powi(2); let before = h
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2);
let mut seen = Vec::new(); let mut seen = Vec::new();
for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] { for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] {
let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject); let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject);
seen.push(before - after.posterior_of(&target).unwrap().sigma().powi(2)); seen.push(
before
- after
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2),
);
} }
for w in seen.windows(2) { for w in seen.windows(2) {
assert!( assert!(
@@ -107,9 +137,13 @@ fn it_ranks_candidates_by_how_much_they_answer_the_question() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let direct = h let direct = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"], &[&"b"]], &target) .expected_variance_reduction(&[&[&"a"], &[&"b"]], &target)
.unwrap(); .unwrap();
let unrelated = h let unrelated = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&"c"], &[&"d"]], &target) .expected_variance_reduction(&[&[&"c"], &[&"d"]], &target)
.unwrap(); .unwrap();
@@ -128,6 +162,8 @@ fn an_unrelated_unseen_matchup_teaches_nothing_about_the_target() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let reduction = h let reduction = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target) .expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target)
.unwrap(); .unwrap();
assert!( assert!(
@@ -142,7 +178,9 @@ fn shape_errors_are_reported() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)]; let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
assert!(matches!( assert!(matches!(
h.expected_variance_reduction(&[&[&"a"]], &target), h.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"]], &target),
Err(InferenceError::MismatchedShape { Err(InferenceError::MismatchedShape {
expected: 2, expected: 2,
got: 1, got: 1,
@@ -150,7 +188,9 @@ fn shape_errors_are_reported() {
}) })
)); ));
assert!(matches!( assert!(matches!(
h.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target), h.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
Err(InferenceError::UnknownKey { .. }) Err(InferenceError::UnknownKey { .. })
)); ));
} }