Compare commits
25
Commits
da55d2a7d1
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cba3d10f6 | ||
|
|
5d9501307e | ||
|
|
327324c411 | ||
|
|
61da3aca33 | ||
|
|
061c481aad | ||
|
|
0b9997354d | ||
|
|
c4194b0051 | ||
|
|
1629176199 | ||
|
|
695bb822ef | ||
|
|
d36d125e52 | ||
|
|
0801acebd1 | ||
|
|
1ad789cf40 | ||
|
|
b553c630f5 | ||
|
|
d2ab4446ef | ||
|
|
e72bf3894c | ||
|
|
56193609f7 | ||
|
|
13a395fdc9 | ||
|
|
6e2ce69728 | ||
|
|
faa25fb3b1 | ||
|
|
ddbac87744 | ||
|
|
076a7ded8c | ||
|
|
8e34410db0 | ||
|
|
92d690d0f8 | ||
|
|
4c2e98c54b | ||
|
|
92ae5fca17 |
@@ -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
@@ -2,6 +2,65 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 0.9.0 - 2026-09-10
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- fix!: propagate NaN through the convergence reduction
|
||||
- fix!: collapse a drift too small to represent, on a relative threshold
|
||||
- fix!: report an unresolvable prediction grid instead of clamping
|
||||
- fix!: validate the constructors below HistoryBuilder
|
||||
- fix!: seal ConstantDrift's field so gamma can be validated
|
||||
- fix!: make the Time generic reachable
|
||||
- refactor!: un-export six types that no caller could reach
|
||||
- fix!: correct eight wrong `# Errors` sections and seal the error variants
|
||||
- fix!: per-key queries report unknown keys instead of a plausible constant
|
||||
- fix!: no prediction path answers from a fit it cannot answer from
|
||||
- docs!: one name for score noise, and say which of beta/sigma to turn
|
||||
- fix!: non_exhaustive on ConvergenceReport, and not on the options structs
|
||||
- feat!: prediction and joint queries take borrowed keys
|
||||
- feat!: Game is the type you get, and one_v_one returns one
|
||||
- feat!: Gaussian's EP operations stop wearing arithmetic's clothes
|
||||
- refactor!: retire Index, intern and lookup
|
||||
- refactor!: scores_with_noise, and History::quality
|
||||
- refactor!: the joint is reached through Joint, not mirrored on History
|
||||
- refactor!: K comes first in History, HistoryBuilder and Joint
|
||||
- perf!: sparse Cholesky with an AMD ordering for the joint
|
||||
- refactor!: typed discriminators for InferenceError
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: take quality's determinant ratio in log space
|
||||
- fix: keep the truncated variance representable in the far tail
|
||||
- fix: route the last three transcendentals through libm, and enforce it
|
||||
- fix: make posterior_of reproducible across processes
|
||||
- fix: warn on dropped builders and values; stop exporting EP internals
|
||||
|
||||
### CI
|
||||
|
||||
- ci: measure the runner's own benchmark variance, and fix the joint bench
|
||||
|
||||
### Documentation
|
||||
|
||||
- docs: document the whole public surface and deny(missing_docs)
|
||||
- docs: add a migration guide for 0.9.0, and drop merge noise from the changelog
|
||||
|
||||
### Features
|
||||
|
||||
- feat: add the missing trait impls and make `#[must_use]` consistent
|
||||
- feat: complete the evidence matrix and add current_skills
|
||||
- feat: PartialEq on the config types, and pin the public trait impls
|
||||
- feat: HistoryBuilder::default_rating_for, a rule instead of a roll call
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: one word per concept
|
||||
|
||||
### Testing
|
||||
|
||||
- test: scale the ceiling sweep by build profile
|
||||
- test: make the determinism test exercise the parallel sweep
|
||||
|
||||
## 0.8.0 - 2026-09-08
|
||||
|
||||
### Breaking Changes
|
||||
@@ -25,13 +84,9 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
- feat: add EventBuilder::members for per-member configuration
|
||||
|
||||
### Other (unconventional)
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Merge branch 'fix/ingestion-shape'
|
||||
- Merge branch 'feat/convergence-strictness'
|
||||
- Merge branch 'fix/non-finite-weights'
|
||||
- Merge branch 'test/close-coverage-gaps'
|
||||
- Merge branch 'fix/game-boundary'
|
||||
- chore: Release trueskill-tt version 0.8.0
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -47,10 +102,6 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
- chore: Release trueskill-tt version 0.7.0
|
||||
|
||||
### Other (unconventional)
|
||||
|
||||
- Merge branch 'feat/joint-handle'
|
||||
|
||||
## 0.6.0 - 2026-09-08
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "trueskill-tt"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
|
||||
@@ -33,10 +33,6 @@ bench = false
|
||||
name = "batch"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "gaussian"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "history_converge"
|
||||
harness = false
|
||||
@@ -51,12 +47,15 @@ harness = false
|
||||
|
||||
[dependencies]
|
||||
approx = { version = "0.5.1", optional = true }
|
||||
feral-amd = "0.2"
|
||||
libm = "0.2.16"
|
||||
rayon = { version = "1", optional = true }
|
||||
smallvec = "1"
|
||||
|
||||
[features]
|
||||
approx = ["dep:approx"]
|
||||
# Exposes the joint sparsity pattern for the #52 measurement. Test-only.
|
||||
measure-sparsity = []
|
||||
rayon = ["dep:rayon"]
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+204
@@ -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.
|
||||
@@ -121,7 +121,7 @@ for everything that accumulates.
|
||||
## `converge` is strict
|
||||
|
||||
`converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the
|
||||
step still above `epsilon`, and `Err(NonFiniteResult)` if a sweep produces NaN.
|
||||
step still above `epsilon`, and `Err(NonFiniteStep)` if a sweep produces NaN.
|
||||
|
||||
It used to return `Ok` with `converged: false`, which was the worst available
|
||||
shape. A fit that stops short is *wrong by a little*: every posterior is finite,
|
||||
@@ -330,7 +330,7 @@ stay available at any size:
|
||||
|
||||
Unknown keys are an error by default, not a silent omission: a team the history
|
||||
has never seen cannot produce a confident-looking probability. The error names
|
||||
the key, and every key must already be known — pre-filter with `lookup` or
|
||||
the key, and every key must already be known — pre-filter with
|
||||
`current_skill` if your caller cannot guarantee that.
|
||||
|
||||
If predicting for competitors you have never seen is the point rather than a
|
||||
@@ -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
|
||||
lives in the issue tracker instead. The crate is in use and the API is still
|
||||
moving — breaking changes are batched into minor releases rather than dribbled
|
||||
out, and `CHANGELOG.md` records them.
|
||||
out. `CHANGELOG.md` lists them and [`MIGRATING.md`](MIGRATING.md) explains what
|
||||
to do about them.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -25,16 +25,14 @@
|
||||
|
||||
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
|
||||
};
|
||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||
|
||||
fn build_history_1v1(
|
||||
n_events: usize,
|
||||
n_competitors: usize,
|
||||
events_per_slice: usize,
|
||||
seed: u64,
|
||||
) -> History<i64, ConstantDrift, NullObserver, String> {
|
||||
) -> History<String> {
|
||||
let mut rng = seed;
|
||||
let mut next = || {
|
||||
rng = rng
|
||||
|
||||
+2
-4
@@ -32,8 +32,7 @@ fn bench_ingest(c: &mut Criterion) {
|
||||
b.iter_batched(
|
||||
|| events(n, 0),
|
||||
|evs| {
|
||||
let mut h: History<i64, _, _, String> =
|
||||
History::builder().key_type::<String>().build();
|
||||
let mut h: History<String> = History::builder().key_type::<String>().build();
|
||||
for ev in evs {
|
||||
h.add_events(std::iter::once(ev)).unwrap();
|
||||
}
|
||||
@@ -47,8 +46,7 @@ fn bench_ingest(c: &mut Criterion) {
|
||||
b.iter_batched(
|
||||
|| events(n, 0),
|
||||
|evs| {
|
||||
let mut h: History<i64, _, _, String> =
|
||||
History::builder().key_type::<String>().build();
|
||||
let mut h: History<String> = History::builder().key_type::<String>().build();
|
||||
h.add_events(evs).unwrap();
|
||||
black_box(h.time_slices_len())
|
||||
},
|
||||
|
||||
+13
-4
@@ -10,16 +10,22 @@ use smallvec::smallvec;
|
||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||
|
||||
/// 30 slices of 8 duels: 480 appearances over 100 competitors.
|
||||
fn fitted() -> History<i64, ConstantDrift, trueskill_tt::NullObserver, String> {
|
||||
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
|
||||
fn fitted() -> History<String> {
|
||||
let mut h: History<String> = History::builder()
|
||||
.key_type::<String>()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift::new(0.05))
|
||||
// `max_iter: 30` was here, and this fixture needs more: `converge`
|
||||
// reported `NotConverged { iterations: 30, final_step: (4.5e-4, 0.0) }`
|
||||
// once it stopped returning short fits silently. The benchmark measures
|
||||
// the factorisation, whose cost depends on the fit's *shape* rather
|
||||
// than its exactness — but measuring it on an unconverged fit is still
|
||||
// measuring something nobody would run.
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
max_iter: trueskill_tt::ITERATIONS,
|
||||
epsilon: 1e-10,
|
||||
alpha: 1.0,
|
||||
})
|
||||
@@ -58,8 +64,11 @@ fn bench_joint(c: &mut Criterion) {
|
||||
bencher.iter(|| std::hint::black_box(h.joint().unwrap().variables()));
|
||||
});
|
||||
|
||||
// Factorise-and-query, the cost the deleted `History::posterior_of`
|
||||
// wrapper paid on every call. Kept as the baseline the cached query below
|
||||
// is measured against.
|
||||
c.bench_function("posterior_of_one_shot_480_appearances", |bencher| {
|
||||
bencher.iter(|| std::hint::black_box(h.posterior_of(&terms).unwrap()));
|
||||
bencher.iter(|| std::hint::black_box(h.joint().unwrap().posterior_of(&terms).unwrap()));
|
||||
});
|
||||
|
||||
let joint = h.joint().unwrap();
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
||||
fn bench_scored_history(c: &mut Criterion) {
|
||||
c.bench_function("scored_history_60_events_30_iter", |bencher| {
|
||||
bencher.iter(|| {
|
||||
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
|
||||
let mut h: History<String> = History::builder()
|
||||
.key_type::<String>()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
|
||||
@@ -58,6 +58,11 @@ commit_parsers = [
|
||||
{ message = "^test", group = "Testing" },
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||
{ message = "^chore", group = "Miscellaneous Tasks" },
|
||||
{ message = "^ci", group = "CI" },
|
||||
# Every branch lands with `--no-ff`, so a release's merge commits outnumber
|
||||
# its real ones and say nothing the merged commits do not. They were
|
||||
# filling an "Other (unconventional)" section with 14 lines of noise.
|
||||
{ message = "^Merge ", skip = true },
|
||||
{ body = ".*security", group = "Security" },
|
||||
{ body = ".*", group = "Other (unconventional)" },
|
||||
]
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
let mut hist: History<i64, _, _, String> = History::builder()
|
||||
let mut hist: History<String> = History::builder()
|
||||
.key_type::<String>()
|
||||
.sigma(1.6)
|
||||
.drift(ConstantDrift::new(0.036))
|
||||
|
||||
+8
-4
@@ -123,7 +123,7 @@ fn u_minus_ln1p(u: f64) -> f64 {
|
||||
/// - `EmptyTeam` if any team has no members.
|
||||
/// - `TooManyTeams` if the outcome space is too large to enumerate; see
|
||||
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
|
||||
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
|
||||
/// - `InvalidParameter` for a `p_draw` outside `[0.0, 1.0)`.
|
||||
/// - `GridTooCoarse` when the performance sigmas are too far apart to
|
||||
/// integrate on one grid. This comes from `outcome_distribution`, which runs
|
||||
/// before any inference — so it is not covered by "anything `Game::ranked`
|
||||
@@ -144,7 +144,8 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
|
||||
});
|
||||
}
|
||||
if !(0.0..1.0).contains(&options.p_draw) {
|
||||
return Err(InferenceError::InvalidProbability {
|
||||
return Err(InferenceError::InvalidParameter {
|
||||
parameter: crate::Parameter::PDraw,
|
||||
value: options.p_draw,
|
||||
});
|
||||
}
|
||||
@@ -159,7 +160,7 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
|
||||
.iter()
|
||||
.map(|team| {
|
||||
team.iter()
|
||||
.fold(crate::N00, |acc, rating| acc + rating.performance())
|
||||
.fold(crate::N00, |acc, rating| acc.convolve(rating.performance()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -367,7 +368,10 @@ mod tests {
|
||||
));
|
||||
assert!(matches!(
|
||||
expected_information_gain(&[&a, &a], &options(1.5)),
|
||||
Err(InferenceError::InvalidProbability { .. })
|
||||
Err(InferenceError::InvalidParameter {
|
||||
parameter: crate::Parameter::PDraw,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -66,13 +66,13 @@ impl ConvergenceOptions {
|
||||
pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> {
|
||||
if !(self.alpha > 0.0 && self.alpha <= 1.0) {
|
||||
return Err(crate::InferenceError::InvalidParameter {
|
||||
name: "alpha",
|
||||
parameter: crate::Parameter::Alpha,
|
||||
value: self.alpha,
|
||||
});
|
||||
}
|
||||
if self.epsilon.is_nan() || self.epsilon < 0.0 {
|
||||
return Err(crate::InferenceError::InvalidParameter {
|
||||
name: "epsilon",
|
||||
parameter: crate::Parameter::Epsilon,
|
||||
value: self.epsilon,
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
|
||||
/// `variance_for_elapsed` would have been worse: it runs inside the sweep, so a
|
||||
/// construction-time mistake would panic mid-inference — and `Gaussian::from_ms`
|
||||
/// is a worked example of why that is the wrong place for a guard, where
|
||||
/// rejecting NaN turned the `NonFiniteResult` reporting path into a crash.
|
||||
/// rejecting NaN turned the `NonFiniteStep` reporting path into a crash.
|
||||
///
|
||||
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
|
||||
/// back with [`ConstantDrift::gamma`].
|
||||
|
||||
+367
-60
@@ -39,6 +39,182 @@ pub enum UnknownKeys {
|
||||
Prior,
|
||||
}
|
||||
|
||||
/// Which scalar an [`InferenceError::InvalidParameter`] is about.
|
||||
///
|
||||
/// A typed discriminator rather than a `&'static str`, so a caller can branch
|
||||
/// on it and `Display` can state each parameter's actual valid range. Nine
|
||||
/// distinct strings used to flow through this position, and the only thing a
|
||||
/// caller could do with one was print it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum Parameter {
|
||||
/// Prior mean skill. Must be finite.
|
||||
Mu,
|
||||
/// Prior standard deviation. Must be finite and strictly positive.
|
||||
Sigma,
|
||||
/// Performance noise. Must be finite and non-negative.
|
||||
Beta,
|
||||
/// Draw probability. Must be in `[0.0, 1.0)`.
|
||||
PDraw,
|
||||
/// Observation noise on a score margin. Must be finite and strictly
|
||||
/// positive.
|
||||
ScoreSigma,
|
||||
/// EP damping factor. Must be in `(0.0, 1.0]`.
|
||||
Alpha,
|
||||
/// Convergence threshold. Must be non-negative and not NaN.
|
||||
Epsilon,
|
||||
/// A competitor's multiplier on the drift variance. Must be finite and
|
||||
/// non-negative.
|
||||
DriftScale,
|
||||
/// The variance a [`Drift`](crate::Drift) implementation actually produced
|
||||
/// for a span. Must be finite and non-negative — checked because a custom
|
||||
/// implementation is the one thing no constructor can validate up front.
|
||||
DriftVariance,
|
||||
/// A per-member weight on an event. Must be finite.
|
||||
Weight,
|
||||
/// A team's score on a scored event. Must be finite.
|
||||
Score,
|
||||
/// A team's rank on a ranked event. Must be finite.
|
||||
Rank,
|
||||
/// The winning team's index, as given to `Outcome::winner`. Must be less
|
||||
/// than the team count.
|
||||
WinnerIndex,
|
||||
}
|
||||
|
||||
impl Parameter {
|
||||
/// The range this parameter must lie in, for the `Display` message.
|
||||
fn range(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mu => "must be finite",
|
||||
Self::Sigma => "must be finite and strictly positive",
|
||||
Self::Beta => "must be finite and non-negative",
|
||||
Self::PDraw => "must be in [0.0, 1.0)",
|
||||
Self::ScoreSigma => "must be finite and strictly positive",
|
||||
Self::Alpha => "must be in (0.0, 1.0]",
|
||||
Self::Epsilon => "must be non-negative and not NaN",
|
||||
Self::DriftScale => "must be finite and non-negative",
|
||||
Self::DriftVariance => "must be finite and non-negative",
|
||||
Self::Weight => "must be finite",
|
||||
Self::Score => "must be finite",
|
||||
Self::Rank => "must be finite",
|
||||
Self::WinnerIndex => "must be less than the number of teams",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Parameter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let name = match self {
|
||||
Self::Mu => "mu",
|
||||
Self::Sigma => "sigma",
|
||||
Self::Beta => "beta",
|
||||
Self::PDraw => "p_draw",
|
||||
Self::ScoreSigma => "score_sigma",
|
||||
Self::Alpha => "alpha",
|
||||
Self::Epsilon => "epsilon",
|
||||
Self::DriftScale => "drift_scale",
|
||||
Self::DriftVariance => "drift variance",
|
||||
Self::Weight => "weight",
|
||||
Self::Score => "score",
|
||||
Self::Rank => "rank",
|
||||
Self::WinnerIndex => "winner index",
|
||||
};
|
||||
f.write_str(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which two lengths an [`InferenceError::MismatchedShape`] found disagreeing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum Shape {
|
||||
/// The outcome describes a different number of teams than the event has.
|
||||
OutcomeVsTeams,
|
||||
/// A per-member weight list does not match the team's membership.
|
||||
Weights,
|
||||
/// A call that takes a fixed number of teams got a different number.
|
||||
Teams,
|
||||
/// One of `add_events_with_prior`'s parallel arrays disagreed with the
|
||||
/// others.
|
||||
///
|
||||
/// Not reachable through the public API — the arrays are built together at
|
||||
/// the ingestion chokepoint. Kept as a checked error rather than a
|
||||
/// `debug_assert!` so it also holds in release, which is where this
|
||||
/// crate's defects have tended to hide.
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Shape {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let what = match self {
|
||||
Self::OutcomeVsTeams => {
|
||||
"the outcome describes a different number of teams than the event has"
|
||||
}
|
||||
Self::Weights => "the weight list does not match the team's membership",
|
||||
Self::Teams => "this call takes a fixed number of teams",
|
||||
Self::Internal => {
|
||||
"an internal array disagreed with its siblings (this is a bug in trueskill-tt)"
|
||||
}
|
||||
};
|
||||
f.write_str(what)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which [`Outcome`](crate::Outcome) variant a call found or wanted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum OutcomeKind {
|
||||
/// [`Outcome::Ranked`](crate::Outcome::Ranked): an ordinal finish.
|
||||
Ranked,
|
||||
/// [`Outcome::Scored`](crate::Outcome::Scored): continuous scores.
|
||||
Scored,
|
||||
}
|
||||
|
||||
impl OutcomeKind {
|
||||
/// The call that takes this kind, for the `Display` message.
|
||||
fn constructor(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ranked => "Game::ranked",
|
||||
Self::Scored => "Game::scored",
|
||||
}
|
||||
}
|
||||
|
||||
/// The adjective form, for prose.
|
||||
fn adjective(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ranked => "ranked",
|
||||
Self::Scored => "scored",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OutcomeKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Ranked => "Outcome::Ranked",
|
||||
Self::Scored => "Outcome::Scored",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Which piece of per-competitor configuration was declared twice.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum CompetitorField {
|
||||
/// The starting skill distribution.
|
||||
Prior,
|
||||
/// The multiplier on the drift variance.
|
||||
DriftScale,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CompetitorField {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Prior => "prior",
|
||||
Self::DriftScale => "drift_scale",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Every way ingestion, inference or prediction can refuse to answer.
|
||||
///
|
||||
/// The crate reports rather than repairs. An input it cannot represent, a fit
|
||||
@@ -56,9 +232,8 @@ pub enum InferenceError {
|
||||
/// Expected and actual lengths of some array-shaped input differ.
|
||||
#[non_exhaustive]
|
||||
MismatchedShape {
|
||||
/// Which input disagreed, as a short label — `"ranks vs teams"`,
|
||||
/// `"weights"`, `"times"`.
|
||||
kind: &'static str,
|
||||
/// Which pair of lengths disagreed.
|
||||
shape: Shape,
|
||||
/// The length it had to have, taken from whatever it must line up with
|
||||
/// (usually the event's team count).
|
||||
expected: usize,
|
||||
@@ -68,28 +243,18 @@ pub enum InferenceError {
|
||||
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
||||
#[non_exhaustive]
|
||||
WrongOutcomeKind {
|
||||
/// The call that rejected the outcome, e.g. `"Game::ranked"`.
|
||||
context: &'static str,
|
||||
/// The [`Outcome`](crate::Outcome) variant that call needs, by name.
|
||||
expected: &'static str,
|
||||
/// The variant actually supplied, by name.
|
||||
got: &'static str,
|
||||
},
|
||||
/// A probability value is outside `[0, 1]`.
|
||||
#[non_exhaustive]
|
||||
InvalidProbability {
|
||||
/// The value supplied, as it fell outside `[0, 1]`. Today only
|
||||
/// `p_draw` reaches here.
|
||||
value: f64,
|
||||
/// The variant the call needs.
|
||||
expected: OutcomeKind,
|
||||
/// The variant actually supplied.
|
||||
got: OutcomeKind,
|
||||
},
|
||||
/// A scalar parameter is outside its valid range.
|
||||
#[non_exhaustive]
|
||||
InvalidParameter {
|
||||
/// The parameter, spelled as the API spells it — `"alpha"`,
|
||||
/// `"epsilon"`, `"score_sigma"`, `"drift_scale"`, `"drift variance"`.
|
||||
name: &'static str,
|
||||
/// The value supplied for it. Out of that parameter's range, or NaN,
|
||||
/// which fails every range comparison and is rejected on that basis.
|
||||
/// Which parameter. `Display` states its valid range.
|
||||
parameter: Parameter,
|
||||
/// The value supplied for it: outside that range, or NaN, which fails
|
||||
/// every range comparison and is rejected on that basis.
|
||||
value: f64,
|
||||
},
|
||||
/// An event contains tied teams, but the draw probability is zero.
|
||||
@@ -130,20 +295,44 @@ pub enum InferenceError {
|
||||
/// The threshold both components of `final_step` had to reach.
|
||||
epsilon: f64,
|
||||
},
|
||||
/// Inference produced a non-finite value (NaN or infinity).
|
||||
/// A convergence sweep produced a non-finite step.
|
||||
///
|
||||
/// Indicates numerical breakdown; the resulting skills are meaningless
|
||||
/// and must not be treated as a converged estimate.
|
||||
/// EP has broken down; the resulting skills are meaningless and must not
|
||||
/// be treated as a converged estimate. Further iterations cannot recover,
|
||||
/// so the loop stops rather than reporting a NaN step as convergence.
|
||||
#[non_exhaustive]
|
||||
NonFiniteResult {
|
||||
/// Where the breakdown was caught — `"History::converge"` for a sweep,
|
||||
/// or a phrase naming the prediction that read an unusable skill.
|
||||
NonFiniteStep {
|
||||
/// Where the breakdown was caught, e.g. `"History::converge"`.
|
||||
context: &'static str,
|
||||
/// The offending pair, at least one component of which is NaN or
|
||||
/// infinite. From `converge` it is the sweep's step; from a prediction
|
||||
/// it is the skill's own `(mu, sigma)`.
|
||||
/// The offending step as `(|d mu|, |d sigma|)`, at least one component
|
||||
/// of which is NaN or infinite.
|
||||
step: (f64, f64),
|
||||
},
|
||||
/// A prediction read a skill with no usable mean or variance.
|
||||
///
|
||||
/// Split from `NonFiniteStep` (#74), which used to carry both under one
|
||||
/// `step: (f64, f64)` field — a sweep step from `converge` and a skill's
|
||||
/// own moments from a prediction. One field name cannot be right for both.
|
||||
///
|
||||
/// Reaching this means a previous `converge` failed and its error was
|
||||
/// ignored: predicting from a NaN fit produced `Ok(NaN)` on some paths and
|
||||
/// a plausible-looking `Ok([0.0, 0.0])` on others.
|
||||
#[non_exhaustive]
|
||||
NonFiniteSkill {
|
||||
/// The skill's mean, which may itself be finite while `sigma` is not.
|
||||
mu: f64,
|
||||
/// The skill's standard deviation.
|
||||
sigma: f64,
|
||||
},
|
||||
/// Every skill in the matchup is a point mass and `beta` is zero, so
|
||||
/// there is no performance distribution to predict from.
|
||||
///
|
||||
/// Not `InvalidParameter`: both values are individually in range, and it
|
||||
/// is their combination that leaves nothing varying. Every prediction is a
|
||||
/// statement about how performances vary, and in this configuration
|
||||
/// nothing does — `quality` would divide by a singular contrast covariance
|
||||
/// and `predict_win_probabilities` would report zeros that sum to zero.
|
||||
NoPerformanceVariance,
|
||||
/// One batch declared two different values for the same competitor's
|
||||
/// configuration.
|
||||
///
|
||||
@@ -155,13 +344,12 @@ pub enum InferenceError {
|
||||
/// when a competitor's configuration is a property of the domain.
|
||||
#[non_exhaustive]
|
||||
ConflictingCompetitorConfig {
|
||||
/// The competitor's interned [`Index`](crate::Index) as a raw `usize`,
|
||||
/// The competitor's interned slot as a raw `usize`,
|
||||
/// not the user key — the batch is already flattened to indices by the
|
||||
/// time the conflict is detectable.
|
||||
competitor: usize,
|
||||
/// Which piece of configuration was declared twice: `"prior"` or
|
||||
/// `"drift_scale"`.
|
||||
field: &'static str,
|
||||
/// Which piece of configuration was declared twice.
|
||||
field: CompetitorField,
|
||||
},
|
||||
/// A prediction referenced a key the history has no skill for.
|
||||
///
|
||||
@@ -231,14 +419,32 @@ pub enum InferenceError {
|
||||
/// Nodes the grid may hold.
|
||||
max: usize,
|
||||
},
|
||||
/// A joint posterior was requested where one cannot be formed exactly.
|
||||
#[non_exhaustive]
|
||||
JointUnavailable {
|
||||
/// Why no exact joint exists here: the history has no events, it holds
|
||||
/// ranked events whose EP factors are not retained past convergence, or
|
||||
/// the assembled precision matrix is not positive-definite.
|
||||
reason: &'static str,
|
||||
},
|
||||
/// A joint posterior was requested from a history with no events.
|
||||
///
|
||||
/// Split out of a single `JointUnavailable { reason: &str }` (#74): the
|
||||
/// three reasons are conditions a caller branches on differently, and
|
||||
/// distinguishing them used to mean matching on English prose. This one
|
||||
/// means "add events".
|
||||
EmptyHistory,
|
||||
/// A joint posterior was requested from a history containing ranked
|
||||
/// events.
|
||||
///
|
||||
/// Exact only for an all-scored history: a scored likelihood is Gaussian
|
||||
/// and its factor can be rebuilt exactly, while a ranked outcome's
|
||||
/// truncation is approximated by EP and reconstructing those factors needs
|
||||
/// the converged messages, which inference does not retain.
|
||||
///
|
||||
/// [`History::predict_win_probabilities`](crate::History::predict_win_probabilities)
|
||||
/// answers the comparable question on a ranked history.
|
||||
JointRequiresScoredEvents,
|
||||
/// The assembled precision matrix is not positive-definite.
|
||||
///
|
||||
/// The usual cause is a competitor with neither a proper prior nor any
|
||||
/// evidence, but an extreme prior or drift can also make the assembled
|
||||
/// matrix indefinite in floating point. Unlike its two siblings this one
|
||||
/// is numerical rather than structural — the same history may factorise
|
||||
/// under different parameters.
|
||||
NotPositiveDefinite,
|
||||
/// Fewer than two teams were supplied to a prediction.
|
||||
#[non_exhaustive]
|
||||
NotEnoughTeams {
|
||||
@@ -268,21 +474,19 @@ impl fmt::Display for InferenceError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MismatchedShape {
|
||||
kind,
|
||||
shape,
|
||||
expected,
|
||||
got,
|
||||
} => {
|
||||
write!(f, "{kind}: expected length {expected}, got {got}")
|
||||
write!(f, "{shape}: expected {expected}, got {got}")
|
||||
}
|
||||
Self::WrongOutcomeKind {
|
||||
context,
|
||||
expected,
|
||||
got,
|
||||
} => {
|
||||
write!(f, "{context}: expected {expected}, got {got}")
|
||||
}
|
||||
Self::InvalidProbability { value } => {
|
||||
write!(f, "probability must be in [0, 1]; got {value}")
|
||||
Self::WrongOutcomeKind { expected, got } => {
|
||||
write!(
|
||||
f,
|
||||
"expected {expected}, got {got}; call {} for a {} outcome",
|
||||
got.constructor(),
|
||||
got.adjective()
|
||||
)
|
||||
}
|
||||
Self::TieWithoutDrawProbability { teams } => {
|
||||
write!(
|
||||
@@ -303,14 +507,23 @@ impl fmt::Display for InferenceError {
|
||||
alpha < 1.0 if it is oscillating"
|
||||
)
|
||||
}
|
||||
Self::NonFiniteResult { context, step } => {
|
||||
Self::NonFiniteStep { context, step } => {
|
||||
write!(
|
||||
f,
|
||||
"{context}: inference produced a non-finite result (step = {step:?})"
|
||||
"{context}: inference produced a non-finite step {step:?}; EP has \
|
||||
broken down and further iterations cannot recover"
|
||||
)
|
||||
}
|
||||
Self::InvalidParameter { name, value } => {
|
||||
write!(f, "{name} is invalid: {value}")
|
||||
Self::NonFiniteSkill { mu, sigma } => {
|
||||
write!(
|
||||
f,
|
||||
"a prediction read a skill with no usable mean or variance \
|
||||
(mu = {mu}, sigma = {sigma}); the fit did not converge, and \
|
||||
`converge` reports that"
|
||||
)
|
||||
}
|
||||
Self::InvalidParameter { parameter, value } => {
|
||||
write!(f, "{parameter} {} (got {value})", parameter.range())
|
||||
}
|
||||
Self::ConflictingCompetitorConfig { competitor, field } => {
|
||||
write!(
|
||||
@@ -323,7 +536,7 @@ impl fmt::Display for InferenceError {
|
||||
f,
|
||||
"team {team}, member {member}: no skill recorded for key {key} \
|
||||
(every key must already be known to the history; pre-filter \
|
||||
with `lookup` or `current_skill` if that is not guaranteed)"
|
||||
with `current_skill` if that is not guaranteed)"
|
||||
)
|
||||
}
|
||||
Self::AlreadyRegistered { key } => {
|
||||
@@ -346,9 +559,25 @@ impl fmt::Display for InferenceError {
|
||||
one grid. Use predict_win_probabilities, which is accurate here"
|
||||
)
|
||||
}
|
||||
Self::JointUnavailable { reason } => {
|
||||
write!(f, "no exact joint posterior is available: {reason}")
|
||||
Self::EmptyHistory => {
|
||||
f.write_str("no exact joint posterior is available: the history has no events")
|
||||
}
|
||||
Self::JointRequiresScoredEvents => f.write_str(
|
||||
"no exact joint posterior is available: the history contains ranked \
|
||||
events, whose EP factors are not retained after convergence. Use \
|
||||
predict_win_probabilities for a ranked history",
|
||||
),
|
||||
Self::NotPositiveDefinite => f.write_str(
|
||||
"the joint precision matrix is not positive-definite; the usual cause \
|
||||
is a competitor with neither a proper prior nor any evidence, but an \
|
||||
extreme prior or drift can also make the assembled matrix indefinite \
|
||||
in floating point",
|
||||
),
|
||||
Self::NoPerformanceVariance => f.write_str(
|
||||
"beta is zero and every skill in this matchup is a point mass, so \
|
||||
there is no performance distribution to predict from; give beta a \
|
||||
positive value, or a competitor a prior with positive sigma",
|
||||
),
|
||||
Self::NotEnoughTeams { got } => {
|
||||
write!(f, "prediction needs at least 2 teams, got {got}")
|
||||
}
|
||||
@@ -364,3 +593,81 @@ impl fmt::Display for InferenceError {
|
||||
}
|
||||
|
||||
impl std::error::Error for InferenceError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod message_tests {
|
||||
use super::*;
|
||||
|
||||
/// Every message must name the problem *and* what to do, which is the
|
||||
/// standard the good ones set and the three #74 called out did not meet.
|
||||
#[test]
|
||||
fn messages_are_actionable() {
|
||||
let cases = [
|
||||
InferenceError::InvalidParameter {
|
||||
parameter: Parameter::Alpha,
|
||||
value: 0.0,
|
||||
},
|
||||
InferenceError::InvalidParameter {
|
||||
parameter: Parameter::DriftVariance,
|
||||
value: f64::NAN,
|
||||
},
|
||||
InferenceError::InvalidParameter {
|
||||
parameter: Parameter::PDraw,
|
||||
value: 1.5,
|
||||
},
|
||||
InferenceError::MismatchedShape {
|
||||
shape: Shape::OutcomeVsTeams,
|
||||
expected: 3,
|
||||
got: 2,
|
||||
},
|
||||
InferenceError::WrongOutcomeKind {
|
||||
expected: OutcomeKind::Ranked,
|
||||
got: OutcomeKind::Scored,
|
||||
},
|
||||
InferenceError::EmptyHistory,
|
||||
InferenceError::JointRequiresScoredEvents,
|
||||
InferenceError::NotPositiveDefinite,
|
||||
InferenceError::NoPerformanceVariance,
|
||||
InferenceError::NonFiniteSkill {
|
||||
mu: f64::NAN,
|
||||
sigma: f64::NAN,
|
||||
},
|
||||
];
|
||||
|
||||
for case in &cases {
|
||||
let rendered = case.to_string();
|
||||
eprintln!("{rendered}");
|
||||
// `InvalidParameter` used to render `drift variance is invalid: NaN`
|
||||
// — no range, no remedy, no location. Every message must at least
|
||||
// be a sentence.
|
||||
assert!(
|
||||
rendered.len() > 30,
|
||||
"message is too terse to act on: {rendered}"
|
||||
);
|
||||
assert!(!rendered.contains("is invalid:"), "{rendered}");
|
||||
}
|
||||
|
||||
// The three that #74 singled out now state a range or a next step.
|
||||
assert!(
|
||||
InferenceError::InvalidParameter {
|
||||
parameter: Parameter::DriftVariance,
|
||||
value: f64::NAN,
|
||||
}
|
||||
.to_string()
|
||||
.contains("must be finite and non-negative")
|
||||
);
|
||||
assert!(
|
||||
InferenceError::WrongOutcomeKind {
|
||||
expected: OutcomeKind::Ranked,
|
||||
got: OutcomeKind::Scored,
|
||||
}
|
||||
.to_string()
|
||||
.contains("Game::scored")
|
||||
);
|
||||
assert!(
|
||||
InferenceError::JointRequiresScoredEvents
|
||||
.to_string()
|
||||
.contains("predict_win_probabilities")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-12
@@ -38,14 +38,15 @@ use crate::{
|
||||
/// ```
|
||||
#[must_use = "an event is only recorded by `.commit()`; a dropped builder \
|
||||
silently ingests nothing"]
|
||||
pub struct EventBuilder<'h, T, D, O, K>
|
||||
pub struct EventBuilder<'h, T, D, O, K, R>
|
||||
where
|
||||
T: Time,
|
||||
D: Drift<T>,
|
||||
O: Observer<T>,
|
||||
K: Eq + std::hash::Hash + Clone,
|
||||
R: crate::RatingRule<K>,
|
||||
{
|
||||
history: &'h mut History<T, D, O, K>,
|
||||
history: &'h mut History<K, T, D, O, R>,
|
||||
event: Event<T, K>,
|
||||
current_team_idx: Option<usize>,
|
||||
/// First validation failure seen while building, surfaced by `commit`.
|
||||
@@ -58,14 +59,15 @@ where
|
||||
error: Option<InferenceError>,
|
||||
}
|
||||
|
||||
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
|
||||
impl<'h, T, D, O, K, R> EventBuilder<'h, T, D, O, K, R>
|
||||
where
|
||||
T: Time,
|
||||
D: Drift<T>,
|
||||
O: Observer<T>,
|
||||
K: Eq + std::hash::Hash + Clone,
|
||||
R: crate::RatingRule<K>,
|
||||
{
|
||||
pub(crate) fn new(history: &'h mut History<T, D, O, K>, time: T) -> Self {
|
||||
pub(crate) fn new(history: &'h mut History<K, T, D, O, R>, time: T) -> Self {
|
||||
Self {
|
||||
history,
|
||||
event: Event {
|
||||
@@ -142,7 +144,7 @@ where
|
||||
|
||||
if ws.len() != team.members.len() {
|
||||
self.error.get_or_insert(InferenceError::MismatchedShape {
|
||||
kind: "weights",
|
||||
shape: crate::Shape::Weights,
|
||||
expected: team.members.len(),
|
||||
got: ws.len(),
|
||||
});
|
||||
@@ -171,13 +173,21 @@ where
|
||||
|
||||
/// Set explicit per-team continuous scores with a per-event noise override.
|
||||
///
|
||||
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only.
|
||||
/// Must be `> 0.0`. Constructing the outcome with a non-positive or NaN
|
||||
/// sigma is allowed; the value is rejected with
|
||||
/// `InferenceError::InvalidParameter` when the event is ingested, so
|
||||
/// callers get an error from `commit` rather than a panic.
|
||||
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self {
|
||||
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
|
||||
/// `score_sigma` is the observation noise on the *score margin*, not a
|
||||
/// skill sigma, and it overrides `HistoryBuilder::score_sigma` for this
|
||||
/// event only. A small value takes the margin near-literally; a large one
|
||||
/// barely moves the ratings.
|
||||
///
|
||||
/// Must be `> 0.0`. Building the outcome with a non-positive or NaN value
|
||||
/// is allowed; it is rejected with `InferenceError::InvalidParameter` when
|
||||
/// the event is ingested, so callers get an error from `commit` rather
|
||||
/// than a panic.
|
||||
pub fn scores_with_noise<I: IntoIterator<Item = f64>>(
|
||||
mut self,
|
||||
scores: I,
|
||||
score_sigma: f64,
|
||||
) -> Self {
|
||||
self.event.outcome = crate::Outcome::scores_with_noise(scores, score_sigma);
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ impl MarginFactor {
|
||||
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
|
||||
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
|
||||
let marginal = vars.get(self.diff);
|
||||
let cavity = marginal / self.msg;
|
||||
let cavity = marginal.cavity(self.msg);
|
||||
|
||||
if self.log_evidence_cached.is_none() {
|
||||
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.m_obs, self.sigma));
|
||||
@@ -49,7 +49,7 @@ impl MarginFactor {
|
||||
let damped = self.msg.damp_natural(new_msg, alpha);
|
||||
let old_msg = self.msg;
|
||||
self.msg = damped;
|
||||
vars.set(self.diff, cavity * damped);
|
||||
vars.set(self.diff, cavity.ep_product(damped));
|
||||
|
||||
old_msg.delta(damped)
|
||||
}
|
||||
|
||||
+3
-3
@@ -41,14 +41,14 @@ impl TruncFactor {
|
||||
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
|
||||
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
|
||||
let marginal = vars.get(self.diff);
|
||||
let cavity = marginal / self.msg;
|
||||
let cavity = marginal.cavity(self.msg);
|
||||
|
||||
if self.log_evidence_cached.is_none() {
|
||||
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.margin, self.tie));
|
||||
}
|
||||
|
||||
let trunc = approx(cavity, self.margin, self.tie);
|
||||
let new_msg = trunc / cavity;
|
||||
let new_msg = trunc.cavity(cavity);
|
||||
|
||||
let damped = self.msg.damp_natural(new_msg, alpha);
|
||||
let old_msg = self.msg;
|
||||
@@ -57,7 +57,7 @@ impl TruncFactor {
|
||||
// marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
|
||||
// `trunc` (since cavity * new_msg = trunc by construction); with
|
||||
// alpha < 1.0 it reflects the partially-applied update.
|
||||
vars.set(self.diff, cavity * damped);
|
||||
vars.set(self.diff, cavity.ep_product(damped));
|
||||
|
||||
old_msg.delta(damped)
|
||||
}
|
||||
|
||||
+163
-116
@@ -102,24 +102,51 @@ impl Default for GameOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned variant of `Game` returned by public constructors.
|
||||
/// One match, fitted on its own.
|
||||
///
|
||||
/// Unlike `Game<'a, T, D>` (which borrows its result/weights slices from
|
||||
/// History's internal state), `OwnedGame<T, D>` owns the team ratings, so it
|
||||
/// can be returned freely from public constructors. The inference inputs
|
||||
/// themselves are not retained — nothing reads them back.
|
||||
/// Rate a single match against ratings you already hold and read the updated
|
||||
/// beliefs straight back. There is no history behind it: nothing is stored,
|
||||
/// nothing propagates backward, and the priors you hand in are the only
|
||||
/// evidence used. That makes it the wrong tool for the thing this crate exists
|
||||
/// for — [`History`](crate::History) is what infers skill *through time*,
|
||||
/// revising past estimates as later matches arrive, and a sequence of `Game`s
|
||||
/// chained by hand is a forward-only filter, not the same answer.
|
||||
///
|
||||
/// A fitted single match, and nothing more: see [`Game`] for why that is not
|
||||
/// the same as a step of a [`History`](crate::History).
|
||||
/// Reach for it when a history would be overkill or unavailable: a one-off
|
||||
/// matchup, replaying a rating step from stored numbers, checking the engine
|
||||
/// against a reference, or a caller that keeps its own persistence and only
|
||||
/// wants the update rule.
|
||||
///
|
||||
/// ```
|
||||
/// use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
|
||||
///
|
||||
/// let strong: Rating = Rating::new(Gaussian::from_ms(30.0, 3.0), 1.0, ConstantDrift::new(0.0));
|
||||
/// let weak: Rating = Rating::new(Gaussian::from_ms(20.0, 3.0), 1.0, ConstantDrift::new(0.0));
|
||||
///
|
||||
/// // The underdog wins.
|
||||
/// let game = Game::ranked(
|
||||
/// &[&[weak], &[strong]],
|
||||
/// Outcome::winner(0, 2),
|
||||
/// &GameOptions::default(),
|
||||
/// )?;
|
||||
///
|
||||
/// let posteriors = game.posteriors();
|
||||
/// assert!(posteriors[0][0].mu() > weak.prior().mu(), "the winner gained");
|
||||
/// assert!(posteriors[1][0].mu() < strong.prior().mu(), "the loser lost");
|
||||
///
|
||||
/// // An upset is improbable, and `log_evidence` says so.
|
||||
/// assert!(game.log_evidence() < 0.5_f64.ln());
|
||||
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
#[must_use]
|
||||
pub struct OwnedGame<T: Time, D: Drift<T>> {
|
||||
pub struct Game<T: Time, D: Drift<T>> {
|
||||
teams: Vec<Vec<Rating<T, D>>>,
|
||||
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
||||
pub(crate) log_evidence: f64,
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
impl<T: Time, D: Drift<T>> Game<T, D> {
|
||||
pub(crate) fn new(
|
||||
teams: Vec<Vec<Rating<T, D>>>,
|
||||
result: Vec<f64>,
|
||||
@@ -131,7 +158,8 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
|
||||
// `Game` takes the teams by value and is dropped here, so take the vec
|
||||
// back out of it rather than handing it a clone.
|
||||
let g = Game::ranked_with_arena(teams, &result, &weights, p_draw, convergence, &mut arena);
|
||||
let g =
|
||||
GameRef::ranked_with_arena(teams, &result, &weights, p_draw, convergence, &mut arena);
|
||||
|
||||
Self {
|
||||
teams: g.teams,
|
||||
@@ -149,7 +177,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
) -> Self {
|
||||
let mut arena = ScratchArena::new();
|
||||
|
||||
let g = Game::scored_with_arena(
|
||||
let g = GameRef::scored_with_arena(
|
||||
teams,
|
||||
&scores,
|
||||
&weights,
|
||||
@@ -177,7 +205,12 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
self.likelihoods
|
||||
.iter()
|
||||
.zip(self.teams.iter())
|
||||
.map(|(l, t)| l.iter().zip(t.iter()).map(|(&l, r)| l * r.prior).collect())
|
||||
.map(|(l, t)| {
|
||||
l.iter()
|
||||
.zip(t.iter())
|
||||
.map(|(&l, r)| l.ep_product(r.prior))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -204,27 +237,15 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
}
|
||||
}
|
||||
|
||||
/// One match's factor graph, fitted on its own.
|
||||
/// The borrowing form of [`Game`], used only inside the crate.
|
||||
///
|
||||
/// Rate a single match against ratings you already hold and get the updated
|
||||
/// beliefs straight back. There is no history behind it: nothing is stored,
|
||||
/// nothing propagates backward, and the priors you hand in are the only
|
||||
/// evidence used. That makes it the wrong tool for the thing this crate exists
|
||||
/// for — [`History`](crate::History) is what infers skill *through time*,
|
||||
/// revising past estimates as later matches arrive, and a sequence of `Game`s
|
||||
/// chained by hand is a forward-only filter, not the same answer.
|
||||
///
|
||||
/// Reach for `Game` when a history would be overkill or unavailable: a
|
||||
/// one-off matchup, replaying a rating step from stored numbers, checking the
|
||||
/// engine against a reference, or a caller that keeps its own persistence and
|
||||
/// only wants the update rule.
|
||||
///
|
||||
/// The type is mostly a namespace. Its constructors — [`Game::ranked`],
|
||||
/// [`Game::scored`], [`Game::one_v_one`], [`Game::free_for_all`] — return an
|
||||
/// [`OwnedGame`], because `Game<'a, …>` borrows the result and weight slices
|
||||
/// that `History` keeps internally and so cannot be handed out.
|
||||
/// `History` keeps each event's result and weight slices in its own storage
|
||||
/// and sweeps them thousands of times, so the inference core borrows them
|
||||
/// rather than copying. That borrow is the whole difference between this and
|
||||
/// [`Game`]; it is why this type cannot be handed to a caller, and why it is
|
||||
/// not part of the public API.
|
||||
#[derive(Debug)]
|
||||
pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
||||
pub(crate) struct GameRef<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
||||
teams: Vec<Vec<Rating<T, D>>>,
|
||||
result: &'a [f64],
|
||||
weights: &'a [Vec<f64>],
|
||||
@@ -234,7 +255,7 @@ pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
||||
pub(crate) log_evidence: f64,
|
||||
}
|
||||
|
||||
impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
|
||||
pub(crate) fn ranked_with_arena(
|
||||
teams: Vec<Vec<Rating<T, D>>>,
|
||||
result: &'a [f64],
|
||||
@@ -348,7 +369,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
.iter()
|
||||
.zip(self.weights[t].iter())
|
||||
.fold(N00, |p, (competitor, &w)| {
|
||||
p + (competitor.performance() * w)
|
||||
p.convolve(competitor.performance().scale(w))
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -368,28 +389,28 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
step = (0.0_f64, 0.0_f64);
|
||||
|
||||
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
|
||||
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||
let raw = pw - pl;
|
||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||
let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
|
||||
let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
|
||||
let raw = pw.convolve_diff(pl);
|
||||
arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
|
||||
let d = lf.propagate(&mut arena.vars, alpha);
|
||||
step = tuple_max(step, d);
|
||||
|
||||
let new_ll = pw - lf.msg();
|
||||
let new_ll = pw.convolve_diff(lf.msg());
|
||||
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
|
||||
arena.lhood_lose[e + 1] = new_ll;
|
||||
}
|
||||
|
||||
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
|
||||
let e = n_diffs - 1 - rev_i;
|
||||
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||
let raw = pw - pl;
|
||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||
let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
|
||||
let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
|
||||
let raw = pw.convolve_diff(pl);
|
||||
arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
|
||||
let d = lf.propagate(&mut arena.vars, alpha);
|
||||
step = tuple_max(step, d);
|
||||
|
||||
let new_lw = pl + lf.msg();
|
||||
let new_lw = pl.convolve(lf.msg());
|
||||
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
|
||||
arena.lhood_win[e] = new_lw;
|
||||
}
|
||||
@@ -399,18 +420,21 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
|
||||
// Special case: exactly 1 diff (2-team game); loop body was empty.
|
||||
if n_diffs == 1 {
|
||||
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
|
||||
- (arena.team_prior[1] * arena.lhood_win[1]);
|
||||
arena.vars.set(links[0].diff(), raw * links[0].msg());
|
||||
let raw = arena.team_prior[0]
|
||||
.ep_product(arena.lhood_lose[0])
|
||||
.convolve_diff(arena.team_prior[1].ep_product(arena.lhood_win[1]));
|
||||
arena
|
||||
.vars
|
||||
.set(links[0].diff(), raw.ep_product(links[0].msg()));
|
||||
links[0].propagate(&mut arena.vars, alpha);
|
||||
}
|
||||
|
||||
// Boundary updates: close the chain at both ends.
|
||||
if n_diffs > 0 {
|
||||
let pl1 = arena.team_prior[1] * arena.lhood_win[1];
|
||||
arena.lhood_win[0] = pl1 + links[0].msg();
|
||||
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2];
|
||||
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
|
||||
let pl1 = arena.team_prior[1].ep_product(arena.lhood_win[1]);
|
||||
arena.lhood_win[0] = pl1.convolve(links[0].msg());
|
||||
let pw_last = arena.team_prior[n_teams - 2].ep_product(arena.lhood_lose[n_teams - 2]);
|
||||
arena.lhood_lose[n_teams - 1] = pw_last.convolve_diff(links[n_diffs - 1].msg());
|
||||
}
|
||||
|
||||
let log_evidence: f64 = links.iter().map(DiffFactor::log_evidence).sum();
|
||||
@@ -428,7 +452,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
.enumerate()
|
||||
.map(|(orig_i, (competitors, weights))| {
|
||||
let si = arena.inv_buf[orig_i];
|
||||
let m = arena.lhood_win[si] * arena.lhood_lose[si];
|
||||
let m = arena.lhood_win[si].ep_product(arena.lhood_lose[si]);
|
||||
// Already folded into `team_prior` at the top of the chain,
|
||||
// indexed by sorted position.
|
||||
let performance = arena.team_prior[si];
|
||||
@@ -436,7 +460,8 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.map(|(competitor, &w)| {
|
||||
((m - performance.exclude(competitor.performance() * w)) * (1.0 / w))
|
||||
m.convolve_diff(performance.exclude(competitor.performance().scale(w)))
|
||||
.scale(1.0 / w)
|
||||
.forget(competitor.beta.powi(2))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -476,32 +501,26 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
self.likelihoods = likelihoods;
|
||||
}
|
||||
|
||||
/// Updated skill belief for every competitor, as `[team][member]` in the
|
||||
/// order the teams and members were passed in — prior times this match's
|
||||
/// likelihood, exactly as [`OwnedGame::posteriors`].
|
||||
#[must_use]
|
||||
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
||||
/// As [`Game::posteriors`].
|
||||
///
|
||||
/// Test-only: inference reads `likelihoods` directly, and `GameRef` is not
|
||||
/// public, so the only callers are this module's own goldens.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
||||
self.likelihoods
|
||||
.iter()
|
||||
.zip(self.teams.iter())
|
||||
.map(|(l, t)| {
|
||||
l.iter()
|
||||
.zip(t.iter())
|
||||
.map(|(&l, p)| l * p.prior)
|
||||
.map(|(&l, p)| l.ep_product(p.prior))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// Natural log of how probable this outcome was under the priors, summed
|
||||
/// over the diff chain's links — as [`OwnedGame::log_evidence`].
|
||||
#[must_use]
|
||||
pub fn log_evidence(&self) -> f64 {
|
||||
self.log_evidence
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
impl<T: Time, D: Drift<T>> Game<T, D> {
|
||||
/// Reject the team shapes inference cannot represent.
|
||||
///
|
||||
/// `run_chain` builds one diff link per adjacent pair of teams, so fewer
|
||||
@@ -525,12 +544,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fit one match from an ordinal result.
|
||||
///
|
||||
/// `teams` is `[team][member]`, and `outcome` ranks those teams in the
|
||||
/// same order. Read the result with [`posteriors`](Game::posteriors) and
|
||||
/// [`log_evidence`](Game::log_evidence).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `InvalidParameter` if `options.convergence` is out of range — an
|
||||
/// `alpha` of zero would leave every EP update unapplied and silently
|
||||
/// return the priors.
|
||||
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
|
||||
/// - `InvalidParameter` for a `p_draw` outside `[0.0, 1.0)`.
|
||||
/// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`.
|
||||
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
|
||||
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
|
||||
@@ -542,17 +567,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
teams: &[&[Rating<T, D>]],
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||
) -> Result<Self, crate::InferenceError> {
|
||||
options.convergence.validate()?;
|
||||
Self::validate_teams(teams)?;
|
||||
if !(0.0..1.0).contains(&options.p_draw) {
|
||||
return Err(crate::InferenceError::InvalidProbability {
|
||||
return Err(crate::InferenceError::InvalidParameter {
|
||||
parameter: crate::Parameter::PDraw,
|
||||
value: options.p_draw,
|
||||
});
|
||||
}
|
||||
if outcome.team_count() != teams.len() {
|
||||
return Err(crate::InferenceError::MismatchedShape {
|
||||
kind: "outcome ranks vs teams",
|
||||
shape: crate::Shape::OutcomeVsTeams,
|
||||
expected: teams.len(),
|
||||
got: outcome.team_count(),
|
||||
});
|
||||
@@ -561,9 +587,8 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
let ranks = outcome
|
||||
.as_ranks()
|
||||
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
||||
context: "Game::ranked",
|
||||
expected: "Outcome::Ranked",
|
||||
got: "Outcome::Scored",
|
||||
expected: crate::OutcomeKind::Ranked,
|
||||
got: crate::OutcomeKind::Scored,
|
||||
})?;
|
||||
|
||||
let tied = if options.p_draw == 0.0 {
|
||||
@@ -581,7 +606,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
|
||||
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
|
||||
|
||||
Ok(OwnedGame::new(
|
||||
Ok(Self::new(
|
||||
teams_owned,
|
||||
result,
|
||||
weights,
|
||||
@@ -590,6 +615,12 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Fit one match from continuous scores.
|
||||
///
|
||||
/// Unlike [`ranked`](Game::ranked), the *size* of each adjacent gap is
|
||||
/// evidence: beating a team by ten says more than beating them by one.
|
||||
/// How much more is set by `options.score_sigma`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `InvalidParameter` if `options.score_sigma` is not strictly positive
|
||||
@@ -602,18 +633,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
teams: &[&[Rating<T, D>]],
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||
) -> Result<Self, crate::InferenceError> {
|
||||
options.convergence.validate()?;
|
||||
Self::validate_teams(teams)?;
|
||||
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
|
||||
return Err(crate::InferenceError::InvalidParameter {
|
||||
name: "score_sigma",
|
||||
parameter: crate::Parameter::ScoreSigma,
|
||||
value: options.score_sigma,
|
||||
});
|
||||
}
|
||||
if outcome.team_count() != teams.len() {
|
||||
return Err(crate::InferenceError::MismatchedShape {
|
||||
kind: "outcome scores vs teams",
|
||||
shape: crate::Shape::OutcomeVsTeams,
|
||||
expected: teams.len(),
|
||||
got: outcome.team_count(),
|
||||
});
|
||||
@@ -621,9 +652,8 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
let scores = outcome
|
||||
.as_scores()
|
||||
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
||||
context: "Game::scored",
|
||||
expected: "Outcome::Scored",
|
||||
got: "Outcome::Ranked",
|
||||
expected: crate::OutcomeKind::Scored,
|
||||
got: crate::OutcomeKind::Ranked,
|
||||
})?
|
||||
.to_vec();
|
||||
// A non-finite score poisons the chain rather than failing it. Ranks
|
||||
@@ -631,14 +661,14 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
for value in &scores {
|
||||
if !value.is_finite() {
|
||||
return Err(crate::InferenceError::InvalidParameter {
|
||||
name: "score",
|
||||
parameter: crate::Parameter::Score,
|
||||
value: *value,
|
||||
});
|
||||
}
|
||||
}
|
||||
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
|
||||
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
|
||||
Ok(OwnedGame::new_scored(
|
||||
Ok(Self::new_scored(
|
||||
teams_owned,
|
||||
scores,
|
||||
weights,
|
||||
@@ -647,7 +677,24 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Convenience wrapper over [`Game::ranked`] for two single-competitor teams.
|
||||
/// Two single-competitor teams: the common case, without the nesting.
|
||||
///
|
||||
/// Returns a `Game` like every other constructor. It used to return
|
||||
/// `(Gaussian, Gaussian)` — the posteriors alone — which made it the one
|
||||
/// member of the family you could not ask for
|
||||
/// [`log_evidence`](Game::log_evidence). Call `.posteriors()` for the old
|
||||
/// shape:
|
||||
///
|
||||
/// ```
|
||||
/// # use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
|
||||
/// # let a: Rating = Rating::new(Gaussian::from_ms(25.0, 8.0), 4.0, ConstantDrift::new(0.0));
|
||||
/// # let b = a;
|
||||
/// let game = Game::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default())?;
|
||||
/// let post = game.posteriors();
|
||||
/// let (a_post, b_post) = (post[0][0], post[1][0]);
|
||||
/// # let _ = (a_post, b_post);
|
||||
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -659,12 +706,12 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
b: &Rating<T, D>,
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<(Gaussian, Gaussian), crate::InferenceError> {
|
||||
let game = Self::ranked(&[&[*a], &[*b]], outcome, options)?;
|
||||
let post = game.posteriors();
|
||||
Ok((post[0][0], post[1][0]))
|
||||
) -> Result<Self, crate::InferenceError> {
|
||||
Self::ranked(&[&[*a], &[*b]], outcome, options)
|
||||
}
|
||||
|
||||
/// A free-for-all: every competitor is their own one-member team.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Wraps each competitor in a one-member team and delegates to
|
||||
@@ -673,7 +720,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
competitors: &[&Rating<T, D>],
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||
) -> Result<Self, crate::InferenceError> {
|
||||
let teams: Vec<Vec<Rating<T, D>>> = competitors.iter().map(|p| vec![**p]).collect();
|
||||
let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
|
||||
Self::ranked(&team_refs, outcome, options)
|
||||
@@ -703,7 +750,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b]],
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
@@ -731,7 +778,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b]],
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
@@ -759,7 +806,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b]],
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
@@ -793,7 +840,7 @@ mod tests {
|
||||
];
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
teams.clone(),
|
||||
&[1.0, 2.0, 0.0],
|
||||
&w,
|
||||
@@ -810,7 +857,7 @@ mod tests {
|
||||
assert_ulps_eq!(b, Gaussian::from_ms(31.311358, 6.698818), epsilon = 1e-6);
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
teams.clone(),
|
||||
&[2.0, 1.0, 0.0],
|
||||
&w,
|
||||
@@ -827,7 +874,7 @@ mod tests {
|
||||
assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6);
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
teams,
|
||||
&[1.0, 2.0, 0.0],
|
||||
&w,
|
||||
@@ -867,7 +914,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b]],
|
||||
&[0.0, 0.0],
|
||||
&w,
|
||||
@@ -899,7 +946,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b]],
|
||||
&[0.0, 0.0],
|
||||
&w,
|
||||
@@ -935,7 +982,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b], vec![t_c]],
|
||||
&[0.0, 0.0, 0.0],
|
||||
&w,
|
||||
@@ -972,7 +1019,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b], vec![t_c]],
|
||||
&[0.0, 0.0, 0.0],
|
||||
&w,
|
||||
@@ -1024,7 +1071,7 @@ mod tests {
|
||||
];
|
||||
|
||||
let w = [vec![1.0, 1.0], vec![1.0], vec![1.0, 1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![t_a, t_b, t_c],
|
||||
&[1.0, 0.0, 0.0],
|
||||
&w,
|
||||
@@ -1058,7 +1105,7 @@ mod tests {
|
||||
)];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![t_a.clone(), t_b.clone()],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -1083,7 +1130,7 @@ mod tests {
|
||||
let w_b = vec![0.7];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![t_a.clone(), t_b.clone()],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -1108,7 +1155,7 @@ mod tests {
|
||||
let w_b = vec![0.7];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![t_a, t_b],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -1144,7 +1191,7 @@ mod tests {
|
||||
)];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![t_a, t_b],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -1180,7 +1227,7 @@ mod tests {
|
||||
)];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![t_a, t_b],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -1226,7 +1273,7 @@ mod tests {
|
||||
let result = vec![10.0, 0.0]; // a beat b by 10
|
||||
let weights = [vec![1.0], vec![1.0]];
|
||||
let mut arena = ScratchArena::new();
|
||||
let g = Game::scored_with_arena(
|
||||
let g = GameRef::scored_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&weights,
|
||||
@@ -1246,7 +1293,7 @@ mod tests {
|
||||
|
||||
// Tighter score_sigma should produce a stronger update.
|
||||
let mut arena2 = ScratchArena::new();
|
||||
let g_tight = Game::scored_with_arena(
|
||||
let g_tight = GameRef::scored_with_arena(
|
||||
vec![vec![prior], vec![prior]],
|
||||
&result,
|
||||
&weights,
|
||||
@@ -1320,7 +1367,7 @@ mod tests {
|
||||
assert!(matches!(
|
||||
err,
|
||||
crate::InferenceError::InvalidParameter {
|
||||
name: "score_sigma",
|
||||
parameter: crate::Parameter::ScoreSigma,
|
||||
..
|
||||
}
|
||||
));
|
||||
@@ -1357,7 +1404,7 @@ mod tests {
|
||||
let w_b = vec![0.9, 0.6];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![t_a.clone(), t_b.clone()],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -1392,7 +1439,7 @@ mod tests {
|
||||
let w_b = vec![0.7, 0.4];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![t_a.clone(), t_b.clone()],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -1427,7 +1474,7 @@ mod tests {
|
||||
let w_b = vec![0.7, 2.4];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![t_a.clone(), t_b.clone()],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -1459,7 +1506,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0, 1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![
|
||||
t_a.clone(),
|
||||
vec![R::new(
|
||||
@@ -1480,7 +1527,7 @@ mod tests {
|
||||
let w_b = vec![1.0, 0.0];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::ranked_with_arena(
|
||||
let g = GameRef::ranked_with_arena(
|
||||
vec![t_a, t_b.clone()],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -1505,7 +1552,7 @@ mod tests {
|
||||
|
||||
// Capped at 1 iteration: cannot fully propagate down a 4-team chain.
|
||||
let mut arena = ScratchArena::new();
|
||||
let g_capped = Game::ranked_with_arena(
|
||||
let g_capped = GameRef::ranked_with_arena(
|
||||
teams.clone(),
|
||||
&result,
|
||||
&weights,
|
||||
@@ -1520,7 +1567,7 @@ mod tests {
|
||||
|
||||
// Same inputs, plenty of iterations: fully converged.
|
||||
let mut arena = ScratchArena::new();
|
||||
let g_full = Game::ranked_with_arena(
|
||||
let g_full = GameRef::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&weights,
|
||||
@@ -1552,7 +1599,7 @@ mod tests {
|
||||
let weights = vec![vec![1.0]; 4];
|
||||
|
||||
let mut arena = ScratchArena::new();
|
||||
let g_undamped = Game::ranked_with_arena(
|
||||
let g_undamped = GameRef::ranked_with_arena(
|
||||
teams.clone(),
|
||||
&result,
|
||||
&weights,
|
||||
@@ -1564,7 +1611,7 @@ mod tests {
|
||||
|
||||
// alpha=0.5 with extra iterations: should reach the same fixed point.
|
||||
let mut arena = ScratchArena::new();
|
||||
let g_damped = Game::ranked_with_arena(
|
||||
let g_damped = GameRef::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&weights,
|
||||
|
||||
+87
-65
@@ -1,5 +1,3 @@
|
||||
use std::ops;
|
||||
|
||||
use crate::{MU, N_INF, SIGMA};
|
||||
|
||||
/// A Gaussian distribution stored in natural parameters.
|
||||
@@ -24,7 +22,7 @@ impl Gaussian {
|
||||
///
|
||||
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a
|
||||
/// broken fit produces one, and `converge` reports that as
|
||||
/// `NonFiniteResult` rather than panicking mid-inference.
|
||||
/// `NonFiniteStep` rather than panicking mid-inference.
|
||||
///
|
||||
/// A negative sigma used to be accepted and returned results **bit
|
||||
/// identical** to its absolute value, because sigma only ever enters as
|
||||
@@ -48,7 +46,7 @@ impl Gaussian {
|
||||
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
||||
// NaN is admitted on purpose. A broken fit legitimately produces a NaN
|
||||
// sigma — `sqrt` of a negative truncated variance — and the design is
|
||||
// to propagate that to `converge`'s `NonFiniteResult` guard, not to
|
||||
// to propagate that to `converge`'s `NonFiniteStep` guard, not to
|
||||
// panic inside inference. Rejecting it here turned that reporting path
|
||||
// into a crash, which two tests caught immediately.
|
||||
assert!(
|
||||
@@ -75,11 +73,12 @@ impl Gaussian {
|
||||
/// Construct from mean and *variance*, skipping the square-root round trip.
|
||||
///
|
||||
/// `from_ms(mu, var.sqrt())` immediately squares the root away again to
|
||||
/// recover `pi = 1/var`. Variance-combining operations (`Add`, `Sub`,
|
||||
/// `exclude`, `forget`) work in variance space throughout, so they go
|
||||
/// through here instead and never take a root.
|
||||
/// recover `pi = 1/var`. Variance-combining operations work in variance
|
||||
/// space throughout, so they go through here instead and never take a
|
||||
/// root. Use it whenever you already hold a variance —
|
||||
/// [`variance`](Gaussian::variance) is its inverse.
|
||||
#[inline]
|
||||
pub(crate) fn from_mv(mu: f64, var: f64) -> Self {
|
||||
pub fn from_mv(mu: f64, var: f64) -> Self {
|
||||
if var == f64::INFINITY {
|
||||
Self { pi: 0.0, tau: 0.0 }
|
||||
} else if var == 0.0 {
|
||||
@@ -107,8 +106,7 @@ impl Gaussian {
|
||||
/// means more certain; `0.0` is an improper, uninformative message and
|
||||
/// `inf` is a point mass.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn pi(&self) -> f64 {
|
||||
pub(crate) fn pi(&self) -> f64 {
|
||||
self.pi
|
||||
}
|
||||
|
||||
@@ -117,8 +115,7 @@ impl Gaussian {
|
||||
/// Stored rather than derived, for the same reason as [`Gaussian::pi`].
|
||||
/// Meaningful only alongside `pi`: on its own it is not a location.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn tau(&self) -> f64 {
|
||||
pub(crate) fn tau(&self) -> f64 {
|
||||
self.tau
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// (`pi == inf`) cases.
|
||||
/// Mirrors [`sigma`](Gaussian::sigma)'s treatment of the improper
|
||||
/// (infinite) and point-mass (zero) cases, and is the inverse of
|
||||
/// [`from_mv`](Gaussian::from_mv).
|
||||
#[inline]
|
||||
pub(crate) fn variance(&self) -> f64 {
|
||||
#[must_use]
|
||||
pub fn variance(&self) -> f64 {
|
||||
if self.pi <= 0.0 {
|
||||
f64::INFINITY
|
||||
} else if self.pi.is_infinite() {
|
||||
@@ -279,34 +278,65 @@ impl Default for Gaussian {
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Add<Gaussian> for Gaussian {
|
||||
type Output = Gaussian;
|
||||
/// Variance addition: (mu1 + mu2, sqrt(σ1² + σ2²)).
|
||||
/// Used for combining performance and noise; rare relative to mul/div.
|
||||
fn add(self, rhs: Gaussian) -> Self::Output {
|
||||
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Sub<Gaussian> for Gaussian {
|
||||
type Output = Gaussian;
|
||||
/// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add.
|
||||
fn sub(self, rhs: Gaussian) -> Self::Output {
|
||||
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Mul<Gaussian> for Gaussian {
|
||||
type Output = Gaussian;
|
||||
/// Factor product: nat-param add. Hot path — two f64 additions, no sqrt.
|
||||
fn mul(self, rhs: Gaussian) -> Self::Output {
|
||||
impl Gaussian {
|
||||
/// The EP factor **product**: multiply two messages about the same
|
||||
/// variable.
|
||||
///
|
||||
/// Two natural-parameter additions and no square root, which is why the
|
||||
/// type stores `pi` and `tau` rather than `mu` and `sigma`. This is the
|
||||
/// hot path.
|
||||
///
|
||||
/// Not arithmetic — `N(10, 2).ep_product(N(4, 3))` is `N(8.15, 1.66)`,
|
||||
/// nowhere near 40. It used to be spelled `a * b`, on a public `Mul` impl,
|
||||
/// where that was a trap rather than a shorthand.
|
||||
#[inline]
|
||||
pub(crate) fn ep_product(self, rhs: Gaussian) -> Gaussian {
|
||||
Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau)
|
||||
}
|
||||
|
||||
/// The EP **cavity**: divide out a message this belief already absorbed.
|
||||
///
|
||||
/// The inverse of [`ep_product`](Gaussian::ep_product), and two
|
||||
/// subtractions rather than two additions.
|
||||
///
|
||||
/// **May return an improper result.** Cancelling a message that carried
|
||||
/// most of the precision leaves `pi <= 0`, which is not a distribution.
|
||||
/// `mu()` reports `0.0` and `sigma()` reports `inf` for such a value —
|
||||
/// both are the accessors' policy for "undefined", not answers. Measured:
|
||||
/// `N(10, 2).cavity(N(1, 1))` has `pi = -0.75`, and its `mu()` prints a
|
||||
/// confident `0`. That is why this is not a public operator.
|
||||
#[inline]
|
||||
pub(crate) fn cavity(self, rhs: Gaussian) -> Gaussian {
|
||||
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
|
||||
}
|
||||
|
||||
impl ops::Mul<f64> for Gaussian {
|
||||
type Output = Gaussian;
|
||||
fn mul(self, scalar: f64) -> Self::Output {
|
||||
/// Convolve two independent Gaussians: `N(mu1 + mu2, sqrt(v1 + v2))`.
|
||||
///
|
||||
/// The distribution of a *sum* of independent variables, so the variances
|
||||
/// add — the result is always wider than either input. Used to combine a
|
||||
/// skill with performance noise. Goes through `from_mv` and takes no root.
|
||||
#[inline]
|
||||
pub(crate) fn convolve(self, rhs: Gaussian) -> Gaussian {
|
||||
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
|
||||
}
|
||||
|
||||
/// Convolve a *difference*: `N(mu1 - mu2, sqrt(v1 + v2))`.
|
||||
///
|
||||
/// The means subtract and the variances still **add**, because a
|
||||
/// difference of independent variables is no more certain than a sum. That
|
||||
/// is the half that made the old `Sub` impl misleading: `a - b` grew the
|
||||
/// sigma from 2 to `sqrt(4 + 9)`.
|
||||
#[inline]
|
||||
pub(crate) fn convolve_diff(self, rhs: Gaussian) -> Gaussian {
|
||||
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
|
||||
}
|
||||
|
||||
/// Scale by a constant: `mu` by `scalar`, `sigma` by `|scalar|`.
|
||||
///
|
||||
/// The one operation that *is* ordinary arithmetic — it is the
|
||||
/// distribution of `scalar * X`. Used for per-member weights.
|
||||
#[inline]
|
||||
pub(crate) fn scale(self, scalar: f64) -> Gaussian {
|
||||
if !scalar.is_finite() {
|
||||
return N_INF;
|
||||
}
|
||||
@@ -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)]
|
||||
mod tests {
|
||||
/// 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).
|
||||
let proper = Gaussian::from_ms(9.75, 1.256);
|
||||
let diff = proper - tiny_neg;
|
||||
let diff = proper.convolve_diff(tiny_neg);
|
||||
assert!(diff.pi().is_finite() && !diff.pi().is_nan());
|
||||
assert!(diff.tau().is_finite() && !diff.tau().is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add() {
|
||||
fn convolve_adds_variances() {
|
||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||
let m = Gaussian::from_ms(0.0, 1.0);
|
||||
let r = n + m;
|
||||
let r = n.convolve(m);
|
||||
assert!((r.mu() - 25.0).abs() < 1e-12);
|
||||
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sub() {
|
||||
fn convolve_diff_subtracts_means_and_adds_variances() {
|
||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||
let m = Gaussian::from_ms(1.0, 1.0);
|
||||
let r = n - m;
|
||||
let r = n.convolve_diff(m);
|
||||
assert!((r.mu() - 24.0).abs() < 1e-12);
|
||||
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mul() {
|
||||
fn ep_product_is_not_arithmetic() {
|
||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||
let m = Gaussian::from_ms(0.0, 1.0);
|
||||
let r = n * m;
|
||||
let r = n.ep_product(m);
|
||||
assert!((r.mu() - 0.35488958990536273).abs() < 1e-10);
|
||||
assert!((r.sigma() - 0.992876838486922).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_div() {
|
||||
fn cavity_undoes_a_product() {
|
||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||
let m = Gaussian::from_ms(0.0, 1.0);
|
||||
let r = m / n;
|
||||
let r = m.cavity(n);
|
||||
assert!((r.mu() - (-0.3652597402597402)).abs() < 1e-10);
|
||||
assert!((r.sigma() - 1.0072787050317253).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_n00_is_add_identity() {
|
||||
// N00 (sigma=0) is the additive identity for the variance-convolution Add op.
|
||||
// N_INF (sigma=inf) is the identity for the EP-product Mul op.
|
||||
// N00 (sigma=0) is the identity for `convolve`.
|
||||
// N_INF (sigma=inf) is the identity for `ep_product`.
|
||||
let g = Gaussian::from_ms(3.0, 2.0);
|
||||
let n00 = Gaussian::from_ms(0.0, 0.0);
|
||||
let r = n00 + g;
|
||||
let r = n00.convolve(g);
|
||||
assert!((r.mu() - g.mu()).abs() < 1e-12);
|
||||
assert!((r.sigma() - g.sigma()).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mul_is_factor_product() {
|
||||
// n * m in nat-params should be pi_n + pi_m, tau_n + tau_m
|
||||
fn ep_product_adds_natural_parameters() {
|
||||
// `ep_product` in nat-params should be pi_n + pi_m, tau_n + tau_m
|
||||
let n = Gaussian::from_ms(2.0, 3.0);
|
||||
let m = Gaussian::from_ms(1.0, 2.0);
|
||||
let r = n * m;
|
||||
let r = n.ep_product(m);
|
||||
let expected_pi = n.pi() + m.pi();
|
||||
let expected_tau = n.tau() + m.tau();
|
||||
assert!((r.pi() - expected_pi).abs() < 1e-15);
|
||||
@@ -451,10 +473,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_div_is_cavity() {
|
||||
fn cavity_subtracts_natural_parameters() {
|
||||
let n = Gaussian::from_ms(2.0, 1.0);
|
||||
let m = Gaussian::from_ms(1.0, 2.0);
|
||||
let r = n / m;
|
||||
let r = n.cavity(m);
|
||||
let expected_pi = n.pi() - m.pi();
|
||||
let expected_tau = n.tau() - m.tau();
|
||||
assert!((r.pi() - expected_pi).abs() < 1e-15);
|
||||
|
||||
+398
-346
File diff suppressed because it is too large
Load Diff
+406
-48
@@ -1,4 +1,4 @@
|
||||
//! Cholesky factorisation of a joint precision matrix.
|
||||
//! Sparse Cholesky factorisation of a joint precision matrix.
|
||||
//!
|
||||
//! Every question the joint answers is a *bilinear form* in the precision
|
||||
//! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the
|
||||
@@ -18,72 +18,324 @@
|
||||
//! negative number, where the same quantity as `|L^-1 c|^2` is a sum of
|
||||
//! squares and cannot.
|
||||
//!
|
||||
//! Factorising is `O(n^3)` and whitening is `O(n^2)`, so the split also
|
||||
//! matters structurally: the expensive half depends only on the fit, and is
|
||||
//! shared across every query a [`Joint`](crate::Joint) answers.
|
||||
//! # Why this is sparse (#52)
|
||||
//!
|
||||
//! A time-expanded joint is *extremely* sparse and gets sparser as the history
|
||||
//! grows: a row couples only to its own previous and next appearance through
|
||||
//! the drift link, and to whoever co-appeared in its slice. Measured on a
|
||||
//! 76-slice, 988-duel, 200-competitor history: `n = 1976`, `nnz = 7504`,
|
||||
//! **0.19% dense**.
|
||||
//!
|
||||
//! This used to store all `n^2` entries and run a dense `O(n^3)` factorisation
|
||||
//! over them. Two measurements decided the replacement:
|
||||
//!
|
||||
//! - **Ordering alone does nothing to a dense factorisation.** Its inner loops
|
||||
//! run over every `k` whether or not the entry is zero. A 700x700 banded
|
||||
//! matrix at 0.43% density factorised in 30.196 ms in band order and
|
||||
//! 29.544 ms under a scramble that destroyed the band — identical, as the
|
||||
//! flop count says it must be. Fill-reducing order is worth nothing until
|
||||
//! the factorisation skips zeros.
|
||||
//! - **Together they are worth four orders of magnitude.** On that `n = 1976`
|
||||
//! fixture, against `n^3/3 = 2.572e9` flops dense: sparse in the natural
|
||||
//! order needs `5.597e7` (46x better), and sparse under an AMD fill-reducing
|
||||
//! order needs `8.656e4` — **29,710x**. AMD is worth 646x *on top of*
|
||||
//! sparsity and nothing without it.
|
||||
//!
|
||||
//! Natural ordering fills in badly here for the reason #52 predicted: a
|
||||
//! competitor who appears in slice 0 and not again until slice 75 creates a
|
||||
//! drift link spanning nearly the whole matrix. `nnz(L)` is 292,437 under the
|
||||
//! natural order against 11,583 under AMD, from an `A` with 7,504.
|
||||
//!
|
||||
//! The ordering comes from `feral-amd`. The factorisation is the up-looking
|
||||
//! sparse Cholesky of Davis's *Direct Methods for Sparse Linear Systems*,
|
||||
//! written here rather than taken from a crate: the sparse solvers on
|
||||
//! crates.io either pull SIMD dispatch (`faer`, and `feral` itself, both
|
||||
//! through `pulp`), which would make results differ between an AVX-512 host
|
||||
//! and an AVX2 one — the same class of drift the `libm`-over-`std` decision
|
||||
//! was made to avoid — or are LGPL, or disclaim fill-reduction in their own
|
||||
//! docs.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A symmetric matrix accumulated entry by entry, before factorisation.
|
||||
///
|
||||
/// A `BTreeMap` rather than a hash map because the iteration order becomes the
|
||||
/// factorisation's summation order, and a hash map's order varies per process.
|
||||
/// `tests/cross_process_determinism.rs` exists because that has bitten before.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SymmetricBuilder {
|
||||
entries: BTreeMap<(usize, usize), f64>,
|
||||
}
|
||||
|
||||
impl SymmetricBuilder {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add `value` to entry `(row, col)`. Both triangles must be supplied.
|
||||
pub(crate) fn add(&mut self, row: usize, col: usize, value: f64) {
|
||||
*self.entries.entry((row, col)).or_insert(0.0) += value;
|
||||
}
|
||||
|
||||
/// The `(row, col)` positions that hold a nonzero. For the #52 measurement.
|
||||
#[cfg(feature = "measure-sparsity")]
|
||||
pub(crate) fn pattern(&self) -> impl Iterator<Item = (usize, usize)> + '_ {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|(_, v)| **v != 0.0)
|
||||
.map(|(&rc, _)| rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// A factorised symmetric positive-definite matrix, reusable across queries.
|
||||
pub(crate) struct Cholesky {
|
||||
/// Lower triangle of `L`, row-major `n * n`. The upper triangle is
|
||||
/// leftover scratch from the factorisation and is never read.
|
||||
l: Vec<f64>,
|
||||
n: usize,
|
||||
/// `inv[old] = new`: where each original row sits after the AMD reorder.
|
||||
inv: Vec<usize>,
|
||||
/// `L` in compressed-column form, permuted. Within a column the diagonal
|
||||
/// is first and the rest ascend by row.
|
||||
col_ptr: Vec<usize>,
|
||||
row_idx: Vec<usize>,
|
||||
val: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Cholesky {
|
||||
/// Factorise `a` (row-major, `n * n`, symmetric) into `L L^T`.
|
||||
///
|
||||
/// `a` is consumed as scratch.
|
||||
/// Factorise the accumulated matrix into `L L^T`, under a fill-reducing
|
||||
/// permutation.
|
||||
///
|
||||
/// Returns `None` if the matrix is not positive-definite, which for a
|
||||
/// precision matrix means the model is improper — a competitor with
|
||||
/// neither a proper prior nor any evidence.
|
||||
pub(crate) fn factor(mut a: Vec<f64>, n: usize) -> Option<Self> {
|
||||
debug_assert_eq!(a.len(), n * n);
|
||||
|
||||
for j in 0..n {
|
||||
let mut d = a[j * n + j];
|
||||
for k in 0..j {
|
||||
d -= a[j * n + k] * a[j * n + k];
|
||||
/// neither a proper prior nor any evidence — or if the ordering fails.
|
||||
pub(crate) fn factor(built: SymmetricBuilder, n: usize) -> Option<Self> {
|
||||
if n == 0 {
|
||||
return Some(Self {
|
||||
n: 0,
|
||||
inv: Vec::new(),
|
||||
col_ptr: vec![0],
|
||||
row_idx: Vec::new(),
|
||||
val: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let inv = Self::amd_permutation(n, &built)?;
|
||||
|
||||
// Upper triangle of the permuted matrix, column-major: column `c`
|
||||
// holds the rows `r <= c`. Exactly one of a symmetric pair survives
|
||||
// the `r <= c` filter, so nothing is double-counted.
|
||||
let mut cols: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
|
||||
for (&(old_r, old_c), &v) in &built.entries {
|
||||
if v == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (r, c) = (inv[old_r], inv[old_c]);
|
||||
if r <= c {
|
||||
cols[c].push((r, v));
|
||||
}
|
||||
}
|
||||
let mut a_ptr = Vec::with_capacity(n + 1);
|
||||
let mut a_row = Vec::new();
|
||||
let mut a_val = Vec::new();
|
||||
a_ptr.push(0usize);
|
||||
for col in &mut cols {
|
||||
col.sort_unstable_by_key(|&(r, _)| r);
|
||||
for &(r, v) in col.iter() {
|
||||
a_row.push(r);
|
||||
a_val.push(v);
|
||||
}
|
||||
a_ptr.push(a_row.len());
|
||||
}
|
||||
|
||||
let parent = Self::etree(n, &a_ptr, &a_row);
|
||||
|
||||
// Symbolic pass: how many entries each column of L will hold. Running
|
||||
// `ereach` per column costs O(nnz(L)) in total, which is the same order
|
||||
// as the numeric pass it sizes.
|
||||
let mut counts = vec![0usize; n];
|
||||
let mut stack = vec![0usize; n];
|
||||
let mut mark = vec![false; n];
|
||||
for k in 0..n {
|
||||
let top = Self::ereach(k, &a_ptr, &a_row, &parent, &mut stack, &mut mark);
|
||||
for &i in &stack[top..] {
|
||||
counts[i] += 1;
|
||||
}
|
||||
counts[k] += 1; // the diagonal
|
||||
}
|
||||
|
||||
let mut col_ptr = Vec::with_capacity(n + 1);
|
||||
col_ptr.push(0usize);
|
||||
for &c in &counts {
|
||||
col_ptr.push(col_ptr[col_ptr.len() - 1] + c);
|
||||
}
|
||||
let nnz = col_ptr[n];
|
||||
let mut row_idx = vec![0usize; nnz];
|
||||
let mut val = vec![0.0f64; nnz];
|
||||
|
||||
// `next[i]` is the slot column `i` will fill next. Column `i`'s
|
||||
// diagonal lands first, at `col_ptr[i]`, because nothing is written to
|
||||
// a column before its own iteration.
|
||||
let mut next: Vec<usize> = col_ptr[..n].to_vec();
|
||||
let mut x = vec![0.0f64; n];
|
||||
|
||||
for k in 0..n {
|
||||
let top = Self::ereach(k, &a_ptr, &a_row, &parent, &mut stack, &mut mark);
|
||||
|
||||
for p in a_ptr[k]..a_ptr[k + 1] {
|
||||
if a_row[p] <= k {
|
||||
x[a_row[p]] = a_val[p];
|
||||
}
|
||||
}
|
||||
|
||||
let mut d = x[k];
|
||||
x[k] = 0.0;
|
||||
|
||||
for &i in &stack[top..] {
|
||||
let lki = x[i] / val[col_ptr[i]];
|
||||
x[i] = 0.0;
|
||||
for p in col_ptr[i] + 1..next[i] {
|
||||
x[row_idx[p]] -= val[p] * lki;
|
||||
}
|
||||
d -= lki * lki;
|
||||
let p = next[i];
|
||||
next[i] += 1;
|
||||
row_idx[p] = k;
|
||||
val[p] = lki;
|
||||
}
|
||||
|
||||
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here
|
||||
// too, and a negated comparison would let it through as "not
|
||||
// positive".
|
||||
if d.is_nan() || d <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let d = d.sqrt();
|
||||
a[j * n + j] = d;
|
||||
|
||||
for i in j + 1..n {
|
||||
let mut s = a[i * n + j];
|
||||
for k in 0..j {
|
||||
s -= a[i * n + k] * a[j * n + k];
|
||||
}
|
||||
a[i * n + j] = s / d;
|
||||
}
|
||||
let p = next[k];
|
||||
next[k] += 1;
|
||||
row_idx[p] = k;
|
||||
val[p] = d.sqrt();
|
||||
}
|
||||
|
||||
Some(Self { l: a, n })
|
||||
Some(Self {
|
||||
n,
|
||||
inv,
|
||||
col_ptr,
|
||||
row_idx,
|
||||
val,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whiten a contrast: `y = L^-1 b`.
|
||||
/// AMD fill-reducing order, as `inv[old] = new`.
|
||||
fn amd_permutation(n: usize, built: &SymmetricBuilder) -> Option<Vec<usize>> {
|
||||
let mut cols: Vec<Vec<i32>> = vec![Vec::new(); n];
|
||||
for (&(r, c), &v) in &built.entries {
|
||||
if v != 0.0 {
|
||||
cols[c].push(i32::try_from(r).ok()?);
|
||||
}
|
||||
}
|
||||
let mut col_ptr = Vec::with_capacity(n + 1);
|
||||
let mut row_idx = Vec::new();
|
||||
col_ptr.push(0i32);
|
||||
for (j, col) in cols.iter_mut().enumerate() {
|
||||
col.push(i32::try_from(j).ok()?);
|
||||
col.sort_unstable();
|
||||
col.dedup();
|
||||
row_idx.extend_from_slice(col);
|
||||
col_ptr.push(i32::try_from(row_idx.len()).ok()?);
|
||||
}
|
||||
|
||||
let pattern = feral_amd::CscPattern::new(n, &col_ptr, &row_idx)?;
|
||||
// `perm[new] = old`; we want the inverse.
|
||||
let perm = feral_amd::amd_order(&pattern).ok()?;
|
||||
let mut inv = vec![0usize; n];
|
||||
for (new, &old) in perm.iter().enumerate() {
|
||||
inv[usize::try_from(old).ok()?] = new;
|
||||
}
|
||||
Some(inv)
|
||||
}
|
||||
|
||||
/// Elimination tree of the upper-triangular pattern. `usize::MAX` is "no
|
||||
/// parent", i.e. a root.
|
||||
fn etree(n: usize, col_ptr: &[usize], row_idx: &[usize]) -> Vec<usize> {
|
||||
let mut parent = vec![usize::MAX; n];
|
||||
let mut ancestor = vec![usize::MAX; n];
|
||||
for k in 0..n {
|
||||
for &row in &row_idx[col_ptr[k]..col_ptr[k + 1]] {
|
||||
let mut i = row;
|
||||
while i != usize::MAX && i < k {
|
||||
let next = ancestor[i];
|
||||
ancestor[i] = k;
|
||||
if next == usize::MAX {
|
||||
parent[i] = k;
|
||||
}
|
||||
i = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
parent
|
||||
}
|
||||
|
||||
/// Nonzero pattern of row `k` of `L`, written into `stack[top..n]` in
|
||||
/// topological order. Returns `top`.
|
||||
///
|
||||
/// `stack` is used from both ends — a scratch region from `0` while walking
|
||||
/// each path up the tree, and the result from `n` downwards. They cannot
|
||||
/// collide because every node is pushed at most once across the whole call.
|
||||
fn ereach(
|
||||
k: usize,
|
||||
col_ptr: &[usize],
|
||||
row_idx: &[usize],
|
||||
parent: &[usize],
|
||||
stack: &mut [usize],
|
||||
mark: &mut [bool],
|
||||
) -> usize {
|
||||
let n = mark.len();
|
||||
let mut top = n;
|
||||
mark[k] = true;
|
||||
for &row in &row_idx[col_ptr[k]..col_ptr[k + 1]] {
|
||||
let mut i = row;
|
||||
if i > k {
|
||||
continue;
|
||||
}
|
||||
let mut len = 0usize;
|
||||
while i != usize::MAX && !mark[i] {
|
||||
stack[len] = i;
|
||||
len += 1;
|
||||
mark[i] = true;
|
||||
i = parent[i];
|
||||
}
|
||||
// Reverse the path onto the output end, so the result stays in
|
||||
// topological order overall.
|
||||
while len > 0 {
|
||||
len -= 1;
|
||||
top -= 1;
|
||||
stack[top] = stack[len];
|
||||
}
|
||||
}
|
||||
for &i in &stack[top..] {
|
||||
mark[i] = false;
|
||||
}
|
||||
mark[k] = false;
|
||||
top
|
||||
}
|
||||
|
||||
/// Whiten a contrast: `y = L^-1 P b`.
|
||||
///
|
||||
/// The point of the result is the dot product, not the vector: for two
|
||||
/// contrasts `b` and `b'`, `y . y'` is `b^T A^-1 b'`. See the module docs.
|
||||
///
|
||||
/// The result is in the permuted order, and stays there — a dot product
|
||||
/// does not care, as long as both operands were permuted the same way.
|
||||
pub(crate) fn whiten(&self, b: &[f64]) -> Vec<f64> {
|
||||
debug_assert_eq!(b.len(), self.n);
|
||||
let n = self.n;
|
||||
let mut y = b.to_vec();
|
||||
for i in 0..n {
|
||||
// Folded from `y[i]` rather than summed and subtracted once, so the
|
||||
// accumulation order matches a plain substitution loop exactly.
|
||||
let row = &self.l[i * n..i * n + i];
|
||||
let s = row
|
||||
.iter()
|
||||
.zip(&y[..i])
|
||||
.fold(y[i], |acc, (l, v)| acc - l * v);
|
||||
y[i] = s / self.l[i * n + i];
|
||||
let mut y = vec![0.0f64; n];
|
||||
for (old, &v) in b.iter().enumerate() {
|
||||
y[self.inv[old]] = v;
|
||||
}
|
||||
for j in 0..n {
|
||||
y[j] /= self.val[self.col_ptr[j]];
|
||||
let yj = y[j];
|
||||
for p in self.col_ptr[j] + 1..self.col_ptr[j + 1] {
|
||||
y[self.row_idx[p]] -= self.val[p] * yj;
|
||||
}
|
||||
}
|
||||
y
|
||||
}
|
||||
@@ -98,11 +350,24 @@ pub(crate) fn bilinear(y: &[f64], y_prime: &[f64]) -> f64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Factorise a dense row-major matrix, for the goldens below.
|
||||
fn dense(a: &[f64], n: usize) -> Option<Cholesky> {
|
||||
let mut b = SymmetricBuilder::new();
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if a[i * n + j] != 0.0 {
|
||||
b.add(i, j, a[i * n + j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Cholesky::factor(b, n)
|
||||
}
|
||||
|
||||
/// `[[4, 1], [1, 3]] z = [1, 2]` has `z = [1/11, 7/11]`, so the quadratic
|
||||
/// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`.
|
||||
#[test]
|
||||
fn reproduces_a_known_quadratic_form() {
|
||||
let c = Cholesky::factor(vec![4.0, 1.0, 1.0, 3.0], 2).unwrap();
|
||||
let c = dense(&[4.0, 1.0, 1.0, 3.0], 2).unwrap();
|
||||
let y = c.whiten(&[1.0, 2.0]);
|
||||
assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12);
|
||||
}
|
||||
@@ -113,8 +378,8 @@ mod tests {
|
||||
fn recovers_the_inverse_diagonal() {
|
||||
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
|
||||
// [0.75, 1.0, 0.75].
|
||||
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
||||
let c = Cholesky::factor(a, 3).unwrap();
|
||||
let a = [2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
||||
let c = dense(&a, 3).unwrap();
|
||||
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
|
||||
let mut e = vec![0.0; 3];
|
||||
e[i] = 1.0;
|
||||
@@ -127,8 +392,8 @@ mod tests {
|
||||
#[test]
|
||||
fn recovers_an_off_diagonal_covariance() {
|
||||
// Same A; (A^-1)_{0,1} = 0.5.
|
||||
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
||||
let c = Cholesky::factor(a, 3).unwrap();
|
||||
let a = [2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
||||
let c = dense(&a, 3).unwrap();
|
||||
let y0 = c.whiten(&[1.0, 0.0, 0.0]);
|
||||
let y1 = c.whiten(&[0.0, 1.0, 0.0]);
|
||||
assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12);
|
||||
@@ -138,15 +403,108 @@ mod tests {
|
||||
/// A variance can never come out negative, because it is a sum of squares.
|
||||
#[test]
|
||||
fn a_quadratic_form_is_never_negative() {
|
||||
let a = vec![1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
|
||||
let c = Cholesky::factor(a, 2).unwrap();
|
||||
let a = [1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
|
||||
let c = dense(&a, 2).unwrap();
|
||||
let y = c.whiten(&[1.0, -1.0]);
|
||||
assert!(bilinear(&y, &y) >= 0.0);
|
||||
}
|
||||
|
||||
/// Against an independent dense reference, on random sparse SPD matrices.
|
||||
///
|
||||
/// The goldens above are 2x2 and 3x3 — small enough that AMD does nothing
|
||||
/// and no fill-in occurs, so they cannot catch a symbolic-pass bug. This
|
||||
/// builds matrices big enough to permute and fill in, and checks every
|
||||
/// bilinear form against a textbook dense factorisation of the *same*
|
||||
/// matrix in its original order.
|
||||
#[test]
|
||||
fn agrees_with_a_dense_reference_on_random_sparse_systems() {
|
||||
/// Dense Cholesky and quadratic form, deliberately naive: this is the
|
||||
/// reference, so it must not share code with what it is checking.
|
||||
fn dense_quadratic_form(a: &[f64], n: usize, b: &[f64], c: &[f64]) -> f64 {
|
||||
let mut l = a.to_vec();
|
||||
for j in 0..n {
|
||||
let mut d = l[j * n + j];
|
||||
for k in 0..j {
|
||||
d -= l[j * n + k] * l[j * n + k];
|
||||
}
|
||||
let d = d.sqrt();
|
||||
l[j * n + j] = d;
|
||||
for i in j + 1..n {
|
||||
let mut sum = l[i * n + j];
|
||||
for k in 0..j {
|
||||
sum -= l[i * n + k] * l[j * n + k];
|
||||
}
|
||||
l[i * n + j] = sum / d;
|
||||
}
|
||||
}
|
||||
let solve = |rhs: &[f64]| -> Vec<f64> {
|
||||
let mut y = rhs.to_vec();
|
||||
for i in 0..n {
|
||||
for k in 0..i {
|
||||
y[i] -= l[i * n + k] * y[k];
|
||||
}
|
||||
y[i] /= l[i * n + i];
|
||||
}
|
||||
y
|
||||
};
|
||||
let (yb, yc) = (solve(b), solve(c));
|
||||
yb.iter().zip(&yc).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
// A cheap deterministic generator; no dependency, and reproducible.
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut rand = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
|
||||
for n in [7usize, 23, 60] {
|
||||
let mut a = vec![0.0f64; n * n];
|
||||
// A chain plus scattered long-range couplings: the shape of a
|
||||
// time-expanded joint, where a competitor's drift link can span
|
||||
// the whole matrix.
|
||||
for i in 0..n {
|
||||
a[i * n + i] = 4.0 + rand();
|
||||
if i + 1 < n {
|
||||
let v = -(0.5 + rand() * 0.5);
|
||||
a[i * n + i + 1] = v;
|
||||
a[(i + 1) * n + i] = v;
|
||||
}
|
||||
}
|
||||
for step in 0..n / 3 {
|
||||
let i = (step * 7) % n;
|
||||
let j = (step * 29 + 3) % n;
|
||||
if i != j {
|
||||
let v = -(0.1 + rand() * 0.2);
|
||||
a[i * n + j] = v;
|
||||
a[j * n + i] = v;
|
||||
// Keep it diagonally dominant, hence positive-definite.
|
||||
a[i * n + i] += 0.6;
|
||||
a[j * n + j] += 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
let sparse = dense(&a, n).expect("spd");
|
||||
|
||||
for trial in 0..8 {
|
||||
let b: Vec<f64> = (0..n).map(|_| rand() * 2.0 - 1.0).collect();
|
||||
let c: Vec<f64> = (0..n).map(|_| rand() * 2.0 - 1.0).collect();
|
||||
let got = bilinear(&sparse.whiten(&b), &sparse.whiten(&c));
|
||||
let want = dense_quadratic_form(&a, n, &b, &c);
|
||||
assert!(
|
||||
(got - want).abs() <= 1e-10 * want.abs().max(1.0),
|
||||
"n={n} trial={trial}: sparse {got} vs dense {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A permutation must not change which matrices are rejected.
|
||||
#[test]
|
||||
fn rejects_a_non_positive_definite_matrix() {
|
||||
// Singular: the second row is a multiple of the first.
|
||||
assert!(Cholesky::factor(vec![1.0, 2.0, 2.0, 4.0], 2).is_none());
|
||||
assert!(dense(&[1.0, 2.0, 2.0, 4.0], 2).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+19
-14
@@ -9,6 +9,10 @@
|
||||
//! This is a Rust port of
|
||||
//! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
|
||||
//!
|
||||
//! Upgrading? `MIGRATING.md` in the repository root covers every breaking
|
||||
//! change, with the ones that alter what an existing call *returns* called out
|
||||
//! first.
|
||||
//!
|
||||
//! # Getting started
|
||||
//!
|
||||
//! Record results, converge, then read off skills:
|
||||
@@ -144,6 +148,7 @@ mod outcome;
|
||||
mod predict;
|
||||
pub(crate) mod quadrature;
|
||||
mod rating;
|
||||
pub mod rating_rule;
|
||||
pub(crate) mod storage;
|
||||
mod time;
|
||||
mod time_slice;
|
||||
@@ -151,10 +156,10 @@ mod time_slice;
|
||||
pub use acquisition::expected_information_gain;
|
||||
pub use convergence::{ConvergenceOptions, ConvergenceReport};
|
||||
pub use drift::{ConstantDrift, Drift};
|
||||
pub use error::{InferenceError, UnknownKeys};
|
||||
pub use error::{CompetitorField, InferenceError, OutcomeKind, Parameter, Shape, UnknownKeys};
|
||||
pub use event::{Event, Member, Team};
|
||||
pub use event_builder::EventBuilder;
|
||||
pub use game::{Game, GameOptions, OwnedGame};
|
||||
pub use game::{Game, GameOptions};
|
||||
pub use gaussian::Gaussian;
|
||||
pub use history::{History, HistoryBuilder, Joint};
|
||||
use matrix::Matrix;
|
||||
@@ -162,6 +167,7 @@ pub use observer::{NullObserver, Observer};
|
||||
pub use outcome::Outcome;
|
||||
pub use predict::Prediction;
|
||||
pub use rating::Rating;
|
||||
pub use rating_rule::{FnRule, NoRule, RatingRule, StartingPoint};
|
||||
/// The `smallvec` crate, re-exported.
|
||||
///
|
||||
/// Four public items name `SmallVec` in their signatures: [`Event::teams`],
|
||||
@@ -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.
|
||||
///
|
||||
/// [`History`] stores skills and messages by `Index` rather than by `K`, so
|
||||
/// the hot path never hashes a key. [`History::intern`] promotes a key to one
|
||||
/// and [`History::lookup`] resolves an existing key without creating.
|
||||
/// `History` stores skills and messages by `Index` rather than by `K`, so the
|
||||
/// hot path never hashes a key. Indices are assigned in interning order and
|
||||
/// are stable for the life of a history; they are not portable between
|
||||
/// histories, since the same key interns to a different slot under a different
|
||||
/// ingestion order.
|
||||
///
|
||||
/// Indices are assigned in interning order and are stable for the life of a
|
||||
/// history. They are **not** portable between histories: the same key interns
|
||||
/// to different slots depending on ingestion order.
|
||||
/// Crate-internal. It was public, along with `History::intern` and
|
||||
/// `History::lookup` that produced one — and **nothing public ever accepted
|
||||
/// one**, so it was a handle with nowhere to go. It also shadowed
|
||||
/// `std::ops::Index`, which `CompetitorStore` implements. See #73.
|
||||
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
|
||||
pub struct Index(usize);
|
||||
pub(crate) struct Index(usize);
|
||||
|
||||
impl Index {
|
||||
/// The underlying slot number.
|
||||
///
|
||||
/// Indices are dense and assigned in interning order, so this is usable as
|
||||
/// a key into a caller-side side table.
|
||||
#[must_use]
|
||||
pub fn get(self) -> usize {
|
||||
pub(crate) fn get(self) -> usize {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -84,7 +84,7 @@ impl Outcome {
|
||||
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
|
||||
if winner >= n {
|
||||
return Err(crate::InferenceError::InvalidParameter {
|
||||
name: "winner",
|
||||
parameter: crate::Parameter::WinnerIndex,
|
||||
value: f64::from(winner),
|
||||
});
|
||||
}
|
||||
@@ -113,11 +113,16 @@ impl Outcome {
|
||||
|
||||
/// 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
|
||||
/// non-positive or NaN value is allowed; the value is rejected with
|
||||
/// `InferenceError::InvalidParameter` when the event is ingested, so
|
||||
/// callers get an error rather than a panic.
|
||||
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, score_sigma: f64) -> Self {
|
||||
pub fn scores_with_noise<I: IntoIterator<Item = f64>>(scores: I, score_sigma: f64) -> Self {
|
||||
Self::Scored {
|
||||
scores: scores.into_iter().collect(),
|
||||
score_sigma: Some(score_sigma),
|
||||
@@ -211,7 +216,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn scores_with_sigma_round_trips() {
|
||||
let o = Outcome::scores_with_sigma([10.0, 4.0], 0.5);
|
||||
let o = Outcome::scores_with_noise([10.0, 4.0], 0.5);
|
||||
assert_eq!(o.team_count(), 2);
|
||||
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
|
||||
}
|
||||
@@ -227,7 +232,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn scores_with_sigma_sets_sigma_some() {
|
||||
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0);
|
||||
let o = Outcome::scores_with_noise([3.0, 1.0], 2.0);
|
||||
match o {
|
||||
Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(2.0)),
|
||||
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||
@@ -239,7 +244,7 @@ mod tests {
|
||||
/// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`.
|
||||
#[test]
|
||||
fn scores_with_sigma_defers_validation_to_ingestion() {
|
||||
let o = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
|
||||
let o = Outcome::scores_with_noise([3.0, 1.0], 0.0);
|
||||
match o {
|
||||
Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(0.0)),
|
||||
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+24
-16
@@ -9,7 +9,7 @@ use crate::{
|
||||
arena::ScratchArena,
|
||||
color_group::ColorGroups,
|
||||
drift::Drift,
|
||||
game::Game,
|
||||
game::GameRef,
|
||||
gaussian::Gaussian,
|
||||
rating::Rating,
|
||||
storage::{CompetitorStore, SkillStore},
|
||||
@@ -26,7 +26,9 @@ pub(crate) struct Skill {
|
||||
|
||||
impl Skill {
|
||||
pub(crate) fn posterior(&self) -> Gaussian {
|
||||
self.likelihood * self.backward * self.forward
|
||||
self.likelihood
|
||||
.ep_product(self.backward)
|
||||
.ep_product(self.forward)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +76,7 @@ impl Item {
|
||||
if forward {
|
||||
Rating::new(skill.forward, r.beta, r.drift).with_drift_scale(r.drift_scale)
|
||||
} else {
|
||||
Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift)
|
||||
Rating::new(skill.posterior().cavity(self.likelihood), r.beta, r.drift)
|
||||
.with_drift_scale(r.drift_scale)
|
||||
}
|
||||
}
|
||||
@@ -141,10 +143,15 @@ impl Event {
|
||||
let teams = self.within_priors(false, skills, competitors);
|
||||
let result = self.outputs();
|
||||
let g = match self.kind {
|
||||
EventKind::Ranked => {
|
||||
Game::ranked_with_arena(teams, &result, &self.weights, p_draw, convergence, arena)
|
||||
}
|
||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||
EventKind::Ranked => GameRef::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&self.weights,
|
||||
p_draw,
|
||||
convergence,
|
||||
arena,
|
||||
),
|
||||
EventKind::Scored { score_sigma } => GameRef::scored_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&self.weights,
|
||||
@@ -166,7 +173,7 @@ impl Event {
|
||||
for (i, item) in team.items.iter_mut().enumerate() {
|
||||
let fresh = update.likelihoods[t][i];
|
||||
let old_likelihood = skills.at(item.slot).likelihood;
|
||||
let new_likelihood = (old_likelihood / item.likelihood) * fresh;
|
||||
let new_likelihood = old_likelihood.cavity(item.likelihood).ep_product(fresh);
|
||||
skills.at_mut(item.slot).likelihood = new_likelihood;
|
||||
item.likelihood = fresh;
|
||||
}
|
||||
@@ -409,7 +416,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
let result = event.outputs();
|
||||
|
||||
let g = match event.kind {
|
||||
EventKind::Ranked => Game::ranked_with_arena(
|
||||
EventKind::Ranked => GameRef::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
@@ -417,7 +424,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
self.convergence,
|
||||
&mut self.arena,
|
||||
),
|
||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||
EventKind::Scored { score_sigma } => GameRef::scored_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
@@ -430,8 +437,9 @@ impl<T: Time> TimeSlice<T> {
|
||||
for (t, team) in event.teams.iter_mut().enumerate() {
|
||||
for (i, item) in team.items.iter_mut().enumerate() {
|
||||
let old_likelihood = self.skills.at(item.slot).likelihood;
|
||||
let new_likelihood =
|
||||
(old_likelihood / item.likelihood) * g.likelihoods[t][i];
|
||||
let new_likelihood = old_likelihood
|
||||
.cavity(item.likelihood)
|
||||
.ep_product(g.likelihoods[t][i]);
|
||||
self.skills.at_mut(item.slot).likelihood = new_likelihood;
|
||||
item.likelihood = g.likelihoods[t][i];
|
||||
}
|
||||
@@ -581,7 +589,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
|
||||
pub(crate) fn forward_prior_out(&self, competitor: &Index) -> Gaussian {
|
||||
let skill = self.skills.get(*competitor).unwrap();
|
||||
skill.forward * skill.likelihood
|
||||
skill.forward.ep_product(skill.likelihood)
|
||||
}
|
||||
|
||||
pub(crate) fn backward_prior_out<D: Drift<T>>(
|
||||
@@ -590,7 +598,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
competitors: &CompetitorStore<T, D>,
|
||||
) -> Gaussian {
|
||||
let skill = self.skills.get(*competitor).unwrap();
|
||||
let n = skill.likelihood * skill.backward;
|
||||
let n = skill.likelihood.ep_product(skill.backward);
|
||||
n.forget(
|
||||
competitors[*competitor]
|
||||
.rating
|
||||
@@ -724,7 +732,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
let result = event.outputs();
|
||||
match event.kind {
|
||||
EventKind::Ranked => {
|
||||
Game::ranked_with_arena(
|
||||
GameRef::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
@@ -735,7 +743,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
.log_evidence
|
||||
}
|
||||
EventKind::Scored { score_sigma } => {
|
||||
Game::scored_with_arena(
|
||||
GameRef::scored_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
|
||||
@@ -29,7 +29,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
|
||||
let players = ["p0", "p1", "p2"];
|
||||
let holes = ["h0", "h1"];
|
||||
|
||||
let mut h: History<i64, _, _, &'static str> = History::builder()
|
||||
let mut h: History = History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
@@ -80,7 +80,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
|
||||
|
||||
println!("\n== the same nodes via posterior_of (exact marginal) ==");
|
||||
for k in players.iter().chain(holes.iter()) {
|
||||
let g = h.posterior_of(&[(k, 1.0)]).unwrap();
|
||||
let g = h.joint().unwrap().posterior_of(&[(k, 1.0)]).unwrap();
|
||||
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
|
||||
vec![(&"h0", 1.0), (&"h1", -1.0)],
|
||||
),
|
||||
] {
|
||||
let joint = h.posterior_of(&terms).unwrap();
|
||||
let joint = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||
// what a consumer gets today by adding marginals
|
||||
let naive: f64 = terms
|
||||
.iter()
|
||||
@@ -132,7 +132,12 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
|
||||
// shares with its partners is pinned only by the prior.
|
||||
for k in players.iter().chain(holes.iter()) {
|
||||
let bp = h.current_skill(k).unwrap().sigma();
|
||||
let exact = h.posterior_of(&[(k, 1.0)]).unwrap().sigma();
|
||||
let exact = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&[(k, 1.0)])
|
||||
.unwrap()
|
||||
.sigma();
|
||||
assert!(
|
||||
exact > 3.0 * bp,
|
||||
"{k}: exact marginal {exact} should be much wider than the reported \
|
||||
|
||||
+7
-7
@@ -41,9 +41,9 @@ fn add_events_bulk_via_iter() {
|
||||
h.add_events(events).unwrap();
|
||||
let report = h.converge().unwrap();
|
||||
assert!(report.converged);
|
||||
assert!(h.lookup(&"a").is_some());
|
||||
assert!(h.lookup(&"b").is_some());
|
||||
assert!(h.lookup(&"c").is_some());
|
||||
assert!(h.current_skill("a").is_some());
|
||||
assert!(h.current_skill("b").is_some());
|
||||
assert!(h.current_skill("c").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -103,9 +103,9 @@ fn fluent_event_builder_basic() {
|
||||
|
||||
let report = h.converge().unwrap();
|
||||
assert!(report.converged);
|
||||
assert!(h.lookup(&"alice").is_some());
|
||||
assert!(h.lookup(&"bob").is_some());
|
||||
assert!(h.lookup(&"carol").is_some());
|
||||
assert!(h.current_skill("alice").is_some());
|
||||
assert!(h.current_skill("bob").is_some());
|
||||
assert!(h.current_skill("carol").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -203,7 +203,7 @@ fn predict_quality_two_teams() {
|
||||
h.record_winner(&"a", &"b", 1).unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
|
||||
let q = h.predict_quality(&[&[&"a"], &[&"b"]]).unwrap();
|
||||
let q = h.quality(&[&[&"a"], &[&"b"]]).unwrap();
|
||||
assert!(q > 0.0 && q <= 1.0);
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,10 @@ fn a_batch_declaring_two_different_priors_is_rejected() {
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::ConflictingCompetitorConfig { field: "prior", .. }
|
||||
InferenceError::ConflictingCompetitorConfig {
|
||||
field: trueskill_tt::CompetitorField::Prior,
|
||||
..
|
||||
}
|
||||
),
|
||||
"got {err:?}"
|
||||
);
|
||||
|
||||
@@ -102,7 +102,7 @@ fn every_magnitude_parameter_rejects_a_negative_value() {
|
||||
}),
|
||||
),
|
||||
(
|
||||
"Outcome::scores_with_sigma (at ingestion)",
|
||||
"Outcome::scores_with_noise (at ingestion)",
|
||||
Box::new(|v| {
|
||||
let mut h = History::builder().build();
|
||||
h.add_events(vec![trueskill_tt::Event {
|
||||
@@ -111,7 +111,7 @@ fn every_magnitude_parameter_rejects_a_negative_value() {
|
||||
trueskill_tt::Team::with_members([Member::new("a")]),
|
||||
trueskill_tt::Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores_with_sigma([3.0, 1.0], v),
|
||||
outcome: Outcome::scores_with_noise([3.0, 1.0], v),
|
||||
}])
|
||||
.is_err()
|
||||
}),
|
||||
|
||||
@@ -11,7 +11,7 @@ use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
|
||||
};
|
||||
|
||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||
type H = History;
|
||||
|
||||
fn duel(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
|
||||
Event {
|
||||
@@ -108,7 +108,7 @@ fn the_two_agree_on_a_converged_fit() {
|
||||
/// At the old value of 30 this history stopped short and said nothing.
|
||||
#[test]
|
||||
fn the_default_cap_clears_an_ordinary_history() {
|
||||
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
|
||||
let mut h: History<String> = History::builder()
|
||||
.key_type::<String>()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
|
||||
@@ -15,8 +15,7 @@ use std::{env, process::Command};
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
|
||||
UnknownKeys,
|
||||
ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team, UnknownKeys,
|
||||
};
|
||||
|
||||
/// Set in the child so it reports instead of re-spawning.
|
||||
@@ -24,7 +23,7 @@ const CHILD: &str = "TSTT_DETERMINISM_CHILD";
|
||||
|
||||
const RUNS: usize = 40;
|
||||
|
||||
type H = History<i64, ConstantDrift, NullObserver, String>;
|
||||
type H = History<String>;
|
||||
|
||||
fn fitted() -> H {
|
||||
let mut h: H = History::builder()
|
||||
@@ -84,13 +83,17 @@ fn fingerprint() -> String {
|
||||
let known = "p0".to_string();
|
||||
terms.push((&known, -1.0));
|
||||
|
||||
let posterior = h.posterior_of(&terms).unwrap();
|
||||
let posterior = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||
|
||||
let a = "p0".to_string();
|
||||
let b = "p1".to_string();
|
||||
let target = [(&a, 1.0), (&b, -1.0)];
|
||||
let teams: [&[&String]; 2] = [&[&a], &[&b]];
|
||||
let evr = h.expected_variance_reduction(&teams, &target).unwrap();
|
||||
let evr = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.expected_variance_reduction(&teams, &target)
|
||||
.unwrap();
|
||||
|
||||
let curves = h.learning_curves();
|
||||
let mut curve_bits: u64 = 0;
|
||||
|
||||
@@ -8,7 +8,7 @@ mod common;
|
||||
use common::assert_finite;
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
|
||||
NullObserver, Outcome, Rating,
|
||||
Outcome, Rating,
|
||||
};
|
||||
|
||||
type R = Rating<i64, ConstantDrift>;
|
||||
@@ -126,7 +126,7 @@ fn empty_history_converges_trivially() {
|
||||
/// indexed out of bounds in release, so this must run in both profiles.
|
||||
#[test]
|
||||
fn converge_on_an_empty_history_with_owned_keys() {
|
||||
let mut history: History<i64, ConstantDrift, NullObserver, String> = History::builder()
|
||||
let mut history: History<String> = History::builder()
|
||||
.key_type::<String>()
|
||||
.score_sigma(5.0)
|
||||
.build();
|
||||
@@ -157,7 +157,7 @@ fn event_builder_rejects_a_weights_length_mismatch() {
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::MismatchedShape {
|
||||
kind: "weights",
|
||||
shape: trueskill_tt::Shape::Weights,
|
||||
expected: 1,
|
||||
got: 2,
|
||||
..
|
||||
@@ -222,13 +222,13 @@ fn scored_event_rejects_non_positive_sigma() {
|
||||
.event(1)
|
||||
.team(["a"])
|
||||
.team(["b"])
|
||||
.scores_with_sigma([3.0, 1.0], f64::NAN)
|
||||
.scores_with_noise([3.0, 1.0], f64::NAN)
|
||||
.commit()
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
name: "score_sigma",
|
||||
parameter: trueskill_tt::Parameter::ScoreSigma,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member,
|
||||
NullObserver, Outcome, Team,
|
||||
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
|
||||
Team,
|
||||
};
|
||||
|
||||
type Fit = History<i64, ConstantDrift, NullObserver, &'static str>;
|
||||
type Fit = History;
|
||||
|
||||
const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
|
||||
max_iter: 64,
|
||||
@@ -279,7 +279,7 @@ fn reject(scale: f64) -> InferenceError {
|
||||
fn negative_scale_is_rejected() {
|
||||
assert!(matches!(
|
||||
reject(-1.0),
|
||||
InferenceError::InvalidParameter { name: "drift_scale", value, .. }
|
||||
InferenceError::InvalidParameter { parameter: trueskill_tt::Parameter::DriftScale, value, .. }
|
||||
if value == -1.0
|
||||
));
|
||||
}
|
||||
@@ -291,7 +291,7 @@ fn non_finite_scale_is_rejected() {
|
||||
matches!(
|
||||
reject(scale),
|
||||
InferenceError::InvalidParameter {
|
||||
name: "drift_scale",
|
||||
parameter: trueskill_tt::Parameter::DriftScale,
|
||||
..
|
||||
}
|
||||
),
|
||||
@@ -488,7 +488,7 @@ fn a_batch_that_contradicts_itself_is_rejected() {
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::ConflictingCompetitorConfig {
|
||||
field: "drift_scale",
|
||||
field: trueskill_tt::CompetitorField::DriftScale,
|
||||
..
|
||||
}
|
||||
),
|
||||
|
||||
@@ -23,8 +23,10 @@ fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R {
|
||||
fn game_1v1_golden_matches_historical() {
|
||||
let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
|
||||
let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
|
||||
let (a_post, b_post) =
|
||||
Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
|
||||
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default())
|
||||
.unwrap()
|
||||
.posteriors();
|
||||
let (a_post, b_post) = (post[0][0], post[1][0]);
|
||||
// Historical golden from pre-T2 test_1vs1 (team 0 wins):
|
||||
assert_ulps_eq!(
|
||||
a_post,
|
||||
|
||||
@@ -11,7 +11,7 @@ use trueskill_tt::{
|
||||
Team,
|
||||
};
|
||||
|
||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||
type H = History;
|
||||
|
||||
fn history() -> H {
|
||||
History::builder()
|
||||
@@ -64,8 +64,11 @@ fn members_matches_the_typed_path_exactly() {
|
||||
for key in ["player", "layout_7"] {
|
||||
let a = typed.current_skill(&key).unwrap();
|
||||
let b = fluent.current_skill(&key).unwrap();
|
||||
assert_eq!(a.pi(), b.pi(), "{key} pi");
|
||||
assert_eq!(a.tau(), b.tau(), "{key} tau");
|
||||
// Exact equality, on the public moments rather than the natural
|
||||
// parameters: `mu` and `variance` are `tau/pi` and `1/pi`, so
|
||||
// bit-equal natural parameters give bit-equal moments.
|
||||
assert_eq!(a.mu(), b.mu(), "{key} mu");
|
||||
assert_eq!(a.variance(), b.variance(), "{key} variance");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +136,7 @@ fn weights_still_guards_a_members_team() {
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::MismatchedShape {
|
||||
kind: "weights",
|
||||
shape: trueskill_tt::Shape::Weights,
|
||||
expected: 2,
|
||||
got: 1,
|
||||
..
|
||||
@@ -161,7 +164,7 @@ fn an_invalid_drift_scale_surfaces_from_commit() {
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
name: "drift_scale",
|
||||
parameter: trueskill_tt::Parameter::DriftScale,
|
||||
..
|
||||
}
|
||||
),
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
//! `filtered_log_evidence_for` was the missing corner: the one a per-competitor
|
||||
//! prequential score needs.
|
||||
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, Event, History, InferenceError, Member, NullObserver, Outcome, Team,
|
||||
};
|
||||
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
|
||||
|
||||
type H = History<i64, ConstantDrift, NullObserver, &'static str>;
|
||||
type H = History;
|
||||
|
||||
/// Two disjoint cohorts, so a key restriction is guaranteed to leave events out.
|
||||
fn two_cohorts() -> H {
|
||||
|
||||
+23
-7
@@ -32,10 +32,16 @@ fn game_ranked_1v1_golden() {
|
||||
fn game_one_v_one_shortcut() {
|
||||
let a = default_rating();
|
||||
let b = default_rating();
|
||||
let (a_post, b_post) =
|
||||
let game =
|
||||
Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
|
||||
let post = game.posteriors();
|
||||
let (a_post, b_post) = (post[0][0], post[1][0]);
|
||||
assert!(a_post.mu() > 25.0);
|
||||
assert!(b_post.mu() < 25.0);
|
||||
|
||||
// It returns a game like every other constructor, so evidence is askable.
|
||||
// Two identical ratings make either result equally likely.
|
||||
assert!((game.log_evidence() - 0.5_f64.ln()).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -51,7 +57,7 @@ fn game_ranked_rejects_bad_p_draw() {
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, InferenceError::InvalidProbability { .. }));
|
||||
assert!(matches!(err, InferenceError::InvalidParameter { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -118,8 +124,10 @@ fn one_v_one_honours_the_draw_probability_it_is_given() {
|
||||
p_draw: 0.25,
|
||||
..GameOptions::default()
|
||||
};
|
||||
let (a_post, b_post) = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &options)
|
||||
.expect("a draw is representable once p_draw is positive");
|
||||
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &options)
|
||||
.expect("a draw is representable once p_draw is positive")
|
||||
.posteriors();
|
||||
let (a_post, b_post) = (post[0][0], post[1][0]);
|
||||
|
||||
// A symmetric draw leaves the means alone and sharpens both sides.
|
||||
assert!((a_post.mu() - b_post.mu()).abs() < 1e-9);
|
||||
@@ -135,8 +143,10 @@ fn one_v_one_honours_convergence_options() {
|
||||
convergence: ConvergenceOptions::default(),
|
||||
..GameOptions::default()
|
||||
};
|
||||
let (a_post, _) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options).unwrap();
|
||||
assert!(a_post.mu() > 25.0);
|
||||
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options)
|
||||
.unwrap()
|
||||
.posteriors();
|
||||
assert!(post[0][0].mu() > 25.0);
|
||||
}
|
||||
|
||||
/// `Game` is a public entry point that does not pass through `History`'s
|
||||
@@ -217,7 +227,13 @@ mod malformed_games {
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
parameter: trueskill_tt::Parameter::Score,
|
||||
..
|
||||
}
|
||||
),
|
||||
"{bad}: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
//! Each test carries a control: the same call on a key the history *does* know,
|
||||
//! so it cannot pass merely because everything returns the same thing.
|
||||
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, Event, History, InferenceError, Member, NullObserver, Outcome, Team,
|
||||
};
|
||||
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
|
||||
|
||||
type H = History<i64, ConstantDrift, NullObserver, &'static str>;
|
||||
type H = History;
|
||||
|
||||
fn history() -> H {
|
||||
let mut h = H::default();
|
||||
|
||||
@@ -47,7 +47,7 @@ fn configured_event(a: &str, b: &str, time: i64, scale: f64) -> Event<i64, Strin
|
||||
}
|
||||
|
||||
fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
|
||||
let mut h: History<i64, _, _, String> = History::builder()
|
||||
let mut h: History<String> = History::builder()
|
||||
.key_type::<String>()
|
||||
.convergence(tight())
|
||||
.build();
|
||||
|
||||
@@ -14,8 +14,7 @@ use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
|
||||
|
||||
type Ev = Event<i64, &'static str>;
|
||||
|
||||
fn history() -> History<i64, trueskill_tt::ConstantDrift, trueskill_tt::NullObserver, &'static str>
|
||||
{
|
||||
fn history() -> History {
|
||||
History::builder().score_sigma(1.0).build()
|
||||
}
|
||||
|
||||
@@ -113,7 +112,13 @@ fn a_non_finite_score_is_rejected_at_ingestion() {
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
parameter: trueskill_tt::Parameter::Score,
|
||||
..
|
||||
}
|
||||
),
|
||||
"{bad}: {err:?}"
|
||||
);
|
||||
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
|
||||
@@ -137,7 +142,13 @@ fn a_non_finite_weight_is_rejected_at_ingestion() {
|
||||
.commit()
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
parameter: trueskill_tt::Parameter::Weight,
|
||||
..
|
||||
}
|
||||
),
|
||||
"{bad}: {err:?}"
|
||||
);
|
||||
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
|
||||
|
||||
+31
-18
@@ -11,7 +11,7 @@ use trueskill_tt::{
|
||||
UnknownKeys,
|
||||
};
|
||||
|
||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||
type H = History;
|
||||
|
||||
fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> {
|
||||
Event {
|
||||
@@ -78,17 +78,22 @@ const PAIRS: [(&str, &str); 6] = [
|
||||
("c", "d"),
|
||||
];
|
||||
|
||||
/// A joint reused across questions answers exactly what a fresh one per
|
||||
/// question does. That is the whole correctness claim behind caching the
|
||||
/// factorisation (#51); it used to be checked against the `History` one-shot
|
||||
/// wrappers, which were deleted in #78, so it is checked against a fresh
|
||||
/// factorisation instead — the same comparison, without the wrapper.
|
||||
#[test]
|
||||
fn a_joint_answers_exactly_what_the_one_shot_call_does() {
|
||||
fn a_reused_joint_answers_exactly_what_a_fresh_one_does() {
|
||||
let h = fitted(UnknownKeys::Reject);
|
||||
let joint = h.joint().unwrap();
|
||||
|
||||
for (a, b) in PAIRS {
|
||||
let terms = [(&a, 1.0), (&b, -1.0)];
|
||||
let one_shot = h.posterior_of(&terms).unwrap();
|
||||
let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||
let cached = joint.posterior_of(&terms).unwrap();
|
||||
assert_eq!(one_shot.pi(), cached.pi(), "{a} - {b}");
|
||||
assert_eq!(one_shot.tau(), cached.tau(), "{a} - {b}");
|
||||
assert_eq!(one_shot.mu(), cached.mu(), "{a} - {b}");
|
||||
assert_eq!(one_shot.variance(), cached.variance(), "{a} - {b}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,12 +105,12 @@ fn a_joint_agrees_at_a_pinned_time_too() {
|
||||
for time in 1..=5 {
|
||||
for (a, b) in PAIRS {
|
||||
let terms = [(&a, 1.0), (&b, -1.0)];
|
||||
let one_shot = h.posterior_of_at(time, &terms);
|
||||
let one_shot = h.joint().unwrap().posterior_of_at(time, &terms);
|
||||
let cached = joint.posterior_of_at(time, &terms);
|
||||
match (one_shot, cached) {
|
||||
(Ok(x), Ok(y)) => {
|
||||
assert_eq!(x.pi(), y.pi(), "t={time} {a} - {b}");
|
||||
assert_eq!(x.tau(), y.tau(), "t={time} {a} - {b}");
|
||||
assert_eq!(x.mu(), y.mu(), "t={time} {a} - {b}");
|
||||
assert_eq!(x.variance(), y.variance(), "t={time} {a} - {b}");
|
||||
}
|
||||
(Err(x), Err(y)) => assert_eq!(x, y, "t={time} {a} - {b}"),
|
||||
(x, y) => panic!("t={time} {a} - {b}: disagreed on success: {x:?} vs {y:?}"),
|
||||
@@ -123,7 +128,11 @@ fn a_joint_scores_candidate_matchups_identically() {
|
||||
|
||||
for (x, y) in PAIRS {
|
||||
let teams: [&[&&str]; 2] = [&[&x], &[&y]];
|
||||
let one_shot = h.expected_variance_reduction(&teams, &target).unwrap();
|
||||
let one_shot = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.expected_variance_reduction(&teams, &target)
|
||||
.unwrap();
|
||||
let cached = joint.expected_variance_reduction(&teams, &target).unwrap();
|
||||
assert_eq!(one_shot, cached, "{x} vs {y}");
|
||||
}
|
||||
@@ -222,16 +231,20 @@ fn a_ranked_history_has_no_exact_joint() {
|
||||
let _ = h.converge().unwrap();
|
||||
assert!(matches!(
|
||||
h.joint().unwrap_err(),
|
||||
InferenceError::JointUnavailable { .. }
|
||||
InferenceError::JointRequiresScoredEvents
|
||||
));
|
||||
}
|
||||
|
||||
/// Distinguishable from the ranked case, which is the point of splitting
|
||||
/// `JointUnavailable { reason: &str }` into three variants (#74): "add events"
|
||||
/// and "use predict_win_probabilities" are different instructions, and telling
|
||||
/// them apart used to mean matching on English prose.
|
||||
#[test]
|
||||
fn an_empty_history_has_no_joint() {
|
||||
let h = history(UnknownKeys::Reject);
|
||||
assert!(matches!(
|
||||
h.joint().unwrap_err(),
|
||||
InferenceError::JointUnavailable { .. }
|
||||
InferenceError::EmptyHistory
|
||||
));
|
||||
}
|
||||
|
||||
@@ -252,18 +265,18 @@ fn unknown_keys_are_rejected_per_query() {
|
||||
}
|
||||
|
||||
/// Under `Prior`, an unseen competitor is independent of everything in the
|
||||
/// history, and the cached path must add the same prior variance the one-shot
|
||||
/// path does.
|
||||
/// history, and a reused joint must add the same prior variance a fresh one
|
||||
/// does.
|
||||
#[test]
|
||||
fn unseen_competitors_match_the_one_shot_path() {
|
||||
fn unseen_competitors_match_a_fresh_factorisation() {
|
||||
let h = fitted(UnknownKeys::Prior);
|
||||
let joint = h.joint().unwrap();
|
||||
let (a, z) = ("a", "nobody");
|
||||
let terms = [(&a, 1.0), (&z, -1.0)];
|
||||
let one_shot = h.posterior_of(&terms).unwrap();
|
||||
let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||
let cached = joint.posterior_of(&terms).unwrap();
|
||||
assert_eq!(one_shot.pi(), cached.pi());
|
||||
assert_eq!(one_shot.tau(), cached.tau());
|
||||
assert_eq!(one_shot.mu(), cached.mu());
|
||||
assert_eq!(one_shot.variance(), cached.variance());
|
||||
}
|
||||
|
||||
/// A drift too small to represent must collapse, not corrupt the matrix.
|
||||
@@ -279,7 +292,7 @@ fn unseen_competitors_match_the_one_shot_path() {
|
||||
#[test]
|
||||
fn a_drift_too_small_to_represent_collapses_rather_than_corrupting() {
|
||||
fn variance(scale: f64) -> f64 {
|
||||
let mut h: History<i64, ConstantDrift, _, String> = History::builder()
|
||||
let mut h: History<String> = History::builder()
|
||||
.key_type::<String>()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! The realistic program: keys arrive owned, queries are written with literals.
|
||||
//!
|
||||
//! Every prediction and joint query used to take `&[&[&K]]`, which at
|
||||
//! `K = String` made a string literal *impossible* — the shape required three
|
||||
//! levels of temporaries that all had to outlive the call. They are generic
|
||||
//! over the borrowed key now, so one spelling works at both key types.
|
||||
//!
|
||||
//! Both key types are exercised in every test, because the point is that the
|
||||
//! spelling is the same.
|
||||
|
||||
use trueskill_tt::{ConstantDrift, History};
|
||||
|
||||
type Owned = History<String>;
|
||||
type Borrowed = History;
|
||||
|
||||
fn owned() -> Owned {
|
||||
let mut h: Owned = History::builder().key_type::<String>().build();
|
||||
for t in 1..=4 {
|
||||
h.record_winner(&"alice".to_string(), &"bob".to_string(), t)
|
||||
.expect("ingests");
|
||||
}
|
||||
h.converge().expect("converges");
|
||||
h
|
||||
}
|
||||
|
||||
fn borrowed() -> Borrowed {
|
||||
let mut h = History::default();
|
||||
for t in 1..=4 {
|
||||
h.record_winner(&"alice", &"bob", t).expect("ingests");
|
||||
}
|
||||
h.converge().expect("converges");
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predictions_take_literals_at_either_key_type() {
|
||||
let teams: &[&[&str]] = &[&["alice"], &["bob"]];
|
||||
|
||||
let a = owned()
|
||||
.predict_win_probabilities(teams)
|
||||
.expect("K = String");
|
||||
let b = borrowed()
|
||||
.predict_win_probabilities(teams)
|
||||
.expect("K = &'static str");
|
||||
|
||||
assert_eq!(a, b, "the same fit through the same spelling");
|
||||
assert!(a[0] > a[1], "alice won every game");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_team_shaped_query_accepts_the_same_slice() {
|
||||
let h = owned();
|
||||
let teams: &[&[&str]] = &[&["alice"], &["bob"]];
|
||||
|
||||
h.quality(teams).expect("quality");
|
||||
let _ = h.predict_outcome(teams).expect("outcome");
|
||||
h.predict_ranking(teams, &[0, 1]).expect("ranking");
|
||||
h.expected_information_gain(teams)
|
||||
.expect("information gain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_combinations_take_bare_keys() {
|
||||
// `&[(&K, f64)]` at `K = String` meant `&[(&String, f64)]` — no literals.
|
||||
// A scored history, because the joint needs one.
|
||||
let mut h: Owned = History::builder().key_type::<String>().build();
|
||||
for t in 1..=4 {
|
||||
h.event(t)
|
||||
.team([String::from("alice")])
|
||||
.team([String::from("bob")])
|
||||
.scores([21.0, 9.0])
|
||||
.commit()
|
||||
.expect("ingests");
|
||||
}
|
||||
h.converge().expect("converges");
|
||||
|
||||
let terms: &[(&str, f64)] = &[("alice", 1.0), ("bob", -1.0)];
|
||||
let gap = h
|
||||
.joint()
|
||||
.expect("scored history has a joint")
|
||||
.posterior_of(terms)
|
||||
.expect("both keys are known");
|
||||
|
||||
assert!(gap.mu() > 0.0, "alice outscored bob every round");
|
||||
}
|
||||
|
||||
/// `lookup` is gone with `Index` (#73); the accessors that answer the same
|
||||
/// question all take a borrowed key.
|
||||
#[test]
|
||||
fn membership_queries_accept_a_borrowed_key() {
|
||||
let h = owned();
|
||||
assert!(h.current_skill("alice").is_some());
|
||||
assert!(h.rating("alice").is_some());
|
||||
assert!(h.learning_curve("alice").is_some());
|
||||
|
||||
assert!(h.current_skill("nobody").is_none());
|
||||
assert!(h.rating("nobody").is_none());
|
||||
assert!(h.learning_curve("nobody").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamma_sets_drift_without_naming_constant_drift() {
|
||||
let mut a: Borrowed = History::builder().gamma(0.5).build();
|
||||
let mut b: Borrowed = History::builder().drift(ConstantDrift::new(0.5)).build();
|
||||
|
||||
for h in [&mut a, &mut b] {
|
||||
h.record_winner(&"x", &"y", 1).unwrap();
|
||||
h.record_winner(&"y", &"x", 100).unwrap();
|
||||
h.converge().unwrap();
|
||||
}
|
||||
|
||||
let (ga, gb) = (a.current_skill("x").unwrap(), b.current_skill("x").unwrap());
|
||||
assert_eq!((ga.mu(), ga.sigma()), (gb.mu(), gb.sigma()));
|
||||
|
||||
// Control: the shorthand is not a no-op — a different gamma differs.
|
||||
let mut c: Borrowed = History::builder().gamma(0.0).build();
|
||||
c.record_winner(&"x", &"y", 1).unwrap();
|
||||
c.record_winner(&"y", &"x", 100).unwrap();
|
||||
c.converge().unwrap();
|
||||
assert_ne!(c.current_skill("x").unwrap().sigma(), ga.sigma());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "gamma must be finite and non-negative")]
|
||||
fn a_negative_gamma_is_rejected_rather_than_squared_away() {
|
||||
let _: Borrowed = History::builder().gamma(-0.5).build();
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the
|
||||
//! moment-space `Sub` in the game chain propagated into every skill once the slice grew past
|
||||
//! ~75 competitors (e.g. a real ranking dataset with hundreds of players).
|
||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS, NullObserver};
|
||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS};
|
||||
|
||||
/// Tiny deterministic LCG — avoids a dev-dependency on `rand`.
|
||||
struct Lcg(u64);
|
||||
@@ -24,7 +24,7 @@ impl Lcg {
|
||||
}
|
||||
|
||||
fn nan_after_fit(players: usize) -> usize {
|
||||
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder()
|
||||
let mut h: History<String> = History::builder()
|
||||
.key_type::<String>()
|
||||
.beta(1.0)
|
||||
.sigma(6.0)
|
||||
|
||||
@@ -132,10 +132,8 @@ fn key(i: usize) -> &'static str {
|
||||
}
|
||||
|
||||
/// Returns (worst mean error, worst sd ratio).
|
||||
fn fitted(
|
||||
obs: &[(usize, usize, f64)],
|
||||
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
|
||||
let mut h: History<i64, _, _, &'static str> = History::builder()
|
||||
fn fitted(obs: &[(usize, usize, f64)]) -> History {
|
||||
let mut h: History = History::builder()
|
||||
.mu(MU0)
|
||||
.sigma(SIGMA0)
|
||||
.beta(BETA)
|
||||
@@ -281,6 +279,8 @@ fn posterior_of_matches_the_exact_joint() {
|
||||
|
||||
for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] {
|
||||
let got = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)])
|
||||
.expect("scored slice should have a joint");
|
||||
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
|
||||
@@ -302,7 +302,7 @@ fn posterior_of_matches_the_exact_joint() {
|
||||
|
||||
// A single competitor: this is where the loopy marginal was 2x narrow.
|
||||
for (i, row) in cov.iter().enumerate() {
|
||||
let got = h.posterior_of(&[(&key(i), 1.0)]).unwrap();
|
||||
let got = h.joint().unwrap().posterior_of(&[(&key(i), 1.0)]).unwrap();
|
||||
let exact_sd = row[i].sqrt();
|
||||
assert!(
|
||||
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
|
||||
@@ -322,7 +322,7 @@ fn cost_scaling() {
|
||||
use std::time::Instant;
|
||||
for n in [50usize, 100, 200, 400, 800] {
|
||||
let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
|
||||
let mut h: History<i64, _, _, String> = History::builder()
|
||||
let mut h: History<String> = History::builder()
|
||||
.key_type::<String>()
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
@@ -361,6 +361,8 @@ fn cost_scaling() {
|
||||
|
||||
let t = Instant::now();
|
||||
let g = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)])
|
||||
.unwrap();
|
||||
println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma());
|
||||
|
||||
@@ -56,7 +56,7 @@ fn overflow_during_inference_is_reported_not_hidden() {
|
||||
|
||||
for (name, sigma, beta, score_sigma, scores) in cases {
|
||||
match scored_fit(sigma, beta, score_sigma, scores) {
|
||||
Err(InferenceError::NonFiniteResult { context, step, .. }) => {
|
||||
Err(InferenceError::NonFiniteStep { context, step, .. }) => {
|
||||
assert_eq!(context, "History::converge", "{name}");
|
||||
assert!(
|
||||
!step.0.is_finite() || !step.1.is_finite(),
|
||||
@@ -86,7 +86,7 @@ fn a_broken_fit_is_never_reported_as_converged() {
|
||||
|
||||
let err = h.converge().unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NonFiniteResult { .. }),
|
||||
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||
"a breakdown must not be reported as convergence: {err:?}"
|
||||
);
|
||||
|
||||
@@ -104,7 +104,7 @@ fn a_broken_fit_is_never_reported_as_converged() {
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
h2.converge_partial().unwrap_err(),
|
||||
InferenceError::NonFiniteResult { .. }
|
||||
InferenceError::NonFiniteStep { .. }
|
||||
));
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
|
||||
.converge()
|
||||
.expect_err("a NaN fit must never be reported as converged");
|
||||
assert!(
|
||||
matches!(err, InferenceError::NonFiniteResult { .. }),
|
||||
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ use trueskill_tt::{
|
||||
UnknownKeys,
|
||||
};
|
||||
|
||||
fn builder(
|
||||
policy: UnknownKeys,
|
||||
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
|
||||
fn builder(policy: UnknownKeys) -> History {
|
||||
History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
@@ -37,9 +35,7 @@ fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'sta
|
||||
|
||||
/// A history where "veteran" and "regular" are well observed and "novice"
|
||||
/// appears once.
|
||||
fn fitted(
|
||||
policy: UnknownKeys,
|
||||
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
|
||||
fn fitted(policy: UnknownKeys) -> History {
|
||||
let mut h = builder(policy);
|
||||
let mut events: Vec<_> = (0..40)
|
||||
.map(|t| round("veteran", "regular", 10.0 + f64::from(t % 3), 5.0))
|
||||
@@ -120,6 +116,8 @@ fn swapping_the_teams_negates_the_margin() {
|
||||
fn the_predictive_interval_exceeds_the_skill_uncertainty() {
|
||||
let h = fitted(UnknownKeys::Prior);
|
||||
let skill_gap = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)])
|
||||
.unwrap();
|
||||
let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
|
||||
|
||||
+7
-3
@@ -34,7 +34,7 @@ fn unknown_keys_are_reported_not_silently_dropped() {
|
||||
h.predict_win_probabilities(&[&[&"a"], &[&"ghost"]])
|
||||
.is_err()
|
||||
);
|
||||
assert!(h.predict_quality(&[&[&"a"], &[&"ghost"]]).is_err());
|
||||
assert!(h.quality(&[&[&"a"], &[&"ghost"]]).is_err());
|
||||
assert!(h.predict_ranking(&[&[&"a"], &[&"ghost"]], &[0, 1]).is_err());
|
||||
}
|
||||
|
||||
@@ -60,8 +60,12 @@ fn degenerate_team_shapes_are_errors_rather_than_panics() {
|
||||
h.predict_outcome(&[&[&"a"]]).unwrap_err(),
|
||||
InferenceError::NotEnoughTeams { got: 1, .. }
|
||||
),);
|
||||
// An empty team list cannot infer the key type — nothing in `&[]` names it.
|
||||
// The annotation is the cost of `predict_*` being generic over the borrowed
|
||||
// key, and it only bites on the degenerate call.
|
||||
let none: &[&[&str]] = &[];
|
||||
assert!(matches!(
|
||||
h.predict_outcome(&[]).unwrap_err(),
|
||||
h.predict_outcome(none).unwrap_err(),
|
||||
InferenceError::NotEnoughTeams { got: 0, .. }
|
||||
),);
|
||||
assert!(matches!(
|
||||
@@ -403,7 +407,7 @@ fn prior_reaches_every_prediction_entry_point() {
|
||||
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
|
||||
let teams: &[&[&&str]] = &[&[&"a"], &[&"ghost"]];
|
||||
|
||||
assert!(h.predict_quality(teams).is_ok());
|
||||
assert!(h.quality(teams).is_ok());
|
||||
assert!(h.predict_win_probabilities(teams).is_ok());
|
||||
assert!(h.predict_outcome(teams).is_ok());
|
||||
assert!(h.predict_ranking(teams, &[0, 1]).is_ok());
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
//! returning `Err`.
|
||||
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, Event, Gaussian, History, InferenceError, Member, NullObserver, Outcome, Team,
|
||||
ConstantDrift, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
|
||||
};
|
||||
|
||||
type H = History<i64, ConstantDrift, NullObserver, &'static str>;
|
||||
type H = History;
|
||||
|
||||
fn build(beta: f64, prior: Option<Gaussian>, outcome: Outcome) -> H {
|
||||
let mut h: H = History::builder()
|
||||
@@ -49,7 +49,7 @@ fn nan_poisoned() -> H {
|
||||
);
|
||||
let err = h.converge().expect_err("this fixture must not converge");
|
||||
assert!(
|
||||
matches!(err, InferenceError::NonFiniteResult { .. }),
|
||||
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
h
|
||||
@@ -78,7 +78,7 @@ macro_rules! all_predictions {
|
||||
($h:ident, $f:expr) => {{
|
||||
let teams: &[&[&&'static str]] = &[&[&"a"], &[&"b"]];
|
||||
let f = $f;
|
||||
f("predict_quality", $h.predict_quality(teams).map(|_| ()));
|
||||
f("quality", $h.quality(teams).map(|_| ()));
|
||||
f(
|
||||
"predict_win_probabilities",
|
||||
$h.predict_win_probabilities(teams).map(|_| ()),
|
||||
@@ -101,7 +101,7 @@ fn a_nan_poisoned_fit_is_refused_by_every_prediction_path() {
|
||||
|
||||
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
||||
match r {
|
||||
Err(InferenceError::NonFiniteResult { .. }) => {}
|
||||
Err(InferenceError::NonFiniteSkill { .. }) => {}
|
||||
other => panic!("{name} answered from a NaN fit: {other:?}"),
|
||||
}
|
||||
});
|
||||
@@ -116,12 +116,12 @@ fn degenerate_performances_are_refused_rather_than_answered_wrongly() {
|
||||
assert_eq!(skill.sigma(), 0.0);
|
||||
assert!(skill.mu().is_finite());
|
||||
|
||||
// `predict_quality` previously PANICKED here, out of a method that returns
|
||||
// `quality` previously PANICKED here, out of a method that returns
|
||||
// `Result`: the contrast covariance is exactly singular when beta is zero
|
||||
// and every skill is a point mass.
|
||||
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
||||
match r {
|
||||
Err(InferenceError::InvalidParameter { .. }) => {}
|
||||
Err(InferenceError::NoPerformanceVariance) => {}
|
||||
other => panic!("{name} predicted from a degenerate fit: {other:?}"),
|
||||
}
|
||||
});
|
||||
|
||||
+2
-5
@@ -110,11 +110,8 @@ fn history_predict_quality_supports_three_teams() {
|
||||
h.record_winner(&"b", &"c", 2).unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
|
||||
let q = h.predict_quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap();
|
||||
assert!(
|
||||
q.is_finite(),
|
||||
"3-team predict_quality must be finite, got {q}"
|
||||
);
|
||||
let q = h.quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap();
|
||||
assert!(q.is_finite(), "3-team quality must be finite, got {q}");
|
||||
assert!((0.0..=1.0).contains(&q), "out of range: {q}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ fn ev(a: &str, b: &str, time: i64) -> Event<i64, String> {
|
||||
|
||||
/// Ingest each chunk in turn, converging fully after every one.
|
||||
fn fit_in_chunks(chunks: Vec<Events>) -> Vec<(String, Gaussian)> {
|
||||
let mut h: History<i64, _, _, String> = History::builder()
|
||||
let mut h: History<String> = History::builder()
|
||||
.key_type::<String>()
|
||||
.convergence(tight())
|
||||
.build();
|
||||
@@ -152,7 +152,7 @@ fn re_converging_an_unchanged_history_costs_one_iteration() {
|
||||
let (early, late) = fixture();
|
||||
let all: Vec<_> = early.into_iter().chain(late).collect();
|
||||
|
||||
let mut h: History<i64, _, _, String> = History::builder()
|
||||
let mut h: History<String> = History::builder()
|
||||
.key_type::<String>()
|
||||
.convergence(tight())
|
||||
.build();
|
||||
|
||||
+20
-12
@@ -17,24 +17,32 @@ fn record_winner_builds_history() {
|
||||
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
|
||||
let a_idx = h.lookup(&"alice").unwrap();
|
||||
let b_idx = h.lookup(&"bob").unwrap();
|
||||
|
||||
assert_ne!(a_idx, b_idx);
|
||||
// `lookup` returned an `Index` that nothing public accepted, so the
|
||||
// observable claim is the one worth making: two distinct competitors, each
|
||||
// with their own posterior, and the winner ahead.
|
||||
assert_eq!(h.competitor_count(), 2);
|
||||
let alice = h.current_skill("alice").expect("alice played");
|
||||
let bob = h.current_skill("bob").expect("bob played");
|
||||
assert!(alice.mu() > bob.mu());
|
||||
}
|
||||
|
||||
/// The same key names the same competitor across events, which is what
|
||||
/// interning bought and the only part of it a caller can observe.
|
||||
#[test]
|
||||
fn intern_is_idempotent() {
|
||||
fn a_repeated_key_is_one_competitor() {
|
||||
let mut h: History = History::builder().build();
|
||||
let a1 = h.intern(&"alice");
|
||||
let a2 = h.intern(&"alice");
|
||||
assert_eq!(a1, a2);
|
||||
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||||
h.record_winner(&"alice", &"carol", 2).unwrap();
|
||||
|
||||
assert_eq!(h.competitor_count(), 3);
|
||||
assert_eq!(h.learning_curve("alice").expect("known").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_returns_none_for_missing() {
|
||||
fn an_unknown_key_is_unknown() {
|
||||
let h: History = History::builder().build();
|
||||
assert!(h.lookup(&"nobody").is_none());
|
||||
assert!(h.current_skill("nobody").is_none());
|
||||
assert!(h.learning_curve("nobody").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -50,6 +58,6 @@ fn record_draw_with_p_draw_set() {
|
||||
h.record_draw(&"alice", &"bob", 1).unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
|
||||
assert!(h.lookup(&"alice").is_some());
|
||||
assert!(h.lookup(&"bob").is_some());
|
||||
assert!(h.current_skill("alice").is_some());
|
||||
assert!(h.current_skill("bob").is_some());
|
||||
}
|
||||
|
||||
+17
-11
@@ -11,7 +11,7 @@ use trueskill_tt::{
|
||||
Team,
|
||||
};
|
||||
|
||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||
type H = History;
|
||||
|
||||
const PINNED: Gaussian = Gaussian::from_ms(2.0, 0.5);
|
||||
|
||||
@@ -94,8 +94,8 @@ fn registering_matches_configuring_on_the_first_event() {
|
||||
};
|
||||
|
||||
for ((k, a), (_, b)) in skills(&configured).into_iter().zip(skills(®istered)) {
|
||||
assert_eq!(a.pi(), b.pi(), "{k} pi");
|
||||
assert_eq!(a.tau(), b.tau(), "{k} tau");
|
||||
assert_eq!(a.mu(), b.mu(), "{k} mu");
|
||||
assert_eq!(a.variance(), b.variance(), "{k} variance");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,13 @@ fn a_weight_on_a_registration_is_rejected() {
|
||||
.register(Member::new("layout").with_weight(0.5))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
parameter: trueskill_tt::Parameter::Weight,
|
||||
..
|
||||
}
|
||||
),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -188,7 +194,7 @@ fn an_invalid_drift_scale_on_a_registration_is_rejected() {
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
name: "drift_scale",
|
||||
parameter: trueskill_tt::Parameter::DriftScale,
|
||||
..
|
||||
}
|
||||
),
|
||||
@@ -225,8 +231,8 @@ fn registration_makes_the_fit_order_independent() {
|
||||
let forward = build(false);
|
||||
let backward = build(true);
|
||||
for ((k, a), (_, b)) in skills(&forward).into_iter().zip(skills(&backward)) {
|
||||
assert_eq!(a.pi(), b.pi(), "{k} pi");
|
||||
assert_eq!(a.tau(), b.tau(), "{k} tau");
|
||||
assert_eq!(a.mu(), b.mu(), "{k} mu");
|
||||
assert_eq!(a.variance(), b.variance(), "{k} variance");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,8 +252,8 @@ fn rating_reads_back_what_was_stored() {
|
||||
.unwrap();
|
||||
let r = h.rating(&"layout").unwrap();
|
||||
assert_eq!(r.drift_scale(), 0.25);
|
||||
assert_eq!(r.prior().pi(), PINNED.pi());
|
||||
assert_eq!(r.prior().tau(), PINNED.tau());
|
||||
assert_eq!(r.prior().mu(), PINNED.mu());
|
||||
assert_eq!(r.prior().variance(), PINNED.variance());
|
||||
|
||||
// A competitor created by an event reports the history defaults.
|
||||
h.record_winner(&"player", &"layout", 1).unwrap();
|
||||
@@ -280,7 +286,7 @@ mod conflicting_configuration {
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::ConflictingCompetitorConfig {
|
||||
field: "drift_scale",
|
||||
field: trueskill_tt::CompetitorField::DriftScale,
|
||||
..
|
||||
}
|
||||
),
|
||||
@@ -297,7 +303,7 @@ mod conflicting_configuration {
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::ConflictingCompetitorConfig {
|
||||
field: "drift_scale",
|
||||
field: trueskill_tt::CompetitorField::DriftScale,
|
||||
..
|
||||
}
|
||||
),
|
||||
|
||||
@@ -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
@@ -144,7 +144,7 @@ fn key_type_replaces_builder_with_key() {
|
||||
/// Both axes at once, via the explicit constructor rather than the setters.
|
||||
#[test]
|
||||
fn new_constructs_on_any_axis_directly() {
|
||||
let mut h = HistoryBuilder::<Season, _, _, String>::new().build();
|
||||
let mut h = HistoryBuilder::<String, Season>::new().build();
|
||||
h.record_winner(&"a".to_string(), &"b".to_string(), Season(7))
|
||||
.unwrap();
|
||||
assert!(h.converge().unwrap().converged);
|
||||
|
||||
@@ -16,7 +16,7 @@ const BETA: f64 = 1.0;
|
||||
const SCORE_SIGMA: f64 = 2.0;
|
||||
const GAMMA: f64 = 0.5;
|
||||
|
||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||
type H = History;
|
||||
|
||||
fn history(gamma: f64) -> H {
|
||||
History::builder()
|
||||
@@ -120,7 +120,11 @@ fn a_two_slice_joint_matches_the_exact_posterior() {
|
||||
|
||||
// The crate reads each competitor at their latest appearance: a1, b1.
|
||||
let exact_gap = (cov[2][2] + cov[3][3] - 2.0 * cov[2][3]).sqrt();
|
||||
let got = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
||||
let got = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||
.unwrap();
|
||||
assert!(
|
||||
(got.sigma() - exact_gap).abs() / exact_gap < 1e-9,
|
||||
"difference: got {} exact {exact_gap}",
|
||||
@@ -128,7 +132,7 @@ fn a_two_slice_joint_matches_the_exact_posterior() {
|
||||
);
|
||||
|
||||
let exact_single = cov[2][2].sqrt();
|
||||
let got_single = h.posterior_of(&[(&"a", 1.0)]).unwrap();
|
||||
let got_single = h.joint().unwrap().posterior_of(&[(&"a", 1.0)]).unwrap();
|
||||
assert!(
|
||||
(got_single.sigma() - exact_single).abs() / exact_single < 1e-9,
|
||||
"single node: got {} exact {exact_single}",
|
||||
@@ -154,6 +158,8 @@ fn competitors_last_seen_in_different_slices_are_comparable() {
|
||||
// b last appeared at time 0; a and c at time 20. All three must resolve.
|
||||
for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] {
|
||||
let g = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&[(&x, 1.0), (&y, -1.0)])
|
||||
.unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}"));
|
||||
assert!(g.sigma() > 0.0 && g.sigma().is_finite());
|
||||
@@ -175,7 +181,7 @@ fn means_agree_with_the_marginals() {
|
||||
|
||||
for k in ["a", "b", "c"] {
|
||||
let marginal = h.current_skill(&k).unwrap().mu();
|
||||
let joint = h.posterior_of(&[(&k, 1.0)]).unwrap().mu();
|
||||
let joint = h.joint().unwrap().posterior_of(&[(&k, 1.0)]).unwrap().mu();
|
||||
assert!(
|
||||
(marginal - joint).abs() < 1e-9,
|
||||
"{k}: marginal {marginal}, joint {joint}"
|
||||
@@ -197,7 +203,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
|
||||
])
|
||||
.unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap()
|
||||
h.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||
.unwrap()
|
||||
};
|
||||
let together = {
|
||||
let mut h = history(0.0);
|
||||
@@ -208,7 +217,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
|
||||
])
|
||||
.unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap()
|
||||
h.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
assert!(
|
||||
@@ -234,7 +246,11 @@ fn drift_widens_a_comparison_across_time() {
|
||||
let _ = h.converge().unwrap();
|
||||
|
||||
// b was last seen at time 0; a at time 100.
|
||||
let g = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
||||
let g = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||
.unwrap();
|
||||
assert!(
|
||||
g.sigma() > previous,
|
||||
"gamma={gamma}: sigma {} did not exceed {previous}",
|
||||
@@ -257,9 +273,21 @@ fn posterior_of_at_reads_as_of_a_time() {
|
||||
.unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
|
||||
let early = h.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
||||
let late = h.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
||||
let latest = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
||||
let early = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)])
|
||||
.unwrap();
|
||||
let late = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)])
|
||||
.unwrap();
|
||||
let latest = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||
.unwrap();
|
||||
|
||||
// Asking as of the final slice is the same as asking for the latest.
|
||||
assert!((late.mu() - latest.mu()).abs() < 1e-9);
|
||||
@@ -275,7 +303,12 @@ fn posterior_of_at_reads_as_of_a_time() {
|
||||
);
|
||||
|
||||
// A time before any event has nothing to read.
|
||||
assert!(h.posterior_of_at(-1, &[(&"a", 1.0)]).is_err());
|
||||
assert!(
|
||||
h.joint()
|
||||
.unwrap()
|
||||
.posterior_of_at(-1, &[(&"a", 1.0)])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Times between slices resolve to the latest appearance at or before them.
|
||||
@@ -289,8 +322,16 @@ fn a_time_between_slices_reads_the_previous_appearance() {
|
||||
.unwrap();
|
||||
let _ = h.converge().unwrap();
|
||||
|
||||
let at_zero = h.posterior_of_at(0, &[(&"a", 1.0)]).unwrap();
|
||||
let between = h.posterior_of_at(50, &[(&"a", 1.0)]).unwrap();
|
||||
let at_zero = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of_at(0, &[(&"a", 1.0)])
|
||||
.unwrap();
|
||||
let between = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of_at(50, &[(&"a", 1.0)])
|
||||
.unwrap();
|
||||
assert!((at_zero.mu() - between.mu()).abs() < 1e-12);
|
||||
assert!((at_zero.sigma() - between.sigma()).abs() < 1e-12);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ fn a_struct_holding_a_history_can_derive_debug() {
|
||||
|
||||
#[test]
|
||||
fn history_builder_is_debug_and_clone() {
|
||||
let b: HistoryBuilder<i64, ConstantDrift, _, &'static str> = History::builder();
|
||||
let b: HistoryBuilder = History::builder();
|
||||
let cloned = b.clone();
|
||||
assert!(!format!("{cloned:?}").is_empty());
|
||||
}
|
||||
|
||||
+34
-9
@@ -49,7 +49,13 @@ fn ranked_rejects_a_zero_damping_factor() {
|
||||
)
|
||||
.expect_err("alpha = 0 must be rejected");
|
||||
assert!(
|
||||
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
parameter: trueskill_tt::Parameter::Alpha,
|
||||
..
|
||||
}
|
||||
),
|
||||
"got {err:?}"
|
||||
);
|
||||
}
|
||||
@@ -65,7 +71,13 @@ fn ranked_rejects_an_out_of_range_damping_factor() {
|
||||
)
|
||||
.expect_err("alpha out of (0, 1] must be rejected");
|
||||
assert!(
|
||||
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
parameter: trueskill_tt::Parameter::Alpha,
|
||||
..
|
||||
}
|
||||
),
|
||||
"alpha={alpha}: got {err:?}"
|
||||
);
|
||||
}
|
||||
@@ -81,7 +93,13 @@ fn scored_rejects_a_bad_damping_factor() {
|
||||
)
|
||||
.expect_err("alpha = 0 must be rejected");
|
||||
assert!(
|
||||
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
parameter: trueskill_tt::Parameter::Alpha,
|
||||
..
|
||||
}
|
||||
),
|
||||
"got {err:?}"
|
||||
);
|
||||
}
|
||||
@@ -139,7 +157,7 @@ fn ingestion_rejects_a_tie_without_a_draw_probability() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Outcome::scores_with_sigma` documents that a non-positive sigma is
|
||||
/// `Outcome::scores_with_noise` documents that a non-positive sigma is
|
||||
/// accepted at construction and rejected at ingestion.
|
||||
#[test]
|
||||
fn ingestion_rejects_a_non_positive_per_event_score_sigma() {
|
||||
@@ -152,7 +170,7 @@ fn ingestion_rejects_a_non_positive_per_event_score_sigma() {
|
||||
Team::with_members([Member::new("a")]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores_with_sigma([21.0, 9.0], sigma),
|
||||
outcome: Outcome::scores_with_noise([21.0, 9.0], sigma),
|
||||
}])
|
||||
.expect_err("a non-positive per-event sigma must be rejected");
|
||||
assert!(
|
||||
@@ -248,9 +266,13 @@ mod builder_parameters {
|
||||
};
|
||||
let zero = fit(0.0);
|
||||
let positive = fit(25.0 / 6.0);
|
||||
assert!(zero.pi().is_finite() && zero.pi() > 0.0);
|
||||
// `variance` rather than `pi`: the natural parameters are the crate's
|
||||
// internal representation and no longer public. It is the same
|
||||
// quantity inverted, so a finite positive precision is a finite
|
||||
// positive variance.
|
||||
assert!(zero.variance().is_finite() && zero.variance() > 0.0);
|
||||
assert!(
|
||||
(zero.pi() - positive.pi()).abs() > 1e-6,
|
||||
(zero.variance() - positive.variance()).abs() > 1e-6,
|
||||
"zero beta must not merely be ignored: {zero:?} vs {positive:?}"
|
||||
);
|
||||
}
|
||||
@@ -280,7 +302,10 @@ mod constructor_parameters {
|
||||
#[test]
|
||||
fn a_nan_sigma_passes_through_from_ms() {
|
||||
let g = Gaussian::from_ms(25.0, f64::NAN);
|
||||
assert!(g.sigma().is_nan() || g.pi().is_nan());
|
||||
// `sigma()` is NaN exactly when the precision is: it guards `pi <= 0`
|
||||
// (reporting `inf`) and `pi == inf` (reporting `0.0`), so NaN survives
|
||||
// only from a NaN precision.
|
||||
assert!(g.sigma().is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -353,7 +378,7 @@ mod constructor_parameters {
|
||||
matches!(
|
||||
err,
|
||||
InferenceError::InvalidParameter {
|
||||
name: "drift variance",
|
||||
parameter: trueskill_tt::Parameter::DriftVariance,
|
||||
..
|
||||
}
|
||||
),
|
||||
|
||||
@@ -6,7 +6,7 @@ use trueskill_tt::{
|
||||
UnknownKeys,
|
||||
};
|
||||
|
||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
||||
type H = History;
|
||||
|
||||
fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> {
|
||||
Event {
|
||||
@@ -30,7 +30,7 @@ fn base() -> Vec<Event<i64, &'static str>> {
|
||||
}
|
||||
|
||||
fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
|
||||
let mut h: History<i64, _, _, &'static str> = History::builder()
|
||||
let mut h: History = History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
@@ -60,15 +60,30 @@ fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
|
||||
fn the_closed_form_matches_an_actual_refit() {
|
||||
let h = fit(None, UnknownKeys::Reject);
|
||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||
let before = h.posterior_of(&target).unwrap().sigma().powi(2);
|
||||
let before = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&target)
|
||||
.unwrap()
|
||||
.sigma()
|
||||
.powi(2);
|
||||
|
||||
for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] {
|
||||
let predicted = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.expected_variance_reduction(&[&[&x], &[&y]], &target)
|
||||
.unwrap();
|
||||
|
||||
let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject);
|
||||
let actual = before - after.posterior_of(&target).unwrap().sigma().powi(2);
|
||||
let actual = before
|
||||
- after
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&target)
|
||||
.unwrap()
|
||||
.sigma()
|
||||
.powi(2);
|
||||
|
||||
assert!(
|
||||
(predicted - actual).abs() / actual.abs() < 1e-9,
|
||||
@@ -84,12 +99,27 @@ fn the_closed_form_matches_an_actual_refit() {
|
||||
fn the_outcome_does_not_change_the_reduction() {
|
||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||
let h = fit(None, UnknownKeys::Reject);
|
||||
let before = h.posterior_of(&target).unwrap().sigma().powi(2);
|
||||
let before = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&target)
|
||||
.unwrap()
|
||||
.sigma()
|
||||
.powi(2);
|
||||
|
||||
let mut seen = Vec::new();
|
||||
for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] {
|
||||
let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject);
|
||||
seen.push(before - after.posterior_of(&target).unwrap().sigma().powi(2));
|
||||
seen.push(
|
||||
before
|
||||
- after
|
||||
.joint()
|
||||
.unwrap()
|
||||
.posterior_of(&target)
|
||||
.unwrap()
|
||||
.sigma()
|
||||
.powi(2),
|
||||
);
|
||||
}
|
||||
for w in seen.windows(2) {
|
||||
assert!(
|
||||
@@ -107,9 +137,13 @@ fn it_ranks_candidates_by_how_much_they_answer_the_question() {
|
||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||
|
||||
let direct = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.expected_variance_reduction(&[&[&"a"], &[&"b"]], &target)
|
||||
.unwrap();
|
||||
let unrelated = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.expected_variance_reduction(&[&[&"c"], &[&"d"]], &target)
|
||||
.unwrap();
|
||||
|
||||
@@ -128,6 +162,8 @@ fn an_unrelated_unseen_matchup_teaches_nothing_about_the_target() {
|
||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||
|
||||
let reduction = h
|
||||
.joint()
|
||||
.unwrap()
|
||||
.expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target)
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -142,7 +178,9 @@ fn shape_errors_are_reported() {
|
||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||
|
||||
assert!(matches!(
|
||||
h.expected_variance_reduction(&[&[&"a"]], &target),
|
||||
h.joint()
|
||||
.unwrap()
|
||||
.expected_variance_reduction(&[&[&"a"]], &target),
|
||||
Err(InferenceError::MismatchedShape {
|
||||
expected: 2,
|
||||
got: 1,
|
||||
@@ -150,7 +188,9 @@ fn shape_errors_are_reported() {
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
h.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
|
||||
h.joint()
|
||||
.unwrap()
|
||||
.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
|
||||
Err(InferenceError::UnknownKey { .. })
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user