Compare commits
11
Commits
1ad789cf40
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cba3d10f6 | ||
|
|
5d9501307e | ||
|
|
327324c411 | ||
|
|
61da3aca33 | ||
|
|
061c481aad | ||
|
|
0b9997354d | ||
|
|
c4194b0051 | ||
|
|
1629176199 | ||
|
|
695bb822ef | ||
|
|
d36d125e52 | ||
|
|
0801acebd1 |
@@ -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.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 0.9.0 - 2026-09-10
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- fix!: propagate NaN through the convergence reduction
|
||||||
|
- fix!: collapse a drift too small to represent, on a relative threshold
|
||||||
|
- fix!: report an unresolvable prediction grid instead of clamping
|
||||||
|
- fix!: validate the constructors below HistoryBuilder
|
||||||
|
- fix!: seal ConstantDrift's field so gamma can be validated
|
||||||
|
- fix!: make the Time generic reachable
|
||||||
|
- refactor!: un-export six types that no caller could reach
|
||||||
|
- fix!: correct eight wrong `# Errors` sections and seal the error variants
|
||||||
|
- fix!: per-key queries report unknown keys instead of a plausible constant
|
||||||
|
- fix!: no prediction path answers from a fit it cannot answer from
|
||||||
|
- docs!: one name for score noise, and say which of beta/sigma to turn
|
||||||
|
- fix!: non_exhaustive on ConvergenceReport, and not on the options structs
|
||||||
|
- feat!: prediction and joint queries take borrowed keys
|
||||||
|
- feat!: Game is the type you get, and one_v_one returns one
|
||||||
|
- feat!: Gaussian's EP operations stop wearing arithmetic's clothes
|
||||||
|
- refactor!: retire Index, intern and lookup
|
||||||
|
- refactor!: scores_with_noise, and History::quality
|
||||||
|
- refactor!: the joint is reached through Joint, not mirrored on History
|
||||||
|
- refactor!: K comes first in History, HistoryBuilder and Joint
|
||||||
|
- perf!: sparse Cholesky with an AMD ordering for the joint
|
||||||
|
- refactor!: typed discriminators for InferenceError
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: take quality's determinant ratio in log space
|
||||||
|
- fix: keep the truncated variance representable in the far tail
|
||||||
|
- fix: route the last three transcendentals through libm, and enforce it
|
||||||
|
- fix: make posterior_of reproducible across processes
|
||||||
|
- fix: warn on dropped builders and values; stop exporting EP internals
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
- ci: measure the runner's own benchmark variance, and fix the joint bench
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- docs: document the whole public surface and deny(missing_docs)
|
||||||
|
- docs: add a migration guide for 0.9.0, and drop merge noise from the changelog
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: add the missing trait impls and make `#[must_use]` consistent
|
||||||
|
- feat: complete the evidence matrix and add current_skills
|
||||||
|
- feat: PartialEq on the config types, and pin the public trait impls
|
||||||
|
- feat: HistoryBuilder::default_rating_for, a rule instead of a roll call
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- refactor: one word per concept
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: scale the ceiling sweep by build profile
|
||||||
|
- test: make the determinism test exercise the parallel sweep
|
||||||
|
|
||||||
## 0.8.0 - 2026-09-08
|
## 0.8.0 - 2026-09-08
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
@@ -25,13 +84,9 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
- feat: add EventBuilder::members for per-member configuration
|
- feat: add EventBuilder::members for per-member configuration
|
||||||
|
|
||||||
### Other (unconventional)
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
- Merge branch 'fix/ingestion-shape'
|
- chore: Release trueskill-tt version 0.8.0
|
||||||
- Merge branch 'feat/convergence-strictness'
|
|
||||||
- Merge branch 'fix/non-finite-weights'
|
|
||||||
- Merge branch 'test/close-coverage-gaps'
|
|
||||||
- Merge branch 'fix/game-boundary'
|
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
@@ -47,10 +102,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
- chore: Release trueskill-tt version 0.7.0
|
- chore: Release trueskill-tt version 0.7.0
|
||||||
|
|
||||||
### Other (unconventional)
|
|
||||||
|
|
||||||
- Merge branch 'feat/joint-handle'
|
|
||||||
|
|
||||||
## 0.6.0 - 2026-09-08
|
## 0.6.0 - 2026-09-08
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "trueskill-tt"
|
name = "trueskill-tt"
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
|
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
|
||||||
@@ -47,12 +47,15 @@ harness = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
approx = { version = "0.5.1", optional = true }
|
approx = { version = "0.5.1", optional = true }
|
||||||
|
feral-amd = "0.2"
|
||||||
libm = "0.2.16"
|
libm = "0.2.16"
|
||||||
rayon = { version = "1", optional = true }
|
rayon = { version = "1", optional = true }
|
||||||
smallvec = "1"
|
smallvec = "1"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
approx = ["dep:approx"]
|
approx = ["dep:approx"]
|
||||||
|
# Exposes the joint sparsity pattern for the #52 measurement. Test-only.
|
||||||
|
measure-sparsity = []
|
||||||
rayon = ["dep:rayon"]
|
rayon = ["dep:rayon"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
+204
@@ -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` is strict
|
||||||
|
|
||||||
`converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the
|
`converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the
|
||||||
step still above `epsilon`, and `Err(NonFiniteResult)` if a sweep produces NaN.
|
step still above `epsilon`, and `Err(NonFiniteStep)` if a sweep produces NaN.
|
||||||
|
|
||||||
It used to return `Ok` with `converged: false`, which was the worst available
|
It used to return `Ok` with `converged: false`, which was the worst available
|
||||||
shape. A fit that stops short is *wrong by a little*: every posterior is finite,
|
shape. A fit that stops short is *wrong by a little*: every posterior is finite,
|
||||||
@@ -419,7 +419,8 @@ expensive than `quality()`. Scoring every pairing among `n` competitors is
|
|||||||
Every box on the old todo list is ticked, so it has been retired; open work
|
Every box on the old todo list is ticked, so it has been retired; open work
|
||||||
lives in the issue tracker instead. The crate is in use and the API is still
|
lives in the issue tracker instead. The crate is in use and the API is still
|
||||||
moving — breaking changes are batched into minor releases rather than dribbled
|
moving — breaking changes are batched into minor releases rather than dribbled
|
||||||
out, and `CHANGELOG.md` records them.
|
out. `CHANGELOG.md` lists them and [`MIGRATING.md`](MIGRATING.md) explains what
|
||||||
|
to do about them.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -18,8 +18,14 @@ fn fitted() -> History<String> {
|
|||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift::new(0.05))
|
.drift(ConstantDrift::new(0.05))
|
||||||
|
// `max_iter: 30` was here, and this fixture needs more: `converge`
|
||||||
|
// reported `NotConverged { iterations: 30, final_step: (4.5e-4, 0.0) }`
|
||||||
|
// once it stopped returning short fits silently. The benchmark measures
|
||||||
|
// the factorisation, whose cost depends on the fit's *shape* rather
|
||||||
|
// than its exactness — but measuring it on an unconverged fit is still
|
||||||
|
// measuring something nobody would run.
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 30,
|
max_iter: trueskill_tt::ITERATIONS,
|
||||||
epsilon: 1e-10,
|
epsilon: 1e-10,
|
||||||
alpha: 1.0,
|
alpha: 1.0,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ commit_parsers = [
|
|||||||
{ message = "^test", group = "Testing" },
|
{ message = "^test", group = "Testing" },
|
||||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||||
{ message = "^chore", group = "Miscellaneous Tasks" },
|
{ message = "^chore", group = "Miscellaneous Tasks" },
|
||||||
|
{ message = "^ci", group = "CI" },
|
||||||
|
# Every branch lands with `--no-ff`, so a release's merge commits outnumber
|
||||||
|
# its real ones and say nothing the merged commits do not. They were
|
||||||
|
# filling an "Other (unconventional)" section with 14 lines of noise.
|
||||||
|
{ message = "^Merge ", skip = true },
|
||||||
{ body = ".*security", group = "Security" },
|
{ body = ".*security", group = "Security" },
|
||||||
{ body = ".*", group = "Other (unconventional)" },
|
{ body = ".*", group = "Other (unconventional)" },
|
||||||
]
|
]
|
||||||
|
|||||||
+7
-3
@@ -123,7 +123,7 @@ fn u_minus_ln1p(u: f64) -> f64 {
|
|||||||
/// - `EmptyTeam` if any team has no members.
|
/// - `EmptyTeam` if any team has no members.
|
||||||
/// - `TooManyTeams` if the outcome space is too large to enumerate; see
|
/// - `TooManyTeams` if the outcome space is too large to enumerate; see
|
||||||
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
|
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
|
||||||
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
|
/// - `InvalidParameter` for a `p_draw` outside `[0.0, 1.0)`.
|
||||||
/// - `GridTooCoarse` when the performance sigmas are too far apart to
|
/// - `GridTooCoarse` when the performance sigmas are too far apart to
|
||||||
/// integrate on one grid. This comes from `outcome_distribution`, which runs
|
/// integrate on one grid. This comes from `outcome_distribution`, which runs
|
||||||
/// before any inference — so it is not covered by "anything `Game::ranked`
|
/// before any inference — so it is not covered by "anything `Game::ranked`
|
||||||
@@ -144,7 +144,8 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if !(0.0..1.0).contains(&options.p_draw) {
|
if !(0.0..1.0).contains(&options.p_draw) {
|
||||||
return Err(InferenceError::InvalidProbability {
|
return Err(InferenceError::InvalidParameter {
|
||||||
|
parameter: crate::Parameter::PDraw,
|
||||||
value: options.p_draw,
|
value: options.p_draw,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -367,7 +368,10 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
expected_information_gain(&[&a, &a], &options(1.5)),
|
expected_information_gain(&[&a, &a], &options(1.5)),
|
||||||
Err(InferenceError::InvalidProbability { .. })
|
Err(InferenceError::InvalidParameter {
|
||||||
|
parameter: crate::Parameter::PDraw,
|
||||||
|
..
|
||||||
|
})
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -66,13 +66,13 @@ impl ConvergenceOptions {
|
|||||||
pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> {
|
pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> {
|
||||||
if !(self.alpha > 0.0 && self.alpha <= 1.0) {
|
if !(self.alpha > 0.0 && self.alpha <= 1.0) {
|
||||||
return Err(crate::InferenceError::InvalidParameter {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
name: "alpha",
|
parameter: crate::Parameter::Alpha,
|
||||||
value: self.alpha,
|
value: self.alpha,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if self.epsilon.is_nan() || self.epsilon < 0.0 {
|
if self.epsilon.is_nan() || self.epsilon < 0.0 {
|
||||||
return Err(crate::InferenceError::InvalidParameter {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
name: "epsilon",
|
parameter: crate::Parameter::Epsilon,
|
||||||
value: self.epsilon,
|
value: self.epsilon,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
|
|||||||
/// `variance_for_elapsed` would have been worse: it runs inside the sweep, so a
|
/// `variance_for_elapsed` would have been worse: it runs inside the sweep, so a
|
||||||
/// construction-time mistake would panic mid-inference — and `Gaussian::from_ms`
|
/// construction-time mistake would panic mid-inference — and `Gaussian::from_ms`
|
||||||
/// is a worked example of why that is the wrong place for a guard, where
|
/// is a worked example of why that is the wrong place for a guard, where
|
||||||
/// rejecting NaN turned the `NonFiniteResult` reporting path into a crash.
|
/// rejecting NaN turned the `NonFiniteStep` reporting path into a crash.
|
||||||
///
|
///
|
||||||
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
|
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
|
||||||
/// back with [`ConstantDrift::gamma`].
|
/// back with [`ConstantDrift::gamma`].
|
||||||
|
|||||||
+365
-58
@@ -39,6 +39,182 @@ pub enum UnknownKeys {
|
|||||||
Prior,
|
Prior,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which scalar an [`InferenceError::InvalidParameter`] is about.
|
||||||
|
///
|
||||||
|
/// A typed discriminator rather than a `&'static str`, so a caller can branch
|
||||||
|
/// on it and `Display` can state each parameter's actual valid range. Nine
|
||||||
|
/// distinct strings used to flow through this position, and the only thing a
|
||||||
|
/// caller could do with one was print it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Parameter {
|
||||||
|
/// Prior mean skill. Must be finite.
|
||||||
|
Mu,
|
||||||
|
/// Prior standard deviation. Must be finite and strictly positive.
|
||||||
|
Sigma,
|
||||||
|
/// Performance noise. Must be finite and non-negative.
|
||||||
|
Beta,
|
||||||
|
/// Draw probability. Must be in `[0.0, 1.0)`.
|
||||||
|
PDraw,
|
||||||
|
/// Observation noise on a score margin. Must be finite and strictly
|
||||||
|
/// positive.
|
||||||
|
ScoreSigma,
|
||||||
|
/// EP damping factor. Must be in `(0.0, 1.0]`.
|
||||||
|
Alpha,
|
||||||
|
/// Convergence threshold. Must be non-negative and not NaN.
|
||||||
|
Epsilon,
|
||||||
|
/// A competitor's multiplier on the drift variance. Must be finite and
|
||||||
|
/// non-negative.
|
||||||
|
DriftScale,
|
||||||
|
/// The variance a [`Drift`](crate::Drift) implementation actually produced
|
||||||
|
/// for a span. Must be finite and non-negative — checked because a custom
|
||||||
|
/// implementation is the one thing no constructor can validate up front.
|
||||||
|
DriftVariance,
|
||||||
|
/// A per-member weight on an event. Must be finite.
|
||||||
|
Weight,
|
||||||
|
/// A team's score on a scored event. Must be finite.
|
||||||
|
Score,
|
||||||
|
/// A team's rank on a ranked event. Must be finite.
|
||||||
|
Rank,
|
||||||
|
/// The winning team's index, as given to `Outcome::winner`. Must be less
|
||||||
|
/// than the team count.
|
||||||
|
WinnerIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Parameter {
|
||||||
|
/// The range this parameter must lie in, for the `Display` message.
|
||||||
|
fn range(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Mu => "must be finite",
|
||||||
|
Self::Sigma => "must be finite and strictly positive",
|
||||||
|
Self::Beta => "must be finite and non-negative",
|
||||||
|
Self::PDraw => "must be in [0.0, 1.0)",
|
||||||
|
Self::ScoreSigma => "must be finite and strictly positive",
|
||||||
|
Self::Alpha => "must be in (0.0, 1.0]",
|
||||||
|
Self::Epsilon => "must be non-negative and not NaN",
|
||||||
|
Self::DriftScale => "must be finite and non-negative",
|
||||||
|
Self::DriftVariance => "must be finite and non-negative",
|
||||||
|
Self::Weight => "must be finite",
|
||||||
|
Self::Score => "must be finite",
|
||||||
|
Self::Rank => "must be finite",
|
||||||
|
Self::WinnerIndex => "must be less than the number of teams",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Parameter {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let name = match self {
|
||||||
|
Self::Mu => "mu",
|
||||||
|
Self::Sigma => "sigma",
|
||||||
|
Self::Beta => "beta",
|
||||||
|
Self::PDraw => "p_draw",
|
||||||
|
Self::ScoreSigma => "score_sigma",
|
||||||
|
Self::Alpha => "alpha",
|
||||||
|
Self::Epsilon => "epsilon",
|
||||||
|
Self::DriftScale => "drift_scale",
|
||||||
|
Self::DriftVariance => "drift variance",
|
||||||
|
Self::Weight => "weight",
|
||||||
|
Self::Score => "score",
|
||||||
|
Self::Rank => "rank",
|
||||||
|
Self::WinnerIndex => "winner index",
|
||||||
|
};
|
||||||
|
f.write_str(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which two lengths an [`InferenceError::MismatchedShape`] found disagreeing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Shape {
|
||||||
|
/// The outcome describes a different number of teams than the event has.
|
||||||
|
OutcomeVsTeams,
|
||||||
|
/// A per-member weight list does not match the team's membership.
|
||||||
|
Weights,
|
||||||
|
/// A call that takes a fixed number of teams got a different number.
|
||||||
|
Teams,
|
||||||
|
/// One of `add_events_with_prior`'s parallel arrays disagreed with the
|
||||||
|
/// others.
|
||||||
|
///
|
||||||
|
/// Not reachable through the public API — the arrays are built together at
|
||||||
|
/// the ingestion chokepoint. Kept as a checked error rather than a
|
||||||
|
/// `debug_assert!` so it also holds in release, which is where this
|
||||||
|
/// crate's defects have tended to hide.
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Shape {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let what = match self {
|
||||||
|
Self::OutcomeVsTeams => {
|
||||||
|
"the outcome describes a different number of teams than the event has"
|
||||||
|
}
|
||||||
|
Self::Weights => "the weight list does not match the team's membership",
|
||||||
|
Self::Teams => "this call takes a fixed number of teams",
|
||||||
|
Self::Internal => {
|
||||||
|
"an internal array disagreed with its siblings (this is a bug in trueskill-tt)"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
f.write_str(what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which [`Outcome`](crate::Outcome) variant a call found or wanted.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum OutcomeKind {
|
||||||
|
/// [`Outcome::Ranked`](crate::Outcome::Ranked): an ordinal finish.
|
||||||
|
Ranked,
|
||||||
|
/// [`Outcome::Scored`](crate::Outcome::Scored): continuous scores.
|
||||||
|
Scored,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutcomeKind {
|
||||||
|
/// The call that takes this kind, for the `Display` message.
|
||||||
|
fn constructor(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Ranked => "Game::ranked",
|
||||||
|
Self::Scored => "Game::scored",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The adjective form, for prose.
|
||||||
|
fn adjective(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Ranked => "ranked",
|
||||||
|
Self::Scored => "scored",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for OutcomeKind {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Ranked => "Outcome::Ranked",
|
||||||
|
Self::Scored => "Outcome::Scored",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which piece of per-competitor configuration was declared twice.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum CompetitorField {
|
||||||
|
/// The starting skill distribution.
|
||||||
|
Prior,
|
||||||
|
/// The multiplier on the drift variance.
|
||||||
|
DriftScale,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CompetitorField {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Prior => "prior",
|
||||||
|
Self::DriftScale => "drift_scale",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Every way ingestion, inference or prediction can refuse to answer.
|
/// Every way ingestion, inference or prediction can refuse to answer.
|
||||||
///
|
///
|
||||||
/// The crate reports rather than repairs. An input it cannot represent, a fit
|
/// The crate reports rather than repairs. An input it cannot represent, a fit
|
||||||
@@ -56,9 +232,8 @@ pub enum InferenceError {
|
|||||||
/// Expected and actual lengths of some array-shaped input differ.
|
/// Expected and actual lengths of some array-shaped input differ.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
MismatchedShape {
|
MismatchedShape {
|
||||||
/// Which input disagreed, as a short label — `"ranks vs teams"`,
|
/// Which pair of lengths disagreed.
|
||||||
/// `"weights"`, `"times"`.
|
shape: Shape,
|
||||||
kind: &'static str,
|
|
||||||
/// The length it had to have, taken from whatever it must line up with
|
/// The length it had to have, taken from whatever it must line up with
|
||||||
/// (usually the event's team count).
|
/// (usually the event's team count).
|
||||||
expected: usize,
|
expected: usize,
|
||||||
@@ -68,28 +243,18 @@ pub enum InferenceError {
|
|||||||
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
WrongOutcomeKind {
|
WrongOutcomeKind {
|
||||||
/// The call that rejected the outcome, e.g. `"Game::ranked"`.
|
/// The variant the call needs.
|
||||||
context: &'static str,
|
expected: OutcomeKind,
|
||||||
/// The [`Outcome`](crate::Outcome) variant that call needs, by name.
|
/// The variant actually supplied.
|
||||||
expected: &'static str,
|
got: OutcomeKind,
|
||||||
/// The variant actually supplied, by name.
|
|
||||||
got: &'static str,
|
|
||||||
},
|
|
||||||
/// A probability value is outside `[0, 1]`.
|
|
||||||
#[non_exhaustive]
|
|
||||||
InvalidProbability {
|
|
||||||
/// The value supplied, as it fell outside `[0, 1]`. Today only
|
|
||||||
/// `p_draw` reaches here.
|
|
||||||
value: f64,
|
|
||||||
},
|
},
|
||||||
/// A scalar parameter is outside its valid range.
|
/// A scalar parameter is outside its valid range.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
InvalidParameter {
|
InvalidParameter {
|
||||||
/// The parameter, spelled as the API spells it — `"alpha"`,
|
/// Which parameter. `Display` states its valid range.
|
||||||
/// `"epsilon"`, `"score_sigma"`, `"drift_scale"`, `"drift variance"`.
|
parameter: Parameter,
|
||||||
name: &'static str,
|
/// The value supplied for it: outside that range, or NaN, which fails
|
||||||
/// The value supplied for it. Out of that parameter's range, or NaN,
|
/// every range comparison and is rejected on that basis.
|
||||||
/// which fails every range comparison and is rejected on that basis.
|
|
||||||
value: f64,
|
value: f64,
|
||||||
},
|
},
|
||||||
/// An event contains tied teams, but the draw probability is zero.
|
/// An event contains tied teams, but the draw probability is zero.
|
||||||
@@ -130,20 +295,44 @@ pub enum InferenceError {
|
|||||||
/// The threshold both components of `final_step` had to reach.
|
/// The threshold both components of `final_step` had to reach.
|
||||||
epsilon: f64,
|
epsilon: f64,
|
||||||
},
|
},
|
||||||
/// Inference produced a non-finite value (NaN or infinity).
|
/// A convergence sweep produced a non-finite step.
|
||||||
///
|
///
|
||||||
/// Indicates numerical breakdown; the resulting skills are meaningless
|
/// EP has broken down; the resulting skills are meaningless and must not
|
||||||
/// and must not be treated as a converged estimate.
|
/// be treated as a converged estimate. Further iterations cannot recover,
|
||||||
|
/// so the loop stops rather than reporting a NaN step as convergence.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
NonFiniteResult {
|
NonFiniteStep {
|
||||||
/// Where the breakdown was caught — `"History::converge"` for a sweep,
|
/// Where the breakdown was caught, e.g. `"History::converge"`.
|
||||||
/// or a phrase naming the prediction that read an unusable skill.
|
|
||||||
context: &'static str,
|
context: &'static str,
|
||||||
/// The offending pair, at least one component of which is NaN or
|
/// The offending step as `(|d mu|, |d sigma|)`, at least one component
|
||||||
/// infinite. From `converge` it is the sweep's step; from a prediction
|
/// of which is NaN or infinite.
|
||||||
/// it is the skill's own `(mu, sigma)`.
|
|
||||||
step: (f64, f64),
|
step: (f64, f64),
|
||||||
},
|
},
|
||||||
|
/// A prediction read a skill with no usable mean or variance.
|
||||||
|
///
|
||||||
|
/// Split from `NonFiniteStep` (#74), which used to carry both under one
|
||||||
|
/// `step: (f64, f64)` field — a sweep step from `converge` and a skill's
|
||||||
|
/// own moments from a prediction. One field name cannot be right for both.
|
||||||
|
///
|
||||||
|
/// Reaching this means a previous `converge` failed and its error was
|
||||||
|
/// ignored: predicting from a NaN fit produced `Ok(NaN)` on some paths and
|
||||||
|
/// a plausible-looking `Ok([0.0, 0.0])` on others.
|
||||||
|
#[non_exhaustive]
|
||||||
|
NonFiniteSkill {
|
||||||
|
/// The skill's mean, which may itself be finite while `sigma` is not.
|
||||||
|
mu: f64,
|
||||||
|
/// The skill's standard deviation.
|
||||||
|
sigma: f64,
|
||||||
|
},
|
||||||
|
/// Every skill in the matchup is a point mass and `beta` is zero, so
|
||||||
|
/// there is no performance distribution to predict from.
|
||||||
|
///
|
||||||
|
/// Not `InvalidParameter`: both values are individually in range, and it
|
||||||
|
/// is their combination that leaves nothing varying. Every prediction is a
|
||||||
|
/// statement about how performances vary, and in this configuration
|
||||||
|
/// nothing does — `quality` would divide by a singular contrast covariance
|
||||||
|
/// and `predict_win_probabilities` would report zeros that sum to zero.
|
||||||
|
NoPerformanceVariance,
|
||||||
/// One batch declared two different values for the same competitor's
|
/// One batch declared two different values for the same competitor's
|
||||||
/// configuration.
|
/// configuration.
|
||||||
///
|
///
|
||||||
@@ -159,9 +348,8 @@ pub enum InferenceError {
|
|||||||
/// not the user key — the batch is already flattened to indices by the
|
/// not the user key — the batch is already flattened to indices by the
|
||||||
/// time the conflict is detectable.
|
/// time the conflict is detectable.
|
||||||
competitor: usize,
|
competitor: usize,
|
||||||
/// Which piece of configuration was declared twice: `"prior"` or
|
/// Which piece of configuration was declared twice.
|
||||||
/// `"drift_scale"`.
|
field: CompetitorField,
|
||||||
field: &'static str,
|
|
||||||
},
|
},
|
||||||
/// A prediction referenced a key the history has no skill for.
|
/// A prediction referenced a key the history has no skill for.
|
||||||
///
|
///
|
||||||
@@ -231,14 +419,32 @@ pub enum InferenceError {
|
|||||||
/// Nodes the grid may hold.
|
/// Nodes the grid may hold.
|
||||||
max: usize,
|
max: usize,
|
||||||
},
|
},
|
||||||
/// A joint posterior was requested where one cannot be formed exactly.
|
/// A joint posterior was requested from a history with no events.
|
||||||
#[non_exhaustive]
|
///
|
||||||
JointUnavailable {
|
/// Split out of a single `JointUnavailable { reason: &str }` (#74): the
|
||||||
/// Why no exact joint exists here: the history has no events, it holds
|
/// three reasons are conditions a caller branches on differently, and
|
||||||
/// ranked events whose EP factors are not retained past convergence, or
|
/// distinguishing them used to mean matching on English prose. This one
|
||||||
/// the assembled precision matrix is not positive-definite.
|
/// means "add events".
|
||||||
reason: &'static str,
|
EmptyHistory,
|
||||||
},
|
/// A joint posterior was requested from a history containing ranked
|
||||||
|
/// events.
|
||||||
|
///
|
||||||
|
/// Exact only for an all-scored history: a scored likelihood is Gaussian
|
||||||
|
/// and its factor can be rebuilt exactly, while a ranked outcome's
|
||||||
|
/// truncation is approximated by EP and reconstructing those factors needs
|
||||||
|
/// the converged messages, which inference does not retain.
|
||||||
|
///
|
||||||
|
/// [`History::predict_win_probabilities`](crate::History::predict_win_probabilities)
|
||||||
|
/// answers the comparable question on a ranked history.
|
||||||
|
JointRequiresScoredEvents,
|
||||||
|
/// The assembled precision matrix is not positive-definite.
|
||||||
|
///
|
||||||
|
/// The usual cause is a competitor with neither a proper prior nor any
|
||||||
|
/// evidence, but an extreme prior or drift can also make the assembled
|
||||||
|
/// matrix indefinite in floating point. Unlike its two siblings this one
|
||||||
|
/// is numerical rather than structural — the same history may factorise
|
||||||
|
/// under different parameters.
|
||||||
|
NotPositiveDefinite,
|
||||||
/// Fewer than two teams were supplied to a prediction.
|
/// Fewer than two teams were supplied to a prediction.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
NotEnoughTeams {
|
NotEnoughTeams {
|
||||||
@@ -268,21 +474,19 @@ impl fmt::Display for InferenceError {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::MismatchedShape {
|
Self::MismatchedShape {
|
||||||
kind,
|
shape,
|
||||||
expected,
|
expected,
|
||||||
got,
|
got,
|
||||||
} => {
|
} => {
|
||||||
write!(f, "{kind}: expected length {expected}, got {got}")
|
write!(f, "{shape}: expected {expected}, got {got}")
|
||||||
}
|
}
|
||||||
Self::WrongOutcomeKind {
|
Self::WrongOutcomeKind { expected, got } => {
|
||||||
context,
|
write!(
|
||||||
expected,
|
f,
|
||||||
got,
|
"expected {expected}, got {got}; call {} for a {} outcome",
|
||||||
} => {
|
got.constructor(),
|
||||||
write!(f, "{context}: expected {expected}, got {got}")
|
got.adjective()
|
||||||
}
|
)
|
||||||
Self::InvalidProbability { value } => {
|
|
||||||
write!(f, "probability must be in [0, 1]; got {value}")
|
|
||||||
}
|
}
|
||||||
Self::TieWithoutDrawProbability { teams } => {
|
Self::TieWithoutDrawProbability { teams } => {
|
||||||
write!(
|
write!(
|
||||||
@@ -303,14 +507,23 @@ impl fmt::Display for InferenceError {
|
|||||||
alpha < 1.0 if it is oscillating"
|
alpha < 1.0 if it is oscillating"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Self::NonFiniteResult { context, step } => {
|
Self::NonFiniteStep { context, step } => {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
"{context}: inference produced a non-finite result (step = {step:?})"
|
"{context}: inference produced a non-finite step {step:?}; EP has \
|
||||||
|
broken down and further iterations cannot recover"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Self::InvalidParameter { name, value } => {
|
Self::NonFiniteSkill { mu, sigma } => {
|
||||||
write!(f, "{name} is invalid: {value}")
|
write!(
|
||||||
|
f,
|
||||||
|
"a prediction read a skill with no usable mean or variance \
|
||||||
|
(mu = {mu}, sigma = {sigma}); the fit did not converge, and \
|
||||||
|
`converge` reports that"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::InvalidParameter { parameter, value } => {
|
||||||
|
write!(f, "{parameter} {} (got {value})", parameter.range())
|
||||||
}
|
}
|
||||||
Self::ConflictingCompetitorConfig { competitor, field } => {
|
Self::ConflictingCompetitorConfig { competitor, field } => {
|
||||||
write!(
|
write!(
|
||||||
@@ -346,9 +559,25 @@ impl fmt::Display for InferenceError {
|
|||||||
one grid. Use predict_win_probabilities, which is accurate here"
|
one grid. Use predict_win_probabilities, which is accurate here"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Self::JointUnavailable { reason } => {
|
Self::EmptyHistory => {
|
||||||
write!(f, "no exact joint posterior is available: {reason}")
|
f.write_str("no exact joint posterior is available: the history has no events")
|
||||||
}
|
}
|
||||||
|
Self::JointRequiresScoredEvents => f.write_str(
|
||||||
|
"no exact joint posterior is available: the history contains ranked \
|
||||||
|
events, whose EP factors are not retained after convergence. Use \
|
||||||
|
predict_win_probabilities for a ranked history",
|
||||||
|
),
|
||||||
|
Self::NotPositiveDefinite => f.write_str(
|
||||||
|
"the joint precision matrix is not positive-definite; the usual cause \
|
||||||
|
is a competitor with neither a proper prior nor any evidence, but an \
|
||||||
|
extreme prior or drift can also make the assembled matrix indefinite \
|
||||||
|
in floating point",
|
||||||
|
),
|
||||||
|
Self::NoPerformanceVariance => f.write_str(
|
||||||
|
"beta is zero and every skill in this matchup is a point mass, so \
|
||||||
|
there is no performance distribution to predict from; give beta a \
|
||||||
|
positive value, or a competitor a prior with positive sigma",
|
||||||
|
),
|
||||||
Self::NotEnoughTeams { got } => {
|
Self::NotEnoughTeams { got } => {
|
||||||
write!(f, "prediction needs at least 2 teams, got {got}")
|
write!(f, "prediction needs at least 2 teams, got {got}")
|
||||||
}
|
}
|
||||||
@@ -364,3 +593,81 @@ impl fmt::Display for InferenceError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for InferenceError {}
|
impl std::error::Error for InferenceError {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod message_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Every message must name the problem *and* what to do, which is the
|
||||||
|
/// standard the good ones set and the three #74 called out did not meet.
|
||||||
|
#[test]
|
||||||
|
fn messages_are_actionable() {
|
||||||
|
let cases = [
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: Parameter::Alpha,
|
||||||
|
value: 0.0,
|
||||||
|
},
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: Parameter::DriftVariance,
|
||||||
|
value: f64::NAN,
|
||||||
|
},
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: Parameter::PDraw,
|
||||||
|
value: 1.5,
|
||||||
|
},
|
||||||
|
InferenceError::MismatchedShape {
|
||||||
|
shape: Shape::OutcomeVsTeams,
|
||||||
|
expected: 3,
|
||||||
|
got: 2,
|
||||||
|
},
|
||||||
|
InferenceError::WrongOutcomeKind {
|
||||||
|
expected: OutcomeKind::Ranked,
|
||||||
|
got: OutcomeKind::Scored,
|
||||||
|
},
|
||||||
|
InferenceError::EmptyHistory,
|
||||||
|
InferenceError::JointRequiresScoredEvents,
|
||||||
|
InferenceError::NotPositiveDefinite,
|
||||||
|
InferenceError::NoPerformanceVariance,
|
||||||
|
InferenceError::NonFiniteSkill {
|
||||||
|
mu: f64::NAN,
|
||||||
|
sigma: f64::NAN,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for case in &cases {
|
||||||
|
let rendered = case.to_string();
|
||||||
|
eprintln!("{rendered}");
|
||||||
|
// `InvalidParameter` used to render `drift variance is invalid: NaN`
|
||||||
|
// — no range, no remedy, no location. Every message must at least
|
||||||
|
// be a sentence.
|
||||||
|
assert!(
|
||||||
|
rendered.len() > 30,
|
||||||
|
"message is too terse to act on: {rendered}"
|
||||||
|
);
|
||||||
|
assert!(!rendered.contains("is invalid:"), "{rendered}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three that #74 singled out now state a range or a next step.
|
||||||
|
assert!(
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: Parameter::DriftVariance,
|
||||||
|
value: f64::NAN,
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
.contains("must be finite and non-negative")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
InferenceError::WrongOutcomeKind {
|
||||||
|
expected: OutcomeKind::Ranked,
|
||||||
|
got: OutcomeKind::Scored,
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
.contains("Game::scored")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
InferenceError::JointRequiresScoredEvents
|
||||||
|
.to_string()
|
||||||
|
.contains("predict_win_probabilities")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,14 +38,15 @@ use crate::{
|
|||||||
/// ```
|
/// ```
|
||||||
#[must_use = "an event is only recorded by `.commit()`; a dropped builder \
|
#[must_use = "an event is only recorded by `.commit()`; a dropped builder \
|
||||||
silently ingests nothing"]
|
silently ingests nothing"]
|
||||||
pub struct EventBuilder<'h, T, D, O, K>
|
pub struct EventBuilder<'h, T, D, O, K, R>
|
||||||
where
|
where
|
||||||
T: Time,
|
T: Time,
|
||||||
D: Drift<T>,
|
D: Drift<T>,
|
||||||
O: Observer<T>,
|
O: Observer<T>,
|
||||||
K: Eq + std::hash::Hash + Clone,
|
K: Eq + std::hash::Hash + Clone,
|
||||||
|
R: crate::RatingRule<K>,
|
||||||
{
|
{
|
||||||
history: &'h mut History<K, T, D, O>,
|
history: &'h mut History<K, T, D, O, R>,
|
||||||
event: Event<T, K>,
|
event: Event<T, K>,
|
||||||
current_team_idx: Option<usize>,
|
current_team_idx: Option<usize>,
|
||||||
/// First validation failure seen while building, surfaced by `commit`.
|
/// First validation failure seen while building, surfaced by `commit`.
|
||||||
@@ -58,14 +59,15 @@ where
|
|||||||
error: Option<InferenceError>,
|
error: Option<InferenceError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
|
impl<'h, T, D, O, K, R> EventBuilder<'h, T, D, O, K, R>
|
||||||
where
|
where
|
||||||
T: Time,
|
T: Time,
|
||||||
D: Drift<T>,
|
D: Drift<T>,
|
||||||
O: Observer<T>,
|
O: Observer<T>,
|
||||||
K: Eq + std::hash::Hash + Clone,
|
K: Eq + std::hash::Hash + Clone,
|
||||||
|
R: crate::RatingRule<K>,
|
||||||
{
|
{
|
||||||
pub(crate) fn new(history: &'h mut History<K, T, D, O>, time: T) -> Self {
|
pub(crate) fn new(history: &'h mut History<K, T, D, O, R>, time: T) -> Self {
|
||||||
Self {
|
Self {
|
||||||
history,
|
history,
|
||||||
event: Event {
|
event: Event {
|
||||||
@@ -142,7 +144,7 @@ where
|
|||||||
|
|
||||||
if ws.len() != team.members.len() {
|
if ws.len() != team.members.len() {
|
||||||
self.error.get_or_insert(InferenceError::MismatchedShape {
|
self.error.get_or_insert(InferenceError::MismatchedShape {
|
||||||
kind: "weights",
|
shape: crate::Shape::Weights,
|
||||||
expected: team.members.len(),
|
expected: team.members.len(),
|
||||||
got: ws.len(),
|
got: ws.len(),
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-13
@@ -555,7 +555,7 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
|
|||||||
/// - `InvalidParameter` if `options.convergence` is out of range — an
|
/// - `InvalidParameter` if `options.convergence` is out of range — an
|
||||||
/// `alpha` of zero would leave every EP update unapplied and silently
|
/// `alpha` of zero would leave every EP update unapplied and silently
|
||||||
/// return the priors.
|
/// return the priors.
|
||||||
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
|
/// - `InvalidParameter` for a `p_draw` outside `[0.0, 1.0)`.
|
||||||
/// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`.
|
/// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`.
|
||||||
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
|
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
|
||||||
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
|
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
|
||||||
@@ -571,13 +571,14 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
|
|||||||
options.convergence.validate()?;
|
options.convergence.validate()?;
|
||||||
Self::validate_teams(teams)?;
|
Self::validate_teams(teams)?;
|
||||||
if !(0.0..1.0).contains(&options.p_draw) {
|
if !(0.0..1.0).contains(&options.p_draw) {
|
||||||
return Err(crate::InferenceError::InvalidProbability {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
|
parameter: crate::Parameter::PDraw,
|
||||||
value: options.p_draw,
|
value: options.p_draw,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if outcome.team_count() != teams.len() {
|
if outcome.team_count() != teams.len() {
|
||||||
return Err(crate::InferenceError::MismatchedShape {
|
return Err(crate::InferenceError::MismatchedShape {
|
||||||
kind: "outcome ranks vs teams",
|
shape: crate::Shape::OutcomeVsTeams,
|
||||||
expected: teams.len(),
|
expected: teams.len(),
|
||||||
got: outcome.team_count(),
|
got: outcome.team_count(),
|
||||||
});
|
});
|
||||||
@@ -586,9 +587,8 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
|
|||||||
let ranks = outcome
|
let ranks = outcome
|
||||||
.as_ranks()
|
.as_ranks()
|
||||||
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
||||||
context: "Game::ranked",
|
expected: crate::OutcomeKind::Ranked,
|
||||||
expected: "Outcome::Ranked",
|
got: crate::OutcomeKind::Scored,
|
||||||
got: "Outcome::Scored",
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let tied = if options.p_draw == 0.0 {
|
let tied = if options.p_draw == 0.0 {
|
||||||
@@ -638,13 +638,13 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
|
|||||||
Self::validate_teams(teams)?;
|
Self::validate_teams(teams)?;
|
||||||
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
|
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
|
||||||
return Err(crate::InferenceError::InvalidParameter {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
name: "score_sigma",
|
parameter: crate::Parameter::ScoreSigma,
|
||||||
value: options.score_sigma,
|
value: options.score_sigma,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if outcome.team_count() != teams.len() {
|
if outcome.team_count() != teams.len() {
|
||||||
return Err(crate::InferenceError::MismatchedShape {
|
return Err(crate::InferenceError::MismatchedShape {
|
||||||
kind: "outcome scores vs teams",
|
shape: crate::Shape::OutcomeVsTeams,
|
||||||
expected: teams.len(),
|
expected: teams.len(),
|
||||||
got: outcome.team_count(),
|
got: outcome.team_count(),
|
||||||
});
|
});
|
||||||
@@ -652,9 +652,8 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
|
|||||||
let scores = outcome
|
let scores = outcome
|
||||||
.as_scores()
|
.as_scores()
|
||||||
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
||||||
context: "Game::scored",
|
expected: crate::OutcomeKind::Scored,
|
||||||
expected: "Outcome::Scored",
|
got: crate::OutcomeKind::Ranked,
|
||||||
got: "Outcome::Ranked",
|
|
||||||
})?
|
})?
|
||||||
.to_vec();
|
.to_vec();
|
||||||
// A non-finite score poisons the chain rather than failing it. Ranks
|
// A non-finite score poisons the chain rather than failing it. Ranks
|
||||||
@@ -662,7 +661,7 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
|
|||||||
for value in &scores {
|
for value in &scores {
|
||||||
if !value.is_finite() {
|
if !value.is_finite() {
|
||||||
return Err(crate::InferenceError::InvalidParameter {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
name: "score",
|
parameter: crate::Parameter::Score,
|
||||||
value: *value,
|
value: *value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1368,7 +1367,7 @@ mod tests {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
crate::InferenceError::InvalidParameter {
|
crate::InferenceError::InvalidParameter {
|
||||||
name: "score_sigma",
|
parameter: crate::Parameter::ScoreSigma,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,7 @@ impl Gaussian {
|
|||||||
///
|
///
|
||||||
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a
|
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a
|
||||||
/// broken fit produces one, and `converge` reports that as
|
/// broken fit produces one, and `converge` reports that as
|
||||||
/// `NonFiniteResult` rather than panicking mid-inference.
|
/// `NonFiniteStep` rather than panicking mid-inference.
|
||||||
///
|
///
|
||||||
/// A negative sigma used to be accepted and returned results **bit
|
/// A negative sigma used to be accepted and returned results **bit
|
||||||
/// identical** to its absolute value, because sigma only ever enters as
|
/// identical** to its absolute value, because sigma only ever enters as
|
||||||
@@ -46,7 +46,7 @@ impl Gaussian {
|
|||||||
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
||||||
// NaN is admitted on purpose. A broken fit legitimately produces a NaN
|
// NaN is admitted on purpose. A broken fit legitimately produces a NaN
|
||||||
// sigma — `sqrt` of a negative truncated variance — and the design is
|
// sigma — `sqrt` of a negative truncated variance — and the design is
|
||||||
// to propagate that to `converge`'s `NonFiniteResult` guard, not to
|
// to propagate that to `converge`'s `NonFiniteStep` guard, not to
|
||||||
// panic inside inference. Rejecting it here turned that reporting path
|
// panic inside inference. Rejecting it here turned that reporting path
|
||||||
// into a crash, which two tests caught immediately.
|
// into a crash, which two tests caught immediately.
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+216
-104
@@ -17,6 +17,7 @@ use crate::{
|
|||||||
observer::{NullObserver, Observer},
|
observer::{NullObserver, Observer},
|
||||||
predict::Prediction,
|
predict::Prediction,
|
||||||
rating::Rating,
|
rating::Rating,
|
||||||
|
rating_rule::{FnRule, NoRule, RatingRule, StartingPoint},
|
||||||
sort_time,
|
sort_time,
|
||||||
storage::CompetitorStore,
|
storage::CompetitorStore,
|
||||||
time::Time,
|
time::Time,
|
||||||
@@ -32,7 +33,7 @@ use crate::{
|
|||||||
/// an unknown key. None of them can be changed after `build`, because they
|
/// an unknown key. None of them can be changed after `build`, because they
|
||||||
/// define the model the fit is of.
|
/// define the model the fit is of.
|
||||||
///
|
///
|
||||||
/// Parameterised as [`History`] is, `HistoryBuilder<K, T, D, O>`, with the
|
/// Parameterised as [`History`] is, `HistoryBuilder<K, T, D, O, R>`, with the
|
||||||
/// same defaults.
|
/// same defaults.
|
||||||
///
|
///
|
||||||
/// Two of the setters change the builder's *type* rather than a field —
|
/// Two of the setters change the builder's *type* rather than a field —
|
||||||
@@ -47,6 +48,7 @@ pub struct HistoryBuilder<
|
|||||||
T: Time = i64,
|
T: Time = i64,
|
||||||
D: Drift<T> = ConstantDrift,
|
D: Drift<T> = ConstantDrift,
|
||||||
O: Observer<T> = NullObserver,
|
O: Observer<T> = NullObserver,
|
||||||
|
R: RatingRule<K> = NoRule,
|
||||||
> {
|
> {
|
||||||
mu: f64,
|
mu: f64,
|
||||||
sigma: f64,
|
sigma: f64,
|
||||||
@@ -57,17 +59,20 @@ pub struct HistoryBuilder<
|
|||||||
convergence: ConvergenceOptions,
|
convergence: ConvergenceOptions,
|
||||||
observer: O,
|
observer: O,
|
||||||
unknown_keys: crate::UnknownKeys,
|
unknown_keys: crate::UnknownKeys,
|
||||||
|
rule: R,
|
||||||
_time: PhantomData<T>,
|
_time: PhantomData<T>,
|
||||||
_key: PhantomData<K>,
|
_key: PhantomData<K>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<K, T, D, O> {
|
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K>>
|
||||||
|
HistoryBuilder<K, T, D, O, R>
|
||||||
|
{
|
||||||
/// Prior mean skill.
|
/// Prior mean skill.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
///
|
///
|
||||||
/// Panics if `mu` is not finite. A non-finite prior mean poisons every
|
/// Panics if `mu` is not finite. A non-finite prior mean poisons every
|
||||||
/// posterior derived from it: `converge` reports `NonFiniteResult`, but a
|
/// posterior derived from it: `converge` reports `NonFiniteStep`, but a
|
||||||
/// caller who reads `current_skill` first is handed `tau: NaN`.
|
/// caller who reads `current_skill` first is handed `tau: NaN`.
|
||||||
pub fn mu(mut self, mu: f64) -> Self {
|
pub fn mu(mut self, mu: f64) -> Self {
|
||||||
assert!(mu.is_finite(), "mu must be finite (got {mu})");
|
assert!(mu.is_finite(), "mu must be finite (got {mu})");
|
||||||
@@ -153,9 +158,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
/// implementation. `converge` checks the variance each competitor actually
|
/// implementation. `converge` checks the variance each competitor actually
|
||||||
/// accumulates and reports `InvalidParameter` if it is negative or
|
/// accumulates and reports `InvalidParameter` if it is negative or
|
||||||
/// non-finite.
|
/// non-finite.
|
||||||
pub fn drift<D2: Drift<T>>(self, drift: D2) -> HistoryBuilder<K, T, D2, O> {
|
pub fn drift<D2: Drift<T>>(self, drift: D2) -> HistoryBuilder<K, T, D2, O, R> {
|
||||||
HistoryBuilder {
|
HistoryBuilder {
|
||||||
drift,
|
drift,
|
||||||
|
rule: self.rule,
|
||||||
mu: self.mu,
|
mu: self.mu,
|
||||||
sigma: self.sigma,
|
sigma: self.sigma,
|
||||||
beta: self.beta,
|
beta: self.beta,
|
||||||
@@ -252,13 +258,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
/// assert!(h.current_skill(&"alice").unwrap().mu() > 0.0);
|
/// assert!(h.current_skill(&"alice").unwrap().mu() > 0.0);
|
||||||
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
/// ```
|
/// ```
|
||||||
pub fn time_type<T2>(self) -> HistoryBuilder<K, T2, D, O>
|
pub fn time_type<T2>(self) -> HistoryBuilder<K, T2, D, O, R>
|
||||||
where
|
where
|
||||||
T2: Time,
|
T2: Time,
|
||||||
D: Drift<T2>,
|
D: Drift<T2>,
|
||||||
O: Observer<T2>,
|
O: Observer<T2>,
|
||||||
{
|
{
|
||||||
HistoryBuilder {
|
HistoryBuilder {
|
||||||
|
rule: self.rule,
|
||||||
mu: self.mu,
|
mu: self.mu,
|
||||||
sigma: self.sigma,
|
sigma: self.sigma,
|
||||||
beta: self.beta,
|
beta: self.beta,
|
||||||
@@ -286,8 +293,79 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
/// h.record_winner(&"alice".to_string(), &"bob".to_string(), 1)?;
|
/// h.record_winner(&"alice".to_string(), &"bob".to_string(), 1)?;
|
||||||
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
/// ```
|
/// ```
|
||||||
pub fn key_type<K2: Eq + Hash + Clone>(self) -> HistoryBuilder<K2, T, D, O> {
|
pub fn key_type<K2: Eq + Hash + Clone>(self) -> HistoryBuilder<K2, T, D, O, NoRule> {
|
||||||
HistoryBuilder {
|
HistoryBuilder {
|
||||||
|
// A `RatingRule<K>` cannot answer questions about `K2`, so
|
||||||
|
// changing the key type drops it. Set the key type first.
|
||||||
|
rule: NoRule,
|
||||||
|
mu: self.mu,
|
||||||
|
sigma: self.sigma,
|
||||||
|
beta: self.beta,
|
||||||
|
drift: self.drift,
|
||||||
|
p_draw: self.p_draw,
|
||||||
|
score_sigma: self.score_sigma,
|
||||||
|
convergence: self.convergence,
|
||||||
|
observer: self.observer,
|
||||||
|
unknown_keys: self.unknown_keys,
|
||||||
|
_time: PhantomData,
|
||||||
|
_key: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supply a rule that configures competitors the history has not seen.
|
||||||
|
///
|
||||||
|
/// `register` states configuration for one competitor at a time, which
|
||||||
|
/// needs the key set up front. This states it for a *class* — "every
|
||||||
|
/// layout is static" — as one statement that cannot be forgotten on an
|
||||||
|
/// ingestion path.
|
||||||
|
///
|
||||||
|
/// Consulted once per competitor, at creation. Explicit configuration from
|
||||||
|
/// `register` or a [`Member`](crate::Member) overrides it field by field;
|
||||||
|
/// see [`RatingRule`] for why the specific beats the general here while
|
||||||
|
/// two explicit declarations that disagree stay an error.
|
||||||
|
///
|
||||||
|
/// Changes the builder's type — bind the result — and must come *after*
|
||||||
|
/// [`key_type`](HistoryBuilder::key_type), since a `RatingRule<K>` cannot
|
||||||
|
/// answer questions about a different key type.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use trueskill_tt::{Gaussian, History, StartingPoint};
|
||||||
|
/// let h = History::builder()
|
||||||
|
/// .default_rating_for(|key: &&'static str| {
|
||||||
|
/// key.starts_with("bot_")
|
||||||
|
/// .then(|| StartingPoint::new().drift_scale(0.0))
|
||||||
|
/// })
|
||||||
|
/// .build();
|
||||||
|
/// # let _ = h;
|
||||||
|
/// ```
|
||||||
|
pub fn default_rating_for<F>(self, rule: F) -> HistoryBuilder<K, T, D, O, FnRule<F>>
|
||||||
|
where
|
||||||
|
F: Fn(&K) -> Option<StartingPoint>,
|
||||||
|
{
|
||||||
|
HistoryBuilder {
|
||||||
|
rule: FnRule(rule),
|
||||||
|
mu: self.mu,
|
||||||
|
sigma: self.sigma,
|
||||||
|
beta: self.beta,
|
||||||
|
drift: self.drift,
|
||||||
|
p_draw: self.p_draw,
|
||||||
|
score_sigma: self.score_sigma,
|
||||||
|
convergence: self.convergence,
|
||||||
|
observer: self.observer,
|
||||||
|
unknown_keys: self.unknown_keys,
|
||||||
|
_time: PhantomData,
|
||||||
|
_key: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supply a rule as a named type implementing [`RatingRule`].
|
||||||
|
///
|
||||||
|
/// The counterpart of [`default_rating_for`](HistoryBuilder::default_rating_for)
|
||||||
|
/// for when the resulting `History<..>` has to be written down in a struct
|
||||||
|
/// field, which a closure's type makes impossible.
|
||||||
|
pub fn rating_rule<R2: RatingRule<K>>(self, rule: R2) -> HistoryBuilder<K, T, D, O, R2> {
|
||||||
|
HistoryBuilder {
|
||||||
|
rule,
|
||||||
mu: self.mu,
|
mu: self.mu,
|
||||||
sigma: self.sigma,
|
sigma: self.sigma,
|
||||||
beta: self.beta,
|
beta: self.beta,
|
||||||
@@ -308,8 +386,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
/// observer by value; to keep a handle on one that accumulates state, pass
|
/// observer by value; to keep a handle on one that accumulates state, pass
|
||||||
/// an `Arc` and keep a clone, or read it back with
|
/// an `Arc` and keep a clone, or read it back with
|
||||||
/// [`History::observer`] / [`History::into_observer`].
|
/// [`History::observer`] / [`History::into_observer`].
|
||||||
pub fn observer<O2: Observer<T>>(self, observer: O2) -> HistoryBuilder<K, T, D, O2> {
|
pub fn observer<O2: Observer<T>>(self, observer: O2) -> HistoryBuilder<K, T, D, O2, R> {
|
||||||
HistoryBuilder {
|
HistoryBuilder {
|
||||||
|
rule: self.rule,
|
||||||
mu: self.mu,
|
mu: self.mu,
|
||||||
sigma: self.sigma,
|
sigma: self.sigma,
|
||||||
beta: self.beta,
|
beta: self.beta,
|
||||||
@@ -327,8 +406,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
/// Finish configuring and produce an empty [`History`].
|
/// Finish configuring and produce an empty [`History`].
|
||||||
///
|
///
|
||||||
/// Every parameter was validated as it was set, so this cannot fail.
|
/// Every parameter was validated as it was set, so this cannot fail.
|
||||||
pub fn build(self) -> History<K, T, D, O> {
|
pub fn build(self) -> History<K, T, D, O, R> {
|
||||||
History {
|
History {
|
||||||
|
rule: self.rule,
|
||||||
size: 0,
|
size: 0,
|
||||||
time_slices: Vec::new(),
|
time_slices: Vec::new(),
|
||||||
competitors: CompetitorStore::new(),
|
competitors: CompetitorStore::new(),
|
||||||
@@ -357,6 +437,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
impl<T: Time, K: Eq + Hash + Clone> Default for HistoryBuilder<K, T, ConstantDrift, NullObserver> {
|
impl<T: Time, K: Eq + Hash + Clone> Default for HistoryBuilder<K, T, ConstantDrift, NullObserver> {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
rule: NoRule,
|
||||||
mu: MU,
|
mu: MU,
|
||||||
sigma: SIGMA,
|
sigma: SIGMA,
|
||||||
beta: BETA,
|
beta: BETA,
|
||||||
@@ -386,8 +467,8 @@ pub(crate) struct CompetitorConfig {
|
|||||||
/// The joint precision over a history's appearances, with the maps needed to
|
/// The joint precision over a history's appearances, with the maps needed to
|
||||||
/// address a competitor either at their latest appearance or at a given slice.
|
/// address a competitor either at their latest appearance or at a given slice.
|
||||||
struct TimeExpanded {
|
struct TimeExpanded {
|
||||||
/// Row-major precision matrix over appearances.
|
/// Precision matrix over appearances, accumulated sparsely.
|
||||||
lambda: Vec<f64>,
|
lambda: crate::joint::SymmetricBuilder,
|
||||||
/// `(row, slice)` of each competitor's latest appearance.
|
/// `(row, slice)` of each competitor's latest appearance.
|
||||||
latest: HashMap<Index, (usize, usize)>,
|
latest: HashMap<Index, (usize, usize)>,
|
||||||
/// Row of each `(competitor, slice)` appearance.
|
/// Row of each `(competitor, slice)` appearance.
|
||||||
@@ -451,7 +532,7 @@ impl CompetitorConfig {
|
|||||||
///
|
///
|
||||||
/// # Type parameters
|
/// # Type parameters
|
||||||
///
|
///
|
||||||
/// `History<K, T, D, O>` — key type, time type, drift model, observer — all
|
/// `History<K, T, D, O, R>` — key type, time type, drift model, observer — all
|
||||||
/// defaulted, so `History` alone means `&'static str` keys on an `i64` time
|
/// defaulted, so `History` alone means `&'static str` keys on an `i64` time
|
||||||
/// axis with [`ConstantDrift`] and no observer, and `History<String>` is the
|
/// axis with [`ConstantDrift`] and no observer, and `History<String>` is the
|
||||||
/// whole spelling for owned keys.
|
/// whole spelling for owned keys.
|
||||||
@@ -465,6 +546,7 @@ pub struct History<
|
|||||||
T: Time = i64,
|
T: Time = i64,
|
||||||
D: Drift<T> = ConstantDrift,
|
D: Drift<T> = ConstantDrift,
|
||||||
O: Observer<T> = NullObserver,
|
O: Observer<T> = NullObserver,
|
||||||
|
R: RatingRule<K> = NoRule,
|
||||||
> {
|
> {
|
||||||
size: usize,
|
size: usize,
|
||||||
pub(crate) time_slices: Vec<TimeSlice<T>>,
|
pub(crate) time_slices: Vec<TimeSlice<T>>,
|
||||||
@@ -478,6 +560,8 @@ pub struct History<
|
|||||||
score_sigma: f64,
|
score_sigma: f64,
|
||||||
convergence: ConvergenceOptions,
|
convergence: ConvergenceOptions,
|
||||||
observer: O,
|
observer: O,
|
||||||
|
/// Supplies a starting point for competitors nobody declared explicitly.
|
||||||
|
rule: R,
|
||||||
unknown_keys: crate::UnknownKeys,
|
unknown_keys: crate::UnknownKeys,
|
||||||
/// Competitor configuration explicitly declared so far, by whichever route.
|
/// Competitor configuration explicitly declared so far, by whichever route.
|
||||||
///
|
///
|
||||||
@@ -552,7 +636,9 @@ impl<T: Time, K: Eq + Hash + Clone> HistoryBuilder<K, T, ConstantDrift, NullObse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D, O> {
|
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K>>
|
||||||
|
History<K, T, D, O, R>
|
||||||
|
{
|
||||||
/// Promote a key to its [`Index`], creating the entry if it is new.
|
/// Promote a key to its [`Index`], creating the entry if it is new.
|
||||||
///
|
///
|
||||||
/// Crate-internal since #73: interning reserves a storage slot and nothing
|
/// Crate-internal since #73: interning reserves a storage slot and nothing
|
||||||
@@ -567,7 +653,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D, O> {
|
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K>>
|
||||||
|
History<K, T, D, O, R>
|
||||||
|
{
|
||||||
fn iteration(&mut self) -> (f64, f64) {
|
fn iteration(&mut self) -> (f64, f64) {
|
||||||
let mut step = (0.0, 0.0);
|
let mut step = (0.0, 0.0);
|
||||||
|
|
||||||
@@ -767,14 +855,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
{
|
{
|
||||||
if member.weight != 1.0 {
|
if member.weight != 1.0 {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "weight",
|
parameter: crate::Parameter::Weight,
|
||||||
value: member.weight,
|
value: member.weight,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(scale) = member.drift_scale {
|
if let Some(scale) = member.drift_scale {
|
||||||
if !scale.is_finite() || scale < 0.0 {
|
if !scale.is_finite() || scale < 0.0 {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "drift_scale",
|
parameter: crate::Parameter::DriftScale,
|
||||||
value: scale,
|
value: scale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -791,10 +879,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
self.beta,
|
self.beta,
|
||||||
self.drift,
|
self.drift,
|
||||||
);
|
);
|
||||||
if let Some(prior) = member.prior {
|
// The rule first, then the explicit values over the top of it: the
|
||||||
|
// specific beats the general, field by field. See `RatingRule`.
|
||||||
|
let from_rule = self.rule.starting_point(&member.key).unwrap_or_default();
|
||||||
|
if let Some(prior) = member.prior.or(from_rule.prior) {
|
||||||
rating.prior = prior;
|
rating.prior = prior;
|
||||||
}
|
}
|
||||||
if let Some(scale) = member.drift_scale {
|
if let Some(scale) = member.drift_scale.or(from_rule.drift_scale) {
|
||||||
rating.drift_scale = scale;
|
rating.drift_scale = scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,7 +1296,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
// `converge` refuses to report a NaN fit, but nothing stopped a
|
// `converge` refuses to report a NaN fit, but nothing stopped a
|
||||||
// caller ignoring that error and predicting anyway. Measured on
|
// caller ignoring that error and predicting anyway. Measured on
|
||||||
// a point-mass-prior history with `beta(0.0)`, after `converge`
|
// a point-mass-prior history with `beta(0.0)`, after `converge`
|
||||||
// returned `NonFiniteResult`: `quality` gave `Ok(NaN)`,
|
// returned `NonFiniteStep`: `quality` gave `Ok(NaN)`,
|
||||||
// `predict_outcome().total()` gave `NaN`, and
|
// `predict_outcome().total()` gave `NaN`, and
|
||||||
// `predict_win_probabilities` gave `Ok([0.0, 0.0])` — finite,
|
// `predict_win_probabilities` gave `Ok([0.0, 0.0])` — finite,
|
||||||
// plausible, and summing to zero against a doc that promises
|
// plausible, and summing to zero against a doc that promises
|
||||||
@@ -1226,10 +1317,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
// stored posterior today, and a prediction from an
|
// stored posterior today, and a prediction from an
|
||||||
// uninformative skill would be meaningless if it did.
|
// uninformative skill would be meaningless if it did.
|
||||||
if !skill.mu().is_finite() || !skill.sigma().is_finite() {
|
if !skill.mu().is_finite() || !skill.sigma().is_finite() {
|
||||||
return Err(InferenceError::NonFiniteResult {
|
return Err(InferenceError::NonFiniteSkill {
|
||||||
context: "prediction read a skill with no usable mean or \
|
mu: skill.mu(),
|
||||||
variance; the fit did not converge",
|
sigma: skill.sigma(),
|
||||||
step: (skill.mu(), skill.sigma()),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1258,10 +1348,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
// time and it is a property of the parameters, not a numerical
|
// time and it is a property of the parameters, not a numerical
|
||||||
// accident.
|
// accident.
|
||||||
if self.beta == 0.0 && gathered.iter().flatten().all(|g| g.sigma() == 0.0) {
|
if self.beta == 0.0 && gathered.iter().flatten().all(|g| g.sigma() == 0.0) {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::NoPerformanceVariance);
|
||||||
name: "beta with point-mass skills",
|
|
||||||
value: 0.0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(gathered)
|
Ok(gathered)
|
||||||
@@ -1349,9 +1436,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
/// `NotEnoughTeams`, `EmptyTeam` or `UnknownKey`.
|
/// `NotEnoughTeams`, `EmptyTeam` or `UnknownKey`.
|
||||||
///
|
///
|
||||||
/// Every prediction reads skills through one gate, which adds two errors to
|
/// Every prediction reads skills through one gate, which adds two errors to
|
||||||
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
|
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
|
||||||
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
|
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
|
||||||
/// and every skill is a point mass, leaving no performance distribution to
|
/// every skill is a point mass, leaving no performance distribution to
|
||||||
/// predict from.
|
/// predict from.
|
||||||
pub fn quality<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
|
pub fn quality<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
|
||||||
where
|
where
|
||||||
@@ -1390,7 +1477,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
/// stored `f64` before the factorisation ever runs. Measured, `drift_scale =
|
/// stored `f64` before the factorisation ever runs. Measured, `drift_scale =
|
||||||
/// 1e-10` returned a posterior variance **12 000x too small** — a 111x
|
/// 1e-10` returned a posterior variance **12 000x too small** — a 111x
|
||||||
/// overconfident interval — as `Ok`, and the band just above it returned a
|
/// overconfident interval — as `Ok`, and the band just above it returned a
|
||||||
/// misleading `JointUnavailable`.
|
/// misleading `NotPositiveDefinite`.
|
||||||
///
|
///
|
||||||
/// Solved exactly in high precision the same system is perfectly well
|
/// Solved exactly in high precision the same system is perfectly well
|
||||||
/// conditioned: it converges smoothly onto the collapsed value and is flat from
|
/// conditioned: it converges smoothly onto the collapsed value and is flat from
|
||||||
@@ -1415,6 +1502,23 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
prior_variance * SQRT_EPSILON
|
prior_variance * SQRT_EPSILON
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test-only: the joint's sparsity pattern, for the #52 measurement.
|
||||||
|
///
|
||||||
|
/// Returns `(n, adjacency)` where `adjacency[i]` holds the off-diagonal
|
||||||
|
/// nonzero columns of row `i`.
|
||||||
|
#[cfg(feature = "measure-sparsity")]
|
||||||
|
pub fn joint_pattern_for_measurement(&self) -> (usize, Vec<std::collections::HashSet<usize>>) {
|
||||||
|
let te = self.time_expanded_joint();
|
||||||
|
let n = te.width;
|
||||||
|
let mut adj = vec![std::collections::HashSet::new(); n];
|
||||||
|
for (i, j) in te.lambda.pattern() {
|
||||||
|
if i != j {
|
||||||
|
adj[i].insert(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(n, adj)
|
||||||
|
}
|
||||||
|
|
||||||
fn time_expanded_joint(&self) -> TimeExpanded {
|
fn time_expanded_joint(&self) -> TimeExpanded {
|
||||||
let mut latest: HashMap<Index, (usize, usize)> = HashMap::new();
|
let mut latest: HashMap<Index, (usize, usize)> = HashMap::new();
|
||||||
let mut at_slice: HashMap<(Index, usize), usize> = HashMap::new();
|
let mut at_slice: HashMap<(Index, usize), usize> = HashMap::new();
|
||||||
@@ -1455,17 +1559,23 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut lambda = vec![0.0; n * n];
|
// Accumulated sparsely: this matrix is ~0.19% dense at scale, and the
|
||||||
|
// dense form was 31 MB at n = 1976 and 128 MB at ustat's ~4000
|
||||||
|
// appearances, of which 99.8% was zeros. See `joint.rs` and #52.
|
||||||
|
let mut lambda = crate::joint::SymmetricBuilder::new();
|
||||||
|
|
||||||
for (row, competitor) in first_rows {
|
for (row, competitor) in first_rows {
|
||||||
lambda[row * n + row] +=
|
lambda.add(
|
||||||
1.0 / self.competitors[competitor].rating.prior.sigma().powi(2);
|
row,
|
||||||
|
row,
|
||||||
|
1.0 / self.competitors[competitor].rating.prior.sigma().powi(2),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (a, b, drift) in drift_links {
|
for (a, b, drift) in drift_links {
|
||||||
lambda[a * n + a] += 1.0 / drift;
|
lambda.add(a, a, 1.0 / drift);
|
||||||
lambda[b * n + b] += 1.0 / drift;
|
lambda.add(b, b, 1.0 / drift);
|
||||||
lambda[a * n + b] -= 1.0 / drift;
|
lambda.add(a, b, -1.0 / drift);
|
||||||
lambda[b * n + a] -= 1.0 / drift;
|
lambda.add(b, a, -1.0 / drift);
|
||||||
}
|
}
|
||||||
for (slice_idx, slice) in self.time_slices.iter().enumerate() {
|
for (slice_idx, slice) in self.time_slices.iter().enumerate() {
|
||||||
for (contrast, noise) in slice.scored_contrasts(&self.competitors) {
|
for (contrast, noise) in slice.scored_contrasts(&self.competitors) {
|
||||||
@@ -1473,7 +1583,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
let ra = at_slice[&(*ia, slice_idx)];
|
let ra = at_slice[&(*ia, slice_idx)];
|
||||||
for (ib, cb) in &contrast {
|
for (ib, cb) in &contrast {
|
||||||
let rb = at_slice[&(*ib, slice_idx)];
|
let rb = at_slice[&(*ib, slice_idx)];
|
||||||
lambda[ra * n + rb] += ca * cb / noise;
|
lambda.add(ra, rb, ca * cb / noise);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1592,19 +1702,15 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// `JointUnavailable` if the history is empty, contains ranked events, or
|
/// `EmptyHistory` for a history with no events, `JointRequiresScoredEvents`
|
||||||
/// yields a precision matrix that is not positive-definite.
|
/// for one containing ranked events, and `NotPositiveDefinite` if the
|
||||||
pub fn joint(&self) -> Result<Joint<'_, K, T, D, O>, InferenceError> {
|
/// assembled matrix is indefinite.
|
||||||
|
pub fn joint(&self) -> Result<Joint<'_, K, T, D, O, R>, InferenceError> {
|
||||||
if self.time_slices.is_empty() {
|
if self.time_slices.is_empty() {
|
||||||
return Err(InferenceError::JointUnavailable {
|
return Err(InferenceError::EmptyHistory);
|
||||||
reason: "the history has no events",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if !self.time_slices.iter().all(TimeSlice::all_scored) {
|
if !self.time_slices.iter().all(TimeSlice::all_scored) {
|
||||||
return Err(InferenceError::JointUnavailable {
|
return Err(InferenceError::JointRequiresScoredEvents);
|
||||||
reason: "the history contains ranked events, whose EP factors are \
|
|
||||||
not retained after convergence",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let TimeExpanded {
|
let TimeExpanded {
|
||||||
@@ -1614,14 +1720,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
width,
|
width,
|
||||||
} = self.time_expanded_joint();
|
} = self.time_expanded_joint();
|
||||||
|
|
||||||
let cholesky = crate::joint::Cholesky::factor(lambda, width).ok_or(
|
let cholesky = crate::joint::Cholesky::factor(lambda, width)
|
||||||
InferenceError::JointUnavailable {
|
.ok_or(InferenceError::NotPositiveDefinite)?;
|
||||||
reason: "the 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",
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(Joint {
|
Ok(Joint {
|
||||||
history: self,
|
history: self,
|
||||||
@@ -1657,8 +1757,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
///
|
///
|
||||||
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam`,
|
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam`,
|
||||||
/// `UnknownKey` under [`UnknownKeys::Reject`](crate::UnknownKeys::Reject),
|
/// `UnknownKey` under [`UnknownKeys::Reject`](crate::UnknownKeys::Reject),
|
||||||
/// and `JointUnavailable` if the history is empty or holds ranked events in
|
/// and `EmptyHistory` / `JointRequiresScoredEvents` — the latter if *any*
|
||||||
/// *any* slice — not merely the latest one.
|
/// slice holds ranked events, not merely the latest one.
|
||||||
pub fn predict_margin<Q>(&self, teams: &[&[&Q]]) -> Result<Gaussian, InferenceError>
|
pub fn predict_margin<Q>(&self, teams: &[&[&Q]]) -> Result<Gaussian, InferenceError>
|
||||||
where
|
where
|
||||||
K: Borrow<Q>,
|
K: Borrow<Q>,
|
||||||
@@ -1666,7 +1766,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
{
|
{
|
||||||
if teams.len() != 2 {
|
if teams.len() != 2 {
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "predict_margin takes exactly 2 teams",
|
shape: crate::Shape::Teams,
|
||||||
expected: 2,
|
expected: 2,
|
||||||
got: teams.len(),
|
got: teams.len(),
|
||||||
});
|
});
|
||||||
@@ -1733,9 +1833,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
/// for a hypothetical outcome.
|
/// for a hypothetical outcome.
|
||||||
///
|
///
|
||||||
/// Every prediction reads skills through one gate, which adds two errors to
|
/// Every prediction reads skills through one gate, which adds two errors to
|
||||||
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
|
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
|
||||||
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
|
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
|
||||||
/// and every skill is a point mass, leaving no performance distribution to
|
/// every skill is a point mass, leaving no performance distribution to
|
||||||
/// predict from.
|
/// predict from.
|
||||||
pub fn expected_information_gain<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
|
pub fn expected_information_gain<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
|
||||||
where
|
where
|
||||||
@@ -1794,9 +1894,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
/// `NotEnoughTeams`, `EmptyTeam`, or `UnknownKey`.
|
/// `NotEnoughTeams`, `EmptyTeam`, or `UnknownKey`.
|
||||||
///
|
///
|
||||||
/// Every prediction reads skills through one gate, which adds two errors to
|
/// Every prediction reads skills through one gate, which adds two errors to
|
||||||
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
|
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
|
||||||
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
|
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
|
||||||
/// and every skill is a point mass, leaving no performance distribution to
|
/// every skill is a point mass, leaving no performance distribution to
|
||||||
/// predict from.
|
/// predict from.
|
||||||
pub fn predict_win_probabilities<Q>(&self, teams: &[&[&Q]]) -> Result<Vec<f64>, InferenceError>
|
pub fn predict_win_probabilities<Q>(&self, teams: &[&[&Q]]) -> Result<Vec<f64>, InferenceError>
|
||||||
where
|
where
|
||||||
@@ -1849,9 +1949,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
/// integrate on one grid.
|
/// integrate on one grid.
|
||||||
///
|
///
|
||||||
/// Every prediction reads skills through one gate, which adds two errors to
|
/// Every prediction reads skills through one gate, which adds two errors to
|
||||||
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
|
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
|
||||||
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
|
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
|
||||||
/// and every skill is a point mass, leaving no performance distribution to
|
/// every skill is a point mass, leaving no performance distribution to
|
||||||
/// predict from.
|
/// predict from.
|
||||||
pub fn predict_outcome<Q>(&self, teams: &[&[&Q]]) -> Result<Prediction, InferenceError>
|
pub fn predict_outcome<Q>(&self, teams: &[&[&Q]]) -> Result<Prediction, InferenceError>
|
||||||
where
|
where
|
||||||
@@ -1899,9 +1999,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
/// performance sigmas are too far apart to integrate on one grid.
|
/// performance sigmas are too far apart to integrate on one grid.
|
||||||
///
|
///
|
||||||
/// Every prediction reads skills through one gate, which adds two errors to
|
/// Every prediction reads skills through one gate, which adds two errors to
|
||||||
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
|
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
|
||||||
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
|
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
|
||||||
/// and every skill is a point mass, leaving no performance distribution to
|
/// every skill is a point mass, leaving no performance distribution to
|
||||||
/// predict from.
|
/// predict from.
|
||||||
pub fn predict_ranking<Q>(&self, teams: &[&[&Q]], ranks: &[u32]) -> Result<f64, InferenceError>
|
pub fn predict_ranking<Q>(&self, teams: &[&[&Q]], ranks: &[u32]) -> Result<f64, InferenceError>
|
||||||
where
|
where
|
||||||
@@ -1910,7 +2010,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
{
|
{
|
||||||
if ranks.len() != teams.len() {
|
if ranks.len() != teams.len() {
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "ranks vs teams",
|
shape: crate::Shape::OutcomeVsTeams,
|
||||||
expected: teams.len(),
|
expected: teams.len(),
|
||||||
got: ranks.len(),
|
got: ranks.len(),
|
||||||
});
|
});
|
||||||
@@ -1946,7 +2046,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
/// `NotConverged` if the sweep hits `max_iter` with the step still above
|
/// `NotConverged` if the sweep hits `max_iter` with the step still above
|
||||||
/// `epsilon`.
|
/// `epsilon`.
|
||||||
///
|
///
|
||||||
/// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has
|
/// `NonFiniteStep` if a sweep produces a NaN or infinite step. EP has
|
||||||
/// broken down at that point and further iterations cannot recover, so the
|
/// broken down at that point and further iterations cannot recover, so the
|
||||||
/// loop stops rather than reporting a NaN step as convergence.
|
/// loop stops rather than reporting a NaN step as convergence.
|
||||||
///
|
///
|
||||||
@@ -1978,7 +2078,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// `NonFiniteResult` if a sweep produces a NaN or infinite step.
|
/// `NonFiniteStep` if a sweep produces a NaN or infinite step.
|
||||||
///
|
///
|
||||||
/// `InvalidParameter` if a competitor's drift model yields a negative or
|
/// `InvalidParameter` if a competitor's drift model yields a negative or
|
||||||
/// non-finite variance. Checked here, before any sweeping, so it applies to
|
/// non-finite variance. Checked here, before any sweeping, so it applies to
|
||||||
@@ -2009,7 +2109,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
.drift_variance_for_elapsed(elapsed);
|
.drift_variance_for_elapsed(elapsed);
|
||||||
if !drift.is_finite() || drift < 0.0 {
|
if !drift.is_finite() || drift < 0.0 {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "drift variance",
|
parameter: crate::Parameter::DriftVariance,
|
||||||
value: drift,
|
value: drift,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2046,7 +2146,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
if !crate::step_is_finite(step) {
|
if !crate::step_is_finite(step) {
|
||||||
self.observer.on_converged(i, step, false);
|
self.observer.on_converged(i, step, false);
|
||||||
|
|
||||||
return Err(InferenceError::NonFiniteResult {
|
return Err(InferenceError::NonFiniteStep {
|
||||||
context: "History::converge",
|
context: "History::converge",
|
||||||
step,
|
step,
|
||||||
});
|
});
|
||||||
@@ -2065,7 +2165,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D, O> {
|
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K>>
|
||||||
|
History<K, T, D, O, R>
|
||||||
|
{
|
||||||
pub(crate) fn add_events_with_prior(
|
pub(crate) fn add_events_with_prior(
|
||||||
&mut self,
|
&mut self,
|
||||||
mut composition: Vec<Vec<Vec<Index>>>,
|
mut composition: Vec<Vec<Vec<Index>>>,
|
||||||
@@ -2082,14 +2184,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
let got = results.as_ref().map_or(0, Vec::len);
|
let got = results.as_ref().map_or(0, Vec::len);
|
||||||
|
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "results",
|
shape: crate::Shape::Internal,
|
||||||
expected: composition.len(),
|
expected: composition.len(),
|
||||||
got,
|
got,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if times.len() != composition.len() {
|
if times.len() != composition.len() {
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "times",
|
shape: crate::Shape::Internal,
|
||||||
expected: composition.len(),
|
expected: composition.len(),
|
||||||
got: times.len(),
|
got: times.len(),
|
||||||
});
|
});
|
||||||
@@ -2101,14 +2203,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
let got = weights.as_ref().map_or(0, Vec::len);
|
let got = weights.as_ref().map_or(0, Vec::len);
|
||||||
|
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "weights",
|
shape: crate::Shape::Weights,
|
||||||
expected: composition.len(),
|
expected: composition.len(),
|
||||||
got,
|
got,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if kinds.len() != composition.len() {
|
if kinds.len() != composition.len() {
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "kinds",
|
shape: crate::Shape::Internal,
|
||||||
expected: composition.len(),
|
expected: composition.len(),
|
||||||
got: kinds.len(),
|
got: kinds.len(),
|
||||||
});
|
});
|
||||||
@@ -2138,19 +2240,19 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A non-finite outcome poisons the history rather than failing it:
|
// A non-finite outcome poisons the history rather than failing it:
|
||||||
// `converge` does report `NonFiniteResult`, but a caller who reads
|
// `converge` does report `NonFiniteStep`, but a caller who reads
|
||||||
// `current_skill` before converging is handed a NaN posterior with
|
// `current_skill` before converging is handed a NaN posterior with
|
||||||
// nothing to say it is one.
|
// nothing to say it is one.
|
||||||
if let Some(results) = results.as_ref() {
|
if let Some(results) = results.as_ref() {
|
||||||
for (event_results, kind) in results.iter().zip(kinds.iter()) {
|
for (event_results, kind) in results.iter().zip(kinds.iter()) {
|
||||||
let name = match kind {
|
let parameter = match kind {
|
||||||
EventKind::Ranked => "rank",
|
EventKind::Ranked => crate::Parameter::Rank,
|
||||||
EventKind::Scored { .. } => "score",
|
EventKind::Scored { .. } => crate::Parameter::Score,
|
||||||
};
|
};
|
||||||
for value in event_results {
|
for value in event_results {
|
||||||
if !value.is_finite() {
|
if !value.is_finite() {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name,
|
parameter,
|
||||||
value: *value,
|
value: *value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2173,7 +2275,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
for weight in team_weights {
|
for weight in team_weights {
|
||||||
if !weight.is_finite() {
|
if !weight.is_finite() {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "weight",
|
parameter: crate::Parameter::Weight,
|
||||||
value: *weight,
|
value: *weight,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2222,7 +2324,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
if existing != new {
|
if existing != new {
|
||||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||||
competitor: competitor.get(),
|
competitor: competitor.get(),
|
||||||
field: "prior",
|
field: crate::CompetitorField::Prior,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2230,7 +2332,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
if existing != new {
|
if existing != new {
|
||||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||||
competitor: competitor.get(),
|
competitor: competitor.get(),
|
||||||
field: "drift_scale",
|
field: crate::CompetitorField::DriftScale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2302,10 +2404,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
self.beta,
|
self.beta,
|
||||||
self.drift,
|
self.drift,
|
||||||
);
|
);
|
||||||
if let Some(prior) = config.prior {
|
// Same precedence as `register`: the rule supplies a starting
|
||||||
|
// point, explicit configuration overrides it field by field.
|
||||||
|
let from_rule = self
|
||||||
|
.keys
|
||||||
|
.key(*competitor)
|
||||||
|
.and_then(|key| self.rule.starting_point(key))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Some(prior) = config.prior.or(from_rule.prior) {
|
||||||
rating.prior = prior;
|
rating.prior = prior;
|
||||||
}
|
}
|
||||||
if let Some(scale) = config.drift_scale {
|
if let Some(scale) = config.drift_scale.or(from_rule.drift_scale) {
|
||||||
rating.drift_scale = scale;
|
rating.drift_scale = scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2510,7 +2619,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start a fluent event builder for a single match at `time`.
|
/// Start a fluent event builder for a single match at `time`.
|
||||||
pub fn event(&mut self, time: T) -> crate::event_builder::EventBuilder<'_, T, D, O, K> {
|
pub fn event(&mut self, time: T) -> crate::event_builder::EventBuilder<'_, T, D, O, K, R> {
|
||||||
crate::event_builder::EventBuilder::new(self, time)
|
crate::event_builder::EventBuilder::new(self, time)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2554,7 +2663,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
for ev in events {
|
for ev in events {
|
||||||
if ev.outcome.team_count() != ev.teams.len() {
|
if ev.outcome.team_count() != ev.teams.len() {
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "outcome vs teams",
|
shape: crate::Shape::OutcomeVsTeams,
|
||||||
expected: ev.teams.len(),
|
expected: ev.teams.len(),
|
||||||
got: ev.outcome.team_count(),
|
got: ev.outcome.team_count(),
|
||||||
});
|
});
|
||||||
@@ -2577,7 +2686,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
// accept a sign the caller cannot have meant.
|
// accept a sign the caller cannot have meant.
|
||||||
if !scale.is_finite() || scale < 0.0 {
|
if !scale.is_finite() || scale < 0.0 {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "drift_scale",
|
parameter: crate::Parameter::DriftScale,
|
||||||
value: scale,
|
value: scale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2601,7 +2710,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
if entry.prior.is_some_and(|held| held != prior) {
|
if entry.prior.is_some_and(|held| held != prior) {
|
||||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||||
competitor: idx.get(),
|
competitor: idx.get(),
|
||||||
field: "prior",
|
field: crate::CompetitorField::Prior,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
entry.prior = Some(prior);
|
entry.prior = Some(prior);
|
||||||
@@ -2610,7 +2719,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
if entry.drift_scale.is_some_and(|held| held != scale) {
|
if entry.drift_scale.is_some_and(|held| held != scale) {
|
||||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||||
competitor: idx.get(),
|
competitor: idx.get(),
|
||||||
field: "drift_scale",
|
field: crate::CompetitorField::DriftScale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
entry.drift_scale = Some(scale);
|
entry.drift_scale = Some(scale);
|
||||||
@@ -2636,7 +2745,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
let resolved = score_sigma.unwrap_or(self.score_sigma);
|
let resolved = score_sigma.unwrap_or(self.score_sigma);
|
||||||
if resolved <= 0.0 || resolved.is_nan() {
|
if resolved <= 0.0 || resolved.is_nan() {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "score_sigma",
|
parameter: crate::Parameter::ScoreSigma,
|
||||||
value: resolved,
|
value: resolved,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2670,8 +2779,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D
|
|||||||
///
|
///
|
||||||
/// It exists at all because without it a consumer cannot `#[derive(Debug)]` on
|
/// It exists at all because without it a consumer cannot `#[derive(Debug)]` on
|
||||||
/// any struct holding a `History`, which is how both known consumers store it.
|
/// any struct holding a `History`, which is how both known consumers store it.
|
||||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> std::fmt::Debug
|
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K>> std::fmt::Debug
|
||||||
for History<K, T, D, O>
|
for History<K, T, D, O, R>
|
||||||
{
|
{
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("History")
|
f.debug_struct("History")
|
||||||
@@ -2735,8 +2844,9 @@ pub struct Joint<
|
|||||||
T: Time = i64,
|
T: Time = i64,
|
||||||
D: Drift<T> = ConstantDrift,
|
D: Drift<T> = ConstantDrift,
|
||||||
O: Observer<T> = NullObserver,
|
O: Observer<T> = NullObserver,
|
||||||
|
R: RatingRule<K> = NoRule,
|
||||||
> {
|
> {
|
||||||
history: &'h History<K, T, D, O>,
|
history: &'h History<K, T, D, O, R>,
|
||||||
cholesky: crate::joint::Cholesky,
|
cholesky: crate::joint::Cholesky,
|
||||||
/// `(row, slice)` of each competitor's latest appearance.
|
/// `(row, slice)` of each competitor's latest appearance.
|
||||||
latest: HashMap<Index, (usize, usize)>,
|
latest: HashMap<Index, (usize, usize)>,
|
||||||
@@ -2748,8 +2858,8 @@ pub struct Joint<
|
|||||||
|
|
||||||
/// Deliberately does not print the factorisation, which is `n^2` floats and
|
/// Deliberately does not print the factorisation, which is `n^2` floats and
|
||||||
/// would make a `{:?}` of a large joint unreadable and slow.
|
/// would make a `{:?}` of a large joint unreadable and slow.
|
||||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> std::fmt::Debug
|
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K>> std::fmt::Debug
|
||||||
for Joint<'_, K, T, D, O>
|
for Joint<'_, K, T, D, O, R>
|
||||||
{
|
{
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("Joint")
|
f.debug_struct("Joint")
|
||||||
@@ -2758,7 +2868,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> std::fmt::Debug
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<K: Eq + Hash + Clone, T: Time, D: Drift<T>, O: Observer<T>> Joint<'_, K, T, D, O> {
|
impl<K: Eq + Hash + Clone, T: Time, D: Drift<T>, O: Observer<T>, R: RatingRule<K>>
|
||||||
|
Joint<'_, K, T, D, O, R>
|
||||||
|
{
|
||||||
/// Number of variables in the joint: the history's appearances, after
|
/// Number of variables in the joint: the history's appearances, after
|
||||||
/// collapsing consecutive pairs a competitor does not drift between.
|
/// collapsing consecutive pairs a competitor does not drift between.
|
||||||
///
|
///
|
||||||
@@ -2885,7 +2997,7 @@ impl<K: Eq + Hash + Clone, T: Time, D: Drift<T>, O: Observer<T>> Joint<'_, K, T,
|
|||||||
{
|
{
|
||||||
if teams.len() != 2 {
|
if teams.len() != 2 {
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "expected_variance_reduction takes exactly 2 teams",
|
shape: crate::Shape::Teams,
|
||||||
expected: 2,
|
expected: 2,
|
||||||
got: teams.len(),
|
got: teams.len(),
|
||||||
});
|
});
|
||||||
|
|||||||
+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
|
//! Every question the joint answers is a *bilinear form* in the precision
|
||||||
//! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the
|
//! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the
|
||||||
@@ -18,72 +18,324 @@
|
|||||||
//! negative number, where the same quantity as `|L^-1 c|^2` is a sum of
|
//! negative number, where the same quantity as `|L^-1 c|^2` is a sum of
|
||||||
//! squares and cannot.
|
//! squares and cannot.
|
||||||
//!
|
//!
|
||||||
//! Factorising is `O(n^3)` and whitening is `O(n^2)`, so the split also
|
//! # Why this is sparse (#52)
|
||||||
//! matters structurally: the expensive half depends only on the fit, and is
|
//!
|
||||||
//! shared across every query a [`Joint`](crate::Joint) answers.
|
//! A time-expanded joint is *extremely* sparse and gets sparser as the history
|
||||||
|
//! grows: a row couples only to its own previous and next appearance through
|
||||||
|
//! the drift link, and to whoever co-appeared in its slice. Measured on a
|
||||||
|
//! 76-slice, 988-duel, 200-competitor history: `n = 1976`, `nnz = 7504`,
|
||||||
|
//! **0.19% dense**.
|
||||||
|
//!
|
||||||
|
//! This used to store all `n^2` entries and run a dense `O(n^3)` factorisation
|
||||||
|
//! over them. Two measurements decided the replacement:
|
||||||
|
//!
|
||||||
|
//! - **Ordering alone does nothing to a dense factorisation.** Its inner loops
|
||||||
|
//! run over every `k` whether or not the entry is zero. A 700x700 banded
|
||||||
|
//! matrix at 0.43% density factorised in 30.196 ms in band order and
|
||||||
|
//! 29.544 ms under a scramble that destroyed the band — identical, as the
|
||||||
|
//! flop count says it must be. Fill-reducing order is worth nothing until
|
||||||
|
//! the factorisation skips zeros.
|
||||||
|
//! - **Together they are worth four orders of magnitude.** On that `n = 1976`
|
||||||
|
//! fixture, against `n^3/3 = 2.572e9` flops dense: sparse in the natural
|
||||||
|
//! order needs `5.597e7` (46x better), and sparse under an AMD fill-reducing
|
||||||
|
//! order needs `8.656e4` — **29,710x**. AMD is worth 646x *on top of*
|
||||||
|
//! sparsity and nothing without it.
|
||||||
|
//!
|
||||||
|
//! Natural ordering fills in badly here for the reason #52 predicted: a
|
||||||
|
//! competitor who appears in slice 0 and not again until slice 75 creates a
|
||||||
|
//! drift link spanning nearly the whole matrix. `nnz(L)` is 292,437 under the
|
||||||
|
//! natural order against 11,583 under AMD, from an `A` with 7,504.
|
||||||
|
//!
|
||||||
|
//! The ordering comes from `feral-amd`. The factorisation is the up-looking
|
||||||
|
//! sparse Cholesky of Davis's *Direct Methods for Sparse Linear Systems*,
|
||||||
|
//! written here rather than taken from a crate: the sparse solvers on
|
||||||
|
//! crates.io either pull SIMD dispatch (`faer`, and `feral` itself, both
|
||||||
|
//! through `pulp`), which would make results differ between an AVX-512 host
|
||||||
|
//! and an AVX2 one — the same class of drift the `libm`-over-`std` decision
|
||||||
|
//! was made to avoid — or are LGPL, or disclaim fill-reduction in their own
|
||||||
|
//! docs.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// A symmetric matrix accumulated entry by entry, before factorisation.
|
||||||
|
///
|
||||||
|
/// A `BTreeMap` rather than a hash map because the iteration order becomes the
|
||||||
|
/// factorisation's summation order, and a hash map's order varies per process.
|
||||||
|
/// `tests/cross_process_determinism.rs` exists because that has bitten before.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct SymmetricBuilder {
|
||||||
|
entries: BTreeMap<(usize, usize), f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SymmetricBuilder {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add `value` to entry `(row, col)`. Both triangles must be supplied.
|
||||||
|
pub(crate) fn add(&mut self, row: usize, col: usize, value: f64) {
|
||||||
|
*self.entries.entry((row, col)).or_insert(0.0) += value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `(row, col)` positions that hold a nonzero. For the #52 measurement.
|
||||||
|
#[cfg(feature = "measure-sparsity")]
|
||||||
|
pub(crate) fn pattern(&self) -> impl Iterator<Item = (usize, usize)> + '_ {
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, v)| **v != 0.0)
|
||||||
|
.map(|(&rc, _)| rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A factorised symmetric positive-definite matrix, reusable across queries.
|
/// A factorised symmetric positive-definite matrix, reusable across queries.
|
||||||
pub(crate) struct Cholesky {
|
pub(crate) struct Cholesky {
|
||||||
/// Lower triangle of `L`, row-major `n * n`. The upper triangle is
|
|
||||||
/// leftover scratch from the factorisation and is never read.
|
|
||||||
l: Vec<f64>,
|
|
||||||
n: usize,
|
n: usize,
|
||||||
|
/// `inv[old] = new`: where each original row sits after the AMD reorder.
|
||||||
|
inv: Vec<usize>,
|
||||||
|
/// `L` in compressed-column form, permuted. Within a column the diagonal
|
||||||
|
/// is first and the rest ascend by row.
|
||||||
|
col_ptr: Vec<usize>,
|
||||||
|
row_idx: Vec<usize>,
|
||||||
|
val: Vec<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cholesky {
|
impl Cholesky {
|
||||||
/// Factorise `a` (row-major, `n * n`, symmetric) into `L L^T`.
|
/// Factorise the accumulated matrix into `L L^T`, under a fill-reducing
|
||||||
///
|
/// permutation.
|
||||||
/// `a` is consumed as scratch.
|
|
||||||
///
|
///
|
||||||
/// Returns `None` if the matrix is not positive-definite, which for a
|
/// Returns `None` if the matrix is not positive-definite, which for a
|
||||||
/// precision matrix means the model is improper — a competitor with
|
/// precision matrix means the model is improper — a competitor with
|
||||||
/// neither a proper prior nor any evidence.
|
/// neither a proper prior nor any evidence — or if the ordering fails.
|
||||||
pub(crate) fn factor(mut a: Vec<f64>, n: usize) -> Option<Self> {
|
pub(crate) fn factor(built: SymmetricBuilder, n: usize) -> Option<Self> {
|
||||||
debug_assert_eq!(a.len(), n * n);
|
if n == 0 {
|
||||||
|
return Some(Self {
|
||||||
for j in 0..n {
|
n: 0,
|
||||||
let mut d = a[j * n + j];
|
inv: Vec::new(),
|
||||||
for k in 0..j {
|
col_ptr: vec![0],
|
||||||
d -= a[j * n + k] * a[j * n + k];
|
row_idx: Vec::new(),
|
||||||
|
val: Vec::new(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let inv = Self::amd_permutation(n, &built)?;
|
||||||
|
|
||||||
|
// Upper triangle of the permuted matrix, column-major: column `c`
|
||||||
|
// holds the rows `r <= c`. Exactly one of a symmetric pair survives
|
||||||
|
// the `r <= c` filter, so nothing is double-counted.
|
||||||
|
let mut cols: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
|
||||||
|
for (&(old_r, old_c), &v) in &built.entries {
|
||||||
|
if v == 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (r, c) = (inv[old_r], inv[old_c]);
|
||||||
|
if r <= c {
|
||||||
|
cols[c].push((r, v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut a_ptr = Vec::with_capacity(n + 1);
|
||||||
|
let mut a_row = Vec::new();
|
||||||
|
let mut a_val = Vec::new();
|
||||||
|
a_ptr.push(0usize);
|
||||||
|
for col in &mut cols {
|
||||||
|
col.sort_unstable_by_key(|&(r, _)| r);
|
||||||
|
for &(r, v) in col.iter() {
|
||||||
|
a_row.push(r);
|
||||||
|
a_val.push(v);
|
||||||
|
}
|
||||||
|
a_ptr.push(a_row.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = Self::etree(n, &a_ptr, &a_row);
|
||||||
|
|
||||||
|
// Symbolic pass: how many entries each column of L will hold. Running
|
||||||
|
// `ereach` per column costs O(nnz(L)) in total, which is the same order
|
||||||
|
// as the numeric pass it sizes.
|
||||||
|
let mut counts = vec![0usize; n];
|
||||||
|
let mut stack = vec![0usize; n];
|
||||||
|
let mut mark = vec![false; n];
|
||||||
|
for k in 0..n {
|
||||||
|
let top = Self::ereach(k, &a_ptr, &a_row, &parent, &mut stack, &mut mark);
|
||||||
|
for &i in &stack[top..] {
|
||||||
|
counts[i] += 1;
|
||||||
|
}
|
||||||
|
counts[k] += 1; // the diagonal
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut col_ptr = Vec::with_capacity(n + 1);
|
||||||
|
col_ptr.push(0usize);
|
||||||
|
for &c in &counts {
|
||||||
|
col_ptr.push(col_ptr[col_ptr.len() - 1] + c);
|
||||||
|
}
|
||||||
|
let nnz = col_ptr[n];
|
||||||
|
let mut row_idx = vec![0usize; nnz];
|
||||||
|
let mut val = vec![0.0f64; nnz];
|
||||||
|
|
||||||
|
// `next[i]` is the slot column `i` will fill next. Column `i`'s
|
||||||
|
// diagonal lands first, at `col_ptr[i]`, because nothing is written to
|
||||||
|
// a column before its own iteration.
|
||||||
|
let mut next: Vec<usize> = col_ptr[..n].to_vec();
|
||||||
|
let mut x = vec![0.0f64; n];
|
||||||
|
|
||||||
|
for k in 0..n {
|
||||||
|
let top = Self::ereach(k, &a_ptr, &a_row, &parent, &mut stack, &mut mark);
|
||||||
|
|
||||||
|
for p in a_ptr[k]..a_ptr[k + 1] {
|
||||||
|
if a_row[p] <= k {
|
||||||
|
x[a_row[p]] = a_val[p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut d = x[k];
|
||||||
|
x[k] = 0.0;
|
||||||
|
|
||||||
|
for &i in &stack[top..] {
|
||||||
|
let lki = x[i] / val[col_ptr[i]];
|
||||||
|
x[i] = 0.0;
|
||||||
|
for p in col_ptr[i] + 1..next[i] {
|
||||||
|
x[row_idx[p]] -= val[p] * lki;
|
||||||
|
}
|
||||||
|
d -= lki * lki;
|
||||||
|
let p = next[i];
|
||||||
|
next[i] += 1;
|
||||||
|
row_idx[p] = k;
|
||||||
|
val[p] = lki;
|
||||||
|
}
|
||||||
|
|
||||||
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here
|
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here
|
||||||
// too, and a negated comparison would let it through as "not
|
// too, and a negated comparison would let it through as "not
|
||||||
// positive".
|
// positive".
|
||||||
if d.is_nan() || d <= 0.0 {
|
if d.is_nan() || d <= 0.0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let d = d.sqrt();
|
let p = next[k];
|
||||||
a[j * n + j] = d;
|
next[k] += 1;
|
||||||
|
row_idx[p] = k;
|
||||||
for i in j + 1..n {
|
val[p] = d.sqrt();
|
||||||
let mut s = a[i * n + j];
|
|
||||||
for k in 0..j {
|
|
||||||
s -= a[i * n + k] * a[j * n + k];
|
|
||||||
}
|
|
||||||
a[i * n + j] = s / d;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(Self { l: a, n })
|
Some(Self {
|
||||||
|
n,
|
||||||
|
inv,
|
||||||
|
col_ptr,
|
||||||
|
row_idx,
|
||||||
|
val,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whiten a contrast: `y = L^-1 b`.
|
/// AMD fill-reducing order, as `inv[old] = new`.
|
||||||
|
fn amd_permutation(n: usize, built: &SymmetricBuilder) -> Option<Vec<usize>> {
|
||||||
|
let mut cols: Vec<Vec<i32>> = vec![Vec::new(); n];
|
||||||
|
for (&(r, c), &v) in &built.entries {
|
||||||
|
if v != 0.0 {
|
||||||
|
cols[c].push(i32::try_from(r).ok()?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut col_ptr = Vec::with_capacity(n + 1);
|
||||||
|
let mut row_idx = Vec::new();
|
||||||
|
col_ptr.push(0i32);
|
||||||
|
for (j, col) in cols.iter_mut().enumerate() {
|
||||||
|
col.push(i32::try_from(j).ok()?);
|
||||||
|
col.sort_unstable();
|
||||||
|
col.dedup();
|
||||||
|
row_idx.extend_from_slice(col);
|
||||||
|
col_ptr.push(i32::try_from(row_idx.len()).ok()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pattern = feral_amd::CscPattern::new(n, &col_ptr, &row_idx)?;
|
||||||
|
// `perm[new] = old`; we want the inverse.
|
||||||
|
let perm = feral_amd::amd_order(&pattern).ok()?;
|
||||||
|
let mut inv = vec![0usize; n];
|
||||||
|
for (new, &old) in perm.iter().enumerate() {
|
||||||
|
inv[usize::try_from(old).ok()?] = new;
|
||||||
|
}
|
||||||
|
Some(inv)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elimination tree of the upper-triangular pattern. `usize::MAX` is "no
|
||||||
|
/// parent", i.e. a root.
|
||||||
|
fn etree(n: usize, col_ptr: &[usize], row_idx: &[usize]) -> Vec<usize> {
|
||||||
|
let mut parent = vec![usize::MAX; n];
|
||||||
|
let mut ancestor = vec![usize::MAX; n];
|
||||||
|
for k in 0..n {
|
||||||
|
for &row in &row_idx[col_ptr[k]..col_ptr[k + 1]] {
|
||||||
|
let mut i = row;
|
||||||
|
while i != usize::MAX && i < k {
|
||||||
|
let next = ancestor[i];
|
||||||
|
ancestor[i] = k;
|
||||||
|
if next == usize::MAX {
|
||||||
|
parent[i] = k;
|
||||||
|
}
|
||||||
|
i = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nonzero pattern of row `k` of `L`, written into `stack[top..n]` in
|
||||||
|
/// topological order. Returns `top`.
|
||||||
|
///
|
||||||
|
/// `stack` is used from both ends — a scratch region from `0` while walking
|
||||||
|
/// each path up the tree, and the result from `n` downwards. They cannot
|
||||||
|
/// collide because every node is pushed at most once across the whole call.
|
||||||
|
fn ereach(
|
||||||
|
k: usize,
|
||||||
|
col_ptr: &[usize],
|
||||||
|
row_idx: &[usize],
|
||||||
|
parent: &[usize],
|
||||||
|
stack: &mut [usize],
|
||||||
|
mark: &mut [bool],
|
||||||
|
) -> usize {
|
||||||
|
let n = mark.len();
|
||||||
|
let mut top = n;
|
||||||
|
mark[k] = true;
|
||||||
|
for &row in &row_idx[col_ptr[k]..col_ptr[k + 1]] {
|
||||||
|
let mut i = row;
|
||||||
|
if i > k {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut len = 0usize;
|
||||||
|
while i != usize::MAX && !mark[i] {
|
||||||
|
stack[len] = i;
|
||||||
|
len += 1;
|
||||||
|
mark[i] = true;
|
||||||
|
i = parent[i];
|
||||||
|
}
|
||||||
|
// Reverse the path onto the output end, so the result stays in
|
||||||
|
// topological order overall.
|
||||||
|
while len > 0 {
|
||||||
|
len -= 1;
|
||||||
|
top -= 1;
|
||||||
|
stack[top] = stack[len];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for &i in &stack[top..] {
|
||||||
|
mark[i] = false;
|
||||||
|
}
|
||||||
|
mark[k] = false;
|
||||||
|
top
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whiten a contrast: `y = L^-1 P b`.
|
||||||
///
|
///
|
||||||
/// The point of the result is the dot product, not the vector: for two
|
/// The point of the result is the dot product, not the vector: for two
|
||||||
/// contrasts `b` and `b'`, `y . y'` is `b^T A^-1 b'`. See the module docs.
|
/// contrasts `b` and `b'`, `y . y'` is `b^T A^-1 b'`. See the module docs.
|
||||||
|
///
|
||||||
|
/// The result is in the permuted order, and stays there — a dot product
|
||||||
|
/// does not care, as long as both operands were permuted the same way.
|
||||||
pub(crate) fn whiten(&self, b: &[f64]) -> Vec<f64> {
|
pub(crate) fn whiten(&self, b: &[f64]) -> Vec<f64> {
|
||||||
debug_assert_eq!(b.len(), self.n);
|
debug_assert_eq!(b.len(), self.n);
|
||||||
let n = self.n;
|
let n = self.n;
|
||||||
let mut y = b.to_vec();
|
let mut y = vec![0.0f64; n];
|
||||||
for i in 0..n {
|
for (old, &v) in b.iter().enumerate() {
|
||||||
// Folded from `y[i]` rather than summed and subtracted once, so the
|
y[self.inv[old]] = v;
|
||||||
// accumulation order matches a plain substitution loop exactly.
|
}
|
||||||
let row = &self.l[i * n..i * n + i];
|
for j in 0..n {
|
||||||
let s = row
|
y[j] /= self.val[self.col_ptr[j]];
|
||||||
.iter()
|
let yj = y[j];
|
||||||
.zip(&y[..i])
|
for p in self.col_ptr[j] + 1..self.col_ptr[j + 1] {
|
||||||
.fold(y[i], |acc, (l, v)| acc - l * v);
|
y[self.row_idx[p]] -= self.val[p] * yj;
|
||||||
y[i] = s / self.l[i * n + i];
|
}
|
||||||
}
|
}
|
||||||
y
|
y
|
||||||
}
|
}
|
||||||
@@ -98,11 +350,24 @@ pub(crate) fn bilinear(y: &[f64], y_prime: &[f64]) -> f64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Factorise a dense row-major matrix, for the goldens below.
|
||||||
|
fn dense(a: &[f64], n: usize) -> Option<Cholesky> {
|
||||||
|
let mut b = SymmetricBuilder::new();
|
||||||
|
for i in 0..n {
|
||||||
|
for j in 0..n {
|
||||||
|
if a[i * n + j] != 0.0 {
|
||||||
|
b.add(i, j, a[i * n + j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cholesky::factor(b, n)
|
||||||
|
}
|
||||||
|
|
||||||
/// `[[4, 1], [1, 3]] z = [1, 2]` has `z = [1/11, 7/11]`, so the quadratic
|
/// `[[4, 1], [1, 3]] z = [1, 2]` has `z = [1/11, 7/11]`, so the quadratic
|
||||||
/// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`.
|
/// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`.
|
||||||
#[test]
|
#[test]
|
||||||
fn reproduces_a_known_quadratic_form() {
|
fn reproduces_a_known_quadratic_form() {
|
||||||
let c = Cholesky::factor(vec![4.0, 1.0, 1.0, 3.0], 2).unwrap();
|
let c = dense(&[4.0, 1.0, 1.0, 3.0], 2).unwrap();
|
||||||
let y = c.whiten(&[1.0, 2.0]);
|
let y = c.whiten(&[1.0, 2.0]);
|
||||||
assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12);
|
assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
@@ -113,8 +378,8 @@ mod tests {
|
|||||||
fn recovers_the_inverse_diagonal() {
|
fn recovers_the_inverse_diagonal() {
|
||||||
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
|
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
|
||||||
// [0.75, 1.0, 0.75].
|
// [0.75, 1.0, 0.75].
|
||||||
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
let a = [2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
||||||
let c = Cholesky::factor(a, 3).unwrap();
|
let c = dense(&a, 3).unwrap();
|
||||||
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
|
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
|
||||||
let mut e = vec![0.0; 3];
|
let mut e = vec![0.0; 3];
|
||||||
e[i] = 1.0;
|
e[i] = 1.0;
|
||||||
@@ -127,8 +392,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn recovers_an_off_diagonal_covariance() {
|
fn recovers_an_off_diagonal_covariance() {
|
||||||
// Same A; (A^-1)_{0,1} = 0.5.
|
// Same A; (A^-1)_{0,1} = 0.5.
|
||||||
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
let a = [2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
||||||
let c = Cholesky::factor(a, 3).unwrap();
|
let c = dense(&a, 3).unwrap();
|
||||||
let y0 = c.whiten(&[1.0, 0.0, 0.0]);
|
let y0 = c.whiten(&[1.0, 0.0, 0.0]);
|
||||||
let y1 = c.whiten(&[0.0, 1.0, 0.0]);
|
let y1 = c.whiten(&[0.0, 1.0, 0.0]);
|
||||||
assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12);
|
assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12);
|
||||||
@@ -138,15 +403,108 @@ mod tests {
|
|||||||
/// A variance can never come out negative, because it is a sum of squares.
|
/// A variance can never come out negative, because it is a sum of squares.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_quadratic_form_is_never_negative() {
|
fn a_quadratic_form_is_never_negative() {
|
||||||
let a = vec![1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
|
let a = [1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
|
||||||
let c = Cholesky::factor(a, 2).unwrap();
|
let c = dense(&a, 2).unwrap();
|
||||||
let y = c.whiten(&[1.0, -1.0]);
|
let y = c.whiten(&[1.0, -1.0]);
|
||||||
assert!(bilinear(&y, &y) >= 0.0);
|
assert!(bilinear(&y, &y) >= 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Against an independent dense reference, on random sparse SPD matrices.
|
||||||
|
///
|
||||||
|
/// The goldens above are 2x2 and 3x3 — small enough that AMD does nothing
|
||||||
|
/// and no fill-in occurs, so they cannot catch a symbolic-pass bug. This
|
||||||
|
/// builds matrices big enough to permute and fill in, and checks every
|
||||||
|
/// bilinear form against a textbook dense factorisation of the *same*
|
||||||
|
/// matrix in its original order.
|
||||||
|
#[test]
|
||||||
|
fn agrees_with_a_dense_reference_on_random_sparse_systems() {
|
||||||
|
/// Dense Cholesky and quadratic form, deliberately naive: this is the
|
||||||
|
/// reference, so it must not share code with what it is checking.
|
||||||
|
fn dense_quadratic_form(a: &[f64], n: usize, b: &[f64], c: &[f64]) -> f64 {
|
||||||
|
let mut l = a.to_vec();
|
||||||
|
for j in 0..n {
|
||||||
|
let mut d = l[j * n + j];
|
||||||
|
for k in 0..j {
|
||||||
|
d -= l[j * n + k] * l[j * n + k];
|
||||||
|
}
|
||||||
|
let d = d.sqrt();
|
||||||
|
l[j * n + j] = d;
|
||||||
|
for i in j + 1..n {
|
||||||
|
let mut sum = l[i * n + j];
|
||||||
|
for k in 0..j {
|
||||||
|
sum -= l[i * n + k] * l[j * n + k];
|
||||||
|
}
|
||||||
|
l[i * n + j] = sum / d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let solve = |rhs: &[f64]| -> Vec<f64> {
|
||||||
|
let mut y = rhs.to_vec();
|
||||||
|
for i in 0..n {
|
||||||
|
for k in 0..i {
|
||||||
|
y[i] -= l[i * n + k] * y[k];
|
||||||
|
}
|
||||||
|
y[i] /= l[i * n + i];
|
||||||
|
}
|
||||||
|
y
|
||||||
|
};
|
||||||
|
let (yb, yc) = (solve(b), solve(c));
|
||||||
|
yb.iter().zip(&yc).map(|(x, y)| x * y).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cheap deterministic generator; no dependency, and reproducible.
|
||||||
|
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||||
|
let mut rand = move || {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 7;
|
||||||
|
seed ^= seed << 17;
|
||||||
|
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
for n in [7usize, 23, 60] {
|
||||||
|
let mut a = vec![0.0f64; n * n];
|
||||||
|
// A chain plus scattered long-range couplings: the shape of a
|
||||||
|
// time-expanded joint, where a competitor's drift link can span
|
||||||
|
// the whole matrix.
|
||||||
|
for i in 0..n {
|
||||||
|
a[i * n + i] = 4.0 + rand();
|
||||||
|
if i + 1 < n {
|
||||||
|
let v = -(0.5 + rand() * 0.5);
|
||||||
|
a[i * n + i + 1] = v;
|
||||||
|
a[(i + 1) * n + i] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for step in 0..n / 3 {
|
||||||
|
let i = (step * 7) % n;
|
||||||
|
let j = (step * 29 + 3) % n;
|
||||||
|
if i != j {
|
||||||
|
let v = -(0.1 + rand() * 0.2);
|
||||||
|
a[i * n + j] = v;
|
||||||
|
a[j * n + i] = v;
|
||||||
|
// Keep it diagonally dominant, hence positive-definite.
|
||||||
|
a[i * n + i] += 0.6;
|
||||||
|
a[j * n + j] += 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sparse = dense(&a, n).expect("spd");
|
||||||
|
|
||||||
|
for trial in 0..8 {
|
||||||
|
let b: Vec<f64> = (0..n).map(|_| rand() * 2.0 - 1.0).collect();
|
||||||
|
let c: Vec<f64> = (0..n).map(|_| rand() * 2.0 - 1.0).collect();
|
||||||
|
let got = bilinear(&sparse.whiten(&b), &sparse.whiten(&c));
|
||||||
|
let want = dense_quadratic_form(&a, n, &b, &c);
|
||||||
|
assert!(
|
||||||
|
(got - want).abs() <= 1e-10 * want.abs().max(1.0),
|
||||||
|
"n={n} trial={trial}: sparse {got} vs dense {want}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A permutation must not change which matrices are rejected.
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_a_non_positive_definite_matrix() {
|
fn rejects_a_non_positive_definite_matrix() {
|
||||||
// Singular: the second row is a multiple of the first.
|
// Singular: the second row is a multiple of the first.
|
||||||
assert!(Cholesky::factor(vec![1.0, 2.0, 2.0, 4.0], 2).is_none());
|
assert!(dense(&[1.0, 2.0, 2.0, 4.0], 2).is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -9,6 +9,10 @@
|
|||||||
//! This is a Rust port of
|
//! This is a Rust port of
|
||||||
//! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
|
//! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
|
||||||
//!
|
//!
|
||||||
|
//! Upgrading? `MIGRATING.md` in the repository root covers every breaking
|
||||||
|
//! change, with the ones that alter what an existing call *returns* called out
|
||||||
|
//! first.
|
||||||
|
//!
|
||||||
//! # Getting started
|
//! # Getting started
|
||||||
//!
|
//!
|
||||||
//! Record results, converge, then read off skills:
|
//! Record results, converge, then read off skills:
|
||||||
@@ -144,6 +148,7 @@ mod outcome;
|
|||||||
mod predict;
|
mod predict;
|
||||||
pub(crate) mod quadrature;
|
pub(crate) mod quadrature;
|
||||||
mod rating;
|
mod rating;
|
||||||
|
pub mod rating_rule;
|
||||||
pub(crate) mod storage;
|
pub(crate) mod storage;
|
||||||
mod time;
|
mod time;
|
||||||
mod time_slice;
|
mod time_slice;
|
||||||
@@ -151,7 +156,7 @@ mod time_slice;
|
|||||||
pub use acquisition::expected_information_gain;
|
pub use acquisition::expected_information_gain;
|
||||||
pub use convergence::{ConvergenceOptions, ConvergenceReport};
|
pub use convergence::{ConvergenceOptions, ConvergenceReport};
|
||||||
pub use drift::{ConstantDrift, Drift};
|
pub use drift::{ConstantDrift, Drift};
|
||||||
pub use error::{InferenceError, UnknownKeys};
|
pub use error::{CompetitorField, InferenceError, OutcomeKind, Parameter, Shape, UnknownKeys};
|
||||||
pub use event::{Event, Member, Team};
|
pub use event::{Event, Member, Team};
|
||||||
pub use event_builder::EventBuilder;
|
pub use event_builder::EventBuilder;
|
||||||
pub use game::{Game, GameOptions};
|
pub use game::{Game, GameOptions};
|
||||||
@@ -162,6 +167,7 @@ pub use observer::{NullObserver, Observer};
|
|||||||
pub use outcome::Outcome;
|
pub use outcome::Outcome;
|
||||||
pub use predict::Prediction;
|
pub use predict::Prediction;
|
||||||
pub use rating::Rating;
|
pub use rating::Rating;
|
||||||
|
pub use rating_rule::{FnRule, NoRule, RatingRule, StartingPoint};
|
||||||
/// The `smallvec` crate, re-exported.
|
/// The `smallvec` crate, re-exported.
|
||||||
///
|
///
|
||||||
/// Four public items name `SmallVec` in their signatures: [`Event::teams`],
|
/// Four public items name `SmallVec` in their signatures: [`Event::teams`],
|
||||||
|
|||||||
+1
-1
@@ -84,7 +84,7 @@ impl Outcome {
|
|||||||
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
|
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
|
||||||
if winner >= n {
|
if winner >= n {
|
||||||
return Err(crate::InferenceError::InvalidParameter {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
name: "winner",
|
parameter: crate::Parameter::WinnerIndex,
|
||||||
value: f64::from(winner),
|
value: f64::from(winner),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,7 +184,10 @@ fn a_batch_declaring_two_different_priors_is_rejected() {
|
|||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::ConflictingCompetitorConfig { field: "prior", .. }
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
|
field: trueskill_tt::CompetitorField::Prior,
|
||||||
|
..
|
||||||
|
}
|
||||||
),
|
),
|
||||||
"got {err:?}"
|
"got {err:?}"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ fn event_builder_rejects_a_weights_length_mismatch() {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::MismatchedShape {
|
InferenceError::MismatchedShape {
|
||||||
kind: "weights",
|
shape: trueskill_tt::Shape::Weights,
|
||||||
expected: 1,
|
expected: 1,
|
||||||
got: 2,
|
got: 2,
|
||||||
..
|
..
|
||||||
@@ -228,7 +228,7 @@ fn scored_event_rejects_non_positive_sigma() {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter {
|
||||||
name: "score_sigma",
|
parameter: trueskill_tt::Parameter::ScoreSigma,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ fn reject(scale: f64) -> InferenceError {
|
|||||||
fn negative_scale_is_rejected() {
|
fn negative_scale_is_rejected() {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
reject(-1.0),
|
reject(-1.0),
|
||||||
InferenceError::InvalidParameter { name: "drift_scale", value, .. }
|
InferenceError::InvalidParameter { parameter: trueskill_tt::Parameter::DriftScale, value, .. }
|
||||||
if value == -1.0
|
if value == -1.0
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -291,7 +291,7 @@ fn non_finite_scale_is_rejected() {
|
|||||||
matches!(
|
matches!(
|
||||||
reject(scale),
|
reject(scale),
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter {
|
||||||
name: "drift_scale",
|
parameter: trueskill_tt::Parameter::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -488,7 +488,7 @@ fn a_batch_that_contradicts_itself_is_rejected() {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::ConflictingCompetitorConfig {
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
field: "drift_scale",
|
field: trueskill_tt::CompetitorField::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ fn weights_still_guards_a_members_team() {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::MismatchedShape {
|
InferenceError::MismatchedShape {
|
||||||
kind: "weights",
|
shape: trueskill_tt::Shape::Weights,
|
||||||
expected: 2,
|
expected: 2,
|
||||||
got: 1,
|
got: 1,
|
||||||
..
|
..
|
||||||
@@ -164,7 +164,7 @@ fn an_invalid_drift_scale_surfaces_from_commit() {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter {
|
||||||
name: "drift_scale",
|
parameter: trueskill_tt::Parameter::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
+8
-2
@@ -57,7 +57,7 @@ fn game_ranked_rejects_bad_p_draw() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, InferenceError::InvalidProbability { .. }));
|
assert!(matches!(err, InferenceError::InvalidParameter { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -227,7 +227,13 @@ mod malformed_games {
|
|||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Score,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"{bad}: {err:?}"
|
"{bad}: {err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,13 @@ fn a_non_finite_score_is_rejected_at_ingestion() {
|
|||||||
}])
|
}])
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Score,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"{bad}: {err:?}"
|
"{bad}: {err:?}"
|
||||||
);
|
);
|
||||||
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
|
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
|
||||||
@@ -136,7 +142,13 @@ fn a_non_finite_weight_is_rejected_at_ingestion() {
|
|||||||
.commit()
|
.commit()
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Weight,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"{bad}: {err:?}"
|
"{bad}: {err:?}"
|
||||||
);
|
);
|
||||||
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
|
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
|
||||||
|
|||||||
@@ -231,16 +231,20 @@ fn a_ranked_history_has_no_exact_joint() {
|
|||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h.joint().unwrap_err(),
|
h.joint().unwrap_err(),
|
||||||
InferenceError::JointUnavailable { .. }
|
InferenceError::JointRequiresScoredEvents
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Distinguishable from the ranked case, which is the point of splitting
|
||||||
|
/// `JointUnavailable { reason: &str }` into three variants (#74): "add events"
|
||||||
|
/// and "use predict_win_probabilities" are different instructions, and telling
|
||||||
|
/// them apart used to mean matching on English prose.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_empty_history_has_no_joint() {
|
fn an_empty_history_has_no_joint() {
|
||||||
let h = history(UnknownKeys::Reject);
|
let h = history(UnknownKeys::Reject);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h.joint().unwrap_err(),
|
h.joint().unwrap_err(),
|
||||||
InferenceError::JointUnavailable { .. }
|
InferenceError::EmptyHistory
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ fn overflow_during_inference_is_reported_not_hidden() {
|
|||||||
|
|
||||||
for (name, sigma, beta, score_sigma, scores) in cases {
|
for (name, sigma, beta, score_sigma, scores) in cases {
|
||||||
match scored_fit(sigma, beta, score_sigma, scores) {
|
match scored_fit(sigma, beta, score_sigma, scores) {
|
||||||
Err(InferenceError::NonFiniteResult { context, step, .. }) => {
|
Err(InferenceError::NonFiniteStep { context, step, .. }) => {
|
||||||
assert_eq!(context, "History::converge", "{name}");
|
assert_eq!(context, "History::converge", "{name}");
|
||||||
assert!(
|
assert!(
|
||||||
!step.0.is_finite() || !step.1.is_finite(),
|
!step.0.is_finite() || !step.1.is_finite(),
|
||||||
@@ -86,7 +86,7 @@ fn a_broken_fit_is_never_reported_as_converged() {
|
|||||||
|
|
||||||
let err = h.converge().unwrap_err();
|
let err = h.converge().unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::NonFiniteResult { .. }),
|
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||||
"a breakdown must not be reported as convergence: {err:?}"
|
"a breakdown must not be reported as convergence: {err:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ fn a_broken_fit_is_never_reported_as_converged() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h2.converge_partial().unwrap_err(),
|
h2.converge_partial().unwrap_err(),
|
||||||
InferenceError::NonFiniteResult { .. }
|
InferenceError::NonFiniteStep { .. }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
|
|||||||
.converge()
|
.converge()
|
||||||
.expect_err("a NaN fit must never be reported as converged");
|
.expect_err("a NaN fit must never be reported as converged");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::NonFiniteResult { .. }),
|
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ fn nan_poisoned() -> H {
|
|||||||
);
|
);
|
||||||
let err = h.converge().expect_err("this fixture must not converge");
|
let err = h.converge().expect_err("this fixture must not converge");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::NonFiniteResult { .. }),
|
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
h
|
h
|
||||||
@@ -101,7 +101,7 @@ fn a_nan_poisoned_fit_is_refused_by_every_prediction_path() {
|
|||||||
|
|
||||||
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
||||||
match r {
|
match r {
|
||||||
Err(InferenceError::NonFiniteResult { .. }) => {}
|
Err(InferenceError::NonFiniteSkill { .. }) => {}
|
||||||
other => panic!("{name} answered from a NaN fit: {other:?}"),
|
other => panic!("{name} answered from a NaN fit: {other:?}"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -121,7 +121,7 @@ fn degenerate_performances_are_refused_rather_than_answered_wrongly() {
|
|||||||
// and every skill is a point mass.
|
// and every skill is a point mass.
|
||||||
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
||||||
match r {
|
match r {
|
||||||
Err(InferenceError::InvalidParameter { .. }) => {}
|
Err(InferenceError::NoPerformanceVariance) => {}
|
||||||
other => panic!("{name} predicted from a degenerate fit: {other:?}"),
|
other => panic!("{name} predicted from a degenerate fit: {other:?}"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+10
-4
@@ -172,7 +172,13 @@ fn a_weight_on_a_registration_is_rejected() {
|
|||||||
.register(Member::new("layout").with_weight(0.5))
|
.register(Member::new("layout").with_weight(0.5))
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Weight,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,7 +194,7 @@ fn an_invalid_drift_scale_on_a_registration_is_rejected() {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter {
|
||||||
name: "drift_scale",
|
parameter: trueskill_tt::Parameter::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -280,7 +286,7 @@ mod conflicting_configuration {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::ConflictingCompetitorConfig {
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
field: "drift_scale",
|
field: trueskill_tt::CompetitorField::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -297,7 +303,7 @@ mod conflicting_configuration {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::ConflictingCompetitorConfig {
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
field: "drift_scale",
|
field: trueskill_tt::CompetitorField::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-4
@@ -49,7 +49,13 @@ fn ranked_rejects_a_zero_damping_factor() {
|
|||||||
)
|
)
|
||||||
.expect_err("alpha = 0 must be rejected");
|
.expect_err("alpha = 0 must be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Alpha,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"got {err:?}"
|
"got {err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,7 +71,13 @@ fn ranked_rejects_an_out_of_range_damping_factor() {
|
|||||||
)
|
)
|
||||||
.expect_err("alpha out of (0, 1] must be rejected");
|
.expect_err("alpha out of (0, 1] must be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Alpha,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"alpha={alpha}: got {err:?}"
|
"alpha={alpha}: got {err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -81,7 +93,13 @@ fn scored_rejects_a_bad_damping_factor() {
|
|||||||
)
|
)
|
||||||
.expect_err("alpha = 0 must be rejected");
|
.expect_err("alpha = 0 must be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Alpha,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"got {err:?}"
|
"got {err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -360,7 +378,7 @@ mod constructor_parameters {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter {
|
||||||
name: "drift variance",
|
parameter: trueskill_tt::Parameter::DriftVariance,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user