Compare commits
60
Commits
v0.8.0
..
5d9501307e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d9501307e | ||
|
|
327324c411 | ||
|
|
61da3aca33 | ||
|
|
061c481aad | ||
|
|
0b9997354d | ||
|
|
c4194b0051 | ||
|
|
1629176199 | ||
|
|
695bb822ef | ||
|
|
d36d125e52 | ||
|
|
0801acebd1 | ||
|
|
1ad789cf40 | ||
|
|
b553c630f5 | ||
|
|
d2ab4446ef | ||
|
|
e72bf3894c | ||
|
|
56193609f7 | ||
|
|
13a395fdc9 | ||
|
|
6e2ce69728 | ||
|
|
faa25fb3b1 | ||
|
|
ddbac87744 | ||
|
|
076a7ded8c | ||
|
|
8e34410db0 | ||
|
|
92d690d0f8 | ||
|
|
4c2e98c54b | ||
|
|
92ae5fca17 | ||
|
|
da55d2a7d1 | ||
|
|
6a893ffe57 | ||
|
|
f14c783c0e | ||
|
|
055575a6f4 | ||
|
|
251211f134 | ||
|
|
31564b71a0 | ||
|
|
78810c0344 | ||
|
|
9d3e002be3 | ||
|
|
9d629d0d94 | ||
|
|
7ca0daa48e | ||
|
|
c3d1afe448 | ||
|
|
e4d6dc4028 | ||
|
|
cc601c06eb | ||
|
|
86e1521f8a | ||
|
|
60fc3e9d05 | ||
|
|
e4a68ba1a7 | ||
|
|
56e8220c86 | ||
|
|
fdd1539cab | ||
|
|
85c4d0d87d | ||
|
|
a0c2f78aed | ||
|
|
4472d98b56 | ||
|
|
5f5a37090a | ||
|
|
dc1f4d5847 | ||
|
|
0ab56248bb | ||
|
|
8dff7513f7 | ||
|
|
a367155778 | ||
|
|
c69a397d80 | ||
|
|
7aa7fb62dd | ||
|
|
305f822964 | ||
|
|
ab23476aaf | ||
|
|
6139061740 | ||
|
|
f1219036b3 | ||
|
|
31cf0998b0 | ||
|
|
bbc7705c75 | ||
|
|
83bdb84152 | ||
|
|
c65373f476 |
@@ -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
|
||||||
+3
-4
@@ -33,10 +33,6 @@ bench = false
|
|||||||
name = "batch"
|
name = "batch"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[[bench]]
|
|
||||||
name = "gaussian"
|
|
||||||
harness = false
|
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "history_converge"
|
name = "history_converge"
|
||||||
harness = false
|
harness = false
|
||||||
@@ -51,12 +47,15 @@ harness = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
approx = { version = "0.5.1", optional = true }
|
approx = { version = "0.5.1", optional = true }
|
||||||
|
feral-amd = "0.2"
|
||||||
libm = "0.2.16"
|
libm = "0.2.16"
|
||||||
rayon = { version = "1", optional = true }
|
rayon = { version = "1", optional = true }
|
||||||
smallvec = "1"
|
smallvec = "1"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
approx = ["dep:approx"]
|
approx = ["dep:approx"]
|
||||||
|
# Exposes the joint sparsity pattern for the #52 measurement. Test-only.
|
||||||
|
measure-sparsity = []
|
||||||
rayon = ["dep:rayon"]
|
rayon = ["dep:rayon"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
+204
@@ -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.
|
||||||
@@ -1,15 +1,142 @@
|
|||||||
# TrueSkill - Through Time
|
# TrueSkill - Through Time
|
||||||
|
|
||||||
Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
|
Bayesian skill rating over a time axis.
|
||||||
|
|
||||||
## Other implementations
|
Where plain TrueSkill gives each competitor one running estimate, TrueSkill
|
||||||
|
Through Time treats a whole history as a single model and infers skill *at every
|
||||||
|
point in time*. Evidence flows both directions: a result today sharpens the
|
||||||
|
estimate of who someone was last year, so early estimates stop being frozen
|
||||||
|
guesses and comparisons across eras become meaningful.
|
||||||
|
|
||||||
- [ttt-scala](https://github.com/ankurdave/ttt-scala)
|
A Rust port of
|
||||||
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
|
[TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
|
||||||
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
|
|
||||||
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
|
## Install
|
||||||
- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf)
|
|
||||||
- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/)
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
trueskill-tt = "0.8"
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional features, both off by default:
|
||||||
|
|
||||||
|
- `approx` — `approx`'s equality traits for `Gaussian`. Useful in tests.
|
||||||
|
- `rayon` — parallelises the within-slice sweep and the per-slice passes of
|
||||||
|
`learning_curves` / `log_evidence`. Results stay bit-identical regardless of
|
||||||
|
worker count; `just determinism` asserts it at 1, 2, 4 and 8 threads.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
Record results, converge, then read off skills.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::History;
|
||||||
|
|
||||||
|
let mut history = History::default();
|
||||||
|
|
||||||
|
history.record_winner(&"alice", &"bob", 1)?;
|
||||||
|
history.record_winner(&"bob", &"carol", 2)?;
|
||||||
|
history.record_winner(&"alice", &"carol", 3)?;
|
||||||
|
|
||||||
|
history.converge()?;
|
||||||
|
|
||||||
|
let alice = history.current_skill("alice").unwrap();
|
||||||
|
assert!(alice.mu() > 0.0, "alice won every game she played");
|
||||||
|
# Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
The third argument is the time. It is what makes this Through Time rather than
|
||||||
|
plain TrueSkill: skill is inferred at each of those moments, not once at the
|
||||||
|
end. `learning_curve` reads the whole trajectory back.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use trueskill_tt::History;
|
||||||
|
# let mut history = History::default();
|
||||||
|
# history.record_winner(&"alice", &"bob", 1)?;
|
||||||
|
# history.record_winner(&"bob", &"carol", 2)?;
|
||||||
|
# history.record_winner(&"alice", &"carol", 3)?;
|
||||||
|
# history.converge()?;
|
||||||
|
// `None` means the key is unknown; `Some(vec![])` means known but unplayed.
|
||||||
|
let curve = history.learning_curve("alice").unwrap();
|
||||||
|
for (time, skill) in &curve {
|
||||||
|
println!("t={time}: {:.2} ± {:.2}", skill.mu(), skill.sigma());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everyone's latest posterior in one pass — the leaderboard query.
|
||||||
|
let latest = history.current_skills();
|
||||||
|
assert_eq!(latest.len(), 3);
|
||||||
|
# Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Teams, rankings and draws
|
||||||
|
|
||||||
|
Anything beyond one-versus-one goes through the fluent event builder. An event
|
||||||
|
is only recorded by the terminal `.commit()`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::History;
|
||||||
|
|
||||||
|
let mut history = History::builder().p_draw(0.1).build();
|
||||||
|
|
||||||
|
history
|
||||||
|
.event(1)
|
||||||
|
.team(["alice", "bob"])
|
||||||
|
.team(["carol", "dave"])
|
||||||
|
.ranking([0, 1]) // lower is better; equal values are a tie
|
||||||
|
.commit()?;
|
||||||
|
|
||||||
|
history.converge()?;
|
||||||
|
# Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
**A tie needs a positive `p_draw`.** A `p_draw` of zero asserts draws cannot
|
||||||
|
happen, so a tied result has no representable likelihood and is rejected rather
|
||||||
|
than fitted to something else:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::{History, InferenceError};
|
||||||
|
|
||||||
|
let mut history = History::default(); // p_draw defaults to 0.0
|
||||||
|
let err = history.record_draw(&"alice", &"bob", 1).unwrap_err();
|
||||||
|
assert!(matches!(err, InferenceError::TieWithoutDrawProbability { .. }));
|
||||||
|
```
|
||||||
|
|
||||||
|
This also catches `Outcome::winner(w, n)` for three or more teams, which ties
|
||||||
|
every loser.
|
||||||
|
|
||||||
|
## Which entry point?
|
||||||
|
|
||||||
|
| You want to | Use |
|
||||||
|
|---|---|
|
||||||
|
| One match, two competitors | `record_winner` / `record_draw` |
|
||||||
|
| Teams, explicit ranks, scores, per-member weights | `history.event(t)…commit()` |
|
||||||
|
| A batch you already have as values | `add_events(iter)` |
|
||||||
|
| Score a hypothetical with no history at all | `Game` |
|
||||||
|
|
||||||
|
`Game` is the odd one out and worth being explicit about: it is a single match's
|
||||||
|
factor graph, it does not participate in a `History`, and nothing it computes is
|
||||||
|
remembered. Reach for it to evaluate a matchup in isolation; reach for `History`
|
||||||
|
for everything that accumulates.
|
||||||
|
|
||||||
|
## `converge` is strict
|
||||||
|
|
||||||
|
`converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the
|
||||||
|
step still above `epsilon`, and `Err(NonFiniteStep)` if a sweep produces NaN.
|
||||||
|
|
||||||
|
It used to return `Ok` with `converged: false`, which was the worst available
|
||||||
|
shape. A fit that stops short is *wrong by a little*: every posterior is finite,
|
||||||
|
the ordering looks sensible, and nothing about the output says the numbers were
|
||||||
|
still moving. Detection was opt-in, and `let _ = h.converge()` silently opted
|
||||||
|
out — which is how a real defect hid in this crate's own test suite.
|
||||||
|
|
||||||
|
The default `max_iter` is high enough that reaching it means something is
|
||||||
|
genuinely wrong rather than that the history is large; the loop exits at
|
||||||
|
`epsilon` long before, so raising the cap costs nothing when it is not needed.
|
||||||
|
Use `converge_partial` when a deliberately capped, unconverged fit is the point.
|
||||||
|
|
||||||
|
Predictions are strict for the same reason: every `predict_*` method reads
|
||||||
|
skills through one gate that refuses a NaN-poisoned fit, rather than returning a
|
||||||
|
plausible number computed from it.
|
||||||
|
|
||||||
## Drift
|
## Drift
|
||||||
|
|
||||||
@@ -45,7 +172,7 @@ grows proportionally to time:
|
|||||||
variance_delta = elapsed * γ²
|
variance_delta = elapsed * γ²
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the standard TrueSkill Through Time model. Pass a `ConstantDrift(gamma)`
|
This is the standard TrueSkill Through Time model. Pass a `ConstantDrift::new(gamma)`
|
||||||
when constructing a `Rating`:
|
when constructing a `Rating`:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -53,9 +180,9 @@ use trueskill_tt::{ConstantDrift, Gaussian, Rating};
|
|||||||
|
|
||||||
// gamma = 0.1 means skill can shift ~0.1 per time unit.
|
// gamma = 0.1 means skill can shift ~0.1 per time unit.
|
||||||
let rating: Rating<i64, ConstantDrift> =
|
let rating: Rating<i64, ConstantDrift> =
|
||||||
Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift(0.1));
|
Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift::new(0.1));
|
||||||
|
|
||||||
assert_eq!(rating.drift().0, 0.1);
|
assert_eq!(rating.drift().gamma(), 0.1);
|
||||||
```
|
```
|
||||||
|
|
||||||
The type annotation is load-bearing: `ConstantDrift` implements `Drift<T>` for
|
The type annotation is load-bearing: `ConstantDrift` implements `Drift<T>` for
|
||||||
@@ -98,14 +225,14 @@ assert_eq!(history.log_evidence(), 0.0);
|
|||||||
```
|
```
|
||||||
|
|
||||||
`HistoryBuilder::drift` is the only way to set a history's drift model; there is
|
`HistoryBuilder::drift` is the only way to set a history's drift model; there is
|
||||||
no `gamma()` shorthand. The default is `ConstantDrift(GAMMA)`.
|
no `gamma()` shorthand. The default is `ConstantDrift::new(GAMMA)`.
|
||||||
|
|
||||||
### Per-competitor drift
|
### Per-competitor drift
|
||||||
|
|
||||||
A `History` has one drift model, but individual competitors can scale it.
|
A `History` has one drift model, but individual competitors can scale it.
|
||||||
`Member::with_drift_scale(s)` multiplies the drift *variance* that competitor
|
`Member::with_drift_scale(s)` multiplies the drift *variance* that competitor
|
||||||
accumulates, so `s` is in the same units as `gamma`: `ConstantDrift(g)` at
|
accumulates, so `s` is in the same units as `gamma`: `ConstantDrift::new(g)` at
|
||||||
scale `s` behaves exactly as `ConstantDrift(g * s)` would, for that competitor
|
scale `s` behaves exactly as `ConstantDrift::new(g * s)` would, for that competitor
|
||||||
alone.
|
alone.
|
||||||
|
|
||||||
`0.0` pins a competitor still. That is what makes a **fixed reference point**
|
`0.0` pins a competitor still. That is what makes a **fixed reference point**
|
||||||
@@ -115,7 +242,7 @@ strength, a rating floor, a course difficulty:
|
|||||||
```rust
|
```rust
|
||||||
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
let mut h = History::builder().drift(ConstantDrift(0.1)).build();
|
let mut h = History::builder().drift(ConstantDrift::new(0.1)).build();
|
||||||
|
|
||||||
h.add_events(vec![Event {
|
h.add_events(vec![Event {
|
||||||
time: 0,
|
time: 0,
|
||||||
@@ -203,7 +330,7 @@ stay available at any size:
|
|||||||
|
|
||||||
Unknown keys are an error by default, not a silent omission: a team the history
|
Unknown keys are an error by default, not a silent omission: a team the history
|
||||||
has never seen cannot produce a confident-looking probability. The error names
|
has never seen cannot produce a confident-looking probability. The error names
|
||||||
the key, and every key must already be known — pre-filter with `lookup` or
|
the key, and every key must already be known — pre-filter with
|
||||||
`current_skill` if your caller cannot guarantee that.
|
`current_skill` if your caller cannot guarantee that.
|
||||||
|
|
||||||
If predicting for competitors you have never seen is the point rather than a
|
If predicting for competitors you have never seen is the point rather than a
|
||||||
@@ -228,11 +355,11 @@ certain because it knows less.
|
|||||||
```rust
|
```rust
|
||||||
use trueskill_tt::History;
|
use trueskill_tt::History;
|
||||||
|
|
||||||
let mut h = History::builder().build();
|
let mut h = History::default();
|
||||||
h.record_winner(&"alice", &"bob", 1).unwrap();
|
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||||||
let _ = h.converge().unwrap();
|
h.converge().unwrap();
|
||||||
|
|
||||||
let skill = h.current_skill(&"alice").unwrap();
|
let skill = h.current_skill("alice").unwrap();
|
||||||
|
|
||||||
// "How sure am I that this is below the cutoff?" — a probability, not a
|
// "How sure am I that this is below the cutoff?" — a probability, not a
|
||||||
// `mu + z * sigma` band whose confidence drifts as sigma changes.
|
// `mu + z * sigma` band whose confidence drifts as sigma changes.
|
||||||
@@ -254,7 +381,7 @@ what you believe now and what you would believe afterwards.
|
|||||||
```rust
|
```rust
|
||||||
use trueskill_tt::History;
|
use trueskill_tt::History;
|
||||||
|
|
||||||
let mut h = History::builder().build();
|
let mut h = History::default();
|
||||||
for t in 1..=10 {
|
for t in 1..=10 {
|
||||||
h.record_winner(&"veteran", &"regular", t).unwrap();
|
h.record_winner(&"veteran", &"regular", t).unwrap();
|
||||||
h.record_winner(&"regular", &"veteran", t + 100).unwrap();
|
h.record_winner(&"regular", &"veteran", t + 100).unwrap();
|
||||||
@@ -278,16 +405,22 @@ expensive than `quality()`. Scoring every pairing among `n` competitors is
|
|||||||
`O(n² × outcomes)` passes — shortlist with `quality()` or
|
`O(n² × outcomes)` passes — shortlist with `quality()` or
|
||||||
`predict_win_probabilities` first, then score only the shortlist.
|
`predict_win_probabilities` first, then score only the shortlist.
|
||||||
|
|
||||||
## Todo
|
## Other implementations
|
||||||
|
|
||||||
- [x] Implement approx for Gaussian
|
- [ttt-scala](https://github.com/ankurdave/ttt-scala)
|
||||||
- [x] Add more tests from `TrueSkillThroughTime.jl`
|
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
|
||||||
- [x] Generalise a time axis — `Time` is now a trait (`Untimed`, `i64`), not an enum
|
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
|
||||||
- [x] Add examples (`examples/atp.rs`, `examples/scored.rs`)
|
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
|
||||||
- [x] Add Observer (`Observer` / `NullObserver`)
|
- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf)
|
||||||
- [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`)
|
- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/)
|
||||||
- [x] N-team `predict_outcome` with draw mass, and `expected_information_gain`
|
|
||||||
- [x] Cross-check `quality()` against [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) — N identical teams follow the closed form `(1/5)^((n-1)/2)` for the conventional parameters, asserted for n = 2..10, and the n=3/n=5 values (0.200, 0.040) match the reference package
|
## Status
|
||||||
|
|
||||||
|
Every box on the old todo list is ticked, so it has been retired; open work
|
||||||
|
lives in the issue tracker instead. The crate is in use and the API is still
|
||||||
|
moving — breaking changes are batched into minor releases rather than dribbled
|
||||||
|
out. `CHANGELOG.md` lists them and [`MIGRATING.md`](MIGRATING.md) explains what
|
||||||
|
to do about them.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+45
-35
@@ -1,45 +1,55 @@
|
|||||||
|
//! One slice's event sweep.
|
||||||
|
//!
|
||||||
|
//! Written against the public API rather than against `TimeSlice` directly.
|
||||||
|
//! It used to reach for `TimeSlice`, `KeyTable`, `CompetitorStore`,
|
||||||
|
//! `Competitor` and `EventKind`, and was the *only* thing outside `src/`
|
||||||
|
//! that did — so a benchmark was dictating five public types that no test,
|
||||||
|
//! example or consumer could otherwise obtain.
|
||||||
|
//!
|
||||||
|
//! A single-slice history's `converge` calls exactly the same per-slice sweep,
|
||||||
|
//! so capping at one iteration measures the same code path.
|
||||||
|
|
||||||
use criterion::{Criterion, criterion_group, criterion_main};
|
use criterion::{Criterion, criterion_group, criterion_main};
|
||||||
use trueskill_tt::{
|
use smallvec::smallvec;
|
||||||
BETA, Competitor, ConvergenceOptions, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA,
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
TimeSlice, drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn criterion_benchmark(criterion: &mut Criterion) {
|
fn criterion_benchmark(criterion: &mut Criterion) {
|
||||||
let mut index_map = KeyTable::new();
|
let build = || {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 1,
|
||||||
|
epsilon: 0.0,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
let a = index_map.get_or_create("a");
|
// 100 events, all at one time, so the history has a single slice.
|
||||||
let b = index_map.get_or_create("b");
|
let events: Vec<Event<i64, &'static str>> = (0..100)
|
||||||
let c = index_map.get_or_create("c");
|
.map(|_| Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
h.add_events(events).expect("fixture ingests");
|
||||||
|
h
|
||||||
|
};
|
||||||
|
|
||||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
criterion.bench_function("slice_sweep_100_events", |b| {
|
||||||
|
b.iter_batched(
|
||||||
for agent in [a, b, c] {
|
build,
|
||||||
agents.insert(
|
|mut h| {
|
||||||
agent,
|
// `converge_partial`, not `converge`: one iteration is
|
||||||
Competitor {
|
// deliberately short of convergence and `converge` reports that
|
||||||
rating: Rating::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)),
|
// as an error.
|
||||||
..Default::default()
|
let _ = h.converge_partial();
|
||||||
},
|
},
|
||||||
|
criterion::BatchSize::SmallInput,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
let mut composition = Vec::new();
|
|
||||||
let mut results = Vec::new();
|
|
||||||
let mut weights = Vec::new();
|
|
||||||
|
|
||||||
for _ in 0..100 {
|
|
||||||
composition.push(vec![vec![a], vec![b]]);
|
|
||||||
results.push(vec![1.0, 0.0]);
|
|
||||||
weights.push(vec![vec![1.0], vec![1.0]]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let kinds = vec![EventKind::Ranked; composition.len()];
|
|
||||||
|
|
||||||
let mut time_slice = TimeSlice::new(1, P_DRAW, ConvergenceOptions::default());
|
|
||||||
time_slice.add_events(composition, Some(results), Some(weights), kinds, &agents);
|
|
||||||
|
|
||||||
criterion.bench_function("Batch::iteration", |b| {
|
|
||||||
b.iter(|| time_slice.iteration(0, &agents))
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
use criterion::{Criterion, criterion_group, criterion_main};
|
|
||||||
use trueskill_tt::gaussian::Gaussian;
|
|
||||||
|
|
||||||
fn benchmark_gaussian_arithmetic(criterion: &mut Criterion) {
|
|
||||||
// Define test Gaussians
|
|
||||||
let g1 = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
|
||||||
let g2 = Gaussian::from_ms(0.0, 1.0);
|
|
||||||
let g3 = Gaussian::from_ms(1.0, 1.0);
|
|
||||||
|
|
||||||
// Benchmark addition
|
|
||||||
criterion.bench_function("Gaussian::add", |bencher| {
|
|
||||||
bencher.iter(|| g1 + g2);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Benchmark subtraction
|
|
||||||
criterion.bench_function("Gaussian::sub", |bencher| {
|
|
||||||
bencher.iter(|| g1 - g3);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Benchmark multiplication
|
|
||||||
criterion.bench_function("Gaussian::mul", |bencher| {
|
|
||||||
bencher.iter(|| g1 * g2);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Benchmark division
|
|
||||||
// NOTE: numerator must have higher precision (smaller sigma) than the
|
|
||||||
// denominator in this representation; g2 (sigma=1) / g1 (sigma=8.33) is
|
|
||||||
// well-defined, whereas g1 / g2 underflows and panics in mu_sigma.
|
|
||||||
criterion.bench_function("Gaussian::div", |bencher| {
|
|
||||||
bencher.iter(|| g2 / g1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Benchmark natural parameter conversions
|
|
||||||
criterion.bench_function("Gaussian::pi", |bencher| {
|
|
||||||
bencher.iter(|| g1.pi());
|
|
||||||
});
|
|
||||||
|
|
||||||
criterion.bench_function("Gaussian::tau", |bencher| {
|
|
||||||
bencher.iter(|| g1.tau());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Benchmark combined pi/tau operations (used in mul/div)
|
|
||||||
criterion.bench_function("Gaussian::pi_tau_combined", |bencher| {
|
|
||||||
bencher.iter(|| {
|
|
||||||
let pi = g1.pi();
|
|
||||||
let tau = g1.tau();
|
|
||||||
(pi, tau)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
criterion_group!(benches, benchmark_gaussian_arithmetic);
|
|
||||||
criterion_main!(benches);
|
|
||||||
@@ -25,16 +25,14 @@
|
|||||||
|
|
||||||
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
|
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
|
||||||
use smallvec::smallvec;
|
use smallvec::smallvec;
|
||||||
use trueskill_tt::{
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn build_history_1v1(
|
fn build_history_1v1(
|
||||||
n_events: usize,
|
n_events: usize,
|
||||||
n_competitors: usize,
|
n_competitors: usize,
|
||||||
events_per_slice: usize,
|
events_per_slice: usize,
|
||||||
seed: u64,
|
seed: u64,
|
||||||
) -> History<i64, ConstantDrift, NullObserver, String> {
|
) -> History<String> {
|
||||||
let mut rng = seed;
|
let mut rng = seed;
|
||||||
let mut next = || {
|
let mut next = || {
|
||||||
rng = rng
|
rng = rng
|
||||||
@@ -43,11 +41,12 @@ fn build_history_1v1(
|
|||||||
rng
|
rng
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut h = History::<i64, _, _, String>::builder_with_key()
|
let mut h = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 30,
|
max_iter: 30,
|
||||||
epsilon: 1e-6,
|
epsilon: 1e-6,
|
||||||
|
|||||||
+2
-2
@@ -32,7 +32,7 @@ fn bench_ingest(c: &mut Criterion) {
|
|||||||
b.iter_batched(
|
b.iter_batched(
|
||||||
|| events(n, 0),
|
|| events(n, 0),
|
||||||
|evs| {
|
|evs| {
|
||||||
let mut h: History<i64, _, _, String> = History::builder_with_key().build();
|
let mut h: History<String> = History::builder().key_type::<String>().build();
|
||||||
for ev in evs {
|
for ev in evs {
|
||||||
h.add_events(std::iter::once(ev)).unwrap();
|
h.add_events(std::iter::once(ev)).unwrap();
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ fn bench_ingest(c: &mut Criterion) {
|
|||||||
b.iter_batched(
|
b.iter_batched(
|
||||||
|| events(n, 0),
|
|| events(n, 0),
|
||||||
|evs| {
|
|evs| {
|
||||||
let mut h: History<i64, _, _, String> = History::builder_with_key().build();
|
let mut h: History<String> = History::builder().key_type::<String>().build();
|
||||||
h.add_events(evs).unwrap();
|
h.add_events(evs).unwrap();
|
||||||
black_box(h.time_slices_len())
|
black_box(h.time_slices_len())
|
||||||
},
|
},
|
||||||
|
|||||||
+15
-5
@@ -10,15 +10,22 @@ use smallvec::smallvec;
|
|||||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
/// 30 slices of 8 duels: 480 appearances over 100 competitors.
|
/// 30 slices of 8 duels: 480 appearances over 100 competitors.
|
||||||
fn fitted() -> History<i64, ConstantDrift, trueskill_tt::NullObserver, String> {
|
fn fitted() -> History<String> {
|
||||||
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.mu(0.0)
|
.mu(0.0)
|
||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift(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,
|
||||||
})
|
})
|
||||||
@@ -57,8 +64,11 @@ fn bench_joint(c: &mut Criterion) {
|
|||||||
bencher.iter(|| std::hint::black_box(h.joint().unwrap().variables()));
|
bencher.iter(|| std::hint::black_box(h.joint().unwrap().variables()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Factorise-and-query, the cost the deleted `History::posterior_of`
|
||||||
|
// wrapper paid on every call. Kept as the baseline the cached query below
|
||||||
|
// is measured against.
|
||||||
c.bench_function("posterior_of_one_shot_480_appearances", |bencher| {
|
c.bench_function("posterior_of_one_shot_480_appearances", |bencher| {
|
||||||
bencher.iter(|| std::hint::black_box(h.posterior_of(&terms).unwrap()));
|
bencher.iter(|| std::hint::black_box(h.joint().unwrap().posterior_of(&terms).unwrap()));
|
||||||
});
|
});
|
||||||
|
|
||||||
let joint = h.joint().unwrap();
|
let joint = h.joint().unwrap();
|
||||||
|
|||||||
+3
-2
@@ -5,11 +5,12 @@ use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
|||||||
fn bench_scored_history(c: &mut Criterion) {
|
fn bench_scored_history(c: &mut Criterion) {
|
||||||
c.bench_function("scored_history_60_events_30_iter", |bencher| {
|
c.bench_function("scored_history_60_events_30_iter", |bencher| {
|
||||||
bencher.iter(|| {
|
bencher.iter(|| {
|
||||||
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(0.03))
|
.drift(ConstantDrift::new(0.03))
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
@@ -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)" },
|
||||||
]
|
]
|
||||||
|
|||||||
+8
-6
@@ -1,7 +1,8 @@
|
|||||||
use plotters::prelude::*;
|
use plotters::prelude::*;
|
||||||
use smallvec::smallvec;
|
|
||||||
use time::{Date, Month};
|
use time::{Date, Month};
|
||||||
use trueskill_tt::{Event, History, Member, Outcome, Team, drift::ConstantDrift};
|
use trueskill_tt::{
|
||||||
|
Event, History, Member, Outcome, Team, drift::ConstantDrift, smallvec::smallvec,
|
||||||
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
|
let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
|
||||||
@@ -42,9 +43,10 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut hist: History<i64, _, _, String> = History::builder_with_key()
|
let mut hist: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.sigma(1.6)
|
.sigma(1.6)
|
||||||
.drift(ConstantDrift(0.036))
|
.drift(ConstantDrift::new(0.036))
|
||||||
.convergence(trueskill_tt::ConvergenceOptions {
|
.convergence(trueskill_tt::ConvergenceOptions {
|
||||||
// This history needs 30 sweeps to reach the epsilon below. It was
|
// This history needs 30 sweeps to reach the epsilon below. It was
|
||||||
// capped at 10 until the `#[must_use]` on `ConvergenceReport`
|
// capped at 10 until the `#[must_use]` on `ConvergenceReport`
|
||||||
@@ -96,7 +98,7 @@ fn main() {
|
|||||||
let mut y_spec = (f64::MAX, f64::MIN);
|
let mut y_spec = (f64::MAX, f64::MIN);
|
||||||
|
|
||||||
for &(_, id, cutoff) in &players {
|
for &(_, id, cutoff) in &players {
|
||||||
for (ts, gs) in hist.learning_curve(id) {
|
for (ts, gs) in hist.learning_curve(id).unwrap() {
|
||||||
if ts >= cutoff {
|
if ts >= cutoff {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -142,7 +144,7 @@ fn main() {
|
|||||||
let mut upper = Vec::new();
|
let mut upper = Vec::new();
|
||||||
let mut lower = Vec::new();
|
let mut lower = Vec::new();
|
||||||
|
|
||||||
for (ts, gs) in hist.learning_curve(id) {
|
for (ts, gs) in hist.learning_curve(id).unwrap() {
|
||||||
if ts >= cutoff {
|
if ts >= cutoff {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -6,15 +6,14 @@
|
|||||||
//!
|
//!
|
||||||
//! Run with: `cargo run --example scored --release`
|
//! Run with: `cargo run --example scored --release`
|
||||||
|
|
||||||
use smallvec::smallvec;
|
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team, smallvec::smallvec};
|
||||||
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut h = History::builder()
|
let mut h = History::builder()
|
||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(0.03))
|
.drift(ConstantDrift::new(0.03))
|
||||||
.score_sigma(2.0) // tune to data; smaller = trust margins more
|
.score_sigma(2.0) // tune to data; smaller = trust margins more
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
+50
-7
@@ -47,7 +47,38 @@ fn kl_divergence(q: Gaussian, p: Gaussian) -> f64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mean_gap = q.mu() - p.mu();
|
let mean_gap = q.mu() - p.mu();
|
||||||
0.5 * (libm::log(var_p / var_q) + (var_q + mean_gap * mean_gap) / var_p - 1.0)
|
|
||||||
|
// Algebraically `0.5 * (ln(var_p/var_q) + (var_q + gap^2)/var_p - 1)`, but
|
||||||
|
// written so that neither term can go negative.
|
||||||
|
//
|
||||||
|
// The direct form cancels against its `- 1.0` for two near-identical
|
||||||
|
// distributions and returns a *negative* divergence — measured, 762 082 of
|
||||||
|
// 3 000 000 near-identical pairs, worst `-5.55e-17`, which is exactly one
|
||||||
|
// ULP of the 1.0. It also loses the answer entirely where it is small:
|
||||||
|
// at `var_q/var_p - 1 = 1e-9` the direct form gives `0.0` where the true
|
||||||
|
// value is `2.5e-19`.
|
||||||
|
//
|
||||||
|
// With `u = var_q/var_p - 1` the variance part is `0.5 * (u - ln(1+u))`,
|
||||||
|
// which is non-negative for every `u > -1`, and the mean part is a square
|
||||||
|
// over a positive variance. Non-negativity is then structural rather than
|
||||||
|
// incidental.
|
||||||
|
let u = var_q / var_p - 1.0;
|
||||||
|
0.5 * u_minus_ln1p(u) + mean_gap * mean_gap / (2.0 * var_p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `u - ln(1 + u)`, without the cancellation that spelling invites.
|
||||||
|
///
|
||||||
|
/// Both terms are approximately `u` for small `u`, so the subtraction loses
|
||||||
|
/// everything just where the result matters. The Taylor series
|
||||||
|
/// `u^2/2 - u^3/3 + u^4/4 - ...` is exact in that regime and manifestly
|
||||||
|
/// non-negative, since `u^2/2` dominates.
|
||||||
|
fn u_minus_ln1p(u: f64) -> f64 {
|
||||||
|
if u.abs() < 1e-4 {
|
||||||
|
let u2 = u * u;
|
||||||
|
u2 * (0.5 - u / 3.0 + u2 / 4.0)
|
||||||
|
} else {
|
||||||
|
u - libm::log1p(u)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expected information gain of a hypothetical matchup, in nats.
|
/// Expected information gain of a hypothetical matchup, in nats.
|
||||||
@@ -92,7 +123,11 @@ fn kl_divergence(q: Gaussian, p: Gaussian) -> 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
|
||||||
|
/// integrate on one grid. This comes from `outcome_distribution`, which runs
|
||||||
|
/// before any inference — so it is not covered by "anything `Game::ranked`
|
||||||
|
/// returns" below.
|
||||||
/// - Anything [`Game::ranked`](crate::Game::ranked) returns for a hypothetical
|
/// - Anything [`Game::ranked`](crate::Game::ranked) returns for a hypothetical
|
||||||
/// outcome.
|
/// outcome.
|
||||||
pub fn expected_information_gain<T: Time, D: Drift<T>>(
|
pub fn expected_information_gain<T: Time, D: Drift<T>>(
|
||||||
@@ -109,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -124,7 +160,7 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|team| {
|
.map(|team| {
|
||||||
team.iter()
|
team.iter()
|
||||||
.fold(crate::N00, |acc, rating| acc + rating.performance())
|
.fold(crate::N00, |acc, rating| acc.convolve(rating.performance()))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -146,7 +182,7 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
|
|||||||
|
|
||||||
let mut gain = 0.0;
|
let mut gain = 0.0;
|
||||||
|
|
||||||
for (ranks, probability) in predict::outcome_distribution(&performances, &margins) {
|
for (ranks, probability) in predict::outcome_distribution(&performances, &margins)? {
|
||||||
if probability <= NEGLIGIBLE {
|
if probability <= NEGLIGIBLE {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -177,7 +213,11 @@ mod tests {
|
|||||||
type R = Rating<i64, ConstantDrift>;
|
type R = Rating<i64, ConstantDrift>;
|
||||||
|
|
||||||
fn rating(mu: f64, sigma: f64) -> R {
|
fn rating(mu: f64, sigma: f64) -> R {
|
||||||
R::new(Gaussian::from_ms(mu, sigma), BETA, ConstantDrift(GAMMA))
|
R::new(
|
||||||
|
Gaussian::from_ms(mu, sigma),
|
||||||
|
BETA,
|
||||||
|
ConstantDrift::new(GAMMA),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn options(p_draw: f64) -> GameOptions {
|
fn options(p_draw: f64) -> GameOptions {
|
||||||
@@ -328,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,
|
||||||
|
..
|
||||||
|
})
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+66
-8
@@ -4,9 +4,38 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
/// The stopping rule for the fixed-point loops, plus how hard they are damped.
|
||||||
|
///
|
||||||
|
/// Set once per history through
|
||||||
|
/// [`HistoryBuilder::convergence`](crate::HistoryBuilder::convergence), and
|
||||||
|
/// carried by `GameOptions` for a single match scored without a history. The
|
||||||
|
/// defaults are the crate's globals: [`ITERATIONS`](crate::ITERATIONS),
|
||||||
|
/// [`EPSILON`](crate::EPSILON), and undamped EP.
|
||||||
|
///
|
||||||
|
/// Deliberately **not** `#[non_exhaustive]`, unlike [`ConvergenceReport`]. The
|
||||||
|
/// usual argument for marking an options struct is that `..Default::default()`
|
||||||
|
/// makes a future field additive — but `Default::default` is not a `const fn`,
|
||||||
|
/// so marking it would make
|
||||||
|
/// `const OPTS: ConvergenceOptions = ConvergenceOptions { .. }` impossible from
|
||||||
|
/// outside the crate, with no workaround. This type is `Copy` and a natural
|
||||||
|
/// const; that cost is permanent, and adding a field is a one-time major bump.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct ConvergenceOptions {
|
pub struct ConvergenceOptions {
|
||||||
|
/// Hard cap on full forward+backward sweeps.
|
||||||
|
///
|
||||||
|
/// A runaway guard, not a budget: the loop exits as soon as the step falls
|
||||||
|
/// to `epsilon`, so raising this costs nothing on a history that converges.
|
||||||
|
/// Reaching it is
|
||||||
|
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged).
|
||||||
pub max_iter: usize,
|
pub max_iter: usize,
|
||||||
|
/// Convergence threshold, in skill units.
|
||||||
|
///
|
||||||
|
/// The sweep stops once *both* components of the step — the largest change
|
||||||
|
/// a whole iteration made to any competitor's posterior mean, and to any
|
||||||
|
/// posterior standard deviation — are at or below this. Larger values stop
|
||||||
|
/// sooner and further from the fixed point. Must be non-negative; NaN is
|
||||||
|
/// rejected, since every comparison against it is false and the loop would
|
||||||
|
/// read it as converged.
|
||||||
pub epsilon: f64,
|
pub epsilon: f64,
|
||||||
/// EP damping factor in natural-parameter space: each per-factor
|
/// EP damping factor in natural-parameter space: each per-factor
|
||||||
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
|
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
|
||||||
@@ -37,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -68,16 +97,45 @@ impl Default for ConvergenceOptions {
|
|||||||
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged) there.
|
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged) there.
|
||||||
/// From [`History::converge_partial`](crate::History::converge_partial) it may
|
/// From [`History::converge_partial`](crate::History::converge_partial) it may
|
||||||
/// not be, and `converged` is what says so.
|
/// not be, and `converged` is what says so.
|
||||||
#[derive(Clone, Debug)]
|
/// Constructed only by `converge` / `converge_partial`, never by a caller, so
|
||||||
#[must_use = "from `converge_partial` this may describe a fit that stopped at \
|
/// `#[non_exhaustive]` costs nothing here and lets a future field be additive.
|
||||||
`max_iter`, which is wrong by a little rather than loudly \
|
/// The two *options* structs deliberately do not carry it — see the note on
|
||||||
broken — check `converged`, or bind it to `_` to say you have \
|
/// [`ConvergenceOptions`].
|
||||||
decided not to"]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct ConvergenceReport {
|
pub struct ConvergenceReport {
|
||||||
|
/// Full forward+backward sweeps actually run. `0` for a history with no
|
||||||
|
/// time slices, which is converged trivially.
|
||||||
pub iterations: usize,
|
pub iterations: usize,
|
||||||
|
/// How far the last sweep still moved the fit, as `(mean, standard
|
||||||
|
/// deviation)`.
|
||||||
|
///
|
||||||
|
/// Not natural parameters: each component is a componentwise maximum of
|
||||||
|
/// `|Δmu|` and `|Δsigma|` over every competitor posterior the sweep
|
||||||
|
/// touched, so both are in skill units and both are non-negative. Each is
|
||||||
|
/// compared against `epsilon` separately — `converged` means neither
|
||||||
|
/// exceeds it. `(0.0, 0.0)` for a history with no time slices.
|
||||||
pub final_step: (f64, f64),
|
pub final_step: (f64, f64),
|
||||||
|
/// Natural log of the model evidence for the whole history at this fit,
|
||||||
|
/// summed over every time slice.
|
||||||
|
///
|
||||||
|
/// The same quantity
|
||||||
|
/// [`History::log_evidence`](crate::History::log_evidence) returns, taken
|
||||||
|
/// once the sweep has stopped. Only comparable between fits of the same
|
||||||
|
/// events; higher means the model explains them better.
|
||||||
pub log_evidence: f64,
|
pub log_evidence: f64,
|
||||||
|
/// Whether the sweep reached `epsilon` rather than stopping at `max_iter`.
|
||||||
|
///
|
||||||
|
/// Always `true` from [`History::converge`](crate::History::converge),
|
||||||
|
/// which reports the other case as `NotConverged`. From
|
||||||
|
/// [`History::converge_partial`](crate::History::converge_partial) this is
|
||||||
|
/// the only thing that distinguishes a finished fit from a capped one.
|
||||||
pub converged: bool,
|
pub converged: bool,
|
||||||
|
/// Wall-clock time each sweep took, in the order they ran.
|
||||||
|
///
|
||||||
|
/// One entry per iteration, so its length equals `iterations`; empty for a
|
||||||
|
/// history with no time slices. It times the sweeps only, so the final
|
||||||
|
/// log-evidence pass is not in any entry.
|
||||||
pub per_iteration_time: SmallVec<[Duration; 32]>,
|
pub per_iteration_time: SmallVec<[Duration; 32]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
-2
@@ -21,8 +21,58 @@ pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
|
|||||||
///
|
///
|
||||||
/// For `Time = i64`: variance added is `(to - from) * gamma^2`.
|
/// For `Time = i64`: variance added is `(to - from) * gamma^2`.
|
||||||
/// For `Time = Untimed`: elapsed is always 0, so drift is always 0.
|
/// For `Time = Untimed`: elapsed is always 0, so drift is always 0.
|
||||||
#[derive(Clone, Copy, Debug)]
|
///
|
||||||
pub struct ConstantDrift(pub f64);
|
/// # Why the field is private
|
||||||
|
///
|
||||||
|
/// `gamma` enters only as `gamma * gamma`, so a negative value is squared away:
|
||||||
|
/// measured against the old public-field form, `ConstantDrift(-0.0833)` produced
|
||||||
|
/// results **bit identical** to `ConstantDrift(0.0833)`. The sign was neither
|
||||||
|
/// rejected nor honoured — it vanished. That is the same sign-absorption `HistoryBuilder::sigma`,
|
||||||
|
/// `HistoryBuilder::beta`, `Gaussian::from_ms` and `Rating::new` all reject.
|
||||||
|
///
|
||||||
|
/// It could not be checked while the field was a public tuple position, because
|
||||||
|
/// there was no constructor to intercept. Validating inside
|
||||||
|
/// `variance_for_elapsed` would have been worse: it runs inside the sweep, so a
|
||||||
|
/// construction-time mistake would panic mid-inference — and `Gaussian::from_ms`
|
||||||
|
/// is a worked example of why that is the wrong place for a guard, where
|
||||||
|
/// rejecting NaN turned the `NonFiniteStep` reporting path into a crash.
|
||||||
|
///
|
||||||
|
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
|
||||||
|
/// back with [`ConstantDrift::gamma`].
|
||||||
|
///
|
||||||
|
/// A non-finite gamma is caught a second time regardless:
|
||||||
|
/// `History::converge` validates the drift variance each competitor actually
|
||||||
|
/// accumulates, which also covers a custom [`Drift`] implementation.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct ConstantDrift(f64);
|
||||||
|
|
||||||
|
impl ConstantDrift {
|
||||||
|
/// Drift of `gamma` standard deviations per unit time.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics unless `gamma` is finite and non-negative.
|
||||||
|
///
|
||||||
|
/// The field is private and this is the only constructor precisely so that
|
||||||
|
/// there is somewhere to check. While it was a public tuple field there was
|
||||||
|
/// nothing to intercept, and a negative gamma was silently squared away —
|
||||||
|
/// see the type docs.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(gamma: f64) -> Self {
|
||||||
|
assert!(
|
||||||
|
gamma.is_finite() && gamma >= 0.0,
|
||||||
|
"gamma must be finite and non-negative (got {gamma}); it is only ever \
|
||||||
|
squared, so a negative value would silently behave as its absolute value"
|
||||||
|
);
|
||||||
|
Self(gamma)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard deviations of drift accumulated per unit time.
|
||||||
|
#[must_use]
|
||||||
|
pub fn gamma(&self) -> f64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<T: Time> Drift<T> for ConstantDrift {
|
impl<T: Time> Drift<T> for ConstantDrift {
|
||||||
fn variance_delta(&self, from: &T, to: &T) -> f64 {
|
fn variance_delta(&self, from: &T, to: &T) -> f64 {
|
||||||
|
|||||||
+472
-38
@@ -39,36 +39,241 @@ 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.
|
||||||
|
///
|
||||||
|
/// The crate reports rather than repairs. An input it cannot represent, a fit
|
||||||
|
/// that never reached its fixed point, a quadrature it cannot resolve — each
|
||||||
|
/// comes back here instead of as a clamped, skipped or truncated result that
|
||||||
|
/// would still look like a number. Several variants exist precisely because the
|
||||||
|
/// silent version was measured and found to return a plausible wrong answer.
|
||||||
|
///
|
||||||
|
/// The enum and most of its variants are `#[non_exhaustive]`: new cases and new
|
||||||
|
/// fields are additive, so match with a `_` arm and construct through the
|
||||||
|
/// library rather than by literal.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub enum InferenceError {
|
pub enum InferenceError {
|
||||||
/// Expected and actual lengths of some array-shaped input differ.
|
/// Expected and actual lengths of some array-shaped input differ.
|
||||||
|
#[non_exhaustive]
|
||||||
MismatchedShape {
|
MismatchedShape {
|
||||||
kind: &'static str,
|
/// Which pair of lengths disagreed.
|
||||||
|
shape: Shape,
|
||||||
|
/// The length it had to have, taken from whatever it must line up with
|
||||||
|
/// (usually the event's team count).
|
||||||
expected: usize,
|
expected: usize,
|
||||||
|
/// The length actually supplied.
|
||||||
got: usize,
|
got: usize,
|
||||||
},
|
},
|
||||||
/// 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]
|
||||||
WrongOutcomeKind {
|
WrongOutcomeKind {
|
||||||
context: &'static str,
|
/// The variant the call needs.
|
||||||
expected: &'static str,
|
expected: OutcomeKind,
|
||||||
got: &'static str,
|
/// The variant actually supplied.
|
||||||
|
got: OutcomeKind,
|
||||||
},
|
},
|
||||||
/// A probability value is outside `[0, 1]`.
|
|
||||||
InvalidProbability { value: f64 },
|
|
||||||
/// A scalar parameter is outside its valid range.
|
/// A scalar parameter is outside its valid range.
|
||||||
InvalidParameter { name: &'static str, value: f64 },
|
#[non_exhaustive]
|
||||||
|
InvalidParameter {
|
||||||
|
/// Which parameter. `Display` states its valid range.
|
||||||
|
parameter: Parameter,
|
||||||
|
/// The value supplied for it: outside that range, or NaN, which fails
|
||||||
|
/// every range comparison and is rejected on that basis.
|
||||||
|
value: f64,
|
||||||
|
},
|
||||||
/// An event contains tied teams, but the draw probability is zero.
|
/// An event contains tied teams, but the draw probability is zero.
|
||||||
///
|
///
|
||||||
/// A zero draw probability asserts that draws cannot occur, so a tied
|
/// A zero draw probability asserts that draws cannot occur, so a tied
|
||||||
/// result has no representable likelihood. Configure a positive `p_draw`
|
/// result has no representable likelihood. Configure a positive `p_draw`
|
||||||
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
|
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
|
||||||
TieWithoutDrawProbability { teams: (usize, usize) },
|
#[non_exhaustive]
|
||||||
|
TieWithoutDrawProbability {
|
||||||
|
/// Positions in the event's team list of the first tied pair, lowest
|
||||||
|
/// index first. Only one pair is reported — the event is rejected
|
||||||
|
/// whole, so enumerating the rest would add nothing.
|
||||||
|
teams: (usize, usize),
|
||||||
|
},
|
||||||
/// The convergence sweep hit `max_iter` with the step still above
|
/// The convergence sweep hit `max_iter` with the step still above
|
||||||
/// `epsilon`.
|
/// `epsilon`.
|
||||||
///
|
///
|
||||||
/// A fit that stops short is wrong by a little, which is the worst
|
/// A fit that stops short is wrong by a little, which is the worst
|
||||||
/// available failure: every rating is finite, the ordering looks sensible,
|
/// available failure: every posterior is finite, the ordering looks sensible,
|
||||||
/// and nothing in the numbers says they were still moving. Reported rather
|
/// and nothing in the numbers says they were still moving. Reported rather
|
||||||
/// than returned as a flag on an `Ok`, because a flag has to be checked
|
/// than returned as a flag on an `Ok`, because a flag has to be checked
|
||||||
/// and `let _ = h.converge()` is the natural way not to.
|
/// and `let _ = h.converge()` is the natural way not to.
|
||||||
@@ -77,19 +282,57 @@ pub enum InferenceError {
|
|||||||
/// oscillating rather than converging, in which case `alpha < 1.0` damps
|
/// oscillating rather than converging, in which case `alpha < 1.0` damps
|
||||||
/// the within-game EP loop. [`History::converge_partial`](crate::History::converge_partial)
|
/// the within-game EP loop. [`History::converge_partial`](crate::History::converge_partial)
|
||||||
/// returns the short fit instead when that is genuinely what is wanted.
|
/// returns the short fit instead when that is genuinely what is wanted.
|
||||||
|
#[non_exhaustive]
|
||||||
NotConverged {
|
NotConverged {
|
||||||
|
/// Full forward+backward sweeps run before the loop gave up.
|
||||||
iterations: usize,
|
iterations: usize,
|
||||||
|
/// How far the last sweep still moved the fit, as
|
||||||
|
/// `(largest change in a mean, largest change in a standard
|
||||||
|
/// deviation)` over every competitor posterior it touched — the same
|
||||||
|
/// quantity as
|
||||||
|
/// [`ConvergenceReport::final_step`](crate::ConvergenceReport).
|
||||||
final_step: (f64, f64),
|
final_step: (f64, f64),
|
||||||
|
/// 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,
|
||||||
NonFiniteResult {
|
/// so the loop stops rather than reporting a NaN step as convergence.
|
||||||
|
#[non_exhaustive]
|
||||||
|
NonFiniteStep {
|
||||||
|
/// Where the breakdown was caught, e.g. `"History::converge"`.
|
||||||
context: &'static str,
|
context: &'static str,
|
||||||
|
/// The offending step as `(|d mu|, |d sigma|)`, at least one component
|
||||||
|
/// of which is NaN or infinite.
|
||||||
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.
|
||||||
///
|
///
|
||||||
@@ -99,9 +342,14 @@ pub enum InferenceError {
|
|||||||
/// "last one wins" would make the result depend on iteration order.
|
/// "last one wins" would make the result depend on iteration order.
|
||||||
/// Declaring the same value repeatedly is fine and is the expected shape
|
/// Declaring the same value repeatedly is fine and is the expected shape
|
||||||
/// when a competitor's configuration is a property of the domain.
|
/// when a competitor's configuration is a property of the domain.
|
||||||
|
#[non_exhaustive]
|
||||||
ConflictingCompetitorConfig {
|
ConflictingCompetitorConfig {
|
||||||
|
/// The competitor's interned slot as a raw `usize`,
|
||||||
|
/// not the user key — the batch is already flattened to indices by the
|
||||||
|
/// time the conflict is detectable.
|
||||||
competitor: usize,
|
competitor: usize,
|
||||||
field: &'static str,
|
/// Which piece of configuration was declared twice.
|
||||||
|
field: CompetitorField,
|
||||||
},
|
},
|
||||||
/// A prediction referenced a key the history has no skill for.
|
/// A prediction referenced a key the history has no skill for.
|
||||||
///
|
///
|
||||||
@@ -113,9 +361,18 @@ pub enum InferenceError {
|
|||||||
/// `UnknownKey { team: 0, member: 0 }` learns nothing about *which* of its
|
/// `UnknownKey { team: 0, member: 0 }` learns nothing about *which* of its
|
||||||
/// keys the history has not seen, and the natural handling — fall back to a
|
/// keys the history has not seen, and the natural handling — fall back to a
|
||||||
/// neutral value — turns the whole thing into a plausible constant.
|
/// neutral value — turns the whole thing into a plausible constant.
|
||||||
|
#[non_exhaustive]
|
||||||
UnknownKey {
|
UnknownKey {
|
||||||
|
/// Position of the offending team in the supplied matchup. `0` on the
|
||||||
|
/// queries that take a flat list of keys rather than teams, where
|
||||||
|
/// there is only one list to index into.
|
||||||
team: usize,
|
team: usize,
|
||||||
|
/// Position of the offending key within that team, or within the flat
|
||||||
|
/// key list.
|
||||||
member: usize,
|
member: usize,
|
||||||
|
/// The key's `Debug` rendering, captured because `K` is only required
|
||||||
|
/// to be `Debug` — see the variant docs for why the indices alone are
|
||||||
|
/// not enough.
|
||||||
key: String,
|
key: String,
|
||||||
},
|
},
|
||||||
/// `History::register` was called for a competitor that already exists.
|
/// `History::register` was called for a competitor that already exists.
|
||||||
@@ -128,13 +385,73 @@ pub enum InferenceError {
|
|||||||
///
|
///
|
||||||
/// To change an existing competitor's configuration, supply it on an event
|
/// To change an existing competitor's configuration, supply it on an event
|
||||||
/// through `Member`; that refits the whole history.
|
/// through `Member`; that refits the whole history.
|
||||||
AlreadyRegistered { key: String },
|
#[non_exhaustive]
|
||||||
|
AlreadyRegistered {
|
||||||
|
/// The already-known competitor's key, in its `Debug` rendering.
|
||||||
|
key: String,
|
||||||
|
},
|
||||||
/// A prediction was given a team with no members.
|
/// A prediction was given a team with no members.
|
||||||
EmptyTeam { team: usize },
|
#[non_exhaustive]
|
||||||
/// A joint posterior was requested where one cannot be formed exactly.
|
EmptyTeam {
|
||||||
JointUnavailable { reason: &'static str },
|
/// Position of the memberless team in the supplied list.
|
||||||
|
team: usize,
|
||||||
|
},
|
||||||
|
/// The prediction grid cannot resolve the narrowest feature in the matchup.
|
||||||
|
///
|
||||||
|
/// `predict_outcome` and `predict_ranking` integrate every team's density
|
||||||
|
/// on one shared grid, whose resolution is set by the narrowest sigma (or a
|
||||||
|
/// narrower draw margin). When the widest and narrowest are far enough
|
||||||
|
/// apart, resolving the narrow one across the wide one's support needs more
|
||||||
|
/// nodes than the grid is allowed to hold.
|
||||||
|
///
|
||||||
|
/// Reported rather than clamped. Clamping is what this replaced, and it
|
||||||
|
/// returned probabilities greater than one — measured, a `P` of 2.79 and a
|
||||||
|
/// `Prediction::total()` of 5.41 — because the trapezoid rule stops
|
||||||
|
/// resolving a density once the step exceeds roughly 1.7 of its sigma.
|
||||||
|
///
|
||||||
|
/// `predict_win_probabilities` answers the same matchup through adaptive
|
||||||
|
/// quadrature and is accurate here; use it when only the per-team win
|
||||||
|
/// probabilities are needed.
|
||||||
|
#[non_exhaustive]
|
||||||
|
GridTooCoarse {
|
||||||
|
/// Nodes required to resolve the narrowest feature.
|
||||||
|
needed: usize,
|
||||||
|
/// Nodes the grid may hold.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// A joint posterior was requested from a history with no events.
|
||||||
|
///
|
||||||
|
/// Split out of a single `JointUnavailable { reason: &str }` (#74): the
|
||||||
|
/// three reasons are conditions a caller branches on differently, and
|
||||||
|
/// distinguishing them used to mean matching on English prose. This one
|
||||||
|
/// means "add events".
|
||||||
|
EmptyHistory,
|
||||||
|
/// A joint posterior was requested from a history containing ranked
|
||||||
|
/// events.
|
||||||
|
///
|
||||||
|
/// Exact only for an all-scored history: a scored likelihood is Gaussian
|
||||||
|
/// and its factor can be rebuilt exactly, while a ranked outcome's
|
||||||
|
/// truncation is approximated by EP and reconstructing those factors needs
|
||||||
|
/// the converged messages, which inference does not retain.
|
||||||
|
///
|
||||||
|
/// [`History::predict_win_probabilities`](crate::History::predict_win_probabilities)
|
||||||
|
/// answers the comparable question on a ranked history.
|
||||||
|
JointRequiresScoredEvents,
|
||||||
|
/// The assembled precision matrix is not positive-definite.
|
||||||
|
///
|
||||||
|
/// The usual cause is a competitor with neither a proper prior nor any
|
||||||
|
/// evidence, but an extreme prior or drift can also make the assembled
|
||||||
|
/// matrix indefinite in floating point. Unlike its two siblings this one
|
||||||
|
/// is numerical rather than structural — the same history may factorise
|
||||||
|
/// under different parameters.
|
||||||
|
NotPositiveDefinite,
|
||||||
/// Fewer than two teams were supplied to a prediction.
|
/// Fewer than two teams were supplied to a prediction.
|
||||||
NotEnoughTeams { got: usize },
|
#[non_exhaustive]
|
||||||
|
NotEnoughTeams {
|
||||||
|
/// How many teams the prediction was actually given. Two is the
|
||||||
|
/// minimum: there is nothing to compare against with fewer.
|
||||||
|
got: usize,
|
||||||
|
},
|
||||||
/// The full outcome distribution was requested for too many teams.
|
/// The full outcome distribution was requested for too many teams.
|
||||||
///
|
///
|
||||||
/// Each realisation sorts into exactly one (order, tie-pattern) event, so
|
/// Each realisation sorts into exactly one (order, tie-pattern) event, so
|
||||||
@@ -143,28 +460,33 @@ pub enum InferenceError {
|
|||||||
/// enumerate on a caller's behalf; ask for individual rankings with
|
/// enumerate on a caller's behalf; ask for individual rankings with
|
||||||
/// `predict_ranking`, or for `predict_win_probabilities`, both of which
|
/// `predict_ranking`, or for `predict_win_probabilities`, both of which
|
||||||
/// stay cheap at any team count.
|
/// stay cheap at any team count.
|
||||||
TooManyTeams { got: usize, max: usize },
|
#[non_exhaustive]
|
||||||
|
TooManyTeams {
|
||||||
|
/// How many teams the outcome distribution was asked for.
|
||||||
|
got: usize,
|
||||||
|
/// The largest team count that will be enumerated,
|
||||||
|
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for InferenceError {
|
impl fmt::Display for InferenceError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::MismatchedShape {
|
Self::MismatchedShape {
|
||||||
kind,
|
shape,
|
||||||
expected,
|
expected,
|
||||||
got,
|
got,
|
||||||
} => {
|
} => {
|
||||||
write!(f, "{kind}: expected length {expected}, got {got}")
|
write!(f, "{shape}: expected {expected}, got {got}")
|
||||||
}
|
}
|
||||||
Self::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!(
|
||||||
@@ -185,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!(
|
||||||
@@ -205,7 +536,7 @@ impl fmt::Display for InferenceError {
|
|||||||
f,
|
f,
|
||||||
"team {team}, member {member}: no skill recorded for key {key} \
|
"team {team}, member {member}: no skill recorded for key {key} \
|
||||||
(every key must already be known to the history; pre-filter \
|
(every key must already be known to the history; pre-filter \
|
||||||
with `lookup` or `current_skill` if that is not guaranteed)"
|
with `current_skill` if that is not guaranteed)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Self::AlreadyRegistered { key } => {
|
Self::AlreadyRegistered { key } => {
|
||||||
@@ -219,9 +550,34 @@ impl fmt::Display for InferenceError {
|
|||||||
Self::EmptyTeam { team } => {
|
Self::EmptyTeam { team } => {
|
||||||
write!(f, "team {team} has no members")
|
write!(f, "team {team} has no members")
|
||||||
}
|
}
|
||||||
Self::JointUnavailable { reason } => {
|
Self::GridTooCoarse { needed, max } => {
|
||||||
write!(f, "no exact joint posterior is available: {reason}")
|
write!(
|
||||||
|
f,
|
||||||
|
"the prediction grid needs {needed} nodes to resolve the narrowest \
|
||||||
|
team's density across the widest team's support, but may hold only \
|
||||||
|
{max}; the sigmas in this matchup are too far apart to integrate on \
|
||||||
|
one grid. Use predict_win_probabilities, which is accurate here"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
Self::EmptyHistory => {
|
||||||
|
f.write_str("no exact joint posterior is available: the history has no events")
|
||||||
|
}
|
||||||
|
Self::JointRequiresScoredEvents => f.write_str(
|
||||||
|
"no exact joint posterior is available: the history contains ranked \
|
||||||
|
events, whose EP factors are not retained after convergence. Use \
|
||||||
|
predict_win_probabilities for a ranked history",
|
||||||
|
),
|
||||||
|
Self::NotPositiveDefinite => f.write_str(
|
||||||
|
"the joint precision matrix is not positive-definite; the usual cause \
|
||||||
|
is a competitor with neither a proper prior nor any evidence, but an \
|
||||||
|
extreme prior or drift can also make the assembled matrix indefinite \
|
||||||
|
in floating point",
|
||||||
|
),
|
||||||
|
Self::NoPerformanceVariance => f.write_str(
|
||||||
|
"beta is zero and every skill in this matchup is a point mass, so \
|
||||||
|
there is no performance distribution to predict from; give beta a \
|
||||||
|
positive value, or a competitor a prior with positive sigma",
|
||||||
|
),
|
||||||
Self::NotEnoughTeams { got } => {
|
Self::NotEnoughTeams { got } => {
|
||||||
write!(f, "prediction needs at least 2 teams, got {got}")
|
write!(f, "prediction needs at least 2 teams, got {got}")
|
||||||
}
|
}
|
||||||
@@ -237,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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+65
-6
@@ -11,27 +11,59 @@ use smallvec::SmallVec;
|
|||||||
use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
|
use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
|
||||||
|
|
||||||
/// A single match at time `time` involving some number of teams.
|
/// A single match at time `time` involving some number of teams.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct Event<T: Time, K> {
|
pub struct Event<T: Time, K> {
|
||||||
|
/// When the match happened, on the history's time axis.
|
||||||
|
///
|
||||||
|
/// Events sharing a `time` land in the same time slice and are fitted
|
||||||
|
/// together, so nothing distinguishes their order. Drift is driven by the
|
||||||
|
/// gap between a competitor's *consecutive appearances*, not by the gap
|
||||||
|
/// between slices, so a competitor idle across several slices accumulates
|
||||||
|
/// the whole span at once when it next plays.
|
||||||
pub time: T,
|
pub time: T,
|
||||||
|
/// The teams that took part, positionally aligned with `outcome`: team `i`
|
||||||
|
/// here is the team `outcome` ranks or scores at index `i`.
|
||||||
|
///
|
||||||
|
/// Ingestion rejects fewer than two teams (`NotEnoughTeams`) and any team
|
||||||
|
/// with no members (`EmptyTeam`).
|
||||||
pub teams: SmallVec<[Team<K>; 4]>,
|
pub teams: SmallVec<[Team<K>; 4]>,
|
||||||
|
/// How the match ended: ranks (lower is better) or per-team scores (higher
|
||||||
|
/// is better), one entry per entry of `teams`.
|
||||||
|
///
|
||||||
|
/// A tie — two equal ranks — needs a positive `p_draw`, otherwise
|
||||||
|
/// ingestion fails with `TieWithoutDrawProbability`.
|
||||||
pub outcome: Outcome,
|
pub outcome: Outcome,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A team: list of members competing together.
|
/// A team: list of members competing together.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
#[must_use]
|
||||||
pub struct Team<K> {
|
pub struct Team<K> {
|
||||||
|
/// The competitors playing together, in no significant order: the team's
|
||||||
|
/// performance is the weight-scaled sum over its members, which does not
|
||||||
|
/// depend on how they are listed.
|
||||||
|
///
|
||||||
|
/// Must be non-empty — an empty team contributes no performance at all, so
|
||||||
|
/// ingestion rejects it with `EmptyTeam` rather than returning a plausible
|
||||||
|
/// posterior for whoever it was matched against.
|
||||||
pub members: SmallVec<[Member<K>; 4]>,
|
pub members: SmallVec<[Member<K>; 4]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<K> Team<K> {
|
impl<K> Team<K> {
|
||||||
#[must_use]
|
/// A team with no members yet, to be filled through the public `members`
|
||||||
|
/// field.
|
||||||
|
///
|
||||||
|
/// Committing it while still empty is an `EmptyTeam` error.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
members: SmallVec::new(),
|
members: SmallVec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A team of exactly these competitors.
|
||||||
|
///
|
||||||
|
/// Members must be built already — `Member::from(key)` covers the common
|
||||||
|
/// case of a plain key at default weight with no overrides.
|
||||||
pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self {
|
pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self {
|
||||||
Self {
|
Self {
|
||||||
members: members.into_iter().collect(),
|
members: members.into_iter().collect(),
|
||||||
@@ -61,10 +93,29 @@ impl<K> Default for Team<K> {
|
|||||||
/// for one competitor within a single batch is
|
/// for one competitor within a single batch is
|
||||||
/// `InferenceError::ConflictingCompetitorConfig`: events in a batch have no
|
/// `InferenceError::ConflictingCompetitorConfig`: events in a batch have no
|
||||||
/// order, so there would be no well-defined winner.
|
/// order, so there would be no well-defined winner.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
#[must_use]
|
||||||
pub struct Member<K> {
|
pub struct Member<K> {
|
||||||
|
/// The competitor's identity. Equal keys across events are the same
|
||||||
|
/// competitor: `History` interns each distinct key to an internal `Index`
|
||||||
|
/// the first time it sees it, and every later appearance resolves to that
|
||||||
|
/// same competitor's temporal state.
|
||||||
pub key: K,
|
pub key: K,
|
||||||
|
/// This member's share of the team's performance, for this event only.
|
||||||
|
///
|
||||||
|
/// The team's performance is the sum of `weight × member performance`, so
|
||||||
|
/// `1.0` is a full share and `0.5` counts the member half; the message
|
||||||
|
/// coming back to the member is divided by the same weight. Defaults to
|
||||||
|
/// `1.0`.
|
||||||
|
///
|
||||||
|
/// Must be finite — a NaN or infinite weight is `InvalidParameter` at
|
||||||
|
/// ingestion. Zero and negative are accepted, both being expressible in
|
||||||
|
/// the same arithmetic.
|
||||||
pub weight: f64,
|
pub weight: f64,
|
||||||
|
/// Starting skill for this competitor, replacing the history's `mu`/`sigma`
|
||||||
|
/// default. `None` keeps the history default.
|
||||||
|
///
|
||||||
|
/// Competitor configuration, not a per-event value; see the type docs.
|
||||||
pub prior: Option<Gaussian>,
|
pub prior: Option<Gaussian>,
|
||||||
/// Multiplier on the drift *variance* this competitor accumulates.
|
/// Multiplier on the drift *variance* this competitor accumulates.
|
||||||
/// `None` means 1.0.
|
/// `None` means 1.0.
|
||||||
@@ -72,6 +123,8 @@ pub struct Member<K> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<K> Member<K> {
|
impl<K> Member<K> {
|
||||||
|
/// A competitor taking a full share of its team's performance, with no
|
||||||
|
/// configuration overrides: the history's prior and drift apply.
|
||||||
pub fn new(key: K) -> Self {
|
pub fn new(key: K) -> Self {
|
||||||
Self {
|
Self {
|
||||||
key,
|
key,
|
||||||
@@ -81,6 +134,12 @@ impl<K> Member<K> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Change how much of the team's performance this member accounts for.
|
||||||
|
///
|
||||||
|
/// Unlike `prior` and `drift_scale`, this is genuinely per-event: the same
|
||||||
|
/// key can carry a different weight in every event it appears in, which is
|
||||||
|
/// what makes it usable for partial participation — a substitute who
|
||||||
|
/// played half the match, a doubles partner credited unequally.
|
||||||
pub fn with_weight(mut self, weight: f64) -> Self {
|
pub fn with_weight(mut self, weight: f64) -> Self {
|
||||||
self.weight = weight;
|
self.weight = weight;
|
||||||
self
|
self
|
||||||
@@ -99,8 +158,8 @@ impl<K> Member<K> {
|
|||||||
/// Scale how fast this competitor drifts, relative to the history's drift.
|
/// Scale how fast this competitor drifts, relative to the history's drift.
|
||||||
///
|
///
|
||||||
/// The scale multiplies the drift *variance*, so it is in the same units as
|
/// The scale multiplies the drift *variance*, so it is in the same units as
|
||||||
/// `gamma`: `ConstantDrift(g)` at `scale = s` behaves exactly as
|
/// `gamma`: `ConstantDrift::new(g)` at `scale = s` behaves exactly as
|
||||||
/// `ConstantDrift(g * s)` would for this competitor alone.
|
/// `ConstantDrift::new(g * s)` would for this competitor alone.
|
||||||
///
|
///
|
||||||
/// `0.0` pins the competitor still — useful for a reference point that
|
/// `0.0` pins the competitor still — useful for a reference point that
|
||||||
/// shares a scale with moving competitors but should not itself move: a bot
|
/// shares a scale with moving competitors but should not itself move: a bot
|
||||||
|
|||||||
+51
-12
@@ -9,14 +9,44 @@ use crate::{
|
|||||||
time::Time,
|
time::Time,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct EventBuilder<'h, T, D, O, K>
|
/// One match under construction, handed back by [`History::event`].
|
||||||
|
///
|
||||||
|
/// Describes a single event a piece at a time — teams, then per-member weights
|
||||||
|
/// if they differ, then how it ended — instead of assembling an
|
||||||
|
/// [`Event`] value and passing it to [`History::add_events`]. The two routes
|
||||||
|
/// ingest through the same chokepoint and accept the same things; this one just
|
||||||
|
/// reads better for a single match written by hand.
|
||||||
|
///
|
||||||
|
/// The builder borrows the history mutably and nothing reaches it until
|
||||||
|
/// [`EventBuilder::commit`]. A builder that is dropped instead ingests
|
||||||
|
/// nothing at all, silently — hence the `#[must_use]`, which is the only
|
||||||
|
/// warning you get. `commit` is also where validation surfaces: the setters
|
||||||
|
/// return `Self` to keep the chain fluent, so a mismatch such as a weight list
|
||||||
|
/// the wrong length is recorded while building and returned as an error from
|
||||||
|
/// `commit`.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use trueskill_tt::History;
|
||||||
|
/// let mut h = History::builder().build();
|
||||||
|
/// h.event(1)
|
||||||
|
/// .team(["alice", "bob"])
|
||||||
|
/// .team(["carol"])
|
||||||
|
/// .ranking([0, 1])
|
||||||
|
/// .commit()?;
|
||||||
|
/// assert_eq!(h.event_count(), 1);
|
||||||
|
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
/// ```
|
||||||
|
#[must_use = "an event is only recorded by `.commit()`; a dropped builder \
|
||||||
|
silently ingests nothing"]
|
||||||
|
pub struct EventBuilder<'h, T, D, O, K, R>
|
||||||
where
|
where
|
||||||
T: Time,
|
T: Time,
|
||||||
D: Drift<T>,
|
D: Drift<T>,
|
||||||
O: Observer<T>,
|
O: Observer<T>,
|
||||||
K: Eq + std::hash::Hash + Clone,
|
K: Eq + std::hash::Hash + Clone,
|
||||||
|
R: crate::RatingRule<K>,
|
||||||
{
|
{
|
||||||
history: &'h mut History<T, D, O, K>,
|
history: &'h mut History<K, T, D, O, R>,
|
||||||
event: Event<T, K>,
|
event: Event<T, K>,
|
||||||
current_team_idx: Option<usize>,
|
current_team_idx: Option<usize>,
|
||||||
/// First validation failure seen while building, surfaced by `commit`.
|
/// First validation failure seen while building, surfaced by `commit`.
|
||||||
@@ -29,14 +59,15 @@ where
|
|||||||
error: Option<InferenceError>,
|
error: Option<InferenceError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
|
impl<'h, T, D, O, K, R> EventBuilder<'h, T, D, O, K, R>
|
||||||
where
|
where
|
||||||
T: Time,
|
T: Time,
|
||||||
D: Drift<T>,
|
D: Drift<T>,
|
||||||
O: Observer<T>,
|
O: Observer<T>,
|
||||||
K: Eq + std::hash::Hash + Clone,
|
K: Eq + std::hash::Hash + Clone,
|
||||||
|
R: crate::RatingRule<K>,
|
||||||
{
|
{
|
||||||
pub(crate) fn new(history: &'h mut History<T, D, O, K>, time: T) -> Self {
|
pub(crate) fn new(history: &'h mut History<K, T, D, O, R>, time: T) -> Self {
|
||||||
Self {
|
Self {
|
||||||
history,
|
history,
|
||||||
event: Event {
|
event: Event {
|
||||||
@@ -113,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(),
|
||||||
});
|
});
|
||||||
@@ -142,13 +173,21 @@ where
|
|||||||
|
|
||||||
/// Set explicit per-team continuous scores with a per-event noise override.
|
/// Set explicit per-team continuous scores with a per-event noise override.
|
||||||
///
|
///
|
||||||
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only.
|
/// `score_sigma` is the observation noise on the *score margin*, not a
|
||||||
/// Must be `> 0.0`. Constructing the outcome with a non-positive or NaN
|
/// skill sigma, and it overrides `HistoryBuilder::score_sigma` for this
|
||||||
/// sigma is allowed; the value is rejected with
|
/// event only. A small value takes the margin near-literally; a large one
|
||||||
/// `InferenceError::InvalidParameter` when the event is ingested, so
|
/// barely moves the ratings.
|
||||||
/// callers get an error from `commit` rather than a panic.
|
///
|
||||||
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self {
|
/// Must be `> 0.0`. Building the outcome with a non-positive or NaN value
|
||||||
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
|
/// is allowed; it is rejected with `InferenceError::InvalidParameter` when
|
||||||
|
/// the event is ingested, so callers get an error from `commit` rather
|
||||||
|
/// than a panic.
|
||||||
|
pub fn scores_with_noise<I: IntoIterator<Item = f64>>(
|
||||||
|
mut self,
|
||||||
|
scores: I,
|
||||||
|
score_sigma: f64,
|
||||||
|
) -> Self {
|
||||||
|
self.event.outcome = crate::Outcome::scores_with_noise(scores, score_sigma);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ impl VarStore {
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn get(&self, id: VarId) -> Gaussian {
|
pub fn get(&self, id: VarId) -> Gaussian {
|
||||||
self.marginals[id.0 as usize]
|
self.marginals[id.0 as usize]
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ impl MarginFactor {
|
|||||||
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
|
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
|
||||||
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
|
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
|
||||||
let marginal = vars.get(self.diff);
|
let marginal = vars.get(self.diff);
|
||||||
let cavity = marginal / self.msg;
|
let cavity = marginal.cavity(self.msg);
|
||||||
|
|
||||||
if self.log_evidence_cached.is_none() {
|
if self.log_evidence_cached.is_none() {
|
||||||
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.m_obs, self.sigma));
|
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.m_obs, self.sigma));
|
||||||
@@ -49,7 +49,7 @@ impl MarginFactor {
|
|||||||
let damped = self.msg.damp_natural(new_msg, alpha);
|
let damped = self.msg.damp_natural(new_msg, alpha);
|
||||||
let old_msg = self.msg;
|
let old_msg = self.msg;
|
||||||
self.msg = damped;
|
self.msg = damped;
|
||||||
vars.set(self.diff, cavity * damped);
|
vars.set(self.diff, cavity.ep_product(damped));
|
||||||
|
|
||||||
old_msg.delta(damped)
|
old_msg.delta(damped)
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ fn cavity_log_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
|
|||||||
// `hypot`, not `sqrt(a^2 + b^2)`: squaring overflows to infinity above a
|
// `hypot`, not `sqrt(a^2 + b^2)`: squaring overflows to infinity above a
|
||||||
// sigma of ~1.3e154 and flushes to zero below ~1.5e-154, and `Gaussian`'s
|
// sigma of ~1.3e154 and flushes to zero below ~1.5e-154, and `Gaussian`'s
|
||||||
// constructors are public so a caller can reach both.
|
// constructors are public so a caller can reach both.
|
||||||
let combined_sigma = cavity.sigma().hypot(sigma);
|
let combined_sigma = libm::hypot(cavity.sigma(), sigma);
|
||||||
let value = ln_pdf(m_obs, cavity.mu(), combined_sigma);
|
let value = ln_pdf(m_obs, cavity.mu(), combined_sigma);
|
||||||
|
|
||||||
// A degenerate cavity (infinite sigma) is the only way to reach a
|
// A degenerate cavity (infinite sigma) is the only way to reach a
|
||||||
@@ -89,7 +89,7 @@ fn cavity_log_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
|
|||||||
if value.is_finite() {
|
if value.is_finite() {
|
||||||
value
|
value
|
||||||
} else {
|
} else {
|
||||||
f64::MIN_POSITIVE.ln()
|
libm::log(f64::MIN_POSITIVE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -41,14 +41,14 @@ impl TruncFactor {
|
|||||||
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
|
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
|
||||||
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
|
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
|
||||||
let marginal = vars.get(self.diff);
|
let marginal = vars.get(self.diff);
|
||||||
let cavity = marginal / self.msg;
|
let cavity = marginal.cavity(self.msg);
|
||||||
|
|
||||||
if self.log_evidence_cached.is_none() {
|
if self.log_evidence_cached.is_none() {
|
||||||
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.margin, self.tie));
|
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.margin, self.tie));
|
||||||
}
|
}
|
||||||
|
|
||||||
let trunc = approx(cavity, self.margin, self.tie);
|
let trunc = approx(cavity, self.margin, self.tie);
|
||||||
let new_msg = trunc / cavity;
|
let new_msg = trunc.cavity(cavity);
|
||||||
|
|
||||||
let damped = self.msg.damp_natural(new_msg, alpha);
|
let damped = self.msg.damp_natural(new_msg, alpha);
|
||||||
let old_msg = self.msg;
|
let old_msg = self.msg;
|
||||||
@@ -57,7 +57,7 @@ impl TruncFactor {
|
|||||||
// marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
|
// marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
|
||||||
// `trunc` (since cavity * new_msg = trunc by construction); with
|
// `trunc` (since cavity * new_msg = trunc by construction); with
|
||||||
// alpha < 1.0 it reflects the partially-applied update.
|
// alpha < 1.0 it reflects the partially-applied update.
|
||||||
vars.set(self.diff, cavity * damped);
|
vars.set(self.diff, cavity.ep_product(damped));
|
||||||
|
|
||||||
old_msg.delta(damped)
|
old_msg.delta(damped)
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ fn cavity_log_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
|
|||||||
if value.is_finite() {
|
if value.is_finite() {
|
||||||
value
|
value
|
||||||
} else {
|
} else {
|
||||||
f64::MIN_POSITIVE.ln()
|
libm::log(f64::MIN_POSITIVE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ mod tests {
|
|||||||
let approx = -0.5 * z * z - z.ln() - (2.0 * std::f64::consts::PI).sqrt().ln();
|
let approx = -0.5 * z * z - z.ln() - (2.0 * std::f64::consts::PI).sqrt().ln();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
got < f64::MIN_POSITIVE.ln(),
|
got < libm::log(f64::MIN_POSITIVE),
|
||||||
"mu={mu}: {got} is still stuck on the old clamp floor"
|
"mu={mu}: {got} is still stuck on the old clamp floor"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+284
-142
@@ -68,10 +68,27 @@ impl DiffFactor {
|
|||||||
/// `p_draw` and `convergence` apply to ranked outcomes (`Game::ranked`).
|
/// `p_draw` and `convergence` apply to ranked outcomes (`Game::ranked`).
|
||||||
/// `score_sigma` applies only to scored outcomes (`Game::scored`); it controls
|
/// `score_sigma` applies only to scored outcomes (`Game::scored`); it controls
|
||||||
/// how much the engine trusts the observed score margin (smaller σ = more trust).
|
/// how much the engine trusts the observed score margin (smaller σ = more trust).
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct GameOptions {
|
pub struct GameOptions {
|
||||||
|
/// Probability the model assigns to two teams drawing, which sets the width
|
||||||
|
/// of the truncation band around a tie. Must be in `[0.0, 1.0)`; defaults
|
||||||
|
/// to [`P_DRAW`](crate::P_DRAW).
|
||||||
|
///
|
||||||
|
/// At `0.0` the band has zero width, so a ranked outcome that ties two
|
||||||
|
/// teams has no representable likelihood and [`Game::ranked`] rejects it
|
||||||
|
/// with `TieWithoutDrawProbability`.
|
||||||
pub p_draw: f64,
|
pub p_draw: f64,
|
||||||
|
/// Standard deviation of the observation noise on an observed score margin,
|
||||||
|
/// used only by [`Game::scored`], which rejects a non-positive or NaN value
|
||||||
|
/// with `InvalidParameter`. Defaults to `1.0`.
|
||||||
|
///
|
||||||
|
/// It is in the units of the scores themselves, and says how much of a
|
||||||
|
/// margin the model reads as skill rather than noise: a small sigma takes
|
||||||
|
/// the margin near-literally, a large one barely moves the ratings.
|
||||||
pub score_sigma: f64,
|
pub score_sigma: f64,
|
||||||
|
/// Stopping rule and damping for the within-game message-passing loop:
|
||||||
|
/// iterate until the largest message change falls below `epsilon`, or
|
||||||
|
/// `max_iter` passes, with each update damped by `alpha`.
|
||||||
pub convergence: crate::ConvergenceOptions,
|
pub convergence: crate::ConvergenceOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,20 +102,51 @@ impl Default for GameOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owned variant of `Game` returned by public constructors.
|
/// One match, fitted on its own.
|
||||||
///
|
///
|
||||||
/// Unlike `Game<'a, T, D>` (which borrows its result/weights slices from
|
/// Rate a single match against ratings you already hold and read the updated
|
||||||
/// History's internal state), `OwnedGame<T, D>` owns the team ratings, so it
|
/// beliefs straight back. There is no history behind it: nothing is stored,
|
||||||
/// can be returned freely from public constructors. The inference inputs
|
/// nothing propagates backward, and the priors you hand in are the only
|
||||||
/// themselves are not retained — nothing reads them back.
|
/// evidence used. That makes it the wrong tool for the thing this crate exists
|
||||||
|
/// for — [`History`](crate::History) is what infers skill *through time*,
|
||||||
|
/// revising past estimates as later matches arrive, and a sequence of `Game`s
|
||||||
|
/// chained by hand is a forward-only filter, not the same answer.
|
||||||
|
///
|
||||||
|
/// Reach for it when a history would be overkill or unavailable: a one-off
|
||||||
|
/// matchup, replaying a rating step from stored numbers, checking the engine
|
||||||
|
/// against a reference, or a caller that keeps its own persistence and only
|
||||||
|
/// wants the update rule.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
|
||||||
|
///
|
||||||
|
/// let strong: Rating = Rating::new(Gaussian::from_ms(30.0, 3.0), 1.0, ConstantDrift::new(0.0));
|
||||||
|
/// let weak: Rating = Rating::new(Gaussian::from_ms(20.0, 3.0), 1.0, ConstantDrift::new(0.0));
|
||||||
|
///
|
||||||
|
/// // The underdog wins.
|
||||||
|
/// let game = Game::ranked(
|
||||||
|
/// &[&[weak], &[strong]],
|
||||||
|
/// Outcome::winner(0, 2),
|
||||||
|
/// &GameOptions::default(),
|
||||||
|
/// )?;
|
||||||
|
///
|
||||||
|
/// let posteriors = game.posteriors();
|
||||||
|
/// assert!(posteriors[0][0].mu() > weak.prior().mu(), "the winner gained");
|
||||||
|
/// assert!(posteriors[1][0].mu() < strong.prior().mu(), "the loser lost");
|
||||||
|
///
|
||||||
|
/// // An upset is improbable, and `log_evidence` says so.
|
||||||
|
/// assert!(game.log_evidence() < 0.5_f64.ln());
|
||||||
|
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
/// ```
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct OwnedGame<T: Time, D: Drift<T>> {
|
#[must_use]
|
||||||
|
pub struct Game<T: Time, D: Drift<T>> {
|
||||||
teams: Vec<Vec<Rating<T, D>>>,
|
teams: Vec<Vec<Rating<T, D>>>,
|
||||||
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
||||||
pub(crate) log_evidence: f64,
|
pub(crate) log_evidence: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
impl<T: Time, D: Drift<T>> Game<T, D> {
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
teams: Vec<Vec<Rating<T, D>>>,
|
teams: Vec<Vec<Rating<T, D>>>,
|
||||||
result: Vec<f64>,
|
result: Vec<f64>,
|
||||||
@@ -110,7 +158,8 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
|||||||
|
|
||||||
// `Game` takes the teams by value and is dropped here, so take the vec
|
// `Game` takes the teams by value and is dropped here, so take the vec
|
||||||
// back out of it rather than handing it a clone.
|
// back out of it rather than handing it a clone.
|
||||||
let g = Game::ranked_with_arena(teams, &result, &weights, p_draw, convergence, &mut arena);
|
let g =
|
||||||
|
GameRef::ranked_with_arena(teams, &result, &weights, p_draw, convergence, &mut arena);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
teams: g.teams,
|
teams: g.teams,
|
||||||
@@ -128,7 +177,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
let mut arena = ScratchArena::new();
|
let mut arena = ScratchArena::new();
|
||||||
|
|
||||||
let g = Game::scored_with_arena(
|
let g = GameRef::scored_with_arena(
|
||||||
teams,
|
teams,
|
||||||
&scores,
|
&scores,
|
||||||
&weights,
|
&weights,
|
||||||
@@ -144,23 +193,59 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updated skill belief for every competitor, as `[team][member]` in the
|
||||||
|
/// order the teams and members were passed in.
|
||||||
|
///
|
||||||
|
/// Each is the competitor's own prior multiplied by the likelihood this one
|
||||||
|
/// match produced for it — so it reflects this match and the rating handed
|
||||||
|
/// in, and nothing else. Feeding it back as the next match's prior is the
|
||||||
|
/// caller's job; that is what a [`History`](crate::History) automates.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
||||||
self.likelihoods
|
self.likelihoods
|
||||||
.iter()
|
.iter()
|
||||||
.zip(self.teams.iter())
|
.zip(self.teams.iter())
|
||||||
.map(|(l, t)| l.iter().zip(t.iter()).map(|(&l, r)| l * r.prior).collect())
|
.map(|(l, t)| {
|
||||||
|
l.iter()
|
||||||
|
.zip(t.iter())
|
||||||
|
.map(|(&l, r)| l.ep_product(r.prior))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Natural log of how probable this outcome was under the priors, summed
|
||||||
|
/// over the diff chain's links.
|
||||||
|
///
|
||||||
|
/// Higher means the result was less surprising, so it doubles as a
|
||||||
|
/// closeness measure — two identically-rated competitors give exactly
|
||||||
|
/// `ln(0.5)`, either of them being equally likely to win:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
|
||||||
|
/// let r = Rating::new(Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift::new(0.0));
|
||||||
|
/// let g = Game::<i64, _>::ranked(&[&[r], &[r]], Outcome::winner(0, 2), &GameOptions::default())?;
|
||||||
|
/// assert!((g.log_evidence() - 0.5_f64.ln()).abs() < 1e-12);
|
||||||
|
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Accumulated in log space because the linear product over a long chain
|
||||||
|
/// underflows to zero, and `ln(0.0)` is `-inf`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn log_evidence(&self) -> f64 {
|
pub fn log_evidence(&self) -> f64 {
|
||||||
self.log_evidence
|
self.log_evidence
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The borrowing form of [`Game`], used only inside the crate.
|
||||||
|
///
|
||||||
|
/// `History` keeps each event's result and weight slices in its own storage
|
||||||
|
/// and sweeps them thousands of times, so the inference core borrows them
|
||||||
|
/// rather than copying. That borrow is the whole difference between this and
|
||||||
|
/// [`Game`]; it is why this type cannot be handed to a caller, and why it is
|
||||||
|
/// not part of the public API.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
pub(crate) struct GameRef<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
||||||
teams: Vec<Vec<Rating<T, D>>>,
|
teams: Vec<Vec<Rating<T, D>>>,
|
||||||
result: &'a [f64],
|
result: &'a [f64],
|
||||||
weights: &'a [Vec<f64>],
|
weights: &'a [Vec<f64>],
|
||||||
@@ -170,7 +255,7 @@ pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
|||||||
pub(crate) log_evidence: f64,
|
pub(crate) log_evidence: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
|
||||||
pub(crate) fn ranked_with_arena(
|
pub(crate) fn ranked_with_arena(
|
||||||
teams: Vec<Vec<Rating<T, D>>>,
|
teams: Vec<Vec<Rating<T, D>>>,
|
||||||
result: &'a [f64],
|
result: &'a [f64],
|
||||||
@@ -283,7 +368,9 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
self.teams[t]
|
self.teams[t]
|
||||||
.iter()
|
.iter()
|
||||||
.zip(self.weights[t].iter())
|
.zip(self.weights[t].iter())
|
||||||
.fold(N00, |p, (player, &w)| p + (player.performance() * w))
|
.fold(N00, |p, (competitor, &w)| {
|
||||||
|
p.convolve(competitor.performance().scale(w))
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let n_diffs = n_teams.saturating_sub(1);
|
let n_diffs = n_teams.saturating_sub(1);
|
||||||
@@ -302,28 +389,28 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
step = (0.0_f64, 0.0_f64);
|
step = (0.0_f64, 0.0_f64);
|
||||||
|
|
||||||
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
|
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
|
||||||
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
|
||||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
|
||||||
let raw = pw - pl;
|
let raw = pw.convolve_diff(pl);
|
||||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
|
||||||
let d = lf.propagate(&mut arena.vars, alpha);
|
let d = lf.propagate(&mut arena.vars, alpha);
|
||||||
step = tuple_max(step, d);
|
step = tuple_max(step, d);
|
||||||
|
|
||||||
let new_ll = pw - lf.msg();
|
let new_ll = pw.convolve_diff(lf.msg());
|
||||||
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
|
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
|
||||||
arena.lhood_lose[e + 1] = new_ll;
|
arena.lhood_lose[e + 1] = new_ll;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
|
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
|
||||||
let e = n_diffs - 1 - rev_i;
|
let e = n_diffs - 1 - rev_i;
|
||||||
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
|
||||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
|
||||||
let raw = pw - pl;
|
let raw = pw.convolve_diff(pl);
|
||||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
|
||||||
let d = lf.propagate(&mut arena.vars, alpha);
|
let d = lf.propagate(&mut arena.vars, alpha);
|
||||||
step = tuple_max(step, d);
|
step = tuple_max(step, d);
|
||||||
|
|
||||||
let new_lw = pl + lf.msg();
|
let new_lw = pl.convolve(lf.msg());
|
||||||
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
|
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
|
||||||
arena.lhood_win[e] = new_lw;
|
arena.lhood_win[e] = new_lw;
|
||||||
}
|
}
|
||||||
@@ -333,18 +420,21 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
|
|
||||||
// Special case: exactly 1 diff (2-team game); loop body was empty.
|
// Special case: exactly 1 diff (2-team game); loop body was empty.
|
||||||
if n_diffs == 1 {
|
if n_diffs == 1 {
|
||||||
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
|
let raw = arena.team_prior[0]
|
||||||
- (arena.team_prior[1] * arena.lhood_win[1]);
|
.ep_product(arena.lhood_lose[0])
|
||||||
arena.vars.set(links[0].diff(), raw * links[0].msg());
|
.convolve_diff(arena.team_prior[1].ep_product(arena.lhood_win[1]));
|
||||||
|
arena
|
||||||
|
.vars
|
||||||
|
.set(links[0].diff(), raw.ep_product(links[0].msg()));
|
||||||
links[0].propagate(&mut arena.vars, alpha);
|
links[0].propagate(&mut arena.vars, alpha);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boundary updates: close the chain at both ends.
|
// Boundary updates: close the chain at both ends.
|
||||||
if n_diffs > 0 {
|
if n_diffs > 0 {
|
||||||
let pl1 = arena.team_prior[1] * arena.lhood_win[1];
|
let pl1 = arena.team_prior[1].ep_product(arena.lhood_win[1]);
|
||||||
arena.lhood_win[0] = pl1 + links[0].msg();
|
arena.lhood_win[0] = pl1.convolve(links[0].msg());
|
||||||
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2];
|
let pw_last = arena.team_prior[n_teams - 2].ep_product(arena.lhood_lose[n_teams - 2]);
|
||||||
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
|
arena.lhood_lose[n_teams - 1] = pw_last.convolve_diff(links[n_diffs - 1].msg());
|
||||||
}
|
}
|
||||||
|
|
||||||
let log_evidence: f64 = links.iter().map(DiffFactor::log_evidence).sum();
|
let log_evidence: f64 = links.iter().map(DiffFactor::log_evidence).sum();
|
||||||
@@ -360,18 +450,19 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
.iter()
|
.iter()
|
||||||
.zip(self.weights.iter())
|
.zip(self.weights.iter())
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(orig_i, (players, weights))| {
|
.map(|(orig_i, (competitors, weights))| {
|
||||||
let si = arena.inv_buf[orig_i];
|
let si = arena.inv_buf[orig_i];
|
||||||
let m = arena.lhood_win[si] * arena.lhood_lose[si];
|
let m = arena.lhood_win[si].ep_product(arena.lhood_lose[si]);
|
||||||
// Already folded into `team_prior` at the top of the chain,
|
// Already folded into `team_prior` at the top of the chain,
|
||||||
// indexed by sorted position.
|
// indexed by sorted position.
|
||||||
let performance = arena.team_prior[si];
|
let performance = arena.team_prior[si];
|
||||||
players
|
competitors
|
||||||
.iter()
|
.iter()
|
||||||
.zip(weights.iter())
|
.zip(weights.iter())
|
||||||
.map(|(player, &w)| {
|
.map(|(competitor, &w)| {
|
||||||
((m - performance.exclude(player.performance() * w)) * (1.0 / w))
|
m.convolve_diff(performance.exclude(competitor.performance().scale(w)))
|
||||||
.forget(player.beta.powi(2))
|
.scale(1.0 / w)
|
||||||
|
.forget(competitor.beta.powi(2))
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
@@ -410,27 +501,26 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
self.likelihoods = likelihoods;
|
self.likelihoods = likelihoods;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
/// As [`Game::posteriors`].
|
||||||
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
///
|
||||||
|
/// Test-only: inference reads `likelihoods` directly, and `GameRef` is not
|
||||||
|
/// public, so the only callers are this module's own goldens.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
||||||
self.likelihoods
|
self.likelihoods
|
||||||
.iter()
|
.iter()
|
||||||
.zip(self.teams.iter())
|
.zip(self.teams.iter())
|
||||||
.map(|(l, t)| {
|
.map(|(l, t)| {
|
||||||
l.iter()
|
l.iter()
|
||||||
.zip(t.iter())
|
.zip(t.iter())
|
||||||
.map(|(&l, p)| l * p.prior)
|
.map(|(&l, p)| l.ep_product(p.prior))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn log_evidence(&self) -> f64 {
|
|
||||||
self.log_evidence
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
impl<T: Time, D: Drift<T>> Game<T, D> {
|
||||||
/// Reject the team shapes inference cannot represent.
|
/// Reject the team shapes inference cannot represent.
|
||||||
///
|
///
|
||||||
/// `run_chain` builds one diff link per adjacent pair of teams, so fewer
|
/// `run_chain` builds one diff link per adjacent pair of teams, so fewer
|
||||||
@@ -454,12 +544,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fit one match from an ordinal result.
|
||||||
|
///
|
||||||
|
/// `teams` is `[team][member]`, and `outcome` ranks those teams in the
|
||||||
|
/// same order. Read the result with [`posteriors`](Game::posteriors) and
|
||||||
|
/// [`log_evidence`](Game::log_evidence).
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// - `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
|
||||||
@@ -471,17 +567,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
teams: &[&[Rating<T, D>]],
|
teams: &[&[Rating<T, D>]],
|
||||||
outcome: crate::Outcome,
|
outcome: crate::Outcome,
|
||||||
options: &GameOptions,
|
options: &GameOptions,
|
||||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
) -> Result<Self, crate::InferenceError> {
|
||||||
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(),
|
||||||
});
|
});
|
||||||
@@ -490,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 {
|
||||||
@@ -510,7 +606,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
|
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
|
||||||
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
|
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
|
||||||
|
|
||||||
Ok(OwnedGame::new(
|
Ok(Self::new(
|
||||||
teams_owned,
|
teams_owned,
|
||||||
result,
|
result,
|
||||||
weights,
|
weights,
|
||||||
@@ -519,6 +615,12 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fit one match from continuous scores.
|
||||||
|
///
|
||||||
|
/// Unlike [`ranked`](Game::ranked), the *size* of each adjacent gap is
|
||||||
|
/// evidence: beating a team by ten says more than beating them by one.
|
||||||
|
/// How much more is set by `options.score_sigma`.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// - `InvalidParameter` if `options.score_sigma` is not strictly positive
|
/// - `InvalidParameter` if `options.score_sigma` is not strictly positive
|
||||||
@@ -531,18 +633,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
teams: &[&[Rating<T, D>]],
|
teams: &[&[Rating<T, D>]],
|
||||||
outcome: crate::Outcome,
|
outcome: crate::Outcome,
|
||||||
options: &GameOptions,
|
options: &GameOptions,
|
||||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
) -> Result<Self, crate::InferenceError> {
|
||||||
options.convergence.validate()?;
|
options.convergence.validate()?;
|
||||||
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(),
|
||||||
});
|
});
|
||||||
@@ -550,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
|
||||||
@@ -560,14 +661,14 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
|
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
|
||||||
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
|
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
|
||||||
Ok(OwnedGame::new_scored(
|
Ok(Self::new_scored(
|
||||||
teams_owned,
|
teams_owned,
|
||||||
scores,
|
scores,
|
||||||
weights,
|
weights,
|
||||||
@@ -576,7 +677,24 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience wrapper over [`Game::ranked`] for two single-player teams.
|
/// Two single-competitor teams: the common case, without the nesting.
|
||||||
|
///
|
||||||
|
/// Returns a `Game` like every other constructor. It used to return
|
||||||
|
/// `(Gaussian, Gaussian)` — the posteriors alone — which made it the one
|
||||||
|
/// member of the family you could not ask for
|
||||||
|
/// [`log_evidence`](Game::log_evidence). Call `.posteriors()` for the old
|
||||||
|
/// shape:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
|
||||||
|
/// # let a: Rating = Rating::new(Gaussian::from_ms(25.0, 8.0), 4.0, ConstantDrift::new(0.0));
|
||||||
|
/// # let b = a;
|
||||||
|
/// let game = Game::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default())?;
|
||||||
|
/// let post = game.posteriors();
|
||||||
|
/// let (a_post, b_post) = (post[0][0], post[1][0]);
|
||||||
|
/// # let _ = (a_post, b_post);
|
||||||
|
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
/// ```
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
@@ -588,22 +706,22 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
b: &Rating<T, D>,
|
b: &Rating<T, D>,
|
||||||
outcome: crate::Outcome,
|
outcome: crate::Outcome,
|
||||||
options: &GameOptions,
|
options: &GameOptions,
|
||||||
) -> Result<(Gaussian, Gaussian), crate::InferenceError> {
|
) -> Result<Self, crate::InferenceError> {
|
||||||
let game = Self::ranked(&[&[*a], &[*b]], outcome, options)?;
|
Self::ranked(&[&[*a], &[*b]], outcome, options)
|
||||||
let post = game.posteriors();
|
|
||||||
Ok((post[0][0], post[1][0]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A free-for-all: every competitor is their own one-member team.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Wraps each player in a one-member team and delegates to
|
/// Wraps each competitor in a one-member team and delegates to
|
||||||
/// [`Game::ranked`], so it returns the same errors.
|
/// [`Game::ranked`], so it returns the same errors.
|
||||||
pub fn free_for_all(
|
pub fn free_for_all(
|
||||||
players: &[&Rating<T, D>],
|
competitors: &[&Rating<T, D>],
|
||||||
outcome: crate::Outcome,
|
outcome: crate::Outcome,
|
||||||
options: &GameOptions,
|
options: &GameOptions,
|
||||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
) -> Result<Self, crate::InferenceError> {
|
||||||
let teams: Vec<Vec<Rating<T, D>>> = players.iter().map(|p| vec![**p]).collect();
|
let teams: Vec<Vec<Rating<T, D>>> = competitors.iter().map(|p| vec![**p]).collect();
|
||||||
let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
|
let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
|
||||||
Self::ranked(&team_refs, outcome, options)
|
Self::ranked(&team_refs, outcome, options)
|
||||||
}
|
}
|
||||||
@@ -623,16 +741,16 @@ mod tests {
|
|||||||
let t_a = R::new(
|
let t_a = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_b = R::new(
|
let t_b = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![vec![t_a], vec![t_b]],
|
vec![vec![t_a], vec![t_b]],
|
||||||
&[0.0, 1.0],
|
&[0.0, 1.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -651,16 +769,16 @@ mod tests {
|
|||||||
let t_a = R::new(
|
let t_a = R::new(
|
||||||
Gaussian::from_ms(29.0, 1.0),
|
Gaussian::from_ms(29.0, 1.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(GAMMA),
|
ConstantDrift::new(GAMMA),
|
||||||
);
|
);
|
||||||
let t_b = R::new(
|
let t_b = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(GAMMA),
|
ConstantDrift::new(GAMMA),
|
||||||
);
|
);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![vec![t_a], vec![t_b]],
|
vec![vec![t_a], vec![t_b]],
|
||||||
&[0.0, 1.0],
|
&[0.0, 1.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -676,11 +794,19 @@ mod tests {
|
|||||||
assert_ulps_eq!(a, Gaussian::from_ms(28.896475, 0.996604), epsilon = 1e-6);
|
assert_ulps_eq!(a, Gaussian::from_ms(28.896475, 0.996604), epsilon = 1e-6);
|
||||||
assert_ulps_eq!(b, Gaussian::from_ms(32.189211, 6.062063), epsilon = 1e-6);
|
assert_ulps_eq!(b, Gaussian::from_ms(32.189211, 6.062063), epsilon = 1e-6);
|
||||||
|
|
||||||
let t_a = R::new(Gaussian::from_ms(1.139, 0.531), 1.0, ConstantDrift(0.2125));
|
let t_a = R::new(
|
||||||
let t_b = R::new(Gaussian::from_ms(15.568, 0.51), 1.0, ConstantDrift(0.2125));
|
Gaussian::from_ms(1.139, 0.531),
|
||||||
|
1.0,
|
||||||
|
ConstantDrift::new(0.2125),
|
||||||
|
);
|
||||||
|
let t_b = R::new(
|
||||||
|
Gaussian::from_ms(15.568, 0.51),
|
||||||
|
1.0,
|
||||||
|
ConstantDrift::new(0.2125),
|
||||||
|
);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![vec![t_a], vec![t_b]],
|
vec![vec![t_a], vec![t_b]],
|
||||||
&[0.0, 1.0],
|
&[0.0, 1.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -699,22 +825,22 @@ mod tests {
|
|||||||
vec![R::new(
|
vec![R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
)],
|
)],
|
||||||
vec![R::new(
|
vec![R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
)],
|
)],
|
||||||
vec![R::new(
|
vec![R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
)],
|
)],
|
||||||
];
|
];
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
teams.clone(),
|
teams.clone(),
|
||||||
&[1.0, 2.0, 0.0],
|
&[1.0, 2.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -731,7 +857,7 @@ mod tests {
|
|||||||
assert_ulps_eq!(b, Gaussian::from_ms(31.311358, 6.698818), epsilon = 1e-6);
|
assert_ulps_eq!(b, Gaussian::from_ms(31.311358, 6.698818), epsilon = 1e-6);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
teams.clone(),
|
teams.clone(),
|
||||||
&[2.0, 1.0, 0.0],
|
&[2.0, 1.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -748,7 +874,7 @@ mod tests {
|
|||||||
assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6);
|
assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
teams,
|
teams,
|
||||||
&[1.0, 2.0, 0.0],
|
&[1.0, 2.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -779,16 +905,16 @@ mod tests {
|
|||||||
let t_a = R::new(
|
let t_a = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_b = R::new(
|
let t_b = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![vec![t_a], vec![t_b]],
|
vec![vec![t_a], vec![t_b]],
|
||||||
&[0.0, 0.0],
|
&[0.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -811,16 +937,16 @@ mod tests {
|
|||||||
let t_a = R::new(
|
let t_a = R::new(
|
||||||
Gaussian::from_ms(25.0, 3.0),
|
Gaussian::from_ms(25.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_b = R::new(
|
let t_b = R::new(
|
||||||
Gaussian::from_ms(29.0, 2.0),
|
Gaussian::from_ms(29.0, 2.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![vec![t_a], vec![t_b]],
|
vec![vec![t_a], vec![t_b]],
|
||||||
&[0.0, 0.0],
|
&[0.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -842,21 +968,21 @@ mod tests {
|
|||||||
let t_a = R::new(
|
let t_a = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_b = R::new(
|
let t_b = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_c = R::new(
|
let t_c = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![vec![t_a], vec![t_b], vec![t_c]],
|
vec![vec![t_a], vec![t_b], vec![t_c]],
|
||||||
&[0.0, 0.0, 0.0],
|
&[0.0, 0.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -879,21 +1005,21 @@ mod tests {
|
|||||||
let t_a = R::new(
|
let t_a = R::new(
|
||||||
Gaussian::from_ms(25.0, 3.0),
|
Gaussian::from_ms(25.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_b = R::new(
|
let t_b = R::new(
|
||||||
Gaussian::from_ms(25.0, 3.0),
|
Gaussian::from_ms(25.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let t_c = R::new(
|
let t_c = R::new(
|
||||||
Gaussian::from_ms(29.0, 2.0),
|
Gaussian::from_ms(29.0, 2.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![vec![t_a], vec![t_b], vec![t_c]],
|
vec![vec![t_a], vec![t_b], vec![t_c]],
|
||||||
&[0.0, 0.0, 0.0],
|
&[0.0, 0.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -918,34 +1044,34 @@ mod tests {
|
|||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(12.0, 3.0),
|
Gaussian::from_ms(12.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(18.0, 3.0),
|
Gaussian::from_ms(18.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
let t_b = vec![R::new(
|
let t_b = vec![R::new(
|
||||||
Gaussian::from_ms(30.0, 3.0),
|
Gaussian::from_ms(30.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
)];
|
)];
|
||||||
let t_c = vec![
|
let t_c = vec![
|
||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(14.0, 3.0),
|
Gaussian::from_ms(14.0, 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(16., 3.0),
|
Gaussian::from_ms(16., 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let w = [vec![1.0, 1.0], vec![1.0], vec![1.0, 1.0]];
|
let w = [vec![1.0, 1.0], vec![1.0], vec![1.0, 1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![t_a, t_b, t_c],
|
vec![t_a, t_b, t_c],
|
||||||
&[1.0, 0.0, 0.0],
|
&[1.0, 0.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -970,16 +1096,16 @@ mod tests {
|
|||||||
let t_a = vec![R::new(
|
let t_a = vec![R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
)];
|
)];
|
||||||
let t_b = vec![R::new(
|
let t_b = vec![R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
)];
|
)];
|
||||||
|
|
||||||
let w = [w_a, w_b];
|
let w = [w_a, w_b];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![t_a.clone(), t_b.clone()],
|
vec![t_a.clone(), t_b.clone()],
|
||||||
&[1.0, 0.0],
|
&[1.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -1004,7 +1130,7 @@ mod tests {
|
|||||||
let w_b = vec![0.7];
|
let w_b = vec![0.7];
|
||||||
|
|
||||||
let w = [w_a, w_b];
|
let w = [w_a, w_b];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![t_a.clone(), t_b.clone()],
|
vec![t_a.clone(), t_b.clone()],
|
||||||
&[1.0, 0.0],
|
&[1.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -1029,7 +1155,7 @@ mod tests {
|
|||||||
let w_b = vec![0.7];
|
let w_b = vec![0.7];
|
||||||
|
|
||||||
let w = [w_a, w_b];
|
let w = [w_a, w_b];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![t_a, t_b],
|
vec![t_a, t_b],
|
||||||
&[1.0, 0.0],
|
&[1.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -1053,11 +1179,19 @@ mod tests {
|
|||||||
let w_a = vec![1.0];
|
let w_a = vec![1.0];
|
||||||
let w_b = vec![0.0];
|
let w_b = vec![0.0];
|
||||||
|
|
||||||
let t_a = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
|
let t_a = vec![R::new(
|
||||||
let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
|
Gaussian::from_ms(2.0, 6.0),
|
||||||
|
1.0,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
)];
|
||||||
|
let t_b = vec![R::new(
|
||||||
|
Gaussian::from_ms(2.0, 6.0),
|
||||||
|
1.0,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
)];
|
||||||
|
|
||||||
let w = [w_a, w_b];
|
let w = [w_a, w_b];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![t_a, t_b],
|
vec![t_a, t_b],
|
||||||
&[1.0, 0.0],
|
&[1.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -1081,11 +1215,19 @@ mod tests {
|
|||||||
let w_a = vec![1.0];
|
let w_a = vec![1.0];
|
||||||
let w_b = vec![-1.0];
|
let w_b = vec![-1.0];
|
||||||
|
|
||||||
let t_a = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
|
let t_a = vec![R::new(
|
||||||
let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
|
Gaussian::from_ms(2.0, 6.0),
|
||||||
|
1.0,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
)];
|
||||||
|
let t_b = vec![R::new(
|
||||||
|
Gaussian::from_ms(2.0, 6.0),
|
||||||
|
1.0,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
)];
|
||||||
|
|
||||||
let w = [w_a, w_b];
|
let w = [w_a, w_b];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![t_a, t_b],
|
vec![t_a, t_b],
|
||||||
&[1.0, 0.0],
|
&[1.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -1125,13 +1267,13 @@ mod tests {
|
|||||||
let prior = R::new(
|
let prior = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let teams = vec![vec![prior], vec![prior]];
|
let teams = vec![vec![prior], vec![prior]];
|
||||||
let result = vec![10.0, 0.0]; // a beat b by 10
|
let result = vec![10.0, 0.0]; // a beat b by 10
|
||||||
let weights = [vec![1.0], vec![1.0]];
|
let weights = [vec![1.0], vec![1.0]];
|
||||||
let mut arena = ScratchArena::new();
|
let mut arena = ScratchArena::new();
|
||||||
let g = Game::scored_with_arena(
|
let g = GameRef::scored_with_arena(
|
||||||
teams,
|
teams,
|
||||||
&result,
|
&result,
|
||||||
&weights,
|
&weights,
|
||||||
@@ -1151,7 +1293,7 @@ mod tests {
|
|||||||
|
|
||||||
// Tighter score_sigma should produce a stronger update.
|
// Tighter score_sigma should produce a stronger update.
|
||||||
let mut arena2 = ScratchArena::new();
|
let mut arena2 = ScratchArena::new();
|
||||||
let g_tight = Game::scored_with_arena(
|
let g_tight = GameRef::scored_with_arena(
|
||||||
vec![vec![prior], vec![prior]],
|
vec![vec![prior], vec![prior]],
|
||||||
&result,
|
&result,
|
||||||
&weights,
|
&weights,
|
||||||
@@ -1175,7 +1317,7 @@ mod tests {
|
|||||||
let prior = R::new(
|
let prior = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let opts = GameOptions {
|
let opts = GameOptions {
|
||||||
score_sigma: 1.0,
|
score_sigma: 1.0,
|
||||||
@@ -1191,7 +1333,7 @@ mod tests {
|
|||||||
let prior = R::new(
|
let prior = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let err = Game::scored(
|
let err = Game::scored(
|
||||||
&[&[prior], &[prior]],
|
&[&[prior], &[prior]],
|
||||||
@@ -1210,7 +1352,7 @@ mod tests {
|
|||||||
let prior = R::new(
|
let prior = R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
);
|
);
|
||||||
let opts = GameOptions {
|
let opts = GameOptions {
|
||||||
score_sigma: 0.0,
|
score_sigma: 0.0,
|
||||||
@@ -1225,7 +1367,7 @@ mod tests {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
crate::InferenceError::InvalidParameter {
|
crate::InferenceError::InvalidParameter {
|
||||||
name: "score_sigma",
|
parameter: crate::Parameter::ScoreSigma,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
@@ -1237,12 +1379,12 @@ mod tests {
|
|||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
),
|
),
|
||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
let w_a = vec![0.4, 0.8];
|
let w_a = vec![0.4, 0.8];
|
||||||
@@ -1251,18 +1393,18 @@ mod tests {
|
|||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
),
|
),
|
||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
let w_b = vec![0.9, 0.6];
|
let w_b = vec![0.9, 0.6];
|
||||||
|
|
||||||
let w = [w_a, w_b];
|
let w = [w_a, w_b];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![t_a.clone(), t_b.clone()],
|
vec![t_a.clone(), t_b.clone()],
|
||||||
&[1.0, 0.0],
|
&[1.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -1297,7 +1439,7 @@ mod tests {
|
|||||||
let w_b = vec![0.7, 0.4];
|
let w_b = vec![0.7, 0.4];
|
||||||
|
|
||||||
let w = [w_a, w_b];
|
let w = [w_a, w_b];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![t_a.clone(), t_b.clone()],
|
vec![t_a.clone(), t_b.clone()],
|
||||||
&[1.0, 0.0],
|
&[1.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -1332,7 +1474,7 @@ mod tests {
|
|||||||
let w_b = vec![0.7, 2.4];
|
let w_b = vec![0.7, 2.4];
|
||||||
|
|
||||||
let w = [w_a, w_b];
|
let w = [w_a, w_b];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![t_a.clone(), t_b.clone()],
|
vec![t_a.clone(), t_b.clone()],
|
||||||
&[1.0, 0.0],
|
&[1.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -1364,13 +1506,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let w = [vec![1.0, 1.0], vec![1.0]];
|
let w = [vec![1.0, 1.0], vec![1.0]];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![
|
vec![
|
||||||
t_a.clone(),
|
t_a.clone(),
|
||||||
vec![R::new(
|
vec![R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
)],
|
)],
|
||||||
],
|
],
|
||||||
&[1.0, 0.0],
|
&[1.0, 0.0],
|
||||||
@@ -1385,7 +1527,7 @@ mod tests {
|
|||||||
let w_b = vec![1.0, 0.0];
|
let w_b = vec![1.0, 0.0];
|
||||||
|
|
||||||
let w = [w_a, w_b];
|
let w = [w_a, w_b];
|
||||||
let g = Game::ranked_with_arena(
|
let g = GameRef::ranked_with_arena(
|
||||||
vec![t_a, t_b.clone()],
|
vec![t_a, t_b.clone()],
|
||||||
&[1.0, 0.0],
|
&[1.0, 0.0],
|
||||||
&w,
|
&w,
|
||||||
@@ -1403,14 +1545,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn run_chain_honours_max_iter_in_convergence_options() {
|
fn run_chain_honours_max_iter_in_convergence_options() {
|
||||||
let players: Vec<R> = (0..4).map(|_| R::default()).collect();
|
let competitors: Vec<R> = (0..4).map(|_| R::default()).collect();
|
||||||
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect();
|
let teams: Vec<Vec<_>> = competitors.iter().map(|p| vec![*p]).collect();
|
||||||
let result = vec![3.0, 2.0, 1.0, 0.0];
|
let result = vec![3.0, 2.0, 1.0, 0.0];
|
||||||
let weights = vec![vec![1.0]; 4];
|
let weights = vec![vec![1.0]; 4];
|
||||||
|
|
||||||
// Capped at 1 iteration: cannot fully propagate down a 4-team chain.
|
// Capped at 1 iteration: cannot fully propagate down a 4-team chain.
|
||||||
let mut arena = ScratchArena::new();
|
let mut arena = ScratchArena::new();
|
||||||
let g_capped = Game::ranked_with_arena(
|
let g_capped = GameRef::ranked_with_arena(
|
||||||
teams.clone(),
|
teams.clone(),
|
||||||
&result,
|
&result,
|
||||||
&weights,
|
&weights,
|
||||||
@@ -1425,7 +1567,7 @@ mod tests {
|
|||||||
|
|
||||||
// Same inputs, plenty of iterations: fully converged.
|
// Same inputs, plenty of iterations: fully converged.
|
||||||
let mut arena = ScratchArena::new();
|
let mut arena = ScratchArena::new();
|
||||||
let g_full = Game::ranked_with_arena(
|
let g_full = GameRef::ranked_with_arena(
|
||||||
teams,
|
teams,
|
||||||
&result,
|
&result,
|
||||||
&weights,
|
&weights,
|
||||||
@@ -1451,13 +1593,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn run_chain_with_damping_converges_to_same_posterior() {
|
fn run_chain_with_damping_converges_to_same_posterior() {
|
||||||
let players: Vec<R> = (0..4).map(|_| R::default()).collect();
|
let competitors: Vec<R> = (0..4).map(|_| R::default()).collect();
|
||||||
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect();
|
let teams: Vec<Vec<_>> = competitors.iter().map(|p| vec![*p]).collect();
|
||||||
let result = vec![3.0, 2.0, 1.0, 0.0];
|
let result = vec![3.0, 2.0, 1.0, 0.0];
|
||||||
let weights = vec![vec![1.0]; 4];
|
let weights = vec![vec![1.0]; 4];
|
||||||
|
|
||||||
let mut arena = ScratchArena::new();
|
let mut arena = ScratchArena::new();
|
||||||
let g_undamped = Game::ranked_with_arena(
|
let g_undamped = GameRef::ranked_with_arena(
|
||||||
teams.clone(),
|
teams.clone(),
|
||||||
&result,
|
&result,
|
||||||
&weights,
|
&weights,
|
||||||
@@ -1469,7 +1611,7 @@ mod tests {
|
|||||||
|
|
||||||
// alpha=0.5 with extra iterations: should reach the same fixed point.
|
// alpha=0.5 with extra iterations: should reach the same fixed point.
|
||||||
let mut arena = ScratchArena::new();
|
let mut arena = ScratchArena::new();
|
||||||
let g_damped = Game::ranked_with_arena(
|
let g_damped = GameRef::ranked_with_arena(
|
||||||
teams,
|
teams,
|
||||||
&result,
|
&result,
|
||||||
&weights,
|
&weights,
|
||||||
|
|||||||
+200
-67
@@ -1,5 +1,3 @@
|
|||||||
use std::ops;
|
|
||||||
|
|
||||||
use crate::{MU, N_INF, SIGMA};
|
use crate::{MU, N_INF, SIGMA};
|
||||||
|
|
||||||
/// A Gaussian distribution stored in natural parameters.
|
/// A Gaussian distribution stored in natural parameters.
|
||||||
@@ -11,6 +9,7 @@ use crate::{MU, N_INF, SIGMA};
|
|||||||
/// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and
|
/// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and
|
||||||
/// `sigma()` are accessors computed on demand.
|
/// `sigma()` are accessors computed on demand.
|
||||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||||
|
#[must_use]
|
||||||
pub struct Gaussian {
|
pub struct Gaussian {
|
||||||
pi: f64,
|
pi: f64,
|
||||||
tau: f64,
|
tau: f64,
|
||||||
@@ -18,8 +17,43 @@ pub struct Gaussian {
|
|||||||
|
|
||||||
impl Gaussian {
|
impl Gaussian {
|
||||||
/// Construct from mean and standard deviation.
|
/// Construct from mean and standard deviation.
|
||||||
#[must_use]
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a
|
||||||
|
/// broken fit produces one, and `converge` reports that as
|
||||||
|
/// `NonFiniteStep` rather than panicking mid-inference.
|
||||||
|
///
|
||||||
|
/// A negative sigma used to be accepted and returned results **bit
|
||||||
|
/// identical** to its absolute value, because sigma only ever enters as
|
||||||
|
/// `sigma * sigma`. The sign was not rejected and not honoured; it simply
|
||||||
|
/// vanished. That is the same defect `HistoryBuilder::sigma`,
|
||||||
|
/// `HistoryBuilder::beta` and `Member::with_drift_scale` already reject.
|
||||||
|
///
|
||||||
|
/// # Very small sigma
|
||||||
|
///
|
||||||
|
/// `pi = 1 / sigma^2` leaves `f64`'s range below about `1.5e-154`, and
|
||||||
|
/// `tau = mu * pi` overflows sooner still — at a threshold that depends on
|
||||||
|
/// `mu`, so there is a band where `pi` is finite and only `tau` is not.
|
||||||
|
/// Both land on the same point-mass representation the `sigma == 0.0`
|
||||||
|
/// branch produces, and a point mass with a non-zero mean has `mu() = NaN`,
|
||||||
|
/// because `tau / pi` is `inf / inf`.
|
||||||
|
///
|
||||||
|
/// This is not rejected, because `approx` legitimately produces a very
|
||||||
|
/// small truncated sigma and inference must not panic. It is worth knowing
|
||||||
|
/// that such a `Gaussian` is not equal to itself, so two identical
|
||||||
|
/// declarations of one can be reported as conflicting.
|
||||||
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
||||||
|
// NaN is admitted on purpose. A broken fit legitimately produces a NaN
|
||||||
|
// sigma — `sqrt` of a negative truncated variance — and the design is
|
||||||
|
// to propagate that to `converge`'s `NonFiniteStep` guard, not to
|
||||||
|
// panic inside inference. Rejecting it here turned that reporting path
|
||||||
|
// into a crash, which two tests caught immediately.
|
||||||
|
assert!(
|
||||||
|
sigma >= 0.0 || sigma.is_nan(),
|
||||||
|
"sigma must not be negative; it is only ever squared, so a negative \
|
||||||
|
value would silently behave as its absolute value"
|
||||||
|
);
|
||||||
if sigma == f64::INFINITY {
|
if sigma == f64::INFINITY {
|
||||||
Self { pi: 0.0, tau: 0.0 }
|
Self { pi: 0.0, tau: 0.0 }
|
||||||
} else if sigma == 0.0 {
|
} else if sigma == 0.0 {
|
||||||
@@ -39,11 +73,12 @@ impl Gaussian {
|
|||||||
/// Construct from mean and *variance*, skipping the square-root round trip.
|
/// Construct from mean and *variance*, skipping the square-root round trip.
|
||||||
///
|
///
|
||||||
/// `from_ms(mu, var.sqrt())` immediately squares the root away again to
|
/// `from_ms(mu, var.sqrt())` immediately squares the root away again to
|
||||||
/// recover `pi = 1/var`. Variance-combining operations (`Add`, `Sub`,
|
/// recover `pi = 1/var`. Variance-combining operations work in variance
|
||||||
/// `exclude`, `forget`) work in variance space throughout, so they go
|
/// space throughout, so they go through here instead and never take a
|
||||||
/// through here instead and never take a root.
|
/// root. Use it whenever you already hold a variance —
|
||||||
|
/// [`variance`](Gaussian::variance) is its inverse.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub(crate) fn from_mv(mu: f64, var: f64) -> Self {
|
pub fn from_mv(mu: f64, var: f64) -> Self {
|
||||||
if var == f64::INFINITY {
|
if var == f64::INFINITY {
|
||||||
Self { pi: 0.0, tau: 0.0 }
|
Self { pi: 0.0, tau: 0.0 }
|
||||||
} else if var == 0.0 {
|
} else if var == 0.0 {
|
||||||
@@ -64,18 +99,32 @@ impl Gaussian {
|
|||||||
Self { pi, tau }
|
Self { pi, tau }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Precision, `1 / sigma^2` — one of the two natural parameters.
|
||||||
|
///
|
||||||
|
/// This is the representation the type actually stores, which is why the EP
|
||||||
|
/// product and cavity (`Mul` / `Div`) are plain adds and subtracts. Larger
|
||||||
|
/// means more certain; `0.0` is an improper, uninformative message and
|
||||||
|
/// `inf` is a point mass.
|
||||||
#[inline]
|
#[inline]
|
||||||
#[must_use]
|
pub(crate) fn pi(&self) -> f64 {
|
||||||
pub fn pi(&self) -> f64 {
|
|
||||||
self.pi
|
self.pi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Precision-adjusted mean, `mu / sigma^2` — the other natural parameter.
|
||||||
|
///
|
||||||
|
/// Stored rather than derived, for the same reason as [`Gaussian::pi`].
|
||||||
|
/// Meaningful only alongside `pi`: on its own it is not a location.
|
||||||
#[inline]
|
#[inline]
|
||||||
#[must_use]
|
pub(crate) fn tau(&self) -> f64 {
|
||||||
pub fn tau(&self) -> f64 {
|
|
||||||
self.tau
|
self.tau
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mean skill: the point estimate.
|
||||||
|
///
|
||||||
|
/// Derived from the natural parameters as `tau / pi`. An improper message
|
||||||
|
/// (`pi <= 0`) has no defined mean and reports `0.0` — see
|
||||||
|
/// [`Gaussian::sigma`], which reports `inf` for the same state, and read
|
||||||
|
/// the two together before treating a mean as informative.
|
||||||
#[inline]
|
#[inline]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn mu(&self) -> f64 {
|
pub fn mu(&self) -> f64 {
|
||||||
@@ -90,12 +139,14 @@ impl Gaussian {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Variance, `1 / pi`, without the root-and-square of `sigma().powi(2)`.
|
/// Variance, without the root-and-square of `sigma().powi(2)`.
|
||||||
///
|
///
|
||||||
/// Mirrors `sigma()`'s treatment of the improper (`pi <= 0`) and point-mass
|
/// Mirrors [`sigma`](Gaussian::sigma)'s treatment of the improper
|
||||||
/// (`pi == inf`) cases.
|
/// (infinite) and point-mass (zero) cases, and is the inverse of
|
||||||
|
/// [`from_mv`](Gaussian::from_mv).
|
||||||
#[inline]
|
#[inline]
|
||||||
pub(crate) fn variance(&self) -> f64 {
|
#[must_use]
|
||||||
|
pub fn variance(&self) -> f64 {
|
||||||
if self.pi <= 0.0 {
|
if self.pi <= 0.0 {
|
||||||
f64::INFINITY
|
f64::INFINITY
|
||||||
} else if self.pi.is_infinite() {
|
} else if self.pi.is_infinite() {
|
||||||
@@ -105,6 +156,12 @@ impl Gaussian {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Standard deviation: how unsure this estimate is.
|
||||||
|
///
|
||||||
|
/// Derived as `1 / sqrt(pi)`. An improper message (`pi <= 0`) reports
|
||||||
|
/// `inf`, and a point mass (`pi == inf`) reports `0.0` — both are real
|
||||||
|
/// states rather than error codes, and both are legitimate for a converged
|
||||||
|
/// fit with degenerate parameters.
|
||||||
#[inline]
|
#[inline]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn sigma(&self) -> f64 {
|
pub fn sigma(&self) -> f64 {
|
||||||
@@ -120,7 +177,25 @@ impl Gaussian {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How far this Gaussian moved from `other`, as `(|d mu|, |d sigma|)`.
|
||||||
|
///
|
||||||
|
/// Identical messages have not moved, whatever their parameters, and that
|
||||||
|
/// case is answered in natural space before touching `mu()`/`sigma()`. An
|
||||||
|
/// improper message has `pi == 0`, so `sigma()` is infinite — and
|
||||||
|
/// `inf - inf` is NaN, a NaN *change* for a message that did not change at
|
||||||
|
/// all. (`mu()` is guarded and returns 0.0 here, so the mean component was
|
||||||
|
/// never the problem; the sigma component alone produced `(0.0, NaN)`.)
|
||||||
|
///
|
||||||
|
/// That is reachable in ordinary inference: once a pairing is more than
|
||||||
|
/// about nine cavity-sigma apart the truncation is a no-op, `trunc / cavity`
|
||||||
|
/// is exactly the identity message, and the chain compares one identity
|
||||||
|
/// against another. Before this guard that produced `(0.0, NaN)`, which
|
||||||
|
/// silently disabled the sigma half of the convergence test.
|
||||||
pub(crate) fn delta(&self, other: Gaussian) -> (f64, f64) {
|
pub(crate) fn delta(&self, other: Gaussian) -> (f64, f64) {
|
||||||
|
if self.pi == other.pi && self.tau == other.tau {
|
||||||
|
return (0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
(
|
(
|
||||||
(self.mu() - other.mu()).abs(),
|
(self.mu() - other.mu()).abs(),
|
||||||
(self.sigma() - other.sigma()).abs(),
|
(self.sigma() - other.sigma()).abs(),
|
||||||
@@ -189,8 +264,7 @@ impl Gaussian {
|
|||||||
/// Used by within-game inference to stabilise oscillating fixed-point
|
/// Used by within-game inference to stabilise oscillating fixed-point
|
||||||
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
|
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
|
||||||
/// `alpha < 1.0` shrinks each per-step update.
|
/// `alpha < 1.0` shrinks each per-step update.
|
||||||
#[must_use]
|
pub(crate) fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
|
||||||
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
|
|
||||||
Gaussian::from_natural(
|
Gaussian::from_natural(
|
||||||
alpha * new.pi() + (1.0 - alpha) * self.pi(),
|
alpha * new.pi() + (1.0 - alpha) * self.pi(),
|
||||||
alpha * new.tau() + (1.0 - alpha) * self.tau(),
|
alpha * new.tau() + (1.0 - alpha) * self.tau(),
|
||||||
@@ -204,34 +278,65 @@ impl Default for Gaussian {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ops::Add<Gaussian> for Gaussian {
|
impl Gaussian {
|
||||||
type Output = Gaussian;
|
/// The EP factor **product**: multiply two messages about the same
|
||||||
/// Variance addition: (mu1 + mu2, sqrt(σ1² + σ2²)).
|
/// variable.
|
||||||
/// Used for combining performance and noise; rare relative to mul/div.
|
///
|
||||||
fn add(self, rhs: Gaussian) -> Self::Output {
|
/// Two natural-parameter additions and no square root, which is why the
|
||||||
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
|
/// type stores `pi` and `tau` rather than `mu` and `sigma`. This is the
|
||||||
}
|
/// hot path.
|
||||||
}
|
///
|
||||||
|
/// Not arithmetic — `N(10, 2).ep_product(N(4, 3))` is `N(8.15, 1.66)`,
|
||||||
impl ops::Sub<Gaussian> for Gaussian {
|
/// nowhere near 40. It used to be spelled `a * b`, on a public `Mul` impl,
|
||||||
type Output = Gaussian;
|
/// where that was a trap rather than a shorthand.
|
||||||
/// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add.
|
#[inline]
|
||||||
fn sub(self, rhs: Gaussian) -> Self::Output {
|
pub(crate) fn ep_product(self, rhs: Gaussian) -> Gaussian {
|
||||||
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ops::Mul<Gaussian> for Gaussian {
|
|
||||||
type Output = Gaussian;
|
|
||||||
/// Factor product: nat-param add. Hot path — two f64 additions, no sqrt.
|
|
||||||
fn mul(self, rhs: Gaussian) -> Self::Output {
|
|
||||||
Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau)
|
Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl ops::Mul<f64> for Gaussian {
|
/// The EP **cavity**: divide out a message this belief already absorbed.
|
||||||
type Output = Gaussian;
|
///
|
||||||
fn mul(self, scalar: f64) -> Self::Output {
|
/// The inverse of [`ep_product`](Gaussian::ep_product), and two
|
||||||
|
/// subtractions rather than two additions.
|
||||||
|
///
|
||||||
|
/// **May return an improper result.** Cancelling a message that carried
|
||||||
|
/// most of the precision leaves `pi <= 0`, which is not a distribution.
|
||||||
|
/// `mu()` reports `0.0` and `sigma()` reports `inf` for such a value —
|
||||||
|
/// both are the accessors' policy for "undefined", not answers. Measured:
|
||||||
|
/// `N(10, 2).cavity(N(1, 1))` has `pi = -0.75`, and its `mu()` prints a
|
||||||
|
/// confident `0`. That is why this is not a public operator.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn cavity(self, rhs: Gaussian) -> Gaussian {
|
||||||
|
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convolve two independent Gaussians: `N(mu1 + mu2, sqrt(v1 + v2))`.
|
||||||
|
///
|
||||||
|
/// The distribution of a *sum* of independent variables, so the variances
|
||||||
|
/// add — the result is always wider than either input. Used to combine a
|
||||||
|
/// skill with performance noise. Goes through `from_mv` and takes no root.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn convolve(self, rhs: Gaussian) -> Gaussian {
|
||||||
|
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convolve a *difference*: `N(mu1 - mu2, sqrt(v1 + v2))`.
|
||||||
|
///
|
||||||
|
/// The means subtract and the variances still **add**, because a
|
||||||
|
/// difference of independent variables is no more certain than a sum. That
|
||||||
|
/// is the half that made the old `Sub` impl misleading: `a - b` grew the
|
||||||
|
/// sigma from 2 to `sqrt(4 + 9)`.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn convolve_diff(self, rhs: Gaussian) -> Gaussian {
|
||||||
|
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scale by a constant: `mu` by `scalar`, `sigma` by `|scalar|`.
|
||||||
|
///
|
||||||
|
/// The one operation that *is* ordinary arithmetic — it is the
|
||||||
|
/// distribution of `scalar * X`. Used for per-member weights.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn scale(self, scalar: f64) -> Gaussian {
|
||||||
if !scalar.is_finite() {
|
if !scalar.is_finite() {
|
||||||
return N_INF;
|
return N_INF;
|
||||||
}
|
}
|
||||||
@@ -246,16 +351,44 @@ impl ops::Mul<f64> for Gaussian {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ops::Div<Gaussian> for Gaussian {
|
|
||||||
type Output = Gaussian;
|
|
||||||
/// Cavity: nat-param sub. Hot path — two f64 subtractions, no sqrt.
|
|
||||||
fn div(self, rhs: Gaussian) -> Self::Output {
|
|
||||||
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
/// A message that did not change must report no change, even when it is
|
||||||
|
/// improper. `mu()` of an improper Gaussian is `0/0 = NaN` and `sigma()` is
|
||||||
|
/// infinite, so the mean/sigma form reported `(NaN, NaN)` for two identical
|
||||||
|
/// identity messages — which silently disabled the sigma half of the
|
||||||
|
/// convergence test in `run_chain`.
|
||||||
|
#[test]
|
||||||
|
fn delta_of_two_identical_improper_messages_is_zero() {
|
||||||
|
let improper = crate::N_INF;
|
||||||
|
// `mu()` is guarded and returns 0.0 for an improper Gaussian, so the
|
||||||
|
// mean component was always fine. The NaN came from the sigma
|
||||||
|
// component alone: `inf - inf`. The pre-fix value was `(0.0, NaN)`.
|
||||||
|
assert!(improper.sigma().is_infinite(), "premise: sigma is infinite");
|
||||||
|
assert_eq!(improper.mu(), 0.0, "premise: mu is guarded, not NaN");
|
||||||
|
assert!(
|
||||||
|
(improper.sigma() - improper.sigma()).is_nan(),
|
||||||
|
"premise: the unguarded sigma difference is NaN"
|
||||||
|
);
|
||||||
|
assert_eq!(improper.delta(improper), (0.0, 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delta_of_identical_proper_messages_is_zero() {
|
||||||
|
let g = Gaussian::from_ms(25.0, 8.0);
|
||||||
|
assert_eq!(g.delta(g), (0.0, 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shortcut must not swallow a real difference.
|
||||||
|
#[test]
|
||||||
|
fn delta_still_measures_a_real_move() {
|
||||||
|
let a = Gaussian::from_ms(25.0, 8.0);
|
||||||
|
let b = Gaussian::from_ms(26.0, 9.0);
|
||||||
|
let (dmu, dsigma) = a.delta(b);
|
||||||
|
assert!((dmu - 1.0).abs() < 1e-12, "{dmu}");
|
||||||
|
assert!((dsigma - 1.0).abs() < 1e-12, "{dsigma}");
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -275,64 +408,64 @@ mod tests {
|
|||||||
|
|
||||||
// Subtracting such a message must not produce NaN (the original failure path).
|
// Subtracting such a message must not produce NaN (the original failure path).
|
||||||
let proper = Gaussian::from_ms(9.75, 1.256);
|
let proper = Gaussian::from_ms(9.75, 1.256);
|
||||||
let diff = proper - tiny_neg;
|
let diff = proper.convolve_diff(tiny_neg);
|
||||||
assert!(diff.pi().is_finite() && !diff.pi().is_nan());
|
assert!(diff.pi().is_finite() && !diff.pi().is_nan());
|
||||||
assert!(diff.tau().is_finite() && !diff.tau().is_nan());
|
assert!(diff.tau().is_finite() && !diff.tau().is_nan());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_add() {
|
fn convolve_adds_variances() {
|
||||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||||
let m = Gaussian::from_ms(0.0, 1.0);
|
let m = Gaussian::from_ms(0.0, 1.0);
|
||||||
let r = n + m;
|
let r = n.convolve(m);
|
||||||
assert!((r.mu() - 25.0).abs() < 1e-12);
|
assert!((r.mu() - 25.0).abs() < 1e-12);
|
||||||
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
|
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sub() {
|
fn convolve_diff_subtracts_means_and_adds_variances() {
|
||||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||||
let m = Gaussian::from_ms(1.0, 1.0);
|
let m = Gaussian::from_ms(1.0, 1.0);
|
||||||
let r = n - m;
|
let r = n.convolve_diff(m);
|
||||||
assert!((r.mu() - 24.0).abs() < 1e-12);
|
assert!((r.mu() - 24.0).abs() < 1e-12);
|
||||||
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
|
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_mul() {
|
fn ep_product_is_not_arithmetic() {
|
||||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||||
let m = Gaussian::from_ms(0.0, 1.0);
|
let m = Gaussian::from_ms(0.0, 1.0);
|
||||||
let r = n * m;
|
let r = n.ep_product(m);
|
||||||
assert!((r.mu() - 0.35488958990536273).abs() < 1e-10);
|
assert!((r.mu() - 0.35488958990536273).abs() < 1e-10);
|
||||||
assert!((r.sigma() - 0.992876838486922).abs() < 1e-10);
|
assert!((r.sigma() - 0.992876838486922).abs() < 1e-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_div() {
|
fn cavity_undoes_a_product() {
|
||||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||||
let m = Gaussian::from_ms(0.0, 1.0);
|
let m = Gaussian::from_ms(0.0, 1.0);
|
||||||
let r = m / n;
|
let r = m.cavity(n);
|
||||||
assert!((r.mu() - (-0.3652597402597402)).abs() < 1e-10);
|
assert!((r.mu() - (-0.3652597402597402)).abs() < 1e-10);
|
||||||
assert!((r.sigma() - 1.0072787050317253).abs() < 1e-10);
|
assert!((r.sigma() - 1.0072787050317253).abs() < 1e-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_n00_is_add_identity() {
|
fn test_n00_is_add_identity() {
|
||||||
// N00 (sigma=0) is the additive identity for the variance-convolution Add op.
|
// N00 (sigma=0) is the identity for `convolve`.
|
||||||
// N_INF (sigma=inf) is the identity for the EP-product Mul op.
|
// N_INF (sigma=inf) is the identity for `ep_product`.
|
||||||
let g = Gaussian::from_ms(3.0, 2.0);
|
let g = Gaussian::from_ms(3.0, 2.0);
|
||||||
let n00 = Gaussian::from_ms(0.0, 0.0);
|
let n00 = Gaussian::from_ms(0.0, 0.0);
|
||||||
let r = n00 + g;
|
let r = n00.convolve(g);
|
||||||
assert!((r.mu() - g.mu()).abs() < 1e-12);
|
assert!((r.mu() - g.mu()).abs() < 1e-12);
|
||||||
assert!((r.sigma() - g.sigma()).abs() < 1e-12);
|
assert!((r.sigma() - g.sigma()).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_mul_is_factor_product() {
|
fn ep_product_adds_natural_parameters() {
|
||||||
// n * m in nat-params should be pi_n + pi_m, tau_n + tau_m
|
// `ep_product` in nat-params should be pi_n + pi_m, tau_n + tau_m
|
||||||
let n = Gaussian::from_ms(2.0, 3.0);
|
let n = Gaussian::from_ms(2.0, 3.0);
|
||||||
let m = Gaussian::from_ms(1.0, 2.0);
|
let m = Gaussian::from_ms(1.0, 2.0);
|
||||||
let r = n * m;
|
let r = n.ep_product(m);
|
||||||
let expected_pi = n.pi() + m.pi();
|
let expected_pi = n.pi() + m.pi();
|
||||||
let expected_tau = n.tau() + m.tau();
|
let expected_tau = n.tau() + m.tau();
|
||||||
assert!((r.pi() - expected_pi).abs() < 1e-15);
|
assert!((r.pi() - expected_pi).abs() < 1e-15);
|
||||||
@@ -340,10 +473,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_div_is_cavity() {
|
fn cavity_subtracts_natural_parameters() {
|
||||||
let n = Gaussian::from_ms(2.0, 1.0);
|
let n = Gaussian::from_ms(2.0, 1.0);
|
||||||
let m = Gaussian::from_ms(1.0, 2.0);
|
let m = Gaussian::from_ms(1.0, 2.0);
|
||||||
let r = n / m;
|
let r = n.cavity(m);
|
||||||
let expected_pi = n.pi() - m.pi();
|
let expected_pi = n.pi() - m.pi();
|
||||||
let expected_tau = n.tau() - m.tau();
|
let expected_tau = n.tau() - m.tau();
|
||||||
assert!((r.pi() - expected_pi).abs() < 1e-15);
|
assert!((r.pi() - expected_pi).abs() < 1e-15);
|
||||||
|
|||||||
+1118
-514
File diff suppressed because it is too large
Load Diff
+405
-47
@@ -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 {
|
||||||
|
n: 0,
|
||||||
|
inv: Vec::new(),
|
||||||
|
col_ptr: vec![0],
|
||||||
|
row_idx: Vec::new(),
|
||||||
|
val: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for j in 0..n {
|
let inv = Self::amd_permutation(n, &built)?;
|
||||||
let mut d = a[j * n + j];
|
|
||||||
for k in 0..j {
|
// Upper triangle of the permuted matrix, column-major: column `c`
|
||||||
d -= a[j * n + k] * a[j * n + k];
|
// 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-12
@@ -26,21 +26,24 @@ where
|
|||||||
K: Eq + Hash + Clone,
|
K: Eq + Hash + Clone,
|
||||||
{
|
{
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub(crate) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
forward: HashMap::new(),
|
forward: HashMap::new(),
|
||||||
reverse: Vec::new(),
|
reverse: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index>
|
pub(crate) fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index>
|
||||||
where
|
where
|
||||||
K: Borrow<Q>,
|
K: Borrow<Q>,
|
||||||
{
|
{
|
||||||
self.forward.get(k).cloned()
|
self.forward.get(k).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_or_create<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(&mut self, k: &Q) -> Index
|
pub(crate) fn get_or_create<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(
|
||||||
|
&mut self,
|
||||||
|
k: &Q,
|
||||||
|
) -> Index
|
||||||
where
|
where
|
||||||
K: Borrow<Q>,
|
K: Borrow<Q>,
|
||||||
{
|
{
|
||||||
@@ -56,23 +59,24 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn key(&self, idx: Index) -> Option<&K> {
|
pub(crate) fn key(&self, idx: Index) -> Option<&K> {
|
||||||
self.reverse.get(idx.0)
|
self.reverse.get(idx.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn keys(&self) -> impl Iterator<Item = &K> {
|
/// Every key, in the order they were first interned.
|
||||||
self.forward.keys()
|
///
|
||||||
|
/// Iterates the dense reverse table rather than the forward `HashMap`.
|
||||||
|
/// Rust seeds its default hasher per process, so a `HashMap` walk yields a
|
||||||
|
/// different order on every run — which is fine for membership but not for
|
||||||
|
/// anything a caller might sum, sort or print.
|
||||||
|
pub(crate) fn keys(&self) -> impl ExactSizeIterator<Item = &K> {
|
||||||
|
self.reverse.iter()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn len(&self) -> usize {
|
pub(crate) fn len(&self) -> usize {
|
||||||
self.reverse.len()
|
self.reverse.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.reverse.is_empty()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<K> Default for KeyTable<K>
|
impl<K> Default for KeyTable<K>
|
||||||
|
|||||||
+358
-43
@@ -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:
|
||||||
@@ -85,6 +89,10 @@
|
|||||||
//! regardless of worker count.
|
//! regardless of worker count.
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
// Turned on once the surface was fully documented (80 items at the time), so
|
||||||
|
// the next undocumented public item is a build failure rather than a warning
|
||||||
|
// nobody reads.
|
||||||
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
/// Compiles every `rust` block in `README.md` as a doctest.
|
/// Compiles every `rust` block in `README.md` as a doctest.
|
||||||
///
|
///
|
||||||
@@ -104,22 +112,32 @@ use std::{
|
|||||||
f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2},
|
f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod acquisition;
|
||||||
#[cfg(feature = "approx")]
|
#[cfg(feature = "approx")]
|
||||||
mod approx;
|
mod approx;
|
||||||
pub(crate) mod arena;
|
pub(crate) mod arena;
|
||||||
mod time;
|
|
||||||
mod time_slice;
|
|
||||||
pub use time_slice::{EventKind, TimeSlice};
|
|
||||||
mod acquisition;
|
|
||||||
mod color_group;
|
mod color_group;
|
||||||
mod competitor;
|
mod competitor;
|
||||||
mod convergence;
|
mod convergence;
|
||||||
|
/// Skill drift: how much a competitor's skill is allowed to move between
|
||||||
|
/// appearances.
|
||||||
|
///
|
||||||
|
/// Public because [`Drift`] is a trait a caller may implement — a per-sport
|
||||||
|
/// off-season, say, or a schedule where drift is a function of the calendar
|
||||||
|
/// rather than of elapsed ticks. [`ConstantDrift`] is what
|
||||||
|
/// [`HistoryBuilder`] uses by default.
|
||||||
pub mod drift;
|
pub mod drift;
|
||||||
mod error;
|
mod error;
|
||||||
mod event;
|
mod event;
|
||||||
mod event_builder;
|
mod event_builder;
|
||||||
pub(crate) mod factor;
|
pub(crate) mod factor;
|
||||||
mod game;
|
mod game;
|
||||||
|
/// The Gaussian message type and its expectation-propagation algebra.
|
||||||
|
///
|
||||||
|
/// Public because [`Gaussian`] appears throughout the results: a posterior
|
||||||
|
/// skill, a learning-curve point, a predicted margin. The module carries the
|
||||||
|
/// operator documentation — `Mul`/`Div` are the EP product and cavity, not
|
||||||
|
/// arithmetic on random variables.
|
||||||
pub mod gaussian;
|
pub mod gaussian;
|
||||||
mod history;
|
mod history;
|
||||||
mod joint;
|
mod joint;
|
||||||
@@ -130,31 +148,78 @@ mod outcome;
|
|||||||
mod predict;
|
mod predict;
|
||||||
pub(crate) mod quadrature;
|
pub(crate) mod quadrature;
|
||||||
mod rating;
|
mod rating;
|
||||||
pub mod storage;
|
pub mod rating_rule;
|
||||||
|
pub(crate) mod storage;
|
||||||
|
mod time;
|
||||||
|
mod time_slice;
|
||||||
|
|
||||||
pub use acquisition::expected_information_gain;
|
pub use acquisition::expected_information_gain;
|
||||||
pub use competitor::Competitor;
|
|
||||||
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, OwnedGame};
|
pub use game::{Game, GameOptions};
|
||||||
pub use gaussian::Gaussian;
|
pub use gaussian::Gaussian;
|
||||||
pub use history::{History, HistoryBuilder, Joint};
|
pub use history::{History, HistoryBuilder, Joint};
|
||||||
pub use key_table::KeyTable;
|
|
||||||
use matrix::Matrix;
|
use matrix::Matrix;
|
||||||
pub use observer::{NullObserver, Observer};
|
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.
|
||||||
|
///
|
||||||
|
/// Four public items name `SmallVec` in their signatures: [`Event::teams`],
|
||||||
|
/// [`Team::members`], [`Outcome::Ranked`]'s payload and
|
||||||
|
/// [`ConvergenceReport::per_iteration_time`]. You can *build* an `Event`
|
||||||
|
/// without ever naming the type — `vec![..].into()` and `.collect()` both work
|
||||||
|
/// — and iterate the timings through `Deref`. But writing a helper that
|
||||||
|
/// *returns* a teams list, or a `match` arm that binds ranks and passes them
|
||||||
|
/// on, requires the type by name.
|
||||||
|
///
|
||||||
|
/// Measured: the only `Joint` doc example failed to compile from a consumer
|
||||||
|
/// crate with `unresolved import \`smallvec\``, because the dependency was in
|
||||||
|
/// the signature but not reachable. Re-exported so a consumer takes this
|
||||||
|
/// crate's version rather than pinning a matching one of their own.
|
||||||
|
pub use smallvec;
|
||||||
pub use time::{Time, Untimed};
|
pub use time::{Time, Untimed};
|
||||||
|
|
||||||
|
/// Default performance noise: how much a single showing varies around skill.
|
||||||
|
///
|
||||||
|
/// Every other default is expressed as a multiple of this, so `BETA` sets the
|
||||||
|
/// scale of the whole rating system. Doubling it and doubling `SIGMA` and
|
||||||
|
/// `GAMMA` with it gives the same fit on a rescaled axis.
|
||||||
pub const BETA: f64 = 1.0;
|
pub const BETA: f64 = 1.0;
|
||||||
|
/// Default prior mean skill.
|
||||||
|
///
|
||||||
|
/// Zero rather than a conventional 25: the scale is set by `BETA`, and a
|
||||||
|
/// centred axis makes a negative rating mean "below the prior" instead of
|
||||||
|
/// looking like an error.
|
||||||
pub const MU: f64 = 0.0;
|
pub const MU: f64 = 0.0;
|
||||||
|
/// Default prior standard deviation: how unsure the model starts out.
|
||||||
|
///
|
||||||
|
/// Six betas is deliberately wide — a new competitor's first result should
|
||||||
|
/// move them a long way, and the prior should not fight the evidence.
|
||||||
pub const SIGMA: f64 = BETA * 6.0;
|
pub const SIGMA: f64 = BETA * 6.0;
|
||||||
|
/// Default drift: the standard deviation of skill movement per unit of time.
|
||||||
|
///
|
||||||
|
/// Enters inference as a *variance* (`gamma^2` per elapsed tick), which is why
|
||||||
|
/// [`ConstantDrift`] squares it and why a negative gamma would be
|
||||||
|
/// indistinguishable from its absolute value — see [`ConstantDrift::new`].
|
||||||
pub const GAMMA: f64 = BETA * 0.03;
|
pub const GAMMA: f64 = BETA * 0.03;
|
||||||
|
/// Default draw probability: zero, meaning ties are not modelled.
|
||||||
|
///
|
||||||
|
/// A history that ingests a tie needs a positive value. With `p_draw == 0.0`
|
||||||
|
/// the truncation margin is zero and the two-sided tie update evaluates
|
||||||
|
/// `0/0`, so ingestion rejects such events with
|
||||||
|
/// [`InferenceError::TieWithoutDrawProbability`].
|
||||||
pub const P_DRAW: f64 = 0.0;
|
pub const P_DRAW: f64 = 0.0;
|
||||||
|
/// Default convergence threshold, in the same units as
|
||||||
|
/// [`ConvergenceReport::final_step`](crate::ConvergenceReport).
|
||||||
|
///
|
||||||
|
/// The sweep stops once the largest change a full iteration makes to any
|
||||||
|
/// message falls below this.
|
||||||
pub const EPSILON: f64 = 1e-6;
|
pub const EPSILON: f64 = 1e-6;
|
||||||
/// Default cap on convergence sweeps.
|
/// Default cap on convergence sweeps.
|
||||||
///
|
///
|
||||||
@@ -218,22 +283,35 @@ const HALF_LINE_WINDOW: f64 = 10.0;
|
|||||||
/// four-term series is good to ~1e-10 by here, so the two are at their closest
|
/// four-term series is good to ~1e-10 by here, so the two are at their closest
|
||||||
/// agreement around this point. Below it the subtraction is exact; above it the
|
/// agreement around this point. Below it the subtraction is exact; above it the
|
||||||
/// series is.
|
/// series is.
|
||||||
|
/// `alpha / width` past which the tie branch's `v^2 - u` has lost too many
|
||||||
|
/// digits to trust, and the narrow-window form takes over.
|
||||||
|
///
|
||||||
|
/// The subtraction retains about `(width / alpha)^2 / EPSILON` of its
|
||||||
|
/// precision, so this is the ratio at which that falls below roughly 1e-6.
|
||||||
|
const NARROW_WINDOW_RATIO: f64 = 2.0e4;
|
||||||
const ASYMPTOTIC_MILLS_ALPHA: f64 = 100.0;
|
const ASYMPTOTIC_MILLS_ALPHA: f64 = 100.0;
|
||||||
|
|
||||||
pub const N01: Gaussian = Gaussian::from_ms(0.0, 1.0);
|
pub(crate) const N00: Gaussian = Gaussian::from_ms(0.0, 0.0);
|
||||||
pub const N00: Gaussian = Gaussian::from_ms(0.0, 0.0);
|
pub(crate) const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
|
||||||
pub const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
|
|
||||||
|
|
||||||
|
/// An interned competitor handle: a dense slot number, not a user key.
|
||||||
|
///
|
||||||
|
/// `History` stores skills and messages by `Index` rather than by `K`, so the
|
||||||
|
/// hot path never hashes a key. Indices are assigned in interning order and
|
||||||
|
/// are stable for the life of a history; they are not portable between
|
||||||
|
/// histories, since the same key interns to a different slot under a different
|
||||||
|
/// ingestion order.
|
||||||
|
///
|
||||||
|
/// Crate-internal. It was public, along with `History::intern` and
|
||||||
|
/// `History::lookup` that produced one — and **nothing public ever accepted
|
||||||
|
/// one**, so it was a handle with nowhere to go. It also shadowed
|
||||||
|
/// `std::ops::Index`, which `CompetitorStore` implements. See #73.
|
||||||
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
|
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
|
||||||
pub struct Index(usize);
|
pub(crate) struct Index(usize);
|
||||||
|
|
||||||
impl Index {
|
impl Index {
|
||||||
/// The underlying slot number.
|
/// The underlying slot number.
|
||||||
///
|
pub(crate) fn get(self) -> usize {
|
||||||
/// Indices are dense and assigned in interning order, so this is usable as
|
|
||||||
/// a key into a caller-side side table.
|
|
||||||
#[must_use]
|
|
||||||
pub fn get(self) -> usize {
|
|
||||||
self.0
|
self.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -476,10 +554,72 @@ fn pdf(x: f64, mu: f64, sigma: f64) -> f64 {
|
|||||||
fn half_line_truncation(alpha: f64) -> (f64, f64) {
|
fn half_line_truncation(alpha: f64) -> (f64, f64) {
|
||||||
let inv = alpha.recip();
|
let inv = alpha.recip();
|
||||||
let inv_sq = inv * inv;
|
let inv_sq = inv * inv;
|
||||||
let gap = inv * (1.0 - inv_sq * (2.0 - inv_sq * (10.0 - 74.0 * inv_sq)));
|
let b = 2.0 - inv_sq * (10.0 - 74.0 * inv_sq);
|
||||||
|
let gap = inv * (1.0 - inv_sq * b);
|
||||||
let v = alpha + gap;
|
let v = alpha + gap;
|
||||||
|
|
||||||
(v, v * gap)
|
// Returns `1 - w`, not `w`, and that is the whole point of this shape.
|
||||||
|
//
|
||||||
|
// `w` tends to 1 out here, so a caller forming `1 - w` loses about
|
||||||
|
// `log10(alpha^2)` digits: measured against the exact truncated variance,
|
||||||
|
// `1 - w` came back with 8.9e-5 relative error at alpha = 1e6 and **0.0**
|
||||||
|
// from alpha = 1e8 — where the true value is 1e-16 and perfectly
|
||||||
|
// representable. `sigma * (1 - w).sqrt()` was then exactly zero, and
|
||||||
|
// `from_ms(mu, 0.0)` is a point mass whose `mu()` is `inf/inf = NaN`.
|
||||||
|
//
|
||||||
|
// Expanding `1 - v*gap` symbolically removes the subtraction: with
|
||||||
|
// `alpha*gap = 1 - inv^2*b`, the leading ones cancel on paper instead of in
|
||||||
|
// floating point, leaving `inv^2` times a bracket that tends to 1. Measured
|
||||||
|
// exact — 0.0 relative error — from alpha = 1e3 to 1e8.
|
||||||
|
let one_minus_w = inv_sq
|
||||||
|
* ((1.0 - inv_sq * (10.0 - 74.0 * inv_sq)) + 2.0 * inv_sq * b - inv_sq * inv_sq * b * b);
|
||||||
|
|
||||||
|
(v, one_minus_w)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncation to a *narrow* window `[alpha, alpha + d]`, as `(v, 1 - w)`.
|
||||||
|
///
|
||||||
|
/// The tie branch forms `w` from `v^2 - u`, and both grow as `alpha^2` while
|
||||||
|
/// their difference stays `O(1)`. Far enough into the tail that subtraction has
|
||||||
|
/// nothing left: measured at `alpha = 1e6` with a window of `1e-6` it kept four
|
||||||
|
/// significant digits and returned `1 - w = -2.4e-4` where the truth is
|
||||||
|
/// `+2.8e-13`, so `sqrt` of it was NaN. One step earlier it was quietly wrong
|
||||||
|
/// instead — `1 - w = 1.0` exactly, a truncation reported as a no-op, where the
|
||||||
|
/// truth was `5e-17`.
|
||||||
|
///
|
||||||
|
/// The existing half-line escape hatch does not cover it, because that keys on
|
||||||
|
/// `alpha * d >= HALF_LINE_WINDOW` — how many window-widths from the mean the
|
||||||
|
/// window sits — and a *narrow* window fails that however deep it is.
|
||||||
|
///
|
||||||
|
/// Over a narrow window the density is `exp(-t*s - s^2 d^2 / 2)` in
|
||||||
|
/// `x = alpha + s*d`, with `t = alpha * d`. Dropping the `d^2` term leaves a
|
||||||
|
/// truncated exponential on `[0, 1]`, whose mean and variance are closed forms.
|
||||||
|
/// So `v = alpha + d*m(t)` and `1 - w = d^2 * V(t)`, with no subtraction of
|
||||||
|
/// large quantities anywhere.
|
||||||
|
///
|
||||||
|
/// Measured against high-precision quadrature over `alpha` in `[1e2, 1e9]`:
|
||||||
|
/// `v` exact to 4e-10 or better, `1 - w` to 4e-10 across the region this is
|
||||||
|
/// used in.
|
||||||
|
fn narrow_window_truncation(alpha: f64, d: f64) -> (f64, f64) {
|
||||||
|
let t = alpha * d;
|
||||||
|
|
||||||
|
// `m` and `V` are the mean and variance of a truncated exponential on
|
||||||
|
// [0, 1] with rate `t`, both of which cancel as `t -> 0`. The series is
|
||||||
|
// their limit (1/2 and 1/12, a uniform window) with the leading correction.
|
||||||
|
let (m, v_s) = if t < 1e-3 {
|
||||||
|
(
|
||||||
|
0.5 - t / 12.0 + t * t * t / 720.0,
|
||||||
|
1.0 / 12.0 - t * t / 240.0,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let em1 = libm::expm1(t);
|
||||||
|
(
|
||||||
|
1.0 / t - 1.0 / em1,
|
||||||
|
1.0 / (t * t) - (em1 + 1.0) / (em1 * em1),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
(alpha + d * m, d * d * v_s)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
||||||
@@ -507,7 +647,7 @@ fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
|||||||
(v, v - alpha)
|
(v, v - alpha)
|
||||||
};
|
};
|
||||||
|
|
||||||
(v, v * gap)
|
(v, 1.0 - v * gap)
|
||||||
} else {
|
} else {
|
||||||
// v is odd in mu and w is even, so fold to mu <= 0. Both truncation
|
// v is odd in mu and w is even, so fold to mu <= 0. Both truncation
|
||||||
// points then sit in the upper tail, where the scaled form applies.
|
// points then sit in the upper tail, where the scaled form applies.
|
||||||
@@ -523,9 +663,22 @@ fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
|||||||
// Once the window sits many of its own widths into the tail it is
|
// Once the window sits many of its own widths into the tail it is
|
||||||
// indistinguishable from a half-line, so the asymptotic covers it with
|
// indistinguishable from a half-line, so the asymptotic covers it with
|
||||||
// no subtraction at all.
|
// no subtraction at all.
|
||||||
if alpha >= ASYMPTOTIC_MILLS_ALPHA && alpha * (beta - alpha) >= HALF_LINE_WINDOW {
|
let width = beta - alpha;
|
||||||
let (v, w) = half_line_truncation(alpha);
|
|
||||||
return (if flipped { -v } else { v }, w);
|
if alpha >= ASYMPTOTIC_MILLS_ALPHA && alpha * width >= HALF_LINE_WINDOW {
|
||||||
|
let (v, one_minus_w) = half_line_truncation(alpha);
|
||||||
|
return (if flipped { -v } else { v }, one_minus_w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A narrow window deep in the tail: too narrow for the half-line above,
|
||||||
|
// too deep for the subtraction below. The direct form keeps roughly
|
||||||
|
// `1 / (alpha/width)^2` of its digits, so the crossover is on that
|
||||||
|
// ratio rather than on either quantity alone — and the approximation is
|
||||||
|
// most accurate exactly where the subtraction is worst, since both
|
||||||
|
// improve as the window narrows.
|
||||||
|
if alpha > 0.0 && alpha > NARROW_WINDOW_RATIO * width {
|
||||||
|
let (v, one_minus_w) = narrow_window_truncation(alpha, width);
|
||||||
|
return (if flipped { -v } else { v }, one_minus_w);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (v, u) = if alpha > 0.0 {
|
let (v, u) = if alpha > 0.0 {
|
||||||
@@ -548,17 +701,23 @@ fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let w = -(u - v.powi(2));
|
// `1 - w` where `w = v^2 - u`. Both `v^2` and `u` grow as alpha^2 while
|
||||||
|
// their difference stays O(1), so this subtraction is the one place the
|
||||||
|
// tie branch can still lose everything — see the escape hatch above,
|
||||||
|
// which is what keeps the far tail away from it.
|
||||||
|
let one_minus_w = 1.0 + u - v.powi(2);
|
||||||
|
|
||||||
(if flipped { -v } else { v }, w)
|
(if flipped { -v } else { v }, one_minus_w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn trunc(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
fn trunc(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
|
||||||
let (v, w) = v_w(mu, sigma, margin, tie);
|
// `v_w` returns `1 - w` rather than `w`: forming the difference here is
|
||||||
|
// what destroyed the truncated variance in the far tail.
|
||||||
|
let (v, one_minus_w) = v_w(mu, sigma, margin, tie);
|
||||||
|
|
||||||
let mu_trunc = mu + sigma * v;
|
let mu_trunc = mu + sigma * v;
|
||||||
let sigma_trunc = sigma * (1.0 - w).sqrt();
|
let sigma_trunc = sigma * one_minus_w.sqrt();
|
||||||
|
|
||||||
(mu_trunc, sigma_trunc)
|
(mu_trunc, sigma_trunc)
|
||||||
}
|
}
|
||||||
@@ -569,13 +728,34 @@ pub(crate) fn approx(n: Gaussian, margin: f64, tie: bool) -> Gaussian {
|
|||||||
Gaussian::from_ms(mu, sigma)
|
Gaussian::from_ms(mu, sigma)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Componentwise maximum that **propagates** NaN rather than dropping it.
|
||||||
|
///
|
||||||
|
/// Every caller folds this as `tuple_max(accumulator, new)`. A plain `>`
|
||||||
|
/// comparison is false against NaN, so a NaN accumulator would be replaced by
|
||||||
|
/// the next finite delta and the breakdown would vanish — leaving `step_is_finite`
|
||||||
|
/// to pass on a fit that is already NaN. Because the fold runs over a `HashMap`,
|
||||||
|
/// whether that happened depended on per-process hash order: measured, a NaN fit
|
||||||
|
/// was reported as `converged: true` in 16 of 30 runs on identical input.
|
||||||
|
///
|
||||||
|
/// `f64::max` is not a substitute: it also ignores NaN by design, which is the
|
||||||
|
/// same defect wearing a standard-library name.
|
||||||
pub(crate) fn tuple_max(v1: (f64, f64), v2: (f64, f64)) -> (f64, f64) {
|
pub(crate) fn tuple_max(v1: (f64, f64), v2: (f64, f64)) -> (f64, f64) {
|
||||||
(
|
(
|
||||||
if v1.0 > v2.0 { v1.0 } else { v2.0 },
|
max_propagating_nan(v1.0, v2.0),
|
||||||
if v1.1 > v2.1 { v1.1 } else { v2.1 },
|
max_propagating_nan(v1.1, v2.1),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn max_propagating_nan(a: f64, b: f64) -> f64 {
|
||||||
|
if a.is_nan() || b.is_nan() {
|
||||||
|
f64::NAN
|
||||||
|
} else if a > b {
|
||||||
|
a
|
||||||
|
} else {
|
||||||
|
b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn tuple_gt(t: (f64, f64), e: f64) -> bool {
|
pub(crate) fn tuple_gt(t: (f64, f64), e: f64) -> bool {
|
||||||
t.0 > e || t.1 > e
|
t.0 > e || t.1 > e
|
||||||
}
|
}
|
||||||
@@ -642,29 +822,36 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
|
|||||||
x.into_iter().map(|(i, _)| i).collect()
|
x.into_iter().map(|(i, _)| i).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates the match quality of the given rating groups. A result is the draw probability in the association
|
/// Calculates the match quality of the given teams. A result is the draw probability in the association
|
||||||
///
|
///
|
||||||
/// Supports any number of groups. Values range roughly `[0, 1]`; 1 means a
|
/// Supports any number of groups. Values range roughly `[0, 1]`; 1 means a
|
||||||
/// perfectly balanced match.
|
/// perfectly balanced match.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
///
|
///
|
||||||
/// Panics if fewer than two rating groups are supplied, or if any group is
|
/// Panics if fewer than two teams are supplied, or if any group is
|
||||||
/// empty — match quality is a property of a contest between at least two
|
/// empty — match quality is a property of a contest between at least two
|
||||||
/// non-empty sides.
|
/// non-empty sides.
|
||||||
|
///
|
||||||
|
/// Also panics with "cannot invert a singular matrix" when every rating has
|
||||||
|
/// zero sigma *and* `beta` is zero. Nothing is then uncertain, so there is no
|
||||||
|
/// distribution to take the quality of; `Gaussian::from_ms(mu, 0.0)` is a point
|
||||||
|
/// mass and its `mu()` is not even well defined. Documented rather than
|
||||||
|
/// converted, because the input has no meaningful answer rather than an
|
||||||
|
/// awkward one.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
pub fn quality(teams: &[&[Gaussian]], beta: f64) -> f64 {
|
||||||
assert!(
|
assert!(
|
||||||
rating_groups.len() >= 2,
|
teams.len() >= 2,
|
||||||
"quality() requires at least 2 rating groups, got {}",
|
"quality() requires at least 2 teams, got {}",
|
||||||
rating_groups.len()
|
teams.len()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
rating_groups.iter().all(|group| !group.is_empty()),
|
teams.iter().all(|group| !group.is_empty()),
|
||||||
"quality() requires every rating group to be non-empty"
|
"quality() requires every team to be non-empty"
|
||||||
);
|
);
|
||||||
|
|
||||||
let flatten_ratings = rating_groups
|
let flatten_ratings = teams
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|group| group.iter())
|
.flat_map(|group| group.iter())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -685,14 +872,14 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
|||||||
variance_matrix[(i, i)] = rating.sigma().powi(2);
|
variance_matrix[(i, i)] = rating.sigma().powi(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut rotated_a_matrix = Matrix::new(rating_groups.len() - 1, length);
|
let mut rotated_a_matrix = Matrix::new(teams.len() - 1, length);
|
||||||
|
|
||||||
// Row `row` contrasts group `row` (+weight) against group `row + 1`
|
// Row `row` contrasts group `row` (+weight) against group `row + 1`
|
||||||
// (-weight). `t` is the column where the current group's players start;
|
// (-weight). `t` is the column where the current group's players start;
|
||||||
// the negative block begins immediately after it.
|
// the negative block begins immediately after it.
|
||||||
let mut t = 0;
|
let mut t = 0;
|
||||||
|
|
||||||
for (row, group) in rating_groups.windows(2).enumerate() {
|
for (row, group) in teams.windows(2).enumerate() {
|
||||||
let current = group[0];
|
let current = group[0];
|
||||||
let next = group[1];
|
let next = group[1];
|
||||||
|
|
||||||
@@ -717,13 +904,141 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
|||||||
let end = &rotated_a_matrix * &mean_matrix;
|
let end = &rotated_a_matrix * &mean_matrix;
|
||||||
|
|
||||||
let e_arg = (-0.5 * &start * &middle.inverse() * &end).determinant();
|
let e_arg = (-0.5 * &start * &middle.inverse() * &end).determinant();
|
||||||
let s_arg = ata.determinant() / middle.determinant();
|
|
||||||
|
|
||||||
libm::exp(e_arg) * s_arg.sqrt()
|
// `sqrt(det(ata) / det(middle))`, taken in log space. Both determinants are
|
||||||
|
// products of `k - 1` diagonal entries, so they leave `f64`'s range long
|
||||||
|
// before their ratio does: measured at the crate defaults, 150 groups was
|
||||||
|
// correct at `8.45e-53`, 200 returned `0`, and 250 returned `NaN` where the
|
||||||
|
// true value is `9.51e-88`. With a small beta it is sharper still — at
|
||||||
|
// `sigma = beta = 1e-3`, 60 groups returned `NaN` against a true `1.32e-9`.
|
||||||
|
//
|
||||||
|
// The ratio is what the answer needs and it is representable throughout, so
|
||||||
|
// the intermediates are the only thing that ever overflowed.
|
||||||
|
let ln_s_arg = ata.ln_abs_determinant() - middle.ln_abs_determinant();
|
||||||
|
|
||||||
|
libm::exp(e_arg + 0.5 * ln_s_arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
/// The truncated variance must stay a variance across every branch, and
|
||||||
|
/// the branches must agree where they meet.
|
||||||
|
///
|
||||||
|
/// `v_w` now has three regimes for a tie — half-line, narrow-window, and
|
||||||
|
/// the direct subtraction — and a misplaced crossover between them is the
|
||||||
|
/// failure mode this guards. A jump at a boundary is visible here even
|
||||||
|
/// though the absolute values are not pinned.
|
||||||
|
#[test]
|
||||||
|
fn truncated_variance_is_continuous_across_the_tie_branches() {
|
||||||
|
for &alpha in &[50.0, 99.0, 100.0, 101.0, 1e3, 1e5, 1e6] {
|
||||||
|
// Sweep the window width across NARROW_WINDOW_RATIO and the
|
||||||
|
// half-line threshold, which sit at different widths per alpha.
|
||||||
|
let mut previous: Option<(f64, f64)> = None;
|
||||||
|
let mut width = alpha / (NARROW_WINDOW_RATIO * 100.0);
|
||||||
|
while width < 40.0 / alpha {
|
||||||
|
// mu = 0 puts the window at [-margin, margin]; shift it out to
|
||||||
|
// `alpha` by moving the mean instead.
|
||||||
|
let margin = width * 0.5;
|
||||||
|
let mu = -(alpha + width * 0.5);
|
||||||
|
let (v, one_minus_w) = v_w(mu, 1.0, margin, true);
|
||||||
|
|
||||||
|
assert!(v.is_finite(), "alpha {alpha}, width {width:e}: v = {v}");
|
||||||
|
assert!(
|
||||||
|
one_minus_w.is_finite() && one_minus_w > 0.0 && one_minus_w <= 1.0,
|
||||||
|
"alpha {alpha}, width {width:e}: 1 - w = {one_minus_w:e} is not a variance"
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some((pv, pw)) = previous {
|
||||||
|
// Consecutive widths differ by 2x, so the moments may not
|
||||||
|
// differ by more than a small multiple of that.
|
||||||
|
assert!(
|
||||||
|
one_minus_w / pw < 32.0 && pw / one_minus_w < 32.0,
|
||||||
|
"alpha {alpha}: 1 - w jumped from {pw:e} to {one_minus_w:e} \
|
||||||
|
at width {width:e} — a branch boundary is misplaced"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(v - pv).abs() <= 8.0 * width.max(1e-12) + 1e-9 * v.abs(),
|
||||||
|
"alpha {alpha}: v jumped from {pv} to {v} at width {width:e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
previous = Some((v, one_minus_w));
|
||||||
|
width *= 2.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The narrow-window form against high-precision quadrature.
|
||||||
|
///
|
||||||
|
/// These are the inputs where the direct `v^2 - u` subtraction had four
|
||||||
|
/// significant digits left and returned a negative variance.
|
||||||
|
#[test]
|
||||||
|
fn narrow_window_truncation_matches_quadrature() {
|
||||||
|
for &(alpha, d, expect_v, expect_w) in &[
|
||||||
|
(1e6, 2e-6, 1_000_000.000_000_687, 2.759_383_390_335_666e-13),
|
||||||
|
(1e4, 1e-6, 10_000.000_000_499_167, 8.333_291_666_831_727e-14),
|
||||||
|
(
|
||||||
|
1e3,
|
||||||
|
1e-5,
|
||||||
|
1_000.000_004_991_666_6,
|
||||||
|
8.333_291_666_803_818e-12,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let (v, one_minus_w) = narrow_window_truncation(alpha, d);
|
||||||
|
assert!(
|
||||||
|
((v - expect_v) / expect_v).abs() < 1e-12,
|
||||||
|
"alpha {alpha:e}: v = {v}, want {expect_v}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
((one_minus_w - expect_w) / expect_w).abs() < 1e-8,
|
||||||
|
"alpha {alpha:e}: 1 - w = {one_minus_w:e}, want {expect_w:e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A NaN must survive the fold from ANY position, not only the last.
|
||||||
|
///
|
||||||
|
/// The fold runs over a `HashMap`, so "last" is per-process hash order. The
|
||||||
|
/// end-to-end symptom was a NaN fit reported as `converged: true` in 16 of
|
||||||
|
/// 30 runs on identical input; these three cases are the deterministic form
|
||||||
|
/// of that, so a regression cannot hide behind a lucky seed.
|
||||||
|
#[test]
|
||||||
|
fn tuple_max_propagates_a_nan_from_any_position() {
|
||||||
|
let nan = (f64::NAN, f64::NAN);
|
||||||
|
let small = (1e-9, 1e-9);
|
||||||
|
let big = (1e-3, 1e-3);
|
||||||
|
|
||||||
|
// NaN last.
|
||||||
|
let step = tuple_max(tuple_max(big, small), nan);
|
||||||
|
assert!(!step_is_finite(step), "NaN last: {step:?}");
|
||||||
|
|
||||||
|
// NaN middle.
|
||||||
|
let step = tuple_max(tuple_max(big, nan), small);
|
||||||
|
assert!(!step_is_finite(step), "NaN middle: {step:?}");
|
||||||
|
|
||||||
|
// NaN first — the case a plain `>` comparison drops.
|
||||||
|
let step = tuple_max(tuple_max(nan, big), small);
|
||||||
|
assert!(!step_is_finite(step), "NaN first: {step:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `f64::max` would pass the test above's first two cases and fail the
|
||||||
|
/// third, so pin that it is not what we use.
|
||||||
|
#[test]
|
||||||
|
fn tuple_max_is_not_f64_max() {
|
||||||
|
assert!(
|
||||||
|
f64::max(f64::NAN, 1.0) == 1.0,
|
||||||
|
"premise: f64::max drops NaN"
|
||||||
|
);
|
||||||
|
let (a, _) = tuple_max((f64::NAN, 0.0), (1.0, 0.0));
|
||||||
|
assert!(a.is_nan(), "tuple_max must not drop what f64::max drops");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordinary values are unaffected.
|
||||||
|
#[test]
|
||||||
|
fn tuple_max_still_takes_the_larger_component() {
|
||||||
|
assert_eq!(tuple_max((1.0, 5.0), (3.0, 2.0)), (3.0, 5.0));
|
||||||
|
assert_eq!(tuple_max((3.0, 2.0), (1.0, 5.0)), (3.0, 5.0));
|
||||||
|
}
|
||||||
|
|
||||||
use ::approx::assert_ulps_eq;
|
use ::approx::assert_ulps_eq;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+45
-4
@@ -91,6 +91,29 @@ impl Lu {
|
|||||||
det
|
det
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ln |det|`, accumulated term by term rather than multiplied out.
|
||||||
|
///
|
||||||
|
/// The determinant of an `n x n` Gram matrix is a product of `n` diagonal
|
||||||
|
/// entries, so it leaves `f64`'s range long before the quantities built
|
||||||
|
/// from it do. `quality()` only ever wants a *ratio* of two determinants,
|
||||||
|
/// and that ratio is perfectly representable while the determinants
|
||||||
|
/// themselves are not — measured, at 250 rating groups both overflow and
|
||||||
|
/// the ratio came back `NaN` where the true answer is `9.51e-88`.
|
||||||
|
///
|
||||||
|
/// Returns `-inf` for a singular matrix, so `exp` of it is zero.
|
||||||
|
fn ln_abs_determinant(&self) -> f64 {
|
||||||
|
if self.sign == 0.0 {
|
||||||
|
return f64::NEG_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut acc = 0.0;
|
||||||
|
for i in 0..self.n {
|
||||||
|
acc += libm::log(self.lu[i * self.n + i].abs());
|
||||||
|
}
|
||||||
|
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
|
||||||
/// Solve `Ax = b` for a single column of the identity, giving one column
|
/// Solve `Ax = b` for a single column of the identity, giving one column
|
||||||
/// of the inverse.
|
/// of the inverse.
|
||||||
fn solve_column(&self, col: usize, out: &mut [f64]) {
|
fn solve_column(&self, col: usize, out: &mut [f64]) {
|
||||||
@@ -117,7 +140,7 @@ impl Lu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Matrix {
|
impl Matrix {
|
||||||
pub fn new(height: usize, width: usize) -> Matrix {
|
pub(crate) fn new(height: usize, width: usize) -> Matrix {
|
||||||
Matrix {
|
Matrix {
|
||||||
data: vec![0.0; height * width].into_boxed_slice(),
|
data: vec![0.0; height * width].into_boxed_slice(),
|
||||||
height,
|
height,
|
||||||
@@ -125,7 +148,7 @@ impl Matrix {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn transpose(&self) -> Matrix {
|
pub(crate) fn transpose(&self) -> Matrix {
|
||||||
let mut matrix = Matrix::new(self.width, self.height);
|
let mut matrix = Matrix::new(self.width, self.height);
|
||||||
|
|
||||||
for c in 0..self.width {
|
for c in 0..self.width {
|
||||||
@@ -143,7 +166,7 @@ impl Matrix {
|
|||||||
/// # Panics
|
/// # Panics
|
||||||
///
|
///
|
||||||
/// Panics if the matrix is not square.
|
/// Panics if the matrix is not square.
|
||||||
pub fn determinant(&self) -> f64 {
|
pub(crate) fn determinant(&self) -> f64 {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
self.width, self.height,
|
self.width, self.height,
|
||||||
"determinant requires a square matrix, got {}x{}",
|
"determinant requires a square matrix, got {}x{}",
|
||||||
@@ -157,12 +180,30 @@ impl Matrix {
|
|||||||
Lu::decompose(self).determinant()
|
Lu::decompose(self).determinant()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ln |det|` of a square matrix; `-inf` when singular.
|
||||||
|
///
|
||||||
|
/// See [`Lu::ln_abs_determinant`] for why a ratio of determinants must be
|
||||||
|
/// taken this way.
|
||||||
|
pub(crate) fn ln_abs_determinant(&self) -> f64 {
|
||||||
|
assert_eq!(
|
||||||
|
self.width, self.height,
|
||||||
|
"determinant requires a square matrix, got {}x{}",
|
||||||
|
self.height, self.width
|
||||||
|
);
|
||||||
|
|
||||||
|
if self.width == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Lu::decompose(self).ln_abs_determinant()
|
||||||
|
}
|
||||||
|
|
||||||
/// Matrix inverse via LU decomposition.
|
/// Matrix inverse via LU decomposition.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
///
|
///
|
||||||
/// Panics if the matrix is not square or is singular.
|
/// Panics if the matrix is not square or is singular.
|
||||||
pub fn inverse(&self) -> Matrix {
|
pub(crate) fn inverse(&self) -> Matrix {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
self.width, self.height,
|
self.width, self.height,
|
||||||
"inverse requires a square matrix, got {}x{}",
|
"inverse requires a square matrix, got {}x{}",
|
||||||
|
|||||||
+48
-17
@@ -1,6 +1,6 @@
|
|||||||
//! Outcome of a match.
|
//! Outcome of a match.
|
||||||
//!
|
//!
|
||||||
//! `Ranked(ranks)` for ordinal results; `Scored { scores, sigma }` for
|
//! `Ranked(ranks)` for ordinal results; `Scored { scores, score_sigma }` for
|
||||||
//! continuous per-team scores (engages `MarginFactor` in the engine).
|
//! continuous per-team scores (engages `MarginFactor` in the engine).
|
||||||
|
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
@@ -10,19 +10,41 @@ use smallvec::SmallVec;
|
|||||||
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
|
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
|
||||||
/// teams. `ranks.len()` must equal the number of teams in the event.
|
/// teams. `ranks.len()` must equal the number of teams in the event.
|
||||||
///
|
///
|
||||||
/// `Scored { scores, sigma }`: higher score = better. Adjacent (sorted) pairs
|
/// `Scored { scores, score_sigma }`: higher score = better. Adjacent (sorted) pairs
|
||||||
/// feed observed margins to `MarginFactor`. `scores.len()` must equal the
|
/// feed observed margins to `MarginFactor`. `scores.len()` must equal the
|
||||||
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
|
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
|
||||||
/// when `Some`; `None` inherits the history default.
|
/// when `Some`; `None` inherits the history default.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
|
#[must_use]
|
||||||
pub enum Outcome {
|
pub enum Outcome {
|
||||||
|
/// An ordinal finish: one rank per team, in the order the teams were given.
|
||||||
|
///
|
||||||
|
/// Lower is better, `0` is first, and equal values are a tie between those
|
||||||
|
/// teams — which needs `p_draw > 0`, or ingestion rejects the event with
|
||||||
|
/// [`InferenceError::TieWithoutDrawProbability`](crate::InferenceError::TieWithoutDrawProbability).
|
||||||
|
///
|
||||||
|
/// Only the ordering and the equalities are used. Ranks need not be dense
|
||||||
|
/// or start at zero: inference sorts the teams and compares rank-adjacent
|
||||||
|
/// pairs against a margin set by `p_draw`, so `[0, 1, 2]` and `[0, 5, 90]`
|
||||||
|
/// are the same observation. A gap does not mean a bigger win — use
|
||||||
|
/// `Scored` when the size of the difference is evidence.
|
||||||
Ranked(SmallVec<[u32; 4]>),
|
Ranked(SmallVec<[u32; 4]>),
|
||||||
|
/// A continuous finish: one score per team, higher is better.
|
||||||
|
///
|
||||||
|
/// Unlike `Ranked`, the *sizes* of the differences are evidence. Teams are
|
||||||
|
/// sorted by score and each adjacent pair's observed gap is fed to a
|
||||||
|
/// `MarginFactor` as a measurement with standard deviation `score_sigma`,
|
||||||
|
/// so
|
||||||
|
/// beating a team by ten says more than beating them by one.
|
||||||
|
#[non_exhaustive]
|
||||||
Scored {
|
Scored {
|
||||||
|
/// Per-team scores, in the order the teams were given; higher is
|
||||||
|
/// better. Must have one entry per team, and every entry finite.
|
||||||
scores: SmallVec<[f64; 4]>,
|
scores: SmallVec<[f64; 4]>,
|
||||||
/// Per-event noise override. `None` means inherit
|
/// Per-event noise override. `None` means inherit
|
||||||
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
|
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
|
||||||
sigma: Option<f64>,
|
score_sigma: Option<f64>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +67,6 @@ impl Outcome {
|
|||||||
/// `p_draw > 0`. Asking "team 5 won" and silently getting "everyone drew"
|
/// `p_draw > 0`. Asking "team 5 won" and silently getting "everyone drew"
|
||||||
/// is exactly the class of quiet wrong answer this crate keeps removing, so
|
/// is exactly the class of quiet wrong answer this crate keeps removing, so
|
||||||
/// the check happens here where the mistake is.
|
/// the check happens here where the mistake is.
|
||||||
#[must_use]
|
|
||||||
pub fn winner(winner: u32, n: u32) -> Self {
|
pub fn winner(winner: u32, n: u32) -> Self {
|
||||||
Self::try_winner(winner, n)
|
Self::try_winner(winner, n)
|
||||||
.unwrap_or_else(|_| panic!("winner index {winner} out of range 0..{n}"))
|
.unwrap_or_else(|_| panic!("winner index {winner} out of range 0..{n}"))
|
||||||
@@ -63,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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -72,7 +93,6 @@ impl Outcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// All `n` teams tied.
|
/// All `n` teams tied.
|
||||||
#[must_use]
|
|
||||||
pub fn draw(n: u32) -> Self {
|
pub fn draw(n: u32) -> Self {
|
||||||
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
|
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
|
||||||
}
|
}
|
||||||
@@ -87,23 +107,34 @@ impl Outcome {
|
|||||||
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
|
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
|
||||||
Self::Scored {
|
Self::Scored {
|
||||||
scores: scores.into_iter().collect(),
|
scores: scores.into_iter().collect(),
|
||||||
sigma: None,
|
score_sigma: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Explicit per-team continuous scores with a per-event noise override.
|
/// Explicit per-team continuous scores with a per-event noise override.
|
||||||
///
|
///
|
||||||
/// `sigma` must be `> 0.0`. Constructing an `Outcome` with a non-positive
|
/// The noise is on the *observed score margin*, in the units of the scores
|
||||||
/// or NaN sigma is allowed; the value is rejected with
|
/// themselves — it is not a skill sigma, which is what the old name
|
||||||
|
/// `scores_with_sigma` read as. It overrides `HistoryBuilder::score_sigma`
|
||||||
|
/// for this event only.
|
||||||
|
///
|
||||||
|
/// `score_sigma` must be `> 0.0`. Constructing an `Outcome` with a
|
||||||
|
/// non-positive or NaN value is allowed; the value is rejected with
|
||||||
/// `InferenceError::InvalidParameter` when the event is ingested, so
|
/// `InferenceError::InvalidParameter` when the event is ingested, so
|
||||||
/// callers get an error rather than a panic.
|
/// callers get an error rather than a panic.
|
||||||
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self {
|
pub fn scores_with_noise<I: IntoIterator<Item = f64>>(scores: I, score_sigma: f64) -> Self {
|
||||||
Self::Scored {
|
Self::Scored {
|
||||||
scores: scores.into_iter().collect(),
|
scores: scores.into_iter().collect(),
|
||||||
sigma: Some(sigma),
|
score_sigma: Some(score_sigma),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many teams this outcome describes — the number of ranks, or of
|
||||||
|
/// scores.
|
||||||
|
///
|
||||||
|
/// Ingestion checks it against the event's own team list and rejects a
|
||||||
|
/// disagreement with `MismatchedShape`, so this is the cheap way to check
|
||||||
|
/// an outcome built elsewhere before committing the event.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn team_count(&self) -> usize {
|
pub fn team_count(&self) -> usize {
|
||||||
match self {
|
match self {
|
||||||
@@ -185,7 +216,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scores_with_sigma_round_trips() {
|
fn scores_with_sigma_round_trips() {
|
||||||
let o = Outcome::scores_with_sigma([10.0, 4.0], 0.5);
|
let o = Outcome::scores_with_noise([10.0, 4.0], 0.5);
|
||||||
assert_eq!(o.team_count(), 2);
|
assert_eq!(o.team_count(), 2);
|
||||||
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
|
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
|
||||||
}
|
}
|
||||||
@@ -194,16 +225,16 @@ mod tests {
|
|||||||
fn scores_constructor_leaves_sigma_unset() {
|
fn scores_constructor_leaves_sigma_unset() {
|
||||||
let o = Outcome::scores([3.0, 1.0]);
|
let o = Outcome::scores([3.0, 1.0]);
|
||||||
match o {
|
match o {
|
||||||
Outcome::Scored { scores: _, sigma } => assert!(sigma.is_none()),
|
Outcome::Scored { score_sigma, .. } => assert!(score_sigma.is_none()),
|
||||||
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scores_with_sigma_sets_sigma_some() {
|
fn scores_with_sigma_sets_sigma_some() {
|
||||||
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0);
|
let o = Outcome::scores_with_noise([3.0, 1.0], 2.0);
|
||||||
match o {
|
match o {
|
||||||
Outcome::Scored { scores: _, sigma } => assert_eq!(sigma, Some(2.0)),
|
Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(2.0)),
|
||||||
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,9 +244,9 @@ mod tests {
|
|||||||
/// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`.
|
/// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`.
|
||||||
#[test]
|
#[test]
|
||||||
fn scores_with_sigma_defers_validation_to_ingestion() {
|
fn scores_with_sigma_defers_validation_to_ingestion() {
|
||||||
let o = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
|
let o = Outcome::scores_with_noise([3.0, 1.0], 0.0);
|
||||||
match o {
|
match o {
|
||||||
Outcome::Scored { sigma, .. } => assert_eq!(sigma, Some(0.0)),
|
Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(0.0)),
|
||||||
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-27
@@ -21,7 +21,7 @@
|
|||||||
//! would have made every `predict_*` call return a slightly different number,
|
//! would have made every `predict_*` call return a slightly different number,
|
||||||
//! which is not a property a rating library should have.
|
//! which is not a property a rating library should have.
|
||||||
|
|
||||||
use crate::{Gaussian, quadrature};
|
use crate::{Gaussian, InferenceError, quadrature};
|
||||||
|
|
||||||
/// Teams beyond this count make the outcome enumeration impractical.
|
/// Teams beyond this count make the outcome enumeration impractical.
|
||||||
///
|
///
|
||||||
@@ -52,6 +52,10 @@ const WIN_TOLERANCE: f64 = 1e-8;
|
|||||||
/// point where refining stops helping.
|
/// point where refining stops helping.
|
||||||
const MIN_GRID_POINTS: usize = 8_192;
|
const MIN_GRID_POINTS: usize = 8_192;
|
||||||
const MAX_GRID_POINTS: usize = 262_144;
|
const MAX_GRID_POINTS: usize = 262_144;
|
||||||
|
/// Nodes requested across the narrowest feature the recursion must resolve.
|
||||||
|
const NODES_PER_FEATURE: f64 = 12.0;
|
||||||
|
/// Nodes below which the trapezoid rule stops resolving that feature at all.
|
||||||
|
const MIN_NODES_PER_FEATURE: f64 = 4.0;
|
||||||
|
|
||||||
/// How many standard deviations of support the grid and integrals cover.
|
/// How many standard deviations of support the grid and integrals cover.
|
||||||
///
|
///
|
||||||
@@ -152,7 +156,7 @@ pub(crate) fn win_probabilities(perf: &[Gaussian], margins: &Margins) -> Vec<f64
|
|||||||
/// Resolution is set by the *smallest* feature in play — the narrowest sigma,
|
/// Resolution is set by the *smallest* feature in play — the narrowest sigma,
|
||||||
/// or a draw margin narrower still — because that is what the recursion has to
|
/// or a draw margin narrower still — because that is what the recursion has to
|
||||||
/// resolve. A grid sized off the widest team would step over the narrow one.
|
/// resolve. A grid sized off the widest team would step over the narrow one.
|
||||||
fn grid_shape(perf: &[Gaussian], margins: &Margins) -> (f64, f64, usize) {
|
fn grid_shape(perf: &[Gaussian], margins: &Margins) -> Result<(f64, f64, usize), InferenceError> {
|
||||||
let lo = perf
|
let lo = perf
|
||||||
.iter()
|
.iter()
|
||||||
.map(|g| g.mu() - SUPPORT_SIGMAS * g.sigma())
|
.map(|g| g.mu() - SUPPORT_SIGMAS * g.sigma())
|
||||||
@@ -175,18 +179,36 @@ fn grid_shape(perf: &[Gaussian], margins: &Margins) -> (f64, f64, usize) {
|
|||||||
|
|
||||||
let feature = narrowest.min(smallest_margin);
|
let feature = narrowest.min(smallest_margin);
|
||||||
let wanted = if feature.is_finite() && feature > 0.0 {
|
let wanted = if feature.is_finite() && feature > 0.0 {
|
||||||
((hi - lo) / (feature / 12.0)).ceil()
|
((hi - lo) / (feature / NODES_PER_FEATURE)).ceil()
|
||||||
} else {
|
} else {
|
||||||
MIN_GRID_POINTS as f64
|
MIN_GRID_POINTS as f64
|
||||||
};
|
};
|
||||||
|
|
||||||
let points = if wanted.is_finite() {
|
if !wanted.is_finite() {
|
||||||
(wanted as usize).clamp(MIN_GRID_POINTS, MAX_GRID_POINTS)
|
return Ok((lo, hi, MIN_GRID_POINTS));
|
||||||
} else {
|
}
|
||||||
MIN_GRID_POINTS
|
|
||||||
};
|
|
||||||
|
|
||||||
(lo, hi, points)
|
// Report rather than clamp. Clamping is what this replaced: it silently
|
||||||
|
// handed the recursion a grid too coarse for the narrowest density, and the
|
||||||
|
// trapezoid rule then returned probabilities greater than one — measured, a
|
||||||
|
// `P` of 2.79 and a total of 5.41. Trapezoid error on a Gaussian is
|
||||||
|
// `~exp(-2 pi^2 (sigma/h)^2)`, which is 1e-12 at `h/sigma = 0.86` and O(1)
|
||||||
|
// by `h/sigma = 17`, so the cliff is sharp and there is no useful answer on
|
||||||
|
// the far side of it.
|
||||||
|
//
|
||||||
|
// The floor is `MIN_NODES_PER_FEATURE` rather than the `NODES_PER_FEATURE`
|
||||||
|
// asked for, because the request carries a large margin: measured accurate
|
||||||
|
// to 2.2e-12 at 1.4 nodes per sigma, and wrong by 1.2e-3 at 0.7.
|
||||||
|
let needed = wanted as usize;
|
||||||
|
let floor = ((hi - lo) / (feature / MIN_NODES_PER_FEATURE)).ceil();
|
||||||
|
if floor.is_finite() && floor as usize > MAX_GRID_POINTS {
|
||||||
|
return Err(InferenceError::GridTooCoarse {
|
||||||
|
needed,
|
||||||
|
max: MAX_GRID_POINTS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((lo, hi, needed.clamp(MIN_GRID_POINTS, MAX_GRID_POINTS)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Densities of each team sampled on the shared grid.
|
/// Densities of each team sampled on the shared grid.
|
||||||
@@ -198,8 +220,8 @@ struct Sampled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Sampled {
|
impl Sampled {
|
||||||
fn new(perf: &[Gaussian], margins: &Margins) -> Self {
|
fn new(perf: &[Gaussian], margins: &Margins) -> Result<Self, InferenceError> {
|
||||||
let (lo, hi, points) = grid_shape(perf, margins);
|
let (lo, hi, points) = grid_shape(perf, margins)?;
|
||||||
let step = (hi - lo) / (points - 1) as f64;
|
let step = (hi - lo) / (points - 1) as f64;
|
||||||
let density = perf
|
let density = perf
|
||||||
.iter()
|
.iter()
|
||||||
@@ -209,12 +231,12 @@ impl Sampled {
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self {
|
Ok(Self {
|
||||||
lo,
|
lo,
|
||||||
step,
|
step,
|
||||||
points,
|
points,
|
||||||
density,
|
density,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn node(&self, i: usize) -> f64 {
|
fn node(&self, i: usize) -> f64 {
|
||||||
@@ -317,9 +339,12 @@ fn events(n: usize, strict_only: bool) -> Vec<(Vec<usize>, Vec<bool>)> {
|
|||||||
///
|
///
|
||||||
/// Orders that differ only *within* a tied group describe the same finishing
|
/// Orders that differ only *within* a tied group describe the same finishing
|
||||||
/// order, so their probabilities are summed into one entry.
|
/// order, so their probabilities are summed into one entry.
|
||||||
pub(crate) fn outcome_distribution(perf: &[Gaussian], margins: &Margins) -> Vec<(Vec<u32>, f64)> {
|
pub(crate) fn outcome_distribution(
|
||||||
|
perf: &[Gaussian],
|
||||||
|
margins: &Margins,
|
||||||
|
) -> Result<Vec<(Vec<u32>, f64)>, InferenceError> {
|
||||||
let n = perf.len();
|
let n = perf.len();
|
||||||
let sampled = Sampled::new(perf, margins);
|
let sampled = Sampled::new(perf, margins)?;
|
||||||
|
|
||||||
let mut aggregated: Vec<(Vec<u32>, f64)> = Vec::new();
|
let mut aggregated: Vec<(Vec<u32>, f64)> = Vec::new();
|
||||||
for (order, tied) in events(n, margins.all_zero()) {
|
for (order, tied) in events(n, margins.all_zero()) {
|
||||||
@@ -332,7 +357,7 @@ pub(crate) fn outcome_distribution(perf: &[Gaussian], margins: &Margins) -> Vec<
|
|||||||
}
|
}
|
||||||
|
|
||||||
aggregated.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
aggregated.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
aggregated
|
Ok(aggregated)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All permutations of `items`.
|
/// All permutations of `items`.
|
||||||
@@ -397,9 +422,13 @@ fn orders_for_groups(groups: &[Vec<usize>]) -> Vec<(Vec<usize>, Vec<bool>)> {
|
|||||||
/// Ties in `ranks` mean the tied teams may finish in any internal order, so
|
/// Ties in `ranks` mean the tied teams may finish in any internal order, so
|
||||||
/// this sums the orders consistent with the requested ranking rather than
|
/// this sums the orders consistent with the requested ranking rather than
|
||||||
/// picking one.
|
/// picking one.
|
||||||
pub(crate) fn ranking_probability(perf: &[Gaussian], margins: &Margins, ranks: &[u32]) -> f64 {
|
pub(crate) fn ranking_probability(
|
||||||
|
perf: &[Gaussian],
|
||||||
|
margins: &Margins,
|
||||||
|
ranks: &[u32],
|
||||||
|
) -> Result<f64, InferenceError> {
|
||||||
let n = perf.len();
|
let n = perf.len();
|
||||||
let sampled = Sampled::new(perf, margins);
|
let sampled = Sampled::new(perf, margins)?;
|
||||||
|
|
||||||
let mut distinct: Vec<u32> = ranks.to_vec();
|
let mut distinct: Vec<u32> = ranks.to_vec();
|
||||||
distinct.sort_unstable();
|
distinct.sort_unstable();
|
||||||
@@ -410,10 +439,10 @@ pub(crate) fn ranking_probability(perf: &[Gaussian], margins: &Margins, ranks: &
|
|||||||
.map(|&r| (0..n).filter(|&i| ranks[i] == r).collect())
|
.map(|&r| (0..n).filter(|&i| ranks[i] == r).collect())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
orders_for_groups(&groups)
|
Ok(orders_for_groups(&groups)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(order, tied)| order_probability(margins, &sampled, order, tied))
|
.map(|(order, tied)| order_probability(margins, &sampled, order, tied))
|
||||||
.sum()
|
.sum())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A distribution over the ways a contest could finish.
|
/// A distribution over the ways a contest could finish.
|
||||||
@@ -427,6 +456,7 @@ pub(crate) fn ranking_probability(perf: &[Gaussian], margins: &Margins, ranks: &
|
|||||||
/// `Game::ranked` asks "what would we believe if *this* happened", which is
|
/// `Game::ranked` asks "what would we believe if *this* happened", which is
|
||||||
/// what an expected-information-gain calculation needs alongside the weight.
|
/// what an expected-information-gain calculation needs alongside the weight.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
#[must_use]
|
||||||
pub struct Prediction {
|
pub struct Prediction {
|
||||||
outcomes: Vec<(Vec<u32>, f64)>,
|
outcomes: Vec<(Vec<u32>, f64)>,
|
||||||
}
|
}
|
||||||
@@ -437,6 +467,7 @@ impl Prediction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Every possible finishing order and its probability, most likely first.
|
/// Every possible finishing order and its probability, most likely first.
|
||||||
|
#[must_use]
|
||||||
pub fn outcomes(&self) -> impl ExactSizeIterator<Item = (&[u32], f64)> {
|
pub fn outcomes(&self) -> impl ExactSizeIterator<Item = (&[u32], f64)> {
|
||||||
self.outcomes.iter().map(|(r, p)| (r.as_slice(), *p))
|
self.outcomes.iter().map(|(r, p)| (r.as_slice(), *p))
|
||||||
}
|
}
|
||||||
@@ -604,7 +635,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
let n = perf.len();
|
let n = perf.len();
|
||||||
let dist = outcome_distribution(&perf, &flat(n, eps));
|
let dist = outcome_distribution(&perf, &flat(n, eps)).unwrap();
|
||||||
let sum: f64 = dist.iter().map(|(_, p)| p).sum();
|
let sum: f64 = dist.iter().map(|(_, p)| p).sum();
|
||||||
assert!(
|
assert!(
|
||||||
(sum - 1.0).abs() < 1e-6,
|
(sum - 1.0).abs() < 1e-6,
|
||||||
@@ -620,7 +651,7 @@ mod tests {
|
|||||||
fn two_team_distribution_matches_the_closed_form() {
|
fn two_team_distribution_matches_the_closed_form() {
|
||||||
let perf = [g(3.0, 6.0), g(-2.0, 1.0)];
|
let perf = [g(3.0, 6.0), g(-2.0, 1.0)];
|
||||||
let eps = 1.5;
|
let eps = 1.5;
|
||||||
let dist = outcome_distribution(&perf, &flat(2, eps));
|
let dist = outcome_distribution(&perf, &flat(2, eps)).unwrap();
|
||||||
let (wa, wb) = closed_form_two(perf[0], perf[1], eps);
|
let (wa, wb) = closed_form_two(perf[0], perf[1], eps);
|
||||||
|
|
||||||
let find = |ranks: &[u32]| {
|
let find = |ranks: &[u32]| {
|
||||||
@@ -653,10 +684,10 @@ mod tests {
|
|||||||
let perf = [g(5.0, 6.0), g(0.0, 3.0), g(-5.0, 1.0)];
|
let perf = [g(5.0, 6.0), g(0.0, 3.0), g(-5.0, 1.0)];
|
||||||
let eps = 1.5;
|
let eps = 1.5;
|
||||||
let margins = flat(3, eps);
|
let margins = flat(3, eps);
|
||||||
let dist = outcome_distribution(&perf, &margins);
|
let dist = outcome_distribution(&perf, &margins).unwrap();
|
||||||
|
|
||||||
for (ranks, expected) in &dist {
|
for (ranks, expected) in &dist {
|
||||||
let direct = ranking_probability(&perf, &margins, ranks);
|
let direct = ranking_probability(&perf, &margins, ranks).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
(direct - expected).abs() < 1e-9,
|
(direct - expected).abs() < 1e-9,
|
||||||
"ranks {ranks:?}: direct {direct} vs distribution {expected}"
|
"ranks {ranks:?}: direct {direct} vs distribution {expected}"
|
||||||
@@ -675,7 +706,7 @@ mod tests {
|
|||||||
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
|
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
|
||||||
let mut previous = 0.0;
|
let mut previous = 0.0;
|
||||||
for eps in [0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 24.0] {
|
for eps in [0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 24.0] {
|
||||||
let p = ranking_probability(&perf, &flat(3, eps), &[0, 0, 0]);
|
let p = ranking_probability(&perf, &flat(3, eps), &[0, 0, 0]).unwrap();
|
||||||
assert!(p >= previous, "eps={eps}: {p} < {previous}");
|
assert!(p >= previous, "eps={eps}: {p} < {previous}");
|
||||||
if eps == 0.0 {
|
if eps == 0.0 {
|
||||||
assert!(p < 1e-12, "a tie needs a margin, got {p}");
|
assert!(p < 1e-12, "a tie needs a margin, got {p}");
|
||||||
@@ -696,7 +727,7 @@ mod tests {
|
|||||||
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
|
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
|
||||||
let sweep: Vec<f64> = [0.5, 2.0, 4.0, 8.0, 16.0]
|
let sweep: Vec<f64> = [0.5, 2.0, 4.0, 8.0, 16.0]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&eps| ranking_probability(&perf, &flat(3, eps), &[0, 0, 1]))
|
.map(|&eps| ranking_probability(&perf, &flat(3, eps), &[0, 0, 1]).unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
let peak = sweep
|
let peak = sweep
|
||||||
.iter()
|
.iter()
|
||||||
@@ -717,7 +748,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ties_are_impossible_without_a_draw_margin() {
|
fn ties_are_impossible_without_a_draw_margin() {
|
||||||
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(0.0, 4.0)];
|
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(0.0, 4.0)];
|
||||||
let dist = outcome_distribution(&perf, &flat(3, 0.0));
|
let dist = outcome_distribution(&perf, &flat(3, 0.0)).unwrap();
|
||||||
assert_eq!(dist.len(), 6, "expected only the 6 strict orders: {dist:?}");
|
assert_eq!(dist.len(), 6, "expected only the 6 strict orders: {dist:?}");
|
||||||
assert!(dist.iter().all(|(r, _)| {
|
assert!(dist.iter().all(|(r, _)| {
|
||||||
let mut seen = r.clone();
|
let mut seen = r.clone();
|
||||||
|
|||||||
+19
-3
@@ -11,7 +11,7 @@ use crate::{
|
|||||||
///
|
///
|
||||||
/// A configuration rather than a person: the per-history temporal state
|
/// A configuration rather than a person: the per-history temporal state
|
||||||
/// (messages, last appearance) lives on `Competitor`.
|
/// (messages, last appearance) lives on `Competitor`.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
||||||
pub(crate) prior: Gaussian,
|
pub(crate) prior: Gaussian,
|
||||||
pub(crate) beta: f64,
|
pub(crate) beta: f64,
|
||||||
@@ -23,7 +23,24 @@ pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>> Rating<T, D> {
|
impl<T: Time, D: Drift<T>> Rating<T, D> {
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics unless `beta` is finite and non-negative, matching
|
||||||
|
/// `HistoryBuilder::beta`.
|
||||||
|
///
|
||||||
|
/// Zero is allowed and meaningful — performance is then exactly skill, and
|
||||||
|
/// the fit differs measurably from a positive beta rather than degenerating.
|
||||||
|
/// Negative is rejected because `beta` enters only as `beta^2`: measured, a
|
||||||
|
/// negative beta returned results **bit identical** to its absolute value,
|
||||||
|
/// and a NaN beta reached `Game::ranked`, which returned `Ok` carrying a
|
||||||
|
/// `Gaussian { pi: NaN, tau: NaN }` — there is no `converge` on that path to
|
||||||
|
/// catch it.
|
||||||
pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self {
|
pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self {
|
||||||
|
assert!(
|
||||||
|
beta.is_finite() && beta >= 0.0,
|
||||||
|
"beta must be finite and non-negative (got {beta}); it is only ever \
|
||||||
|
squared, so a negative value would silently behave as its absolute value"
|
||||||
|
);
|
||||||
Self {
|
Self {
|
||||||
prior,
|
prior,
|
||||||
beta,
|
beta,
|
||||||
@@ -44,7 +61,6 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The configured prior skill estimate.
|
/// The configured prior skill estimate.
|
||||||
#[must_use]
|
|
||||||
pub fn prior(&self) -> Gaussian {
|
pub fn prior(&self) -> Gaussian {
|
||||||
self.prior
|
self.prior
|
||||||
}
|
}
|
||||||
@@ -93,7 +109,7 @@ impl Default for Rating<i64, ConstantDrift> {
|
|||||||
Self {
|
Self {
|
||||||
prior: Gaussian::default(),
|
prior: Gaussian::default(),
|
||||||
beta: BETA,
|
beta: BETA,
|
||||||
drift: ConstantDrift(GAMMA),
|
drift: ConstantDrift::new(GAMMA),
|
||||||
drift_scale: 1.0,
|
drift_scale: 1.0,
|
||||||
_time: PhantomData,
|
_time: PhantomData,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,16 +56,16 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
|
|||||||
self.get(idx).is_some()
|
self.get(idx).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test-only: no code path in the crate needs a count.
|
||||||
|
#[cfg(test)]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.n_present
|
self.n_present
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
/// Test-only: iterating every competitor is an assertion helper, not part
|
||||||
pub fn is_empty(&self) -> bool {
|
/// of inference, which walks slices rather than the store.
|
||||||
self.n_present == 0
|
#[cfg(test)]
|
||||||
}
|
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> {
|
pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> {
|
||||||
self.competitors
|
self.competitors
|
||||||
.iter()
|
.iter()
|
||||||
@@ -73,13 +73,6 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
|
|||||||
.filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a)))
|
.filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Competitor<T, D>)> {
|
|
||||||
self.competitors
|
|
||||||
.iter_mut()
|
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(i, slot)| slot.as_mut().map(|a| (Index(i), a)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Competitor<T, D>> {
|
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Competitor<T, D>> {
|
||||||
self.competitors.iter_mut().filter_map(|s| s.as_mut())
|
self.competitors.iter_mut().filter_map(|s| s.as_mut())
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-124
@@ -9,7 +9,7 @@ use crate::{
|
|||||||
arena::ScratchArena,
|
arena::ScratchArena,
|
||||||
color_group::ColorGroups,
|
color_group::ColorGroups,
|
||||||
drift::Drift,
|
drift::Drift,
|
||||||
game::Game,
|
game::GameRef,
|
||||||
gaussian::Gaussian,
|
gaussian::Gaussian,
|
||||||
rating::Rating,
|
rating::Rating,
|
||||||
storage::{CompetitorStore, SkillStore},
|
storage::{CompetitorStore, SkillStore},
|
||||||
@@ -26,7 +26,9 @@ pub(crate) struct Skill {
|
|||||||
|
|
||||||
impl Skill {
|
impl Skill {
|
||||||
pub(crate) fn posterior(&self) -> Gaussian {
|
pub(crate) fn posterior(&self) -> Gaussian {
|
||||||
self.likelihood * self.backward * self.forward
|
self.likelihood
|
||||||
|
.ep_product(self.backward)
|
||||||
|
.ep_product(self.forward)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,12 +52,12 @@ pub enum EventKind {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct Item {
|
struct Item {
|
||||||
agent: Index,
|
competitor: Index,
|
||||||
/// This competitor's slot in the owning slice's `SkillStore`, resolved
|
/// This competitor's slot in the owning slice's `SkillStore`, resolved
|
||||||
/// once at ingestion.
|
/// once at ingestion.
|
||||||
///
|
///
|
||||||
/// The convergence loop reaches skills through this rather than through
|
/// The convergence loop reaches skills through this rather than through
|
||||||
/// `agent`, which is what keeps `HashMap` hashing out of the hot path now
|
/// `competitor`, which is what keeps `HashMap` hashing out of the hot path now
|
||||||
/// that the store is compact rather than indexed by the global `Index`.
|
/// that the store is compact rather than indexed by the global `Index`.
|
||||||
slot: u32,
|
slot: u32,
|
||||||
likelihood: Gaussian,
|
likelihood: Gaussian,
|
||||||
@@ -66,15 +68,15 @@ impl Item {
|
|||||||
&self,
|
&self,
|
||||||
forward: bool,
|
forward: bool,
|
||||||
skills: &SkillStore,
|
skills: &SkillStore,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) -> Rating<T, D> {
|
) -> Rating<T, D> {
|
||||||
let r = &agents[self.agent].rating;
|
let r = &competitors[self.competitor].rating;
|
||||||
let skill = skills.at(self.slot);
|
let skill = skills.at(self.slot);
|
||||||
|
|
||||||
if forward {
|
if forward {
|
||||||
Rating::new(skill.forward, r.beta, r.drift).with_drift_scale(r.drift_scale)
|
Rating::new(skill.forward, r.beta, r.drift).with_drift_scale(r.drift_scale)
|
||||||
} else {
|
} else {
|
||||||
Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift)
|
Rating::new(skill.posterior().cavity(self.likelihood), r.beta, r.drift)
|
||||||
.with_drift_scale(r.drift_scale)
|
.with_drift_scale(r.drift_scale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,10 +97,10 @@ pub(crate) struct Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Event {
|
impl Event {
|
||||||
pub(crate) fn iter_agents(&self) -> impl Iterator<Item = Index> + '_ {
|
pub(crate) fn iter_competitors(&self) -> impl Iterator<Item = Index> + '_ {
|
||||||
self.teams
|
self.teams
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|t| t.items.iter().map(|it| it.agent))
|
.flat_map(|t| t.items.iter().map(|it| it.competitor))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn outputs(&self) -> Vec<f64> {
|
fn outputs(&self) -> Vec<f64> {
|
||||||
@@ -112,14 +114,14 @@ impl Event {
|
|||||||
&self,
|
&self,
|
||||||
forward: bool,
|
forward: bool,
|
||||||
skills: &SkillStore,
|
skills: &SkillStore,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) -> Vec<Vec<Rating<T, D>>> {
|
) -> Vec<Vec<Rating<T, D>>> {
|
||||||
self.teams
|
self.teams
|
||||||
.iter()
|
.iter()
|
||||||
.map(|team| {
|
.map(|team| {
|
||||||
team.items
|
team.items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|item| item.within_prior(forward, skills, agents))
|
.map(|item| item.within_prior(forward, skills, competitors))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -133,18 +135,23 @@ impl Event {
|
|||||||
fn compute<T: Time, D: Drift<T>>(
|
fn compute<T: Time, D: Drift<T>>(
|
||||||
&self,
|
&self,
|
||||||
skills: &SkillStore,
|
skills: &SkillStore,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
p_draw: f64,
|
p_draw: f64,
|
||||||
convergence: crate::ConvergenceOptions,
|
convergence: crate::ConvergenceOptions,
|
||||||
arena: &mut ScratchArena,
|
arena: &mut ScratchArena,
|
||||||
) -> EventUpdate {
|
) -> EventUpdate {
|
||||||
let teams = self.within_priors(false, skills, agents);
|
let teams = self.within_priors(false, skills, competitors);
|
||||||
let result = self.outputs();
|
let result = self.outputs();
|
||||||
let g = match self.kind {
|
let g = match self.kind {
|
||||||
EventKind::Ranked => {
|
EventKind::Ranked => GameRef::ranked_with_arena(
|
||||||
Game::ranked_with_arena(teams, &result, &self.weights, p_draw, convergence, arena)
|
teams,
|
||||||
}
|
&result,
|
||||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
&self.weights,
|
||||||
|
p_draw,
|
||||||
|
convergence,
|
||||||
|
arena,
|
||||||
|
),
|
||||||
|
EventKind::Scored { score_sigma } => GameRef::scored_with_arena(
|
||||||
teams,
|
teams,
|
||||||
&result,
|
&result,
|
||||||
&self.weights,
|
&self.weights,
|
||||||
@@ -166,7 +173,7 @@ impl Event {
|
|||||||
for (i, item) in team.items.iter_mut().enumerate() {
|
for (i, item) in team.items.iter_mut().enumerate() {
|
||||||
let fresh = update.likelihoods[t][i];
|
let fresh = update.likelihoods[t][i];
|
||||||
let old_likelihood = skills.at(item.slot).likelihood;
|
let old_likelihood = skills.at(item.slot).likelihood;
|
||||||
let new_likelihood = (old_likelihood / item.likelihood) * fresh;
|
let new_likelihood = old_likelihood.cavity(item.likelihood).ep_product(fresh);
|
||||||
skills.at_mut(item.slot).likelihood = new_likelihood;
|
skills.at_mut(item.slot).likelihood = new_likelihood;
|
||||||
item.likelihood = fresh;
|
item.likelihood = fresh;
|
||||||
}
|
}
|
||||||
@@ -179,12 +186,12 @@ impl Event {
|
|||||||
fn iteration_direct<T: Time, D: Drift<T>>(
|
fn iteration_direct<T: Time, D: Drift<T>>(
|
||||||
&mut self,
|
&mut self,
|
||||||
skills: &mut SkillStore,
|
skills: &mut SkillStore,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
p_draw: f64,
|
p_draw: f64,
|
||||||
convergence: crate::ConvergenceOptions,
|
convergence: crate::ConvergenceOptions,
|
||||||
arena: &mut ScratchArena,
|
arena: &mut ScratchArena,
|
||||||
) {
|
) {
|
||||||
let update = self.compute(skills, agents, p_draw, convergence, arena);
|
let update = self.compute(skills, competitors, p_draw, convergence, arena);
|
||||||
self.apply(skills, update);
|
self.apply(skills, update);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,7 +235,7 @@ pub struct TimeSlice<T: Time = i64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time> TimeSlice<T> {
|
impl<T: Time> TimeSlice<T> {
|
||||||
pub fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
|
pub(crate) fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
|
||||||
Self {
|
Self {
|
||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
skills: SkillStore::new(),
|
skills: SkillStore::new(),
|
||||||
@@ -255,7 +262,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cg = color_greedy(n, |ev_idx| {
|
let cg = color_greedy(n, |ev_idx| {
|
||||||
self.events[ev_idx].iter_agents().collect::<Vec<_>>()
|
self.events[ev_idx].iter_competitors().collect::<Vec<_>>()
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut reordered: Vec<Event> = Vec::with_capacity(n);
|
let mut reordered: Vec<Event> = Vec::with_capacity(n);
|
||||||
@@ -282,17 +289,17 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_events<D: Drift<T>>(
|
pub(crate) fn add_events<D: Drift<T>>(
|
||||||
&mut self,
|
&mut self,
|
||||||
composition: Vec<Vec<Vec<Index>>>,
|
composition: Vec<Vec<Vec<Index>>>,
|
||||||
results: Option<Vec<Vec<f64>>>,
|
results: Option<Vec<Vec<f64>>>,
|
||||||
weights: Option<Vec<Vec<Vec<f64>>>>,
|
weights: Option<Vec<Vec<Vec<f64>>>>,
|
||||||
kinds: Vec<EventKind>,
|
kinds: Vec<EventKind>,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) {
|
) {
|
||||||
let mut unique = Vec::with_capacity(10);
|
let mut unique = Vec::with_capacity(10);
|
||||||
|
|
||||||
let this_agent = composition.iter().flatten().flatten().filter(|idx| {
|
let these_competitors = composition.iter().flatten().flatten().filter(|idx| {
|
||||||
if !unique.contains(idx) {
|
if !unique.contains(idx) {
|
||||||
unique.push(*idx);
|
unique.push(*idx);
|
||||||
|
|
||||||
@@ -302,10 +309,10 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
false
|
false
|
||||||
});
|
});
|
||||||
|
|
||||||
for idx in this_agent {
|
for idx in these_competitors {
|
||||||
let elapsed = compute_elapsed(agents[*idx].last_time.as_ref(), &self.time);
|
let elapsed = compute_elapsed(competitors[*idx].last_time.as_ref(), &self.time);
|
||||||
|
|
||||||
let forward = agents[*idx].receive(&self.time);
|
let forward = competitors[*idx].receive(&self.time);
|
||||||
|
|
||||||
if let Some(skill) = self.skills.get_mut(*idx) {
|
if let Some(skill) = self.skills.get_mut(*idx) {
|
||||||
skill.elapsed = elapsed;
|
skill.elapsed = elapsed;
|
||||||
@@ -332,12 +339,12 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
.map(|(t, team)| {
|
.map(|(t, team)| {
|
||||||
let items = team
|
let items = team
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&agent| Item {
|
.map(|&competitor| Item {
|
||||||
agent,
|
competitor,
|
||||||
// Every participant was inserted into `skills`
|
// Every participant was inserted into `skills`
|
||||||
// just above, so the slot always resolves.
|
// just above, so the slot always resolves.
|
||||||
slot: skills
|
slot: skills
|
||||||
.slot_of(agent)
|
.slot_of(competitor)
|
||||||
.expect("participant must be present in the slice store"),
|
.expect("participant must be present in the slice store"),
|
||||||
likelihood: N_INF,
|
likelihood: N_INF,
|
||||||
})
|
})
|
||||||
@@ -376,7 +383,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
|
|
||||||
self.color_groups_dirty = true;
|
self.color_groups_dirty = true;
|
||||||
|
|
||||||
self.iteration(from, agents);
|
self.iteration(from, competitors);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
|
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
|
||||||
@@ -393,7 +400,11 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
/// Panics if an event references a competitor with no entry in this
|
/// Panics if an event references a competitor with no entry in this
|
||||||
/// slice's skill store. `add_events` inserts one for every participant, so
|
/// slice's skill store. `add_events` inserts one for every participant, so
|
||||||
/// this cannot happen for slices built through the public API.
|
/// this cannot happen for slices built through the public API.
|
||||||
pub fn iteration<D: Drift<T>>(&mut self, from: usize, agents: &CompetitorStore<T, D>) {
|
pub(crate) fn iteration<D: Drift<T>>(
|
||||||
|
&mut self,
|
||||||
|
from: usize,
|
||||||
|
competitors: &CompetitorStore<T, D>,
|
||||||
|
) {
|
||||||
if from == 0 && self.color_groups_dirty {
|
if from == 0 && self.color_groups_dirty {
|
||||||
self.recompute_color_groups();
|
self.recompute_color_groups();
|
||||||
}
|
}
|
||||||
@@ -401,11 +412,11 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
if from > 0 || self.color_groups.is_empty() {
|
if from > 0 || self.color_groups.is_empty() {
|
||||||
// Initial pass (add_events) or no color groups yet: simple sequential sweep.
|
// Initial pass (add_events) or no color groups yet: simple sequential sweep.
|
||||||
for event in self.events.iter_mut().skip(from) {
|
for event in self.events.iter_mut().skip(from) {
|
||||||
let teams = event.within_priors(false, &self.skills, agents);
|
let teams = event.within_priors(false, &self.skills, competitors);
|
||||||
let result = event.outputs();
|
let result = event.outputs();
|
||||||
|
|
||||||
let g = match event.kind {
|
let g = match event.kind {
|
||||||
EventKind::Ranked => Game::ranked_with_arena(
|
EventKind::Ranked => GameRef::ranked_with_arena(
|
||||||
teams,
|
teams,
|
||||||
&result,
|
&result,
|
||||||
&event.weights,
|
&event.weights,
|
||||||
@@ -413,7 +424,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
self.convergence,
|
self.convergence,
|
||||||
&mut self.arena,
|
&mut self.arena,
|
||||||
),
|
),
|
||||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
EventKind::Scored { score_sigma } => GameRef::scored_with_arena(
|
||||||
teams,
|
teams,
|
||||||
&result,
|
&result,
|
||||||
&event.weights,
|
&event.weights,
|
||||||
@@ -426,8 +437,9 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
for (t, team) in event.teams.iter_mut().enumerate() {
|
for (t, team) in event.teams.iter_mut().enumerate() {
|
||||||
for (i, item) in team.items.iter_mut().enumerate() {
|
for (i, item) in team.items.iter_mut().enumerate() {
|
||||||
let old_likelihood = self.skills.at(item.slot).likelihood;
|
let old_likelihood = self.skills.at(item.slot).likelihood;
|
||||||
let new_likelihood =
|
let new_likelihood = old_likelihood
|
||||||
(old_likelihood / item.likelihood) * g.likelihoods[t][i];
|
.cavity(item.likelihood)
|
||||||
|
.ep_product(g.likelihoods[t][i]);
|
||||||
self.skills.at_mut(item.slot).likelihood = new_likelihood;
|
self.skills.at_mut(item.slot).likelihood = new_likelihood;
|
||||||
item.likelihood = g.likelihoods[t][i];
|
item.likelihood = g.likelihoods[t][i];
|
||||||
}
|
}
|
||||||
@@ -436,14 +448,14 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
event.log_evidence = g.log_evidence;
|
event.log_evidence = g.log_evidence;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.sweep_color_groups(agents);
|
self.sweep_color_groups(competitors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full event sweep using the color-group partition. Colors are processed
|
/// Full event sweep using the color-group partition. Colors are processed
|
||||||
/// sequentially; within each color the inner loop is parallel under rayon.
|
/// sequentially; within each color the inner loop is parallel under rayon.
|
||||||
///
|
///
|
||||||
/// Events in one color group touch disjoint agent sets, so none of them
|
/// Events in one color group touch disjoint competitor sets, so none of them
|
||||||
/// can observe another's writes. That makes the sweep separable: inference
|
/// can observe another's writes. That makes the sweep separable: inference
|
||||||
/// runs concurrently over shared `&self.skills`, and the resulting updates
|
/// runs concurrently over shared `&self.skills`, and the resulting updates
|
||||||
/// are folded in afterwards in index order. Splitting it this way needs no
|
/// are folded in afterwards in index order. Splitting it this way needs no
|
||||||
@@ -451,7 +463,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
/// across thread counts because the apply order does not depend on which
|
/// across thread counts because the apply order does not depend on which
|
||||||
/// worker finished first.
|
/// worker finished first.
|
||||||
#[cfg(feature = "rayon")]
|
#[cfg(feature = "rayon")]
|
||||||
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
fn sweep_color_groups<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
@@ -483,7 +495,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
let mut arena = cell.borrow_mut();
|
let mut arena = cell.borrow_mut();
|
||||||
arena.reset();
|
arena.reset();
|
||||||
|
|
||||||
ev.compute(skills, agents, p_draw, convergence, &mut arena)
|
ev.compute(skills, competitors, p_draw, convergence, &mut arena)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -495,7 +507,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
for ev in &mut self.events[range] {
|
for ev in &mut self.events[range] {
|
||||||
ev.iteration_direct(
|
ev.iteration_direct(
|
||||||
&mut self.skills,
|
&mut self.skills,
|
||||||
agents,
|
competitors,
|
||||||
p_draw,
|
p_draw,
|
||||||
self.convergence,
|
self.convergence,
|
||||||
&mut self.arena,
|
&mut self.arena,
|
||||||
@@ -509,7 +521,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
/// Events within each color group are updated inline — no EventOutput allocation —
|
/// Events within each color group are updated inline — no EventOutput allocation —
|
||||||
/// matching the T2 performance profile.
|
/// matching the T2 performance profile.
|
||||||
#[cfg(not(feature = "rayon"))]
|
#[cfg(not(feature = "rayon"))]
|
||||||
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
fn sweep_color_groups<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
|
||||||
for color_idx in 0..self.color_groups.groups.len() {
|
for color_idx in 0..self.color_groups.groups.len() {
|
||||||
if self.color_groups.groups[color_idx].is_empty() {
|
if self.color_groups.groups[color_idx].is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -523,7 +535,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
for ev in &mut self.events[range] {
|
for ev in &mut self.events[range] {
|
||||||
ev.iteration_direct(
|
ev.iteration_direct(
|
||||||
&mut self.skills,
|
&mut self.skills,
|
||||||
agents,
|
competitors,
|
||||||
p_draw,
|
p_draw,
|
||||||
self.convergence,
|
self.convergence,
|
||||||
&mut self.arena,
|
&mut self.arena,
|
||||||
@@ -544,7 +556,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
/// schedule default.
|
/// schedule default.
|
||||||
pub(crate) fn iterate_to_convergence<D: Drift<T>>(
|
pub(crate) fn iterate_to_convergence<D: Drift<T>>(
|
||||||
&mut self,
|
&mut self,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) -> usize {
|
) -> usize {
|
||||||
use crate::{tuple_gt, tuple_max};
|
use crate::{tuple_gt, tuple_max};
|
||||||
|
|
||||||
@@ -557,7 +569,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
while tuple_gt(step, epsilon) && i < max_iter {
|
while tuple_gt(step, epsilon) && i < max_iter {
|
||||||
let old = self.posteriors();
|
let old = self.posteriors();
|
||||||
|
|
||||||
self.iteration(0, agents);
|
self.iteration(0, competitors);
|
||||||
|
|
||||||
let new = self.posteriors();
|
let new = self.posteriors();
|
||||||
|
|
||||||
@@ -575,37 +587,37 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
i
|
i
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn forward_prior_out(&self, agent: &Index) -> Gaussian {
|
pub(crate) fn forward_prior_out(&self, competitor: &Index) -> Gaussian {
|
||||||
let skill = self.skills.get(*agent).unwrap();
|
let skill = self.skills.get(*competitor).unwrap();
|
||||||
skill.forward * skill.likelihood
|
skill.forward.ep_product(skill.likelihood)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn backward_prior_out<D: Drift<T>>(
|
pub(crate) fn backward_prior_out<D: Drift<T>>(
|
||||||
&self,
|
&self,
|
||||||
agent: &Index,
|
competitor: &Index,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) -> Gaussian {
|
) -> Gaussian {
|
||||||
let skill = self.skills.get(*agent).unwrap();
|
let skill = self.skills.get(*competitor).unwrap();
|
||||||
let n = skill.likelihood * skill.backward;
|
let n = skill.likelihood.ep_product(skill.backward);
|
||||||
n.forget(
|
n.forget(
|
||||||
agents[*agent]
|
competitors[*competitor]
|
||||||
.rating
|
.rating
|
||||||
.drift_variance_for_elapsed(skill.elapsed),
|
.drift_variance_for_elapsed(skill.elapsed),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
|
||||||
for (agent, skill) in self.skills.iter_mut() {
|
for (competitor, skill) in self.skills.iter_mut() {
|
||||||
skill.backward = agents[agent].message.unwrap_or(N_INF);
|
skill.backward = competitors[competitor].message.unwrap_or(N_INF);
|
||||||
}
|
}
|
||||||
self.iteration(0, agents);
|
self.iteration(0, competitors);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new_forward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
pub(crate) fn new_forward_info<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
|
||||||
for (agent, skill) in self.skills.iter_mut() {
|
for (competitor, skill) in self.skills.iter_mut() {
|
||||||
skill.forward = agents[agent].receive_for_elapsed(skill.elapsed);
|
skill.forward = competitors[competitor].receive_for_elapsed(skill.elapsed);
|
||||||
}
|
}
|
||||||
self.iteration(0, agents);
|
self.iteration(0, competitors);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run this slice's events on forward (filtering) information alone.
|
/// Run this slice's events on forward (filtering) information alone.
|
||||||
@@ -615,10 +627,18 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
/// configured prior. The sweep runs on a scratch copy, so the real slice
|
/// configured prior. The sweep runs on a scratch copy, so the real slice
|
||||||
/// is untouched — which is what makes the filtered estimates independent
|
/// is untouched — which is what makes the filtered estimates independent
|
||||||
/// of whether `History::converge` has run.
|
/// of whether `History::converge` has run.
|
||||||
|
/// One forward-only step for this slice.
|
||||||
|
///
|
||||||
|
/// `targets` restricts only the *evidence sum*, to events in which at
|
||||||
|
/// least one target competitor appears; an empty set means no restriction.
|
||||||
|
/// The forward messages are always built from every event in the slice —
|
||||||
|
/// restricting those instead would answer a different question (a history
|
||||||
|
/// in which the other events never happened), not a held-out one.
|
||||||
pub(crate) fn filtered_step<D: Drift<T>>(
|
pub(crate) fn filtered_step<D: Drift<T>>(
|
||||||
&self,
|
&self,
|
||||||
incoming: &HashMap<Index, Gaussian>,
|
incoming: &HashMap<Index, Gaussian>,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
|
targets: &std::collections::HashSet<Index>,
|
||||||
) -> FilteredStep {
|
) -> FilteredStep {
|
||||||
let mut scratch = TimeSlice {
|
let mut scratch = TimeSlice {
|
||||||
events: self.events.clone(),
|
events: self.events.clone(),
|
||||||
@@ -641,16 +661,16 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
event.log_evidence = 0.0;
|
event.log_evidence = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (agent, skill) in self.skills.iter() {
|
for (competitor, skill) in self.skills.iter() {
|
||||||
let rating = &agents[agent].rating;
|
let rating = &competitors[competitor].rating;
|
||||||
|
|
||||||
let forward = match incoming.get(&agent) {
|
let forward = match incoming.get(&competitor) {
|
||||||
Some(message) => message.forget(rating.drift_variance_for_elapsed(skill.elapsed)),
|
Some(message) => message.forget(rating.drift_variance_for_elapsed(skill.elapsed)),
|
||||||
None => rating.prior,
|
None => rating.prior,
|
||||||
};
|
};
|
||||||
|
|
||||||
let slot = scratch.skills.insert(
|
let slot = scratch.skills.insert(
|
||||||
agent,
|
competitor,
|
||||||
Skill {
|
Skill {
|
||||||
forward,
|
forward,
|
||||||
backward: N_INF,
|
backward: N_INF,
|
||||||
@@ -666,19 +686,31 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
// than leave it to be rediscovered after it breaks.
|
// than leave it to be rediscovered after it breaks.
|
||||||
debug_assert_eq!(
|
debug_assert_eq!(
|
||||||
Some(slot),
|
Some(slot),
|
||||||
self.skills.slot_of(agent),
|
self.skills.slot_of(competitor),
|
||||||
"scratch slot must match the real slice's slot for {agent:?}"
|
"scratch slot must match the real slice's slot for {competitor:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
scratch.iterate_to_convergence(agents);
|
scratch.iterate_to_convergence(competitors);
|
||||||
|
|
||||||
FilteredStep {
|
FilteredStep {
|
||||||
log_evidence: scratch.events.iter().map(|event| event.log_evidence).sum(),
|
log_evidence: scratch
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| {
|
||||||
|
targets.is_empty()
|
||||||
|
|| event
|
||||||
|
.teams
|
||||||
|
.iter()
|
||||||
|
.flat_map(|team| &team.items)
|
||||||
|
.any(|item| targets.contains(&item.competitor))
|
||||||
|
})
|
||||||
|
.map(|event| event.log_evidence)
|
||||||
|
.sum(),
|
||||||
posteriors: scratch
|
posteriors: scratch
|
||||||
.skills
|
.skills
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(agent, skill)| (agent, skill.posterior()))
|
.map(|(competitor, skill)| (competitor, skill.posterior()))
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,7 +719,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
&self,
|
&self,
|
||||||
targets: &[Index],
|
targets: &[Index],
|
||||||
forward: bool,
|
forward: bool,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
// Hashed once rather than scanned per player per event, so a
|
// Hashed once rather than scanned per player per event, so a
|
||||||
// `log_evidence_for` with many keys is not quadratic.
|
// `log_evidence_for` with many keys is not quadratic.
|
||||||
@@ -696,11 +728,11 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
let mut arena = ScratchArena::new();
|
let mut arena = ScratchArena::new();
|
||||||
|
|
||||||
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
|
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
|
||||||
let teams = event.within_priors(forward, &self.skills, agents);
|
let teams = event.within_priors(forward, &self.skills, competitors);
|
||||||
let result = event.outputs();
|
let result = event.outputs();
|
||||||
match event.kind {
|
match event.kind {
|
||||||
EventKind::Ranked => {
|
EventKind::Ranked => {
|
||||||
Game::ranked_with_arena(
|
GameRef::ranked_with_arena(
|
||||||
teams,
|
teams,
|
||||||
&result,
|
&result,
|
||||||
&event.weights,
|
&event.weights,
|
||||||
@@ -711,7 +743,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
.log_evidence
|
.log_evidence
|
||||||
}
|
}
|
||||||
EventKind::Scored { score_sigma } => {
|
EventKind::Scored { score_sigma } => {
|
||||||
Game::scored_with_arena(
|
GameRef::scored_with_arena(
|
||||||
teams,
|
teams,
|
||||||
&result,
|
&result,
|
||||||
&event.weights,
|
&event.weights,
|
||||||
@@ -741,7 +773,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
.teams
|
.teams
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|team| &team.items)
|
.flat_map(|team| &team.items)
|
||||||
.any(|item| target_set.contains(&item.agent))
|
.any(|item| target_set.contains(&item.competitor))
|
||||||
})
|
})
|
||||||
.map(|event| run_event(event, &mut arena))
|
.map(|event| run_event(event, &mut arena))
|
||||||
.sum()
|
.sum()
|
||||||
@@ -753,27 +785,36 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
.teams
|
.teams
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|team| &team.items)
|
.flat_map(|team| &team.items)
|
||||||
.any(|item| target_set.contains(&item.agent))
|
.any(|item| target_set.contains(&item.competitor))
|
||||||
})
|
})
|
||||||
.map(|event| event.log_evidence)
|
.map(|event| event.log_evidence)
|
||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_composition(&self) -> Vec<Vec<Vec<Index>>> {
|
/// Test-only: reads the slice's shape back for assertions.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn get_composition(&self) -> Vec<Vec<Vec<Index>>> {
|
||||||
self.events
|
self.events
|
||||||
.iter()
|
.iter()
|
||||||
.map(|event| {
|
.map(|event| {
|
||||||
event
|
event
|
||||||
.teams
|
.teams
|
||||||
.iter()
|
.iter()
|
||||||
.map(|team| team.items.iter().map(|item| item.agent).collect::<Vec<_>>())
|
.map(|team| {
|
||||||
|
team.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.competitor)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_results(&self) -> Vec<Vec<f64>> {
|
/// Test-only: reads the slice's shape back for assertions.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn get_results(&self) -> Vec<Vec<f64>> {
|
||||||
self.events
|
self.events
|
||||||
.iter()
|
.iter()
|
||||||
.map(|event| {
|
.map(|event| {
|
||||||
@@ -827,7 +868,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
/// approximations that inference does not retain.
|
/// approximations that inference does not retain.
|
||||||
pub(crate) fn scored_contrasts<D: Drift<T>>(
|
pub(crate) fn scored_contrasts<D: Drift<T>>(
|
||||||
&self,
|
&self,
|
||||||
agents: &CompetitorStore<T, D>,
|
competitors: &CompetitorStore<T, D>,
|
||||||
) -> Vec<(Vec<(Index, f64)>, f64)> {
|
) -> Vec<(Vec<(Index, f64)>, f64)> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
|
||||||
@@ -853,8 +894,8 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
for (team, sign) in [(hi, 1.0), (lo, -1.0)] {
|
for (team, sign) in [(hi, 1.0), (lo, -1.0)] {
|
||||||
for (m, item) in event.teams[team].items.iter().enumerate() {
|
for (m, item) in event.teams[team].items.iter().enumerate() {
|
||||||
let w = event.weights[team][m];
|
let w = event.weights[team][m];
|
||||||
noise += w * w * agents[item.agent].rating.beta.powi(2);
|
noise += w * w * competitors[item.competitor].rating.beta.powi(2);
|
||||||
contrast.push((item.agent, sign * w));
|
contrast.push((item.competitor, sign * w));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -887,7 +928,7 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
KeyTable, competitor::Competitor, drift::ConstantDrift, rating::Rating,
|
competitor::Competitor, drift::ConstantDrift, key_table::KeyTable, rating::Rating,
|
||||||
storage::CompetitorStore,
|
storage::CompetitorStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -902,16 +943,16 @@ mod tests {
|
|||||||
let e = index_map.get_or_create("e");
|
let e = index_map.get_or_create("e");
|
||||||
let f = index_map.get_or_create("f");
|
let f = index_map.get_or_create("f");
|
||||||
|
|
||||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||||
|
|
||||||
for agent in [a, b, c, d, e, f] {
|
for competitor in [a, b, c, d, e, f] {
|
||||||
agents.insert(
|
competitors.insert(
|
||||||
agent,
|
competitor,
|
||||||
Competitor {
|
Competitor {
|
||||||
rating: Rating::new(
|
rating: Rating::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -929,7 +970,7 @@ mod tests {
|
|||||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||||
None,
|
None,
|
||||||
vec![EventKind::Ranked; 3],
|
vec![EventKind::Ranked; 3],
|
||||||
&agents,
|
&competitors,
|
||||||
);
|
);
|
||||||
|
|
||||||
let post = time_slice.posteriors();
|
let post = time_slice.posteriors();
|
||||||
@@ -965,7 +1006,7 @@ mod tests {
|
|||||||
epsilon = 1e-6
|
epsilon = 1e-6
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(time_slice.iterate_to_convergence(&agents), 1);
|
assert_eq!(time_slice.iterate_to_convergence(&competitors), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -979,16 +1020,16 @@ mod tests {
|
|||||||
let e = index_map.get_or_create("e");
|
let e = index_map.get_or_create("e");
|
||||||
let f = index_map.get_or_create("f");
|
let f = index_map.get_or_create("f");
|
||||||
|
|
||||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||||
|
|
||||||
for agent in [a, b, c, d, e, f] {
|
for competitor in [a, b, c, d, e, f] {
|
||||||
agents.insert(
|
competitors.insert(
|
||||||
agent,
|
competitor,
|
||||||
Competitor {
|
Competitor {
|
||||||
rating: Rating::new(
|
rating: Rating::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -1006,7 +1047,7 @@ mod tests {
|
|||||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||||
None,
|
None,
|
||||||
vec![EventKind::Ranked; 3],
|
vec![EventKind::Ranked; 3],
|
||||||
&agents,
|
&competitors,
|
||||||
);
|
);
|
||||||
|
|
||||||
let post = time_slice.posteriors();
|
let post = time_slice.posteriors();
|
||||||
@@ -1027,7 +1068,7 @@ mod tests {
|
|||||||
epsilon = 1e-6
|
epsilon = 1e-6
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(time_slice.iterate_to_convergence(&agents) > 1);
|
assert!(time_slice.iterate_to_convergence(&competitors) > 1);
|
||||||
|
|
||||||
let post = time_slice.posteriors();
|
let post = time_slice.posteriors();
|
||||||
|
|
||||||
@@ -1059,16 +1100,16 @@ mod tests {
|
|||||||
let e = index_map.get_or_create("e");
|
let e = index_map.get_or_create("e");
|
||||||
let f = index_map.get_or_create("f");
|
let f = index_map.get_or_create("f");
|
||||||
|
|
||||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||||
|
|
||||||
for agent in [a, b, c, d, e, f] {
|
for competitor in [a, b, c, d, e, f] {
|
||||||
agents.insert(
|
competitors.insert(
|
||||||
agent,
|
competitor,
|
||||||
Competitor {
|
Competitor {
|
||||||
rating: Rating::new(
|
rating: Rating::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -1086,10 +1127,10 @@ mod tests {
|
|||||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||||
None,
|
None,
|
||||||
vec![EventKind::Ranked; 3],
|
vec![EventKind::Ranked; 3],
|
||||||
&agents,
|
&competitors,
|
||||||
);
|
);
|
||||||
|
|
||||||
time_slice.iterate_to_convergence(&agents);
|
time_slice.iterate_to_convergence(&competitors);
|
||||||
|
|
||||||
let post = time_slice.posteriors();
|
let post = time_slice.posteriors();
|
||||||
|
|
||||||
@@ -1118,12 +1159,12 @@ mod tests {
|
|||||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||||
None,
|
None,
|
||||||
vec![EventKind::Ranked; 3],
|
vec![EventKind::Ranked; 3],
|
||||||
&agents,
|
&competitors,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(time_slice.events.len(), 6);
|
assert_eq!(time_slice.events.len(), 6);
|
||||||
|
|
||||||
time_slice.iterate_to_convergence(&agents);
|
time_slice.iterate_to_convergence(&competitors);
|
||||||
|
|
||||||
let post = time_slice.posteriors();
|
let post = time_slice.posteriors();
|
||||||
|
|
||||||
@@ -1162,16 +1203,16 @@ mod tests {
|
|||||||
let c = index_map.get_or_create("c");
|
let c = index_map.get_or_create("c");
|
||||||
let d = index_map.get_or_create("d");
|
let d = index_map.get_or_create("d");
|
||||||
|
|
||||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||||
|
|
||||||
for agent in [a, b, c, d] {
|
for competitor in [a, b, c, d] {
|
||||||
agents.insert(
|
competitors.insert(
|
||||||
agent,
|
competitor,
|
||||||
Competitor {
|
Competitor {
|
||||||
rating: Rating::new(
|
rating: Rating::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
),
|
),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -1189,7 +1230,7 @@ mod tests {
|
|||||||
Some(vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]),
|
Some(vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]),
|
||||||
None,
|
None,
|
||||||
vec![EventKind::Ranked; 3],
|
vec![EventKind::Ranked; 3],
|
||||||
&agents,
|
&competitors,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(ts.color_groups.n_colors(), 2);
|
assert_eq!(ts.color_groups.n_colors(), 2);
|
||||||
@@ -1200,16 +1241,24 @@ mod tests {
|
|||||||
assert_eq!(ts.color_groups.color_range(1), 2..3);
|
assert_eq!(ts.color_groups.color_range(1), 2..3);
|
||||||
|
|
||||||
// Events at positions 0 and 1 (color 0) must be disjoint — verify by
|
// Events at positions 0 and 1 (color 0) must be disjoint — verify by
|
||||||
// checking that the agent sets of self.events[0] and self.events[1] do
|
// checking that the competitor sets of self.events[0] and self.events[1] do
|
||||||
// not include the agent at self.events[2].
|
// not include the competitor at self.events[2].
|
||||||
let agents_in_ev2: Vec<Index> = ts.events[2].iter_agents().collect();
|
let competitors_in_ev2: Vec<Index> = ts.events[2].iter_competitors().collect();
|
||||||
let agents_in_ev0: Vec<Index> = ts.events[0].iter_agents().collect();
|
let competitors_in_ev0: Vec<Index> = ts.events[0].iter_competitors().collect();
|
||||||
let agents_in_ev1: Vec<Index> = ts.events[1].iter_agents().collect();
|
let competitors_in_ev1: Vec<Index> = ts.events[1].iter_competitors().collect();
|
||||||
// ev0 and ev1 must be disjoint from each other (color-0 invariant).
|
// ev0 and ev1 must be disjoint from each other (color-0 invariant).
|
||||||
assert!(agents_in_ev0.iter().all(|ag| !agents_in_ev1.contains(ag)));
|
assert!(
|
||||||
// ev2 must share an agent with ev0 or ev1 (it needed its own color).
|
competitors_in_ev0
|
||||||
let ev2_overlaps_ev0 = agents_in_ev2.iter().any(|ag| agents_in_ev0.contains(ag));
|
.iter()
|
||||||
let ev2_overlaps_ev1 = agents_in_ev2.iter().any(|ag| agents_in_ev1.contains(ag));
|
.all(|ag| !competitors_in_ev1.contains(ag))
|
||||||
|
);
|
||||||
|
// ev2 must share an competitor with ev0 or ev1 (it needed its own color).
|
||||||
|
let ev2_overlaps_ev0 = competitors_in_ev2
|
||||||
|
.iter()
|
||||||
|
.any(|ag| competitors_in_ev0.contains(ag));
|
||||||
|
let ev2_overlaps_ev1 = competitors_in_ev2
|
||||||
|
.iter()
|
||||||
|
.any(|ag| competitors_in_ev1.contains(ag));
|
||||||
assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1);
|
assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-5
@@ -29,12 +29,12 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
|
|||||||
let players = ["p0", "p1", "p2"];
|
let players = ["p0", "p1", "p2"];
|
||||||
let holes = ["h0", "h1"];
|
let holes = ["h0", "h1"];
|
||||||
|
|
||||||
let mut h: History<i64, _, _, &'static str> = History::builder()
|
let mut h: History = History::builder()
|
||||||
.mu(0.0)
|
.mu(0.0)
|
||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 20_000,
|
max_iter: 20_000,
|
||||||
epsilon: 1e-12,
|
epsilon: 1e-12,
|
||||||
@@ -80,7 +80,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
|
|||||||
|
|
||||||
println!("\n== the same nodes via posterior_of (exact marginal) ==");
|
println!("\n== the same nodes via posterior_of (exact marginal) ==");
|
||||||
for k in players.iter().chain(holes.iter()) {
|
for k in players.iter().chain(holes.iter()) {
|
||||||
let g = h.posterior_of(&[(k, 1.0)]).unwrap();
|
let g = h.joint().unwrap().posterior_of(&[(k, 1.0)]).unwrap();
|
||||||
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
|
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
|
|||||||
vec![(&"h0", 1.0), (&"h1", -1.0)],
|
vec![(&"h0", 1.0), (&"h1", -1.0)],
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
let joint = h.posterior_of(&terms).unwrap();
|
let joint = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||||
// what a consumer gets today by adding marginals
|
// what a consumer gets today by adding marginals
|
||||||
let naive: f64 = terms
|
let naive: f64 = terms
|
||||||
.iter()
|
.iter()
|
||||||
@@ -132,7 +132,12 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
|
|||||||
// shares with its partners is pinned only by the prior.
|
// shares with its partners is pinned only by the prior.
|
||||||
for k in players.iter().chain(holes.iter()) {
|
for k in players.iter().chain(holes.iter()) {
|
||||||
let bp = h.current_skill(k).unwrap().sigma();
|
let bp = h.current_skill(k).unwrap().sigma();
|
||||||
let exact = h.posterior_of(&[(k, 1.0)]).unwrap().sigma();
|
let exact = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(k, 1.0)])
|
||||||
|
.unwrap()
|
||||||
|
.sigma();
|
||||||
assert!(
|
assert!(
|
||||||
exact > 3.0 * bp,
|
exact > 3.0 * bp,
|
||||||
"{k}: exact marginal {exact} should be much wider than the reported \
|
"{k}: exact marginal {exact} should be much wider than the reported \
|
||||||
|
|||||||
+13
-13
@@ -11,7 +11,7 @@ fn add_events_bulk_via_iter() {
|
|||||||
.sigma(2.0)
|
.sigma(2.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.p_draw(0.0)
|
.p_draw(0.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 30,
|
max_iter: 30,
|
||||||
epsilon: 1e-6,
|
epsilon: 1e-6,
|
||||||
@@ -41,9 +41,9 @@ fn add_events_bulk_via_iter() {
|
|||||||
h.add_events(events).unwrap();
|
h.add_events(events).unwrap();
|
||||||
let report = h.converge().unwrap();
|
let report = h.converge().unwrap();
|
||||||
assert!(report.converged);
|
assert!(report.converged);
|
||||||
assert!(h.lookup(&"a").is_some());
|
assert!(h.current_skill("a").is_some());
|
||||||
assert!(h.lookup(&"b").is_some());
|
assert!(h.current_skill("b").is_some());
|
||||||
assert!(h.lookup(&"c").is_some());
|
assert!(h.current_skill("c").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -53,7 +53,7 @@ fn add_events_draw() {
|
|||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.p_draw(0.25)
|
.p_draw(0.25)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||||
@@ -103,9 +103,9 @@ fn fluent_event_builder_basic() {
|
|||||||
|
|
||||||
let report = h.converge().unwrap();
|
let report = h.converge().unwrap();
|
||||||
assert!(report.converged);
|
assert!(report.converged);
|
||||||
assert!(h.lookup(&"alice").is_some());
|
assert!(h.current_skill("alice").is_some());
|
||||||
assert!(h.lookup(&"bob").is_some());
|
assert!(h.current_skill("bob").is_some());
|
||||||
assert!(h.lookup(&"carol").is_some());
|
assert!(h.current_skill("carol").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -162,7 +162,7 @@ fn current_skill_and_learning_curve() {
|
|||||||
let b = h.current_skill(&"b").unwrap();
|
let b = h.current_skill(&"b").unwrap();
|
||||||
assert!(b.mu() < 25.0);
|
assert!(b.mu() < 25.0);
|
||||||
|
|
||||||
let a_curve = h.learning_curve(&"a");
|
let a_curve = h.learning_curve(&"a").unwrap();
|
||||||
assert_eq!(a_curve.len(), 2);
|
assert_eq!(a_curve.len(), 2);
|
||||||
assert_eq!(a_curve[0].0, 1);
|
assert_eq!(a_curve[0].0, 1);
|
||||||
assert_eq!(a_curve[1].0, 2);
|
assert_eq!(a_curve[1].0, 2);
|
||||||
@@ -181,12 +181,12 @@ fn log_evidence_total_vs_subset() {
|
|||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.p_draw(0.0)
|
.p_draw(0.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.build();
|
.build();
|
||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
h.record_winner(&"b", &"a", 2).unwrap();
|
h.record_winner(&"b", &"a", 2).unwrap();
|
||||||
let total = h.log_evidence();
|
let total = h.log_evidence();
|
||||||
let a_only = h.log_evidence_for(&[&"a"]);
|
let a_only = h.log_evidence_for(&[&"a"]).unwrap();
|
||||||
assert!(total.is_finite());
|
assert!(total.is_finite());
|
||||||
assert!(a_only.is_finite());
|
assert!(a_only.is_finite());
|
||||||
}
|
}
|
||||||
@@ -203,7 +203,7 @@ fn predict_quality_two_teams() {
|
|||||||
h.record_winner(&"a", &"b", 1).unwrap();
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let q = h.predict_quality(&[&[&"a"], &[&"b"]]).unwrap();
|
let q = h.quality(&[&[&"a"], &[&"b"]]).unwrap();
|
||||||
assert!(q > 0.0 && q <= 1.0);
|
assert!(q > 0.0 && q <= 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ fn fluent_event_builder_scores() {
|
|||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
h.event(1)
|
h.event(1)
|
||||||
|
|||||||
@@ -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:?}"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
//! Every public entry point that takes a magnitude, in one place.
|
||||||
|
//!
|
||||||
|
//! This defect class was closed three times in one session and reopened twice,
|
||||||
|
//! because each fix validated the layer it had just touched and inferred the
|
||||||
|
//! rest: `HistoryBuilder` first, then `Game`'s own entry points, then the
|
||||||
|
//! constructors beneath both. A per-site fix cannot notice the site nobody
|
||||||
|
//! thought of.
|
||||||
|
//!
|
||||||
|
//! So this enumerates them. `sigma`, `beta` and `gamma` all enter inference
|
||||||
|
//! only as squares, which means a negative value does not fail — it behaves as
|
||||||
|
//! its absolute value, bit for bit, and the sign vanishes with no diagnostic.
|
||||||
|
//! Non-finite values poison every posterior derived from them.
|
||||||
|
//!
|
||||||
|
//! Adding a public constructor that takes one of these and not adding it here
|
||||||
|
//! is the failure this file exists to make harder.
|
||||||
|
|
||||||
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||||
|
|
||||||
|
use trueskill_tt::{ConstantDrift, Gaussian, History, Member, Outcome, Rating};
|
||||||
|
|
||||||
|
/// Did the entry point refuse the value, by panic or by `Err`?
|
||||||
|
fn refuses(f: impl FnOnce() -> bool) -> bool {
|
||||||
|
catch_unwind(AssertUnwindSafe(f)).unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry point, as a name and a closure that applies a value to it.
|
||||||
|
type Case = (&'static str, Box<dyn Fn(f64) -> bool>);
|
||||||
|
|
||||||
|
/// Entry points that must reject a negative magnitude.
|
||||||
|
///
|
||||||
|
/// Each closure returns `true` if it refused by returning an error; a panic is
|
||||||
|
/// also a refusal and is caught.
|
||||||
|
#[test]
|
||||||
|
fn every_magnitude_parameter_rejects_a_negative_value() {
|
||||||
|
let cases: Vec<Case> = vec![
|
||||||
|
(
|
||||||
|
"Gaussian::from_ms(sigma)",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = Gaussian::from_ms(25.0, v);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Rating::new(beta)",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = Rating::<i64, ConstantDrift>::new(
|
||||||
|
Gaussian::default(),
|
||||||
|
v,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ConstantDrift::new(gamma)",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = ConstantDrift::new(v);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"HistoryBuilder::sigma",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = History::builder().sigma(v);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"HistoryBuilder::beta",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = History::builder().beta(v);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"HistoryBuilder::score_sigma",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = History::builder().score_sigma(v);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"HistoryBuilder::p_draw",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = History::builder().p_draw(v);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Member::with_drift_scale (at ingestion)",
|
||||||
|
Box::new(|v| {
|
||||||
|
let mut h = History::builder().build();
|
||||||
|
h.add_events(vec![trueskill_tt::Event {
|
||||||
|
time: 1i64,
|
||||||
|
teams: smallvec::smallvec![
|
||||||
|
trueskill_tt::Team::with_members([Member::new("a").with_drift_scale(v)]),
|
||||||
|
trueskill_tt::Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}])
|
||||||
|
.is_err()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Outcome::scores_with_noise (at ingestion)",
|
||||||
|
Box::new(|v| {
|
||||||
|
let mut h = History::builder().build();
|
||||||
|
h.add_events(vec![trueskill_tt::Event {
|
||||||
|
time: 1i64,
|
||||||
|
teams: smallvec::smallvec![
|
||||||
|
trueskill_tt::Team::with_members([Member::new("a")]),
|
||||||
|
trueskill_tt::Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores_with_noise([3.0, 1.0], v),
|
||||||
|
}])
|
||||||
|
.is_err()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut accepted = Vec::new();
|
||||||
|
for (name, f) in &cases {
|
||||||
|
if !refuses(|| f(-1.0)) {
|
||||||
|
accepted.push(*name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
accepted.is_empty(),
|
||||||
|
"these accepted a negative magnitude, which is squared away silently \
|
||||||
|
rather than honoured or refused:\n {}",
|
||||||
|
accepted.join("\n ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same set, for NaN and infinity.
|
||||||
|
///
|
||||||
|
/// `Gaussian::from_ms` is deliberately absent: a broken fit produces a NaN
|
||||||
|
/// sigma legitimately and `converge` reports it as `NonFiniteResult`. Rejecting
|
||||||
|
/// it in the constructor turned that reporting path into a panic inside
|
||||||
|
/// inference — see the comment on `from_ms`.
|
||||||
|
#[test]
|
||||||
|
fn every_magnitude_parameter_rejects_a_non_finite_value() {
|
||||||
|
let cases: Vec<Case> = vec![
|
||||||
|
(
|
||||||
|
"Rating::new(beta)",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = Rating::<i64, ConstantDrift>::new(
|
||||||
|
Gaussian::default(),
|
||||||
|
v,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ConstantDrift::new(gamma)",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = ConstantDrift::new(v);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"HistoryBuilder::sigma",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = History::builder().sigma(v);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"HistoryBuilder::beta",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = History::builder().beta(v);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"HistoryBuilder::mu",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = History::builder().mu(v);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"HistoryBuilder::score_sigma",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = History::builder().score_sigma(v);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"HistoryBuilder::p_draw",
|
||||||
|
Box::new(|v| {
|
||||||
|
let _ = History::builder().p_draw(v);
|
||||||
|
false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut accepted = Vec::new();
|
||||||
|
for (name, f) in &cases {
|
||||||
|
for bad in [f64::NAN, f64::INFINITY] {
|
||||||
|
if !refuses(|| f(bad)) {
|
||||||
|
accepted.push(format!("{name} accepted {bad}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
accepted.is_empty(),
|
||||||
|
"these accepted a non-finite magnitude:\n {}",
|
||||||
|
accepted.join("\n ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The suite must not pass by refusing everything.
|
||||||
|
#[test]
|
||||||
|
fn ordinary_values_are_still_accepted() {
|
||||||
|
let _ = Gaussian::from_ms(25.0, 8.33);
|
||||||
|
let _ = Rating::<i64, ConstantDrift>::new(Gaussian::default(), 4.17, ConstantDrift::new(0.05));
|
||||||
|
let _ = ConstantDrift::new(0.0833);
|
||||||
|
let _ = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(8.33)
|
||||||
|
.beta(4.17)
|
||||||
|
.score_sigma(1.0)
|
||||||
|
.p_draw(0.1);
|
||||||
|
|
||||||
|
// Zero beta and zero gamma are legitimate, not degenerate.
|
||||||
|
let _ = ConstantDrift::new(0.0);
|
||||||
|
let _ = Rating::<i64, ConstantDrift>::new(Gaussian::default(), 0.0, ConstantDrift::new(0.0));
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ use trueskill_tt::{
|
|||||||
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
|
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
|
||||||
};
|
};
|
||||||
|
|
||||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
type H = History;
|
||||||
|
|
||||||
fn duel(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
|
fn duel(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
|
||||||
Event {
|
Event {
|
||||||
@@ -30,7 +30,7 @@ fn capped(max_iter: usize) -> H {
|
|||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift(0.5))
|
.drift(ConstantDrift::new(0.5))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter,
|
max_iter,
|
||||||
epsilon: 1e-13,
|
epsilon: 1e-13,
|
||||||
@@ -54,6 +54,7 @@ fn hitting_the_cap_is_an_error() {
|
|||||||
iterations,
|
iterations,
|
||||||
final_step,
|
final_step,
|
||||||
epsilon,
|
epsilon,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(iterations, 1);
|
assert_eq!(iterations, 1);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -107,12 +108,13 @@ fn the_two_agree_on_a_converged_fit() {
|
|||||||
/// At the old value of 30 this history stopped short and said nothing.
|
/// At the old value of 30 this history stopped short and said nothing.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_default_cap_clears_an_ordinary_history() {
|
fn the_default_cap_clears_an_ordinary_history() {
|
||||||
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.mu(0.0)
|
.mu(0.0)
|
||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift(0.05))
|
.drift(ConstantDrift::new(0.05))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
//! Determinism across *processes*, which an in-process test cannot see.
|
||||||
|
//!
|
||||||
|
//! Rust seeds its default hasher once per process, so every `HashMap`
|
||||||
|
//! iteration order is fixed for a run and varies between runs. A test that
|
||||||
|
//! compares results within one process therefore cannot detect a float sum
|
||||||
|
//! whose order comes from a map — all its samples share one seed.
|
||||||
|
//!
|
||||||
|
//! That is not hypothetical. `tests/determinism.rs` compares four thread counts
|
||||||
|
//! inside one process and passed throughout, while `posterior_of` was returning
|
||||||
|
//! two distinct bit patterns across 40 separate runs on identical input.
|
||||||
|
//!
|
||||||
|
//! This re-executes the test binary and compares `f64::to_bits`.
|
||||||
|
|
||||||
|
use std::{env, process::Command};
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team, UnknownKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Set in the child so it reports instead of re-spawning.
|
||||||
|
const CHILD: &str = "TSTT_DETERMINISM_CHILD";
|
||||||
|
|
||||||
|
const RUNS: usize = 40;
|
||||||
|
|
||||||
|
type H = History<String>;
|
||||||
|
|
||||||
|
fn fitted() -> H {
|
||||||
|
let mut h: H = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.05))
|
||||||
|
.unknown_keys(UnknownKeys::Prior)
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for t in 0..12i64 {
|
||||||
|
for k in 0..6usize {
|
||||||
|
let a = format!("p{}", (t as usize * 6 + k) % 10);
|
||||||
|
let b = format!("p{}", (t as usize * 6 + k + 4) % 10);
|
||||||
|
events.push(Event {
|
||||||
|
time: t,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a)]),
|
||||||
|
Team::with_members([Member::new(b)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([3.0, 1.0]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every quantity that could plausibly depend on iteration order, as bits.
|
||||||
|
fn fingerprint() -> String {
|
||||||
|
let h = fitted();
|
||||||
|
|
||||||
|
// Unknown keys with UNEQUAL but COMPARABLE coefficients, which is what
|
||||||
|
// makes the sum order-sensitive.
|
||||||
|
//
|
||||||
|
// Equal terms sum order-independently and would make this pass vacuously.
|
||||||
|
// Terms of wildly different magnitudes are no better: the small ones fall
|
||||||
|
// below the running total's ULP and are absorbed whatever the order —
|
||||||
|
// measured, spreading these over nine decades dropped the detection rate
|
||||||
|
// to roughly one run in forty. Comparable sizes keep every term able to
|
||||||
|
// change the last bits.
|
||||||
|
let ghosts: Vec<String> = (0..24).map(|i| format!("ghost{i}")).collect();
|
||||||
|
let mut terms: Vec<(&String, f64)> = ghosts
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, k)| (k, 1.0 + i as f64 * 0.37))
|
||||||
|
.collect();
|
||||||
|
let known = "p0".to_string();
|
||||||
|
terms.push((&known, -1.0));
|
||||||
|
|
||||||
|
let posterior = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||||
|
|
||||||
|
let a = "p0".to_string();
|
||||||
|
let b = "p1".to_string();
|
||||||
|
let target = [(&a, 1.0), (&b, -1.0)];
|
||||||
|
let teams: [&[&String]; 2] = [&[&a], &[&b]];
|
||||||
|
let evr = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.expected_variance_reduction(&teams, &target)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let curves = h.learning_curves();
|
||||||
|
let mut curve_bits: u64 = 0;
|
||||||
|
let mut keys: Vec<&String> = curves.keys().collect();
|
||||||
|
keys.sort();
|
||||||
|
for key in keys {
|
||||||
|
for (t, g) in &curves[key] {
|
||||||
|
curve_bits ^= (*t as u64).rotate_left(17)
|
||||||
|
^ g.mu().to_bits().rotate_left(31)
|
||||||
|
^ g.sigma().to_bits();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"post={:016x} evr={:016x} le={:016x} curves={curve_bits:016x}",
|
||||||
|
posterior.sigma().to_bits(),
|
||||||
|
evr.to_bits(),
|
||||||
|
h.log_evidence().to_bits(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn results_are_identical_across_processes() {
|
||||||
|
if env::var(CHILD).is_ok() {
|
||||||
|
println!("FINGERPRINT {}", fingerprint());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exe = env::current_exe().expect("current exe");
|
||||||
|
let mut seen: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for run in 0..RUNS {
|
||||||
|
let out = Command::new(&exe)
|
||||||
|
.args([
|
||||||
|
"results_are_identical_across_processes",
|
||||||
|
"--exact",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.env(CHILD, "1")
|
||||||
|
.output()
|
||||||
|
.expect("spawn child");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"child {run} failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let line = stdout
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("FINGERPRINT "))
|
||||||
|
.unwrap_or_else(|| panic!("child {run} printed no fingerprint:\n{stdout}"))
|
||||||
|
.to_string();
|
||||||
|
seen.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let first = &seen[0];
|
||||||
|
let differing: Vec<&String> = seen.iter().filter(|s| *s != first).collect();
|
||||||
|
assert!(
|
||||||
|
differing.is_empty(),
|
||||||
|
"results differ across processes on identical input.\n {} of {RUNS} runs differed\n \
|
||||||
|
first: {first}\n differing: {}",
|
||||||
|
differing.len(),
|
||||||
|
differing[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
+27
-13
@@ -8,7 +8,7 @@ mod common;
|
|||||||
use common::assert_finite;
|
use common::assert_finite;
|
||||||
use trueskill_tt::{
|
use trueskill_tt::{
|
||||||
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
|
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
|
||||||
NullObserver, Outcome, Rating,
|
Outcome, Rating,
|
||||||
};
|
};
|
||||||
|
|
||||||
type R = Rating<i64, ConstantDrift>;
|
type R = Rating<i64, ConstantDrift>;
|
||||||
@@ -17,7 +17,7 @@ fn rating() -> R {
|
|||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +126,10 @@ fn empty_history_converges_trivially() {
|
|||||||
/// indexed out of bounds in release, so this must run in both profiles.
|
/// indexed out of bounds in release, so this must run in both profiles.
|
||||||
#[test]
|
#[test]
|
||||||
fn converge_on_an_empty_history_with_owned_keys() {
|
fn converge_on_an_empty_history_with_owned_keys() {
|
||||||
let mut history: History<i64, ConstantDrift, NullObserver, String> =
|
let mut history: History<String> = History::builder()
|
||||||
History::builder_with_key().score_sigma(5.0).build();
|
.key_type::<String>()
|
||||||
|
.score_sigma(5.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
let report = history.converge().unwrap();
|
let report = history.converge().unwrap();
|
||||||
|
|
||||||
@@ -155,9 +157,10 @@ 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,
|
||||||
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
"expected a weights MismatchedShape, got {err:?}"
|
"expected a weights MismatchedShape, got {err:?}"
|
||||||
@@ -181,7 +184,10 @@ fn event_builder_weights_mismatch_leaves_the_history_untouched() {
|
|||||||
.winner(0)
|
.winner(0)
|
||||||
.commit();
|
.commit();
|
||||||
|
|
||||||
assert!(h.learning_curve("a").is_empty());
|
// The rejected event never reached the history, so "a" was never interned.
|
||||||
|
// `None` is the honest answer, and it is distinguishable from a competitor
|
||||||
|
// that IS known but has no appearances yet.
|
||||||
|
assert!(h.learning_curve("a").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -196,7 +202,7 @@ fn empty_event_stream_then_converge() {
|
|||||||
fn empty_history_queries_do_not_panic() {
|
fn empty_history_queries_do_not_panic() {
|
||||||
let h = History::default();
|
let h = History::default();
|
||||||
assert!(h.learning_curves().is_empty());
|
assert!(h.learning_curves().is_empty());
|
||||||
assert!(h.learning_curve("nobody").is_empty());
|
assert!(h.learning_curve("nobody").is_none());
|
||||||
assert!(h.current_skill("nobody").is_none());
|
assert!(h.current_skill("nobody").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,13 +222,13 @@ fn scored_event_rejects_non_positive_sigma() {
|
|||||||
.event(1)
|
.event(1)
|
||||||
.team(["a"])
|
.team(["a"])
|
||||||
.team(["b"])
|
.team(["b"])
|
||||||
.scores_with_sigma([3.0, 1.0], f64::NAN)
|
.scores_with_noise([3.0, 1.0], f64::NAN)
|
||||||
.commit()
|
.commit()
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter {
|
||||||
name: "score_sigma",
|
parameter: trueskill_tt::Parameter::ScoreSigma,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
@@ -281,8 +287,16 @@ fn log_evidence_survives_a_long_diff_chain() {
|
|||||||
/// `erfc` approximation; the evidence floor keeps `ln` finite.
|
/// `erfc` approximation; the evidence floor keeps `ln` finite.
|
||||||
#[test]
|
#[test]
|
||||||
fn log_evidence_finite_for_near_certain_outcome() {
|
fn log_evidence_finite_for_near_certain_outcome() {
|
||||||
let overwhelming = R::new(Gaussian::from_ms(5_000.0, 0.5), 1.0, ConstantDrift(0.0));
|
let overwhelming = R::new(
|
||||||
let hopeless = R::new(Gaussian::from_ms(-5_000.0, 0.5), 1.0, ConstantDrift(0.0));
|
Gaussian::from_ms(5_000.0, 0.5),
|
||||||
|
1.0,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
);
|
||||||
|
let hopeless = R::new(
|
||||||
|
Gaussian::from_ms(-5_000.0, 0.5),
|
||||||
|
1.0,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
);
|
||||||
let a = [overwhelming];
|
let a = [overwhelming];
|
||||||
let b = [hopeless];
|
let b = [hopeless];
|
||||||
let teams: Vec<&[R]> = vec![&a, &b];
|
let teams: Vec<&[R]> = vec![&a, &b];
|
||||||
@@ -311,7 +325,7 @@ fn empty_history_has_no_filtered_estimates() {
|
|||||||
|
|
||||||
assert!(history.filtered_learning_curves().is_empty());
|
assert!(history.filtered_learning_curves().is_empty());
|
||||||
|
|
||||||
assert!(history.filtered_learning_curve("nobody").is_empty());
|
assert!(history.filtered_learning_curve("nobody").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Boundary inputs (#26) ----------------------------------------------
|
// --- Boundary inputs (#26) ----------------------------------------------
|
||||||
@@ -326,7 +340,7 @@ fn tight() -> ConvergenceOptions {
|
|||||||
|
|
||||||
fn assert_curve_finite(h: &History, keys: &[&str], what: &str) {
|
fn assert_curve_finite(h: &History, keys: &[&str], what: &str) {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
for (time, g) in h.learning_curve(*key) {
|
for (time, g) in h.learning_curve(*key).unwrap() {
|
||||||
assert!(
|
assert!(
|
||||||
g.mu().is_finite() && g.sigma().is_finite(),
|
g.mu().is_finite() && g.sigma().is_finite(),
|
||||||
"{what}: non-finite posterior for {key} at t={time} (mu={} sigma={})",
|
"{what}: non-finite posterior for {key} at t={time} (mu={} sigma={})",
|
||||||
|
|||||||
+166
-65
@@ -1,101 +1,202 @@
|
|||||||
//! Determinism tests: identical posteriors across RAYON_NUM_THREADS
|
//! Determinism across `RAYON_NUM_THREADS`, on a workload that actually reaches
|
||||||
//! values. Only compiled with the `rayon` feature.
|
//! the parallel path.
|
||||||
|
//!
|
||||||
|
//! This test previously proved less than it appeared to. `sweep_color_groups`
|
||||||
|
//! takes its `par_iter` branch only for colour groups of at least
|
||||||
|
//! `RAYON_THRESHOLD` (64) events, and the old fixture built 20 slices of 10
|
||||||
|
//! events — a colour group is a subset of one slice's events, so it could never
|
||||||
|
//! exceed 10. The branch was unreachable, confirmed by CPU-vs-wall time:
|
||||||
|
//! `user 0.64` on eight threads is one core.
|
||||||
|
//!
|
||||||
|
//! It also compared a single competitor's curve out of forty, and never
|
||||||
|
//! compared `log_evidence`, `final_step` or `iterations`.
|
||||||
|
//!
|
||||||
|
//! The fixture below guarantees the parallel branch **by construction**: within
|
||||||
|
//! a slice every event uses a disjoint pair of competitors, so greedy colouring
|
||||||
|
//! puts all of them in colour 0, and that group is `EVENTS_PER_SLICE` long.
|
||||||
|
//! Competitors recur across slices, so the fit still has temporal coupling and
|
||||||
|
//! drift rather than being a set of independent duels.
|
||||||
|
|
||||||
#![cfg(feature = "rayon")]
|
#![cfg(feature = "rayon")]
|
||||||
|
|
||||||
use smallvec::smallvec;
|
use smallvec::smallvec;
|
||||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team,
|
||||||
|
};
|
||||||
|
|
||||||
/// Build a deterministic workload using a simple LCG (no external rand crate).
|
/// Comfortably above the crate's internal `RAYON_THRESHOLD` of 64.
|
||||||
fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> {
|
const EVENTS_PER_SLICE: usize = 96;
|
||||||
let mut h = History::<i64, _, _, String>::builder_with_key()
|
const SLICES: i64 = 8;
|
||||||
|
/// Two per event, all disjoint within a slice.
|
||||||
|
const COMPETITORS: usize = EVENTS_PER_SLICE * 2;
|
||||||
|
|
||||||
|
/// Everything a thread count could plausibly perturb.
|
||||||
|
struct Fingerprint {
|
||||||
|
curves: Vec<(String, Vec<(i64, Gaussian)>)>,
|
||||||
|
log_evidence: f64,
|
||||||
|
final_step: (f64, f64),
|
||||||
|
iterations: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_and_converge() -> Fingerprint {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 30,
|
max_iter: 20_000,
|
||||||
epsilon: 1e-6,
|
epsilon: 1e-9,
|
||||||
alpha: 1.0,
|
alpha: 1.0,
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// LCG for deterministic pseudo-random ints.
|
let mut events: Vec<Event<i64, String>> = Vec::new();
|
||||||
let mut rng = seed;
|
for slice in 0..SLICES {
|
||||||
let mut next = || {
|
for e in 0..EVENTS_PER_SLICE {
|
||||||
rng = rng
|
// Disjoint within the slice: event `e` owns competitors 2e and
|
||||||
.wrapping_mul(6364136223846793005)
|
// 2e+1. Rotating by the slice index makes the pairings differ
|
||||||
.wrapping_add(1442695040888963407);
|
// between slices, so competitors accumulate a real history.
|
||||||
rng
|
let a = (2 * e + slice as usize) % COMPETITORS;
|
||||||
};
|
let b = (2 * e + 1 + slice as usize * 3) % COMPETITORS;
|
||||||
|
if a == b {
|
||||||
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(200);
|
continue;
|
||||||
for ev_i in 0..200 {
|
}
|
||||||
let a = (next() % 40) as usize;
|
events.push(Event {
|
||||||
let mut b = (next() % 40) as usize;
|
time: slice + 1,
|
||||||
while b == a {
|
teams: smallvec![
|
||||||
b = (next() % 40) as usize;
|
Team::with_members([Member::new(format!("p{a}"))]),
|
||||||
|
Team::with_members([Member::new(format!("p{b}"))]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(u32::from((e + slice as usize) % 2 == 0), 2),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// ~10 events per slice so color groups have material parallelism.
|
|
||||||
events.push(Event {
|
|
||||||
time: (ev_i as i64 / 10) + 1,
|
|
||||||
teams: smallvec![
|
|
||||||
Team::with_members([Member::new(format!("p{a}"))]),
|
|
||||||
Team::with_members([Member::new(format!("p{b}"))]),
|
|
||||||
],
|
|
||||||
outcome: Outcome::winner((next() % 2) as u32, 2),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
h.add_events(events).unwrap();
|
h.add_events(events).unwrap();
|
||||||
let _ = h.converge().unwrap();
|
|
||||||
// Sample one competitor's curve for the comparison.
|
let report = h.converge().expect("fixture must converge");
|
||||||
h.learning_curve("p0")
|
|
||||||
|
let mut curves: Vec<(String, Vec<(i64, Gaussian)>)> = h
|
||||||
|
.learning_curves()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v))
|
||||||
|
.collect();
|
||||||
|
curves.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
Fingerprint {
|
||||||
|
curves,
|
||||||
|
log_evidence: h.log_evidence(),
|
||||||
|
final_step: report.final_step,
|
||||||
|
iterations: report.iterations,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn posteriors_identical_across_thread_counts() {
|
fn posteriors_identical_across_thread_counts() {
|
||||||
let sizes = [1usize, 2, 4, 8];
|
let sizes = [1usize, 2, 4, 8];
|
||||||
let mut results: Vec<Vec<(i64, trueskill_tt::Gaussian)>> = Vec::new();
|
let mut results: Vec<Fingerprint> = Vec::new();
|
||||||
|
|
||||||
for &n in &sizes {
|
for &n in &sizes {
|
||||||
let pool = rayon::ThreadPoolBuilder::new()
|
let pool = rayon::ThreadPoolBuilder::new()
|
||||||
.num_threads(n)
|
.num_threads(n)
|
||||||
.build()
|
.build()
|
||||||
.expect("rayon pool build");
|
.expect("rayon pool build");
|
||||||
let curve = pool.install(|| build_and_converge(42));
|
results.push(pool.install(build_and_converge));
|
||||||
results.push(curve);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let reference = &results[0];
|
let reference = &results[0];
|
||||||
for (i, curve) in results.iter().enumerate().skip(1) {
|
|
||||||
|
// Guard against the failure this test previously had: passing while
|
||||||
|
// measuring almost nothing.
|
||||||
|
assert!(
|
||||||
|
reference.curves.len() > 100,
|
||||||
|
"expected every competitor's curve, got {}",
|
||||||
|
reference.curves.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, got) in results.iter().enumerate().skip(1) {
|
||||||
|
let n = sizes[i];
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
curve.len(),
|
got.iterations, reference.iterations,
|
||||||
reference.len(),
|
"iterations differ at {n} threads"
|
||||||
"curve length differs at {n} threads",
|
|
||||||
n = sizes[i],
|
|
||||||
);
|
);
|
||||||
for (j, (&(t_ref, g_ref), &(t, g))) in reference.iter().zip(curve.iter()).enumerate() {
|
assert_eq!(
|
||||||
|
got.final_step.0.to_bits(),
|
||||||
|
reference.final_step.0.to_bits(),
|
||||||
|
"final_step.0 differs at {n} threads: {:?} vs {:?}",
|
||||||
|
reference.final_step,
|
||||||
|
got.final_step
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
got.final_step.1.to_bits(),
|
||||||
|
reference.final_step.1.to_bits(),
|
||||||
|
"final_step.1 differs at {n} threads"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
got.log_evidence.to_bits(),
|
||||||
|
reference.log_evidence.to_bits(),
|
||||||
|
"log_evidence differs at {n} threads: {} vs {}",
|
||||||
|
reference.log_evidence,
|
||||||
|
got.log_evidence
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
got.curves.len(),
|
||||||
|
reference.curves.len(),
|
||||||
|
"competitor count differs at {n} threads"
|
||||||
|
);
|
||||||
|
|
||||||
|
for ((ref_key, ref_curve), (key, curve)) in reference.curves.iter().zip(got.curves.iter()) {
|
||||||
|
assert_eq!(ref_key, key, "competitor order differs at {n} threads");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
t_ref,
|
curve.len(),
|
||||||
t,
|
ref_curve.len(),
|
||||||
"time point {j} differs at {n} threads: ref={t_ref} vs got={t}",
|
"curve length differs for {key} at {n} threads"
|
||||||
n = sizes[i],
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
g_ref.mu().to_bits(),
|
|
||||||
g.mu().to_bits(),
|
|
||||||
"mu bits differ at {n} threads, time {t}: ref={ref_mu} got={got_mu}",
|
|
||||||
n = sizes[i],
|
|
||||||
ref_mu = g_ref.mu(),
|
|
||||||
got_mu = g.mu(),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
g_ref.sigma().to_bits(),
|
|
||||||
g.sigma().to_bits(),
|
|
||||||
"sigma bits differ at {n} threads, time {t}: ref={ref_sigma} got={got_sigma}",
|
|
||||||
n = sizes[i],
|
|
||||||
ref_sigma = g_ref.sigma(),
|
|
||||||
got_sigma = g.sigma(),
|
|
||||||
);
|
);
|
||||||
|
for (&(t_ref, g_ref), &(t, g)) in ref_curve.iter().zip(curve.iter()) {
|
||||||
|
assert_eq!(t_ref, t, "time point differs for {key} at {n} threads");
|
||||||
|
assert_eq!(
|
||||||
|
g_ref.mu().to_bits(),
|
||||||
|
g.mu().to_bits(),
|
||||||
|
"mu differs for {key} at t={t}, {n} threads: {} vs {}",
|
||||||
|
g_ref.mu(),
|
||||||
|
g.mu()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
g_ref.sigma().to_bits(),
|
||||||
|
g.sigma().to_bits(),
|
||||||
|
"sigma differs for {key} at t={t}, {n} threads: {} vs {}",
|
||||||
|
g_ref.sigma(),
|
||||||
|
g.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The fixture must keep reaching the parallel branch.
|
||||||
|
///
|
||||||
|
/// `RAYON_THRESHOLD` is private, so this pins the property that makes the
|
||||||
|
/// branch reachable rather than the branch itself: within a slice every event
|
||||||
|
/// uses a disjoint competitor pair, so greedy colouring puts all
|
||||||
|
/// `EVENTS_PER_SLICE` of them in one colour group. If someone shrinks the
|
||||||
|
/// fixture, this fails rather than the suite quietly going back to testing the
|
||||||
|
/// sequential path.
|
||||||
|
#[test]
|
||||||
|
fn the_fixture_still_exceeds_the_rayon_threshold() {
|
||||||
|
const RAYON_THRESHOLD: usize = 64;
|
||||||
|
const {
|
||||||
|
assert!(
|
||||||
|
EVENTS_PER_SLICE >= RAYON_THRESHOLD,
|
||||||
|
"a colour group holds at most EVENTS_PER_SLICE events, which must \
|
||||||
|
reach the crate's RAYON_THRESHOLD for the parallel sweep to run"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measured by instrumenting `sweep_color_groups`: this fixture produces
|
||||||
|
// one colour group of 96 events and takes the parallel branch on all 872
|
||||||
|
// sweeps. The old fixture's 10-event slices could not reach 64 at all.
|
||||||
|
assert_eq!(EVENTS_PER_SLICE, 96);
|
||||||
|
}
|
||||||
|
|||||||
+15
-17
@@ -2,17 +2,17 @@
|
|||||||
//!
|
//!
|
||||||
//! The scale multiplies the *variance* the history's `Drift` contributes for
|
//! The scale multiplies the *variance* the history's `Drift` contributes for
|
||||||
//! that competitor, so `scale` is in the same units as `gamma`:
|
//! that competitor, so `scale` is in the same units as `gamma`:
|
||||||
//! `ConstantDrift(g)` at `scale = s` behaves as `ConstantDrift(g * s)` would.
|
//! `ConstantDrift::new(g)` at `scale = s` behaves as `ConstantDrift::new(g * s)` would.
|
||||||
//! `scale = 0.0` pins a competitor still — an anchor, a rating floor, a course
|
//! `scale = 0.0` pins a competitor still — an anchor, a rating floor, a course
|
||||||
//! difficulty — while everyone around them keeps drifting.
|
//! difficulty — while everyone around them keeps drifting.
|
||||||
|
|
||||||
use smallvec::smallvec;
|
use smallvec::smallvec;
|
||||||
use trueskill_tt::{
|
use trueskill_tt::{
|
||||||
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member,
|
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
|
||||||
NullObserver, Outcome, Team,
|
Team,
|
||||||
};
|
};
|
||||||
|
|
||||||
type Fit = History<i64, ConstantDrift, NullObserver, &'static str>;
|
type Fit = History;
|
||||||
|
|
||||||
const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
|
const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
|
||||||
max_iter: 64,
|
max_iter: 64,
|
||||||
@@ -53,7 +53,7 @@ fn fit(events: Vec<Event<i64, &'static str>>, gamma: f64) -> Fit {
|
|||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.p_draw(0.0)
|
.p_draw(0.0)
|
||||||
.drift(ConstantDrift(gamma))
|
.drift(ConstantDrift::new(gamma))
|
||||||
.convergence(CONVERGENCE)
|
.convergence(CONVERGENCE)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ fn scale_is_equivalent_to_scaling_gamma() {
|
|||||||
assert_eq!(t_l, t_r);
|
assert_eq!(t_l, t_r);
|
||||||
assert!(
|
assert!(
|
||||||
(g_l.mu() - g_r.mu()).abs() < 1e-9 && (g_l.sigma() - g_r.sigma()).abs() < 1e-9,
|
(g_l.mu() - g_r.mu()).abs() < 1e-9 && (g_l.sigma() - g_r.sigma()).abs() < 1e-9,
|
||||||
"ConstantDrift(0.3) at scale 0.5 must equal ConstantDrift(0.15) for {key} at \
|
"ConstantDrift::new(0.3) at scale 0.5 must equal ConstantDrift::new(0.15) for {key} at \
|
||||||
t={t_l}: ({}, {}) vs ({}, {})",
|
t={t_l}: ({}, {}) vs ({}, {})",
|
||||||
g_l.mu(),
|
g_l.mu(),
|
||||||
g_l.sigma(),
|
g_l.sigma(),
|
||||||
@@ -218,7 +218,7 @@ fn mixed_static_and_drifting_graph_converges() {
|
|||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.p_draw(0.0)
|
.p_draw(0.0)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.convergence(CONVERGENCE)
|
.convergence(CONVERGENCE)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ fn mixed_static_and_drifting_graph_converges() {
|
|||||||
|
|
||||||
fn reject(scale: f64) -> InferenceError {
|
fn reject(scale: f64) -> InferenceError {
|
||||||
let mut h = History::builder()
|
let mut h = History::builder()
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||||
@@ -277,13 +277,11 @@ fn reject(scale: f64) -> InferenceError {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn negative_scale_is_rejected() {
|
fn negative_scale_is_rejected() {
|
||||||
assert_eq!(
|
assert!(matches!(
|
||||||
reject(-1.0),
|
reject(-1.0),
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter { parameter: trueskill_tt::Parameter::DriftScale, value, .. }
|
||||||
name: "drift_scale",
|
if value == -1.0
|
||||||
value: -1.0
|
));
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -293,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,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -360,7 +358,7 @@ fn drift_scale_applies_when_set_after_first_appearance() {
|
|||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.p_draw(0.0)
|
.p_draw(0.0)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.convergence(CONVERGENCE)
|
.convergence(CONVERGENCE)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -490,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,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,15 +12,21 @@ use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
|
|||||||
type R = Rating<i64, ConstantDrift>;
|
type R = Rating<i64, ConstantDrift>;
|
||||||
|
|
||||||
fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R {
|
fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R {
|
||||||
R::new(Gaussian::from_ms(mu, sigma), beta, ConstantDrift(gamma))
|
R::new(
|
||||||
|
Gaussian::from_ms(mu, sigma),
|
||||||
|
beta,
|
||||||
|
ConstantDrift::new(gamma),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn game_1v1_golden_matches_historical() {
|
fn game_1v1_golden_matches_historical() {
|
||||||
let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
|
let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
|
||||||
let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
|
let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
|
||||||
let (a_post, b_post) =
|
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default())
|
||||||
Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
|
.unwrap()
|
||||||
|
.posteriors();
|
||||||
|
let (a_post, b_post) = (post[0][0], post[1][0]);
|
||||||
// Historical golden from pre-T2 test_1vs1 (team 0 wins):
|
// Historical golden from pre-T2 test_1vs1 (team 0 wins):
|
||||||
assert_ulps_eq!(
|
assert_ulps_eq!(
|
||||||
a_post,
|
a_post,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use trueskill_tt::{
|
|||||||
Team,
|
Team,
|
||||||
};
|
};
|
||||||
|
|
||||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
type H = History;
|
||||||
|
|
||||||
fn history() -> H {
|
fn history() -> H {
|
||||||
History::builder()
|
History::builder()
|
||||||
@@ -19,7 +19,7 @@ fn history() -> H {
|
|||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift(0.5))
|
.drift(ConstantDrift::new(0.5))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 20_000,
|
max_iter: 20_000,
|
||||||
epsilon: 1e-13,
|
epsilon: 1e-13,
|
||||||
@@ -64,8 +64,11 @@ fn members_matches_the_typed_path_exactly() {
|
|||||||
for key in ["player", "layout_7"] {
|
for key in ["player", "layout_7"] {
|
||||||
let a = typed.current_skill(&key).unwrap();
|
let a = typed.current_skill(&key).unwrap();
|
||||||
let b = fluent.current_skill(&key).unwrap();
|
let b = fluent.current_skill(&key).unwrap();
|
||||||
assert_eq!(a.pi(), b.pi(), "{key} pi");
|
// Exact equality, on the public moments rather than the natural
|
||||||
assert_eq!(a.tau(), b.tau(), "{key} tau");
|
// parameters: `mu` and `variance` are `tau/pi` and `1/pi`, so
|
||||||
|
// bit-equal natural parameters give bit-equal moments.
|
||||||
|
assert_eq!(a.mu(), b.mu(), "{key} mu");
|
||||||
|
assert_eq!(a.variance(), b.variance(), "{key} variance");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +84,7 @@ fn members_matches_the_typed_path_exactly() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_drift_scale_set_through_members_is_applied() {
|
fn a_drift_scale_set_through_members_is_applied() {
|
||||||
fn spread(h: &H, key: &'static str) -> f64 {
|
fn spread(h: &H, key: &'static str) -> f64 {
|
||||||
let curve = h.learning_curve(&key);
|
let curve = h.learning_curve(&key).unwrap();
|
||||||
assert!(curve.len() >= 2, "{key}: expected several appearances");
|
assert!(curve.len() >= 2, "{key}: expected several appearances");
|
||||||
let (lo, hi) = curve.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (_, g)| {
|
let (lo, hi) = curve.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (_, g)| {
|
||||||
(lo.min(g.sigma()), hi.max(g.sigma()))
|
(lo.min(g.sigma()), hi.max(g.sigma()))
|
||||||
@@ -133,9 +136,10 @@ 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,
|
||||||
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
@@ -160,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,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! The evidence accessors span two independent axes — smoothed vs forward-only,
|
||||||
|
//! all-keys vs key-restricted — and all four corners must exist and differ.
|
||||||
|
//!
|
||||||
|
//! `filtered_log_evidence_for` was the missing corner: the one a per-competitor
|
||||||
|
//! prequential score needs.
|
||||||
|
|
||||||
|
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
|
||||||
|
|
||||||
|
type H = History;
|
||||||
|
|
||||||
|
/// Two disjoint cohorts, so a key restriction is guaranteed to leave events out.
|
||||||
|
fn two_cohorts() -> H {
|
||||||
|
let mut h = H::default();
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for t in 1..=6 {
|
||||||
|
for (x, y) in [("a", "b"), ("c", "d")] {
|
||||||
|
events.push(Event {
|
||||||
|
time: t,
|
||||||
|
teams: [
|
||||||
|
Team::with_members([Member::new(x)]),
|
||||||
|
Team::with_members([Member::new(y)]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.add_events(events).expect("fixture ingests");
|
||||||
|
h.converge().expect("fixture converges");
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_four_corners_are_distinct_quantities() {
|
||||||
|
let h = two_cohorts();
|
||||||
|
|
||||||
|
let smoothed_all = h.log_evidence();
|
||||||
|
let smoothed_ab = h.log_evidence_for(&[&"a", &"b"]).unwrap();
|
||||||
|
let filtered_all = h.filtered_log_evidence();
|
||||||
|
let filtered_ab = h.filtered_log_evidence_for(&[&"a", &"b"]).unwrap();
|
||||||
|
|
||||||
|
for (name, v) in [
|
||||||
|
("smoothed_all", smoothed_all),
|
||||||
|
("smoothed_ab", smoothed_ab),
|
||||||
|
("filtered_all", filtered_all),
|
||||||
|
("filtered_ab", filtered_ab),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
v.is_finite() && v <= 0.0,
|
||||||
|
"{name} = {v} is not a log probability"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restricting to one cohort must drop the other cohort's events. Half the
|
||||||
|
// events, and the two cohorts are symmetric, so it lands near half.
|
||||||
|
assert!(
|
||||||
|
smoothed_ab > smoothed_all,
|
||||||
|
"restricting must drop evidence terms: {smoothed_ab} vs {smoothed_all}"
|
||||||
|
);
|
||||||
|
assert!(filtered_ab > filtered_all);
|
||||||
|
|
||||||
|
// The forward-only corner is a genuinely different quantity from the
|
||||||
|
// smoothed one, not an alias for it.
|
||||||
|
assert!(
|
||||||
|
(filtered_ab - smoothed_ab).abs() > 1e-9,
|
||||||
|
"filtered and smoothed restricted evidence coincide ({filtered_ab} vs {smoothed_ab}); \
|
||||||
|
one of them is not computing what it claims"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restricting_to_both_cohorts_recovers_the_unrestricted_value() {
|
||||||
|
let h = two_cohorts();
|
||||||
|
|
||||||
|
// Control on the filter itself: naming every competitor must restrict
|
||||||
|
// nothing, so this catches a filter that drops events it should keep.
|
||||||
|
let all_named = h
|
||||||
|
.filtered_log_evidence_for(&[&"a", &"b", &"c", &"d"])
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
(all_named - h.filtered_log_evidence()).abs() < 1e-12,
|
||||||
|
"naming everyone changed the answer: {all_named} vs {}",
|
||||||
|
h.filtered_log_evidence()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The restriction selects *events*, not competitors: naming one member of a
|
||||||
|
/// pair that only ever plays each other selects the same events as naming both.
|
||||||
|
#[test]
|
||||||
|
fn naming_either_member_of_a_pair_selects_the_same_events() {
|
||||||
|
let h = two_cohorts();
|
||||||
|
|
||||||
|
let ab = h.filtered_log_evidence_for(&[&"a"]).unwrap();
|
||||||
|
let ab_pair = h.filtered_log_evidence_for(&[&"a", &"b"]).unwrap();
|
||||||
|
assert!(
|
||||||
|
(ab - ab_pair).abs() < 1e-12,
|
||||||
|
"a and b only ever play each other, so naming either or both selects \
|
||||||
|
the same events: {ab} vs {ab_pair}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_key_is_an_error_here_too() {
|
||||||
|
let h = two_cohorts();
|
||||||
|
|
||||||
|
let err = h
|
||||||
|
.filtered_log_evidence_for(&[&"typo"])
|
||||||
|
.expect_err("unknown key");
|
||||||
|
assert!(matches!(err, InferenceError::UnknownKey { .. }), "{err:?}");
|
||||||
|
|
||||||
|
// Control: the same call on a known key succeeds.
|
||||||
|
h.filtered_log_evidence_for(&[&"a"]).expect("a is known");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_skills_agrees_with_current_skill() {
|
||||||
|
let h = two_cohorts();
|
||||||
|
|
||||||
|
let all = h.current_skills();
|
||||||
|
assert_eq!(all.len(), 4, "four competitors played");
|
||||||
|
|
||||||
|
for key in ["a", "b", "c", "d"] {
|
||||||
|
let one = h.current_skill(key).expect("played");
|
||||||
|
let from_map = all[key];
|
||||||
|
assert_eq!(
|
||||||
|
(one.mu(), one.sigma()),
|
||||||
|
(from_map.mu(), from_map.sigma()),
|
||||||
|
"current_skills disagrees with current_skill for {key}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_skills_omits_a_registered_but_unplayed_competitor() {
|
||||||
|
let mut h = two_cohorts();
|
||||||
|
h.register(Member::new("e")).expect("e is new");
|
||||||
|
|
||||||
|
let all = h.current_skills();
|
||||||
|
assert!(
|
||||||
|
!all.contains_key("e"),
|
||||||
|
"a competitor with no appearances has no posterior to report"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
h.current_skill("e").is_none(),
|
||||||
|
"control: the singular agrees"
|
||||||
|
);
|
||||||
|
assert_eq!(all.len(), 4);
|
||||||
|
}
|
||||||
+7
-7
@@ -73,8 +73,8 @@ fn filtered_first_point_is_less_certain_than_smoothed() {
|
|||||||
|
|
||||||
let _ = history.converge().unwrap();
|
let _ = history.converge().unwrap();
|
||||||
|
|
||||||
let smoothed = history.learning_curve("a");
|
let smoothed = history.learning_curve("a").unwrap();
|
||||||
let filtered = history.filtered_learning_curve("a");
|
let filtered = history.filtered_learning_curve("a").unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
smoothed.len(),
|
smoothed.len(),
|
||||||
@@ -127,7 +127,7 @@ fn filtered_curves_plural_agrees_with_singular() {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
curves["b"],
|
curves["b"],
|
||||||
history.filtered_learning_curve("b"),
|
history.filtered_learning_curve("b").unwrap(),
|
||||||
"the plural form must agree with the singular for the same key"
|
"the plural form must agree with the singular for the same key"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -182,8 +182,8 @@ fn single_slice_filtered_matches_smoothed() {
|
|||||||
|
|
||||||
let _ = history.converge().unwrap();
|
let _ = history.converge().unwrap();
|
||||||
|
|
||||||
let smoothed = history.learning_curve("a");
|
let smoothed = history.learning_curve("a").unwrap();
|
||||||
let filtered = history.filtered_learning_curve("a");
|
let filtered = history.filtered_learning_curve("a").unwrap();
|
||||||
|
|
||||||
assert_eq!(smoothed.len(), 1);
|
assert_eq!(smoothed.len(), 1);
|
||||||
assert_eq!(filtered.len(), 1);
|
assert_eq!(filtered.len(), 1);
|
||||||
@@ -231,8 +231,8 @@ fn filtered_curves_do_not_depend_on_ingestion_order() {
|
|||||||
}
|
}
|
||||||
let _ = incremental.converge().unwrap();
|
let _ = incremental.converge().unwrap();
|
||||||
|
|
||||||
let from_batched = batched.filtered_learning_curve("a");
|
let from_batched = batched.filtered_learning_curve("a").unwrap();
|
||||||
let from_incremental = incremental.filtered_learning_curve("a");
|
let from_incremental = incremental.filtered_learning_curve("a").unwrap();
|
||||||
|
|
||||||
assert_eq!(from_batched.len(), from_incremental.len());
|
assert_eq!(from_batched.len(), from_incremental.len());
|
||||||
|
|
||||||
|
|||||||
+30
-14
@@ -8,7 +8,7 @@ fn default_rating() -> R {
|
|||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(25.0 / 300.0),
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,15 +32,21 @@ fn game_ranked_1v1_golden() {
|
|||||||
fn game_one_v_one_shortcut() {
|
fn game_one_v_one_shortcut() {
|
||||||
let a = default_rating();
|
let a = default_rating();
|
||||||
let b = default_rating();
|
let b = default_rating();
|
||||||
let (a_post, b_post) =
|
let game =
|
||||||
Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
|
Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
|
||||||
|
let post = game.posteriors();
|
||||||
|
let (a_post, b_post) = (post[0][0], post[1][0]);
|
||||||
assert!(a_post.mu() > 25.0);
|
assert!(a_post.mu() > 25.0);
|
||||||
assert!(b_post.mu() < 25.0);
|
assert!(b_post.mu() < 25.0);
|
||||||
|
|
||||||
|
// It returns a game like every other constructor, so evidence is askable.
|
||||||
|
// Two identical ratings make either result equally likely.
|
||||||
|
assert!((game.log_evidence() - 0.5_f64.ln()).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn game_ranked_rejects_bad_p_draw() {
|
fn game_ranked_rejects_bad_p_draw() {
|
||||||
let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0));
|
let a = R::new(Gaussian::default(), 1.0, ConstantDrift::new(0.0));
|
||||||
let err = Game::<i64, _>::ranked(
|
let err = Game::<i64, _>::ranked(
|
||||||
&[&[a], &[a]],
|
&[&[a], &[a]],
|
||||||
Outcome::winner(0, 2),
|
Outcome::winner(0, 2),
|
||||||
@@ -51,12 +57,12 @@ fn game_ranked_rejects_bad_p_draw() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, InferenceError::InvalidProbability { .. }));
|
assert!(matches!(err, InferenceError::InvalidParameter { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn game_ranked_rejects_mismatched_ranks() {
|
fn game_ranked_rejects_mismatched_ranks() {
|
||||||
let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0));
|
let a = R::new(Gaussian::default(), 1.0, ConstantDrift::new(0.0));
|
||||||
let err = Game::<i64, _>::ranked(
|
let err = Game::<i64, _>::ranked(
|
||||||
&[&[a], &[a]],
|
&[&[a], &[a]],
|
||||||
Outcome::ranking([0, 1, 2]),
|
Outcome::ranking([0, 1, 2]),
|
||||||
@@ -118,8 +124,10 @@ fn one_v_one_honours_the_draw_probability_it_is_given() {
|
|||||||
p_draw: 0.25,
|
p_draw: 0.25,
|
||||||
..GameOptions::default()
|
..GameOptions::default()
|
||||||
};
|
};
|
||||||
let (a_post, b_post) = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &options)
|
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &options)
|
||||||
.expect("a draw is representable once p_draw is positive");
|
.expect("a draw is representable once p_draw is positive")
|
||||||
|
.posteriors();
|
||||||
|
let (a_post, b_post) = (post[0][0], post[1][0]);
|
||||||
|
|
||||||
// A symmetric draw leaves the means alone and sharpens both sides.
|
// A symmetric draw leaves the means alone and sharpens both sides.
|
||||||
assert!((a_post.mu() - b_post.mu()).abs() < 1e-9);
|
assert!((a_post.mu() - b_post.mu()).abs() < 1e-9);
|
||||||
@@ -135,8 +143,10 @@ fn one_v_one_honours_convergence_options() {
|
|||||||
convergence: ConvergenceOptions::default(),
|
convergence: ConvergenceOptions::default(),
|
||||||
..GameOptions::default()
|
..GameOptions::default()
|
||||||
};
|
};
|
||||||
let (a_post, _) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options).unwrap();
|
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options)
|
||||||
assert!(a_post.mu() > 25.0);
|
.unwrap()
|
||||||
|
.posteriors();
|
||||||
|
assert!(post[0][0].mu() > 25.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Game` is a public entry point that does not pass through `History`'s
|
/// `Game` is a public entry point that does not pass through `History`'s
|
||||||
@@ -155,7 +165,7 @@ mod malformed_games {
|
|||||||
let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default())
|
let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default())
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -173,7 +183,7 @@ mod malformed_games {
|
|||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -184,7 +194,7 @@ mod malformed_games {
|
|||||||
Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default())
|
Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default())
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
|
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -198,7 +208,7 @@ mod malformed_games {
|
|||||||
Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default())
|
Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default())
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::EmptyTeam { team: 0 }),
|
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -217,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:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//! Per-key queries must distinguish "I have never heard of this key" from a
|
||||||
|
//! genuine, empty-but-real answer.
|
||||||
|
//!
|
||||||
|
//! Each test carries a control: the same call on a key the history *does* know,
|
||||||
|
//! so it cannot pass merely because everything returns the same thing.
|
||||||
|
|
||||||
|
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
|
||||||
|
|
||||||
|
type H = History;
|
||||||
|
|
||||||
|
fn history() -> H {
|
||||||
|
let mut h = H::default();
|
||||||
|
h.add_events((1..=4).map(|t| {
|
||||||
|
Event {
|
||||||
|
time: t,
|
||||||
|
teams: [
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.expect("fixture ingests");
|
||||||
|
h.converge().expect("fixture converges");
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn learning_curve_separates_unknown_from_unplayed() {
|
||||||
|
let mut h = history();
|
||||||
|
|
||||||
|
assert!(h.learning_curve("typo").is_none(), "unknown key is None");
|
||||||
|
assert_eq!(
|
||||||
|
h.learning_curve("a").expect("a is known").len(),
|
||||||
|
4,
|
||||||
|
"control: a played every round"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Registered but never played: known, so `Some`, and empty because there
|
||||||
|
// are no appearances to report.
|
||||||
|
h.register(Member::new("c")).expect("c is new");
|
||||||
|
assert_eq!(
|
||||||
|
h.learning_curve("c").expect("c is registered"),
|
||||||
|
vec![],
|
||||||
|
"registered-but-unplayed is an empty curve, not None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_learning_curve_separates_unknown_from_unplayed() {
|
||||||
|
let mut h = history();
|
||||||
|
|
||||||
|
assert!(h.filtered_learning_curve("typo").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
h.filtered_learning_curve("a").expect("a is known").len(),
|
||||||
|
4,
|
||||||
|
"control"
|
||||||
|
);
|
||||||
|
|
||||||
|
h.register(Member::new("c")).expect("c is new");
|
||||||
|
assert_eq!(
|
||||||
|
h.filtered_learning_curve("c").expect("c is registered"),
|
||||||
|
vec![]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_evidence_for_rejects_unknown_keys() {
|
||||||
|
let h = history();
|
||||||
|
|
||||||
|
// The defect this guards: an all-unknown target list left the internal
|
||||||
|
// filter empty, which means "no restriction" — so the call returned the
|
||||||
|
// whole-history evidence, a plausible number that silently invalidates the
|
||||||
|
// leave-one-out comparison it was computed for.
|
||||||
|
let whole = h.log_evidence();
|
||||||
|
let err = h
|
||||||
|
.log_evidence_for(&[&"typo"])
|
||||||
|
.expect_err("unknown key is an error");
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::UnknownKey { .. }),
|
||||||
|
"expected UnknownKey, got {err:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Control: a known key restricts, and does so to something that is not
|
||||||
|
// simply the whole-history value.
|
||||||
|
let restricted = h.log_evidence_for(&[&"a"]).expect("a is known");
|
||||||
|
assert!(restricted.is_finite());
|
||||||
|
assert!(restricted <= 0.0);
|
||||||
|
let _ = whole;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_evidence_for_rejects_a_mix_of_known_and_unknown() {
|
||||||
|
let h = history();
|
||||||
|
|
||||||
|
let err = h
|
||||||
|
.log_evidence_for(&[&"a", &"typo"])
|
||||||
|
.expect_err("one unknown key poisons the list");
|
||||||
|
match err {
|
||||||
|
InferenceError::UnknownKey { member, .. } => {
|
||||||
|
assert_eq!(member, 1, "the reported position is the offending key's");
|
||||||
|
}
|
||||||
|
other => panic!("expected UnknownKey, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.log_evidence_for(&[&"a", &"b"])
|
||||||
|
.expect("control: both known");
|
||||||
|
}
|
||||||
@@ -47,8 +47,10 @@ fn configured_event(a: &str, b: &str, time: i64, scale: f64) -> Event<i64, Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
|
fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
|
||||||
let mut h: History<i64, _, _, String> =
|
let mut h: History<String> = History::builder()
|
||||||
History::builder_with_key().convergence(tight()).build();
|
.key_type::<String>()
|
||||||
|
.convergence(tight())
|
||||||
|
.build();
|
||||||
|
|
||||||
if batched {
|
if batched {
|
||||||
h.add_events(events).unwrap();
|
h.add_events(events).unwrap();
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
|
|||||||
|
|
||||||
type Ev = Event<i64, &'static str>;
|
type Ev = Event<i64, &'static str>;
|
||||||
|
|
||||||
fn history() -> History<i64, trueskill_tt::ConstantDrift, trueskill_tt::NullObserver, &'static str>
|
fn history() -> History {
|
||||||
{
|
|
||||||
History::builder().score_sigma(1.0).build()
|
History::builder().score_sigma(1.0).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ fn a_one_team_event_is_an_error_not_a_panic() {
|
|||||||
}])
|
}])
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,7 +55,7 @@ fn a_zero_team_event_is_an_error() {
|
|||||||
}])
|
}])
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
|
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,7 +74,7 @@ fn an_empty_team_is_an_error_rather_than_a_free_win() {
|
|||||||
}])
|
}])
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::EmptyTeam { team: 0 }),
|
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
// Nothing was recorded, so the history is still empty.
|
// Nothing was recorded, so the history is still empty.
|
||||||
@@ -93,7 +92,7 @@ fn an_empty_team_is_reported_by_position() {
|
|||||||
}])
|
}])
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::EmptyTeam { team: 1 }),
|
matches!(err, InferenceError::EmptyTeam { team: 1, .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -113,7 +112,13 @@ fn a_non_finite_score_is_rejected_at_ingestion() {
|
|||||||
}])
|
}])
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Score,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"{bad}: {err:?}"
|
"{bad}: {err:?}"
|
||||||
);
|
);
|
||||||
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
|
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
|
||||||
@@ -137,7 +142,13 @@ fn a_non_finite_weight_is_rejected_at_ingestion() {
|
|||||||
.commit()
|
.commit()
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Weight,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"{bad}: {err:?}"
|
"{bad}: {err:?}"
|
||||||
);
|
);
|
||||||
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
|
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
|
||||||
@@ -170,7 +181,7 @@ fn the_event_builder_inherits_the_shape_checks() {
|
|||||||
let mut h = history();
|
let mut h = history();
|
||||||
let err = h.event(1).team(["a"]).winner(0).commit().unwrap_err();
|
let err = h.event(1).team(["a"]).winner(0).commit().unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-20
@@ -11,7 +11,7 @@ use trueskill_tt::{
|
|||||||
UnknownKeys,
|
UnknownKeys,
|
||||||
};
|
};
|
||||||
|
|
||||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
type H = History;
|
||||||
|
|
||||||
fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> {
|
fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> {
|
||||||
Event {
|
Event {
|
||||||
@@ -41,7 +41,7 @@ fn history(unknown: UnknownKeys) -> H {
|
|||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift(0.5))
|
.drift(ConstantDrift::new(0.5))
|
||||||
.unknown_keys(unknown)
|
.unknown_keys(unknown)
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 20_000,
|
max_iter: 20_000,
|
||||||
@@ -78,17 +78,22 @@ const PAIRS: [(&str, &str); 6] = [
|
|||||||
("c", "d"),
|
("c", "d"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// A joint reused across questions answers exactly what a fresh one per
|
||||||
|
/// question does. That is the whole correctness claim behind caching the
|
||||||
|
/// factorisation (#51); it used to be checked against the `History` one-shot
|
||||||
|
/// wrappers, which were deleted in #78, so it is checked against a fresh
|
||||||
|
/// factorisation instead — the same comparison, without the wrapper.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_joint_answers_exactly_what_the_one_shot_call_does() {
|
fn a_reused_joint_answers_exactly_what_a_fresh_one_does() {
|
||||||
let h = fitted(UnknownKeys::Reject);
|
let h = fitted(UnknownKeys::Reject);
|
||||||
let joint = h.joint().unwrap();
|
let joint = h.joint().unwrap();
|
||||||
|
|
||||||
for (a, b) in PAIRS {
|
for (a, b) in PAIRS {
|
||||||
let terms = [(&a, 1.0), (&b, -1.0)];
|
let terms = [(&a, 1.0), (&b, -1.0)];
|
||||||
let one_shot = h.posterior_of(&terms).unwrap();
|
let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||||
let cached = joint.posterior_of(&terms).unwrap();
|
let cached = joint.posterior_of(&terms).unwrap();
|
||||||
assert_eq!(one_shot.pi(), cached.pi(), "{a} - {b}");
|
assert_eq!(one_shot.mu(), cached.mu(), "{a} - {b}");
|
||||||
assert_eq!(one_shot.tau(), cached.tau(), "{a} - {b}");
|
assert_eq!(one_shot.variance(), cached.variance(), "{a} - {b}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +105,12 @@ fn a_joint_agrees_at_a_pinned_time_too() {
|
|||||||
for time in 1..=5 {
|
for time in 1..=5 {
|
||||||
for (a, b) in PAIRS {
|
for (a, b) in PAIRS {
|
||||||
let terms = [(&a, 1.0), (&b, -1.0)];
|
let terms = [(&a, 1.0), (&b, -1.0)];
|
||||||
let one_shot = h.posterior_of_at(time, &terms);
|
let one_shot = h.joint().unwrap().posterior_of_at(time, &terms);
|
||||||
let cached = joint.posterior_of_at(time, &terms);
|
let cached = joint.posterior_of_at(time, &terms);
|
||||||
match (one_shot, cached) {
|
match (one_shot, cached) {
|
||||||
(Ok(x), Ok(y)) => {
|
(Ok(x), Ok(y)) => {
|
||||||
assert_eq!(x.pi(), y.pi(), "t={time} {a} - {b}");
|
assert_eq!(x.mu(), y.mu(), "t={time} {a} - {b}");
|
||||||
assert_eq!(x.tau(), y.tau(), "t={time} {a} - {b}");
|
assert_eq!(x.variance(), y.variance(), "t={time} {a} - {b}");
|
||||||
}
|
}
|
||||||
(Err(x), Err(y)) => assert_eq!(x, y, "t={time} {a} - {b}"),
|
(Err(x), Err(y)) => assert_eq!(x, y, "t={time} {a} - {b}"),
|
||||||
(x, y) => panic!("t={time} {a} - {b}: disagreed on success: {x:?} vs {y:?}"),
|
(x, y) => panic!("t={time} {a} - {b}: disagreed on success: {x:?} vs {y:?}"),
|
||||||
@@ -123,7 +128,11 @@ fn a_joint_scores_candidate_matchups_identically() {
|
|||||||
|
|
||||||
for (x, y) in PAIRS {
|
for (x, y) in PAIRS {
|
||||||
let teams: [&[&&str]; 2] = [&[&x], &[&y]];
|
let teams: [&[&&str]; 2] = [&[&x], &[&y]];
|
||||||
let one_shot = h.expected_variance_reduction(&teams, &target).unwrap();
|
let one_shot = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.expected_variance_reduction(&teams, &target)
|
||||||
|
.unwrap();
|
||||||
let cached = joint.expected_variance_reduction(&teams, &target).unwrap();
|
let cached = joint.expected_variance_reduction(&teams, &target).unwrap();
|
||||||
assert_eq!(one_shot, cached, "{x} vs {y}");
|
assert_eq!(one_shot, cached, "{x} vs {y}");
|
||||||
}
|
}
|
||||||
@@ -158,7 +167,7 @@ fn drift_free_competitors_shrink_the_joint_by_the_slice_count() {
|
|||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift(gamma))
|
.drift(ConstantDrift::new(gamma))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 20_000,
|
max_iter: 20_000,
|
||||||
epsilon: 1e-13,
|
epsilon: 1e-13,
|
||||||
@@ -197,7 +206,7 @@ fn pinned_competitors_collapse_consecutive_appearances() {
|
|||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 20_000,
|
max_iter: 20_000,
|
||||||
epsilon: 1e-13,
|
epsilon: 1e-13,
|
||||||
@@ -222,16 +231,20 @@ fn a_ranked_history_has_no_exact_joint() {
|
|||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h.joint().unwrap_err(),
|
h.joint().unwrap_err(),
|
||||||
InferenceError::JointUnavailable { .. }
|
InferenceError::JointRequiresScoredEvents
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Distinguishable from the ranked case, which is the point of splitting
|
||||||
|
/// `JointUnavailable { reason: &str }` into three variants (#74): "add events"
|
||||||
|
/// and "use predict_win_probabilities" are different instructions, and telling
|
||||||
|
/// them apart used to mean matching on English prose.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_empty_history_has_no_joint() {
|
fn an_empty_history_has_no_joint() {
|
||||||
let h = history(UnknownKeys::Reject);
|
let h = history(UnknownKeys::Reject);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h.joint().unwrap_err(),
|
h.joint().unwrap_err(),
|
||||||
InferenceError::JointUnavailable { .. }
|
InferenceError::EmptyHistory
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,16 +265,89 @@ fn unknown_keys_are_rejected_per_query() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Under `Prior`, an unseen competitor is independent of everything in the
|
/// Under `Prior`, an unseen competitor is independent of everything in the
|
||||||
/// history, and the cached path must add the same prior variance the one-shot
|
/// history, and a reused joint must add the same prior variance a fresh one
|
||||||
/// path does.
|
/// does.
|
||||||
#[test]
|
#[test]
|
||||||
fn unseen_competitors_match_the_one_shot_path() {
|
fn unseen_competitors_match_a_fresh_factorisation() {
|
||||||
let h = fitted(UnknownKeys::Prior);
|
let h = fitted(UnknownKeys::Prior);
|
||||||
let joint = h.joint().unwrap();
|
let joint = h.joint().unwrap();
|
||||||
let (a, z) = ("a", "nobody");
|
let (a, z) = ("a", "nobody");
|
||||||
let terms = [(&a, 1.0), (&z, -1.0)];
|
let terms = [(&a, 1.0), (&z, -1.0)];
|
||||||
let one_shot = h.posterior_of(&terms).unwrap();
|
let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||||
let cached = joint.posterior_of(&terms).unwrap();
|
let cached = joint.posterior_of(&terms).unwrap();
|
||||||
assert_eq!(one_shot.pi(), cached.pi());
|
assert_eq!(one_shot.mu(), cached.mu());
|
||||||
assert_eq!(one_shot.tau(), cached.tau());
|
assert_eq!(one_shot.variance(), cached.variance());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A drift too small to represent must collapse, not corrupt the matrix.
|
||||||
|
///
|
||||||
|
/// The collapse rule used to fire only at `drift <= 0.0` exactly. Anything
|
||||||
|
/// smaller-but-positive got an explicit `1.0 / drift` precision, and at
|
||||||
|
/// `drift = 1e-16` that entry is `1e16` — so `1e16 + 0.28` rounds back to
|
||||||
|
/// `1e16` and the prior and contrasts are annihilated in the stored `f64`.
|
||||||
|
///
|
||||||
|
/// Measured before the fix, at `drift_scale = 1e-10` this returned a variance
|
||||||
|
/// **12 000x too small** (a 111x overconfident interval) as `Ok`, with a band
|
||||||
|
/// just above it returning a misleading `JointUnavailable`.
|
||||||
|
#[test]
|
||||||
|
fn a_drift_too_small_to_represent_collapses_rather_than_corrupting() {
|
||||||
|
fn variance(scale: f64) -> f64 {
|
||||||
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.5))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for t in 0..15i64 {
|
||||||
|
for k in 0..4usize {
|
||||||
|
let x = format!("p{}", (t as usize * 4 + k) % 8);
|
||||||
|
let y = format!("p{}", (t as usize * 4 + k + 3) % 8);
|
||||||
|
events.push(Event {
|
||||||
|
time: t,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(x).with_drift_scale(scale)]),
|
||||||
|
Team::with_members([Member::new(y).with_drift_scale(scale)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([3.0, 1.0]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
let (a, b) = ("p0".to_string(), "p1".to_string());
|
||||||
|
let joint = h
|
||||||
|
.joint()
|
||||||
|
.expect("a tiny drift must not make the joint unavailable");
|
||||||
|
let g = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).unwrap();
|
||||||
|
g.sigma() * g.sigma()
|
||||||
|
}
|
||||||
|
|
||||||
|
let collapsed = variance(0.0);
|
||||||
|
|
||||||
|
// Below the threshold every scale must reach the collapsed answer exactly,
|
||||||
|
// and none may error.
|
||||||
|
for scale in [1e-3, 1e-4, 1e-6, 1e-8, 1e-10, 1e-12] {
|
||||||
|
let v = variance(scale);
|
||||||
|
assert_eq!(
|
||||||
|
v.to_bits(),
|
||||||
|
collapsed.to_bits(),
|
||||||
|
"drift_scale {scale:e}: {v} vs collapsed {collapsed}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Above it, real drift is still modelled — otherwise this test would pass
|
||||||
|
// by collapsing everything.
|
||||||
|
let drifting = variance(1e-2);
|
||||||
|
assert!(
|
||||||
|
(drifting - collapsed).abs() / collapsed > 1e-5,
|
||||||
|
"a drift of 1e-2 must still move the answer: {drifting} vs {collapsed}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! The realistic program: keys arrive owned, queries are written with literals.
|
||||||
|
//!
|
||||||
|
//! Every prediction and joint query used to take `&[&[&K]]`, which at
|
||||||
|
//! `K = String` made a string literal *impossible* — the shape required three
|
||||||
|
//! levels of temporaries that all had to outlive the call. They are generic
|
||||||
|
//! over the borrowed key now, so one spelling works at both key types.
|
||||||
|
//!
|
||||||
|
//! Both key types are exercised in every test, because the point is that the
|
||||||
|
//! spelling is the same.
|
||||||
|
|
||||||
|
use trueskill_tt::{ConstantDrift, History};
|
||||||
|
|
||||||
|
type Owned = History<String>;
|
||||||
|
type Borrowed = History;
|
||||||
|
|
||||||
|
fn owned() -> Owned {
|
||||||
|
let mut h: Owned = History::builder().key_type::<String>().build();
|
||||||
|
for t in 1..=4 {
|
||||||
|
h.record_winner(&"alice".to_string(), &"bob".to_string(), t)
|
||||||
|
.expect("ingests");
|
||||||
|
}
|
||||||
|
h.converge().expect("converges");
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
fn borrowed() -> Borrowed {
|
||||||
|
let mut h = History::default();
|
||||||
|
for t in 1..=4 {
|
||||||
|
h.record_winner(&"alice", &"bob", t).expect("ingests");
|
||||||
|
}
|
||||||
|
h.converge().expect("converges");
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn predictions_take_literals_at_either_key_type() {
|
||||||
|
let teams: &[&[&str]] = &[&["alice"], &["bob"]];
|
||||||
|
|
||||||
|
let a = owned()
|
||||||
|
.predict_win_probabilities(teams)
|
||||||
|
.expect("K = String");
|
||||||
|
let b = borrowed()
|
||||||
|
.predict_win_probabilities(teams)
|
||||||
|
.expect("K = &'static str");
|
||||||
|
|
||||||
|
assert_eq!(a, b, "the same fit through the same spelling");
|
||||||
|
assert!(a[0] > a[1], "alice won every game");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_team_shaped_query_accepts_the_same_slice() {
|
||||||
|
let h = owned();
|
||||||
|
let teams: &[&[&str]] = &[&["alice"], &["bob"]];
|
||||||
|
|
||||||
|
h.quality(teams).expect("quality");
|
||||||
|
let _ = h.predict_outcome(teams).expect("outcome");
|
||||||
|
h.predict_ranking(teams, &[0, 1]).expect("ranking");
|
||||||
|
h.expected_information_gain(teams)
|
||||||
|
.expect("information gain");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linear_combinations_take_bare_keys() {
|
||||||
|
// `&[(&K, f64)]` at `K = String` meant `&[(&String, f64)]` — no literals.
|
||||||
|
// A scored history, because the joint needs one.
|
||||||
|
let mut h: Owned = History::builder().key_type::<String>().build();
|
||||||
|
for t in 1..=4 {
|
||||||
|
h.event(t)
|
||||||
|
.team([String::from("alice")])
|
||||||
|
.team([String::from("bob")])
|
||||||
|
.scores([21.0, 9.0])
|
||||||
|
.commit()
|
||||||
|
.expect("ingests");
|
||||||
|
}
|
||||||
|
h.converge().expect("converges");
|
||||||
|
|
||||||
|
let terms: &[(&str, f64)] = &[("alice", 1.0), ("bob", -1.0)];
|
||||||
|
let gap = h
|
||||||
|
.joint()
|
||||||
|
.expect("scored history has a joint")
|
||||||
|
.posterior_of(terms)
|
||||||
|
.expect("both keys are known");
|
||||||
|
|
||||||
|
assert!(gap.mu() > 0.0, "alice outscored bob every round");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `lookup` is gone with `Index` (#73); the accessors that answer the same
|
||||||
|
/// question all take a borrowed key.
|
||||||
|
#[test]
|
||||||
|
fn membership_queries_accept_a_borrowed_key() {
|
||||||
|
let h = owned();
|
||||||
|
assert!(h.current_skill("alice").is_some());
|
||||||
|
assert!(h.rating("alice").is_some());
|
||||||
|
assert!(h.learning_curve("alice").is_some());
|
||||||
|
|
||||||
|
assert!(h.current_skill("nobody").is_none());
|
||||||
|
assert!(h.rating("nobody").is_none());
|
||||||
|
assert!(h.learning_curve("nobody").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamma_sets_drift_without_naming_constant_drift() {
|
||||||
|
let mut a: Borrowed = History::builder().gamma(0.5).build();
|
||||||
|
let mut b: Borrowed = History::builder().drift(ConstantDrift::new(0.5)).build();
|
||||||
|
|
||||||
|
for h in [&mut a, &mut b] {
|
||||||
|
h.record_winner(&"x", &"y", 1).unwrap();
|
||||||
|
h.record_winner(&"y", &"x", 100).unwrap();
|
||||||
|
h.converge().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (ga, gb) = (a.current_skill("x").unwrap(), b.current_skill("x").unwrap());
|
||||||
|
assert_eq!((ga.mu(), ga.sigma()), (gb.mu(), gb.sigma()));
|
||||||
|
|
||||||
|
// Control: the shorthand is not a no-op — a different gamma differs.
|
||||||
|
let mut c: Borrowed = History::builder().gamma(0.0).build();
|
||||||
|
c.record_winner(&"x", &"y", 1).unwrap();
|
||||||
|
c.record_winner(&"y", &"x", 100).unwrap();
|
||||||
|
c.converge().unwrap();
|
||||||
|
assert_ne!(c.current_skill("x").unwrap().sigma(), ga.sigma());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "gamma must be finite and non-negative")]
|
||||||
|
fn a_negative_gamma_is_rejected_rather_than_squared_away() {
|
||||||
|
let _: Borrowed = History::builder().gamma(-0.5).build();
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
//! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the
|
//! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the
|
||||||
//! moment-space `Sub` in the game chain propagated into every skill once the slice grew past
|
//! moment-space `Sub` in the game chain propagated into every skill once the slice grew past
|
||||||
//! ~75 competitors (e.g. a real ranking dataset with hundreds of players).
|
//! ~75 competitors (e.g. a real ranking dataset with hundreds of players).
|
||||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS, NullObserver};
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS};
|
||||||
|
|
||||||
/// Tiny deterministic LCG — avoids a dev-dependency on `rand`.
|
/// Tiny deterministic LCG — avoids a dev-dependency on `rand`.
|
||||||
struct Lcg(u64);
|
struct Lcg(u64);
|
||||||
@@ -24,10 +24,11 @@ impl Lcg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn nan_after_fit(players: usize) -> usize {
|
fn nan_after_fit(players: usize) -> usize {
|
||||||
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder_with_key()
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.drift(ConstantDrift(0.1))
|
.drift(ConstantDrift::new(0.1))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: ITERATIONS,
|
max_iter: ITERATIONS,
|
||||||
epsilon: EPSILON,
|
epsilon: EPSILON,
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//! The libm rule, enforced rather than asserted in prose.
|
||||||
|
//!
|
||||||
|
//! CLAUDE.md requires transcendentals to go through `libm`, not `std`:
|
||||||
|
//!
|
||||||
|
//! > IEEE 754 pins the basic operations and `sqrt` but says nothing about
|
||||||
|
//! > `exp`/`log`/`erf`, and `std` delegates to the *system* math library —
|
||||||
|
//! > measured, `f64::exp` and `libm::exp` disagree on 9.7% of inputs by one
|
||||||
|
//! > ULP. Since inference is an iterative fixed point, one ULP can change an
|
||||||
|
//! > iteration count.
|
||||||
|
//!
|
||||||
|
//! The rule was stated clearly and still violated in three production sites,
|
||||||
|
//! one of them `hypot` on the path of every scored event — whose measured
|
||||||
|
//! divergence, 12.1%, is *higher* than the `exp` figure the rule cites as its
|
||||||
|
//! own justification. Prose is evidently not enough, so this is a test.
|
||||||
|
//!
|
||||||
|
//! Tests may use either, which the crate documents, so `#[cfg(test)]` blocks
|
||||||
|
//! are excluded.
|
||||||
|
|
||||||
|
use std::{fs, path::Path};
|
||||||
|
|
||||||
|
/// Method-call spellings that reach the system math library.
|
||||||
|
///
|
||||||
|
/// `sqrt` is deliberately absent: IEEE 754 specifies it exactly, so `std` and
|
||||||
|
/// `libm` cannot disagree. `abs`, `recip`, `powi` and `mul_add` are likewise
|
||||||
|
/// exact or specified.
|
||||||
|
const FORBIDDEN: &[&str] = &[
|
||||||
|
"exp", "exp2", "exp_m1", "ln", "ln_1p", "log", "log2", "log10", "powf", "sin", "cos", "tan",
|
||||||
|
"asin", "acos", "atan", "atan2", "sinh", "cosh", "tanh", "hypot", "cbrt", "erf", "erfc",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Strip `#[cfg(test)]` items by brace matching, plus comments and string
|
||||||
|
/// literals, so a mention in prose is not mistaken for a call.
|
||||||
|
fn production_code(source: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(source.len());
|
||||||
|
let bytes: Vec<char> = source.chars().collect();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < bytes.len() {
|
||||||
|
let rest: String = bytes[i..].iter().take(16).collect();
|
||||||
|
|
||||||
|
if rest.starts_with("#[cfg(test)]") {
|
||||||
|
// Skip to the opening brace of the guarded item, then past its
|
||||||
|
// matching close.
|
||||||
|
let mut j = i;
|
||||||
|
while j < bytes.len() && bytes[j] != '{' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
let mut depth = 0usize;
|
||||||
|
while j < bytes.len() {
|
||||||
|
match bytes[j] {
|
||||||
|
'{' => depth += 1,
|
||||||
|
'}' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
j += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rest.starts_with("//") {
|
||||||
|
while i < bytes.len() && bytes[i] != '\n' {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rest.starts_with("/*") {
|
||||||
|
i += 2;
|
||||||
|
while i + 1 < bytes.len() && !(bytes[i] == '*' && bytes[i + 1] == '/') {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes[i] == '"' {
|
||||||
|
i += 1;
|
||||||
|
while i < bytes.len() && bytes[i] != '"' {
|
||||||
|
if bytes[i] == '\\' {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rust_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
||||||
|
for entry in fs::read_dir(dir).expect("read src") {
|
||||||
|
let path = entry.expect("dir entry").path();
|
||||||
|
if path.is_dir() {
|
||||||
|
rust_files(&path, out);
|
||||||
|
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_code_never_calls_a_std_transcendental() {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
rust_files(Path::new("src"), &mut files);
|
||||||
|
assert!(files.len() > 10, "expected to find the crate's sources");
|
||||||
|
|
||||||
|
let mut offences = Vec::new();
|
||||||
|
|
||||||
|
for path in &files {
|
||||||
|
let source = fs::read_to_string(path).expect("read source");
|
||||||
|
let code = production_code(&source);
|
||||||
|
|
||||||
|
for (n, line) in code.lines().enumerate() {
|
||||||
|
for name in FORBIDDEN {
|
||||||
|
let needle = format!(".{name}(");
|
||||||
|
if line.contains(&needle) {
|
||||||
|
offences.push(format!("{}:{}: {}", path.display(), n + 1, line.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
offences.is_empty(),
|
||||||
|
"production code must call libm, not std, for transcendentals \
|
||||||
|
(`sqrt` is exempt — IEEE 754 specifies it):\n{}",
|
||||||
|
offences.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stripper has to actually strip, or the test above passes vacuously.
|
||||||
|
#[test]
|
||||||
|
fn the_test_module_stripper_works() {
|
||||||
|
let source = r#"
|
||||||
|
fn production() { let _ = libm::exp(1.0); }
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
fn allowed() { let x = 1.0f64.exp(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn also_production() {}
|
||||||
|
"#;
|
||||||
|
let code = production_code(source);
|
||||||
|
assert!(
|
||||||
|
code.contains("also_production"),
|
||||||
|
"stripped too much: {code}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!code.contains(".exp()"),
|
||||||
|
"failed to strip cfg(test): {code}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And it must not strip a doc comment's worth of prose into oblivion, nor
|
||||||
|
/// mistake prose for a call.
|
||||||
|
#[test]
|
||||||
|
fn prose_is_not_mistaken_for_a_call() {
|
||||||
|
let source = "/// Uses `x.exp()` in the docs.\nfn f() { let _ = libm::exp(1.0); }\n";
|
||||||
|
let code = production_code(source);
|
||||||
|
assert!(!code.contains(".exp()"), "doc comment leaked: {code}");
|
||||||
|
assert!(code.contains("libm::exp"), "stripped real code: {code}");
|
||||||
|
}
|
||||||
@@ -132,15 +132,13 @@ fn key(i: usize) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns (worst mean error, worst sd ratio).
|
/// Returns (worst mean error, worst sd ratio).
|
||||||
fn fitted(
|
fn fitted(obs: &[(usize, usize, f64)]) -> History {
|
||||||
obs: &[(usize, usize, f64)],
|
let mut h: History = History::builder()
|
||||||
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
|
|
||||||
let mut h: History<i64, _, _, &'static str> = History::builder()
|
|
||||||
.mu(MU0)
|
.mu(MU0)
|
||||||
.sigma(SIGMA0)
|
.sigma(SIGMA0)
|
||||||
.beta(BETA)
|
.beta(BETA)
|
||||||
.score_sigma(SCORE_SIGMA)
|
.score_sigma(SCORE_SIGMA)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 20_000,
|
max_iter: 20_000,
|
||||||
epsilon: 1e-13,
|
epsilon: 1e-13,
|
||||||
@@ -281,6 +279,8 @@ fn posterior_of_matches_the_exact_joint() {
|
|||||||
|
|
||||||
for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] {
|
for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] {
|
||||||
let got = h
|
let got = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)])
|
.posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)])
|
||||||
.expect("scored slice should have a joint");
|
.expect("scored slice should have a joint");
|
||||||
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
|
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
|
||||||
@@ -302,7 +302,7 @@ fn posterior_of_matches_the_exact_joint() {
|
|||||||
|
|
||||||
// A single competitor: this is where the loopy marginal was 2x narrow.
|
// A single competitor: this is where the loopy marginal was 2x narrow.
|
||||||
for (i, row) in cov.iter().enumerate() {
|
for (i, row) in cov.iter().enumerate() {
|
||||||
let got = h.posterior_of(&[(&key(i), 1.0)]).unwrap();
|
let got = h.joint().unwrap().posterior_of(&[(&key(i), 1.0)]).unwrap();
|
||||||
let exact_sd = row[i].sqrt();
|
let exact_sd = row[i].sqrt();
|
||||||
assert!(
|
assert!(
|
||||||
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
|
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
|
||||||
@@ -322,9 +322,10 @@ fn cost_scaling() {
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
for n in [50usize, 100, 200, 400, 800] {
|
for n in [50usize, 100, 200, 400, 800] {
|
||||||
let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
|
let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
|
||||||
let mut h: History<i64, _, _, String> = History::builder_with_key()
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 200,
|
max_iter: 200,
|
||||||
epsilon: 1e-8,
|
epsilon: 1e-8,
|
||||||
@@ -360,6 +361,8 @@ fn cost_scaling() {
|
|||||||
|
|
||||||
let t = Instant::now();
|
let t = Instant::now();
|
||||||
let g = h
|
let g = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)])
|
.posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma());
|
println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma());
|
||||||
|
|||||||
+104
-4
@@ -10,7 +10,9 @@
|
|||||||
//! `!tuple_gt(..)`. These tests pin the guard from outside.
|
//! `!tuple_gt(..)`. These tests pin the guard from outside.
|
||||||
|
|
||||||
use smallvec::smallvec;
|
use smallvec::smallvec;
|
||||||
use trueskill_tt::{Event, Gaussian, History, InferenceError, Member, Outcome, Team};
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
|
||||||
|
};
|
||||||
|
|
||||||
fn scored_fit(
|
fn scored_fit(
|
||||||
sigma: f64,
|
sigma: f64,
|
||||||
@@ -54,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(),
|
||||||
@@ -84,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:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -102,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 { .. }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,3 +117,101 @@ fn merely_extreme_parameters_still_converge() {
|
|||||||
assert!(scored_fit(6.0, 1.0, 1e6, [3.0, 1.0]).unwrap());
|
assert!(scored_fit(6.0, 1.0, 1e6, [3.0, 1.0]).unwrap());
|
||||||
assert!(scored_fit(6.0, 1.0, 1.0, [1e150, -1e150]).unwrap());
|
assert!(scored_fit(6.0, 1.0, 1.0, [1e150, -1e150]).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A NaN in one competitor must not be masked by a healthy competitor reduced
|
||||||
|
/// after it.
|
||||||
|
///
|
||||||
|
/// The convergence step is a fold over a `HashMap`, so which competitor is
|
||||||
|
/// reduced last is per-process hash order. Before the fix, `tuple_max` dropped
|
||||||
|
/// a NaN accumulator in favour of the next finite delta and this returned
|
||||||
|
/// `Ok(converged: true)` with a NaN posterior in **16 of 30 runs** on identical
|
||||||
|
/// input. Deterministic now, but note this test can only ever sample one hash
|
||||||
|
/// order per run — the ordering guarantee itself is pinned by
|
||||||
|
/// `tuple_max_propagates_a_nan_from_any_position` in the crate's unit tests.
|
||||||
|
#[test]
|
||||||
|
fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.p_draw(0.1)
|
||||||
|
.build();
|
||||||
|
h.add_events(vec![
|
||||||
|
Event {
|
||||||
|
time: 1i64,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, 1e-200))]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
// A healthy pair in the same slice, to be reduced alongside the NaN.
|
||||||
|
Event {
|
||||||
|
time: 1i64,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("c")]),
|
||||||
|
Team::with_members([Member::new("d")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = h
|
||||||
|
.converge()
|
||||||
|
.expect_err("a NaN fit must never be reported as converged");
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tie observed with a narrow draw margin between far-apart competitors must
|
||||||
|
/// produce a fit, not NaN skills.
|
||||||
|
///
|
||||||
|
/// The tie branch forms the truncated variance from `v^2 - u`, and both grow as
|
||||||
|
/// `alpha^2` while their difference stays `O(1)`. Deep enough into the tail
|
||||||
|
/// that subtraction had four digits left: measured, it returned `1 - w`
|
||||||
|
/// negative and `sqrt` of it was NaN. The half-line escape hatch did not cover
|
||||||
|
/// it, because that keys on how many window-widths from the mean the window
|
||||||
|
/// sits and a narrow window fails that however deep it is.
|
||||||
|
///
|
||||||
|
/// These parameters are ordinary for a precise-scoring domain, and the
|
||||||
|
/// neighbouring wider-margin case always worked — so this was a cliff, not
|
||||||
|
/// "extreme inputs break".
|
||||||
|
#[test]
|
||||||
|
fn a_narrow_draw_margin_far_into_the_tail_still_fits() {
|
||||||
|
for (beta, p_draw, sd, gap) in [
|
||||||
|
(1e-2, 1e-8, 1e-2, 10.0),
|
||||||
|
(1e-3, 1e-9, 1e-3, 1.0),
|
||||||
|
(1e-4, 1e-12, 1e-4, 1.0),
|
||||||
|
] {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(sd)
|
||||||
|
.beta(beta)
|
||||||
|
.p_draw(p_draw)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.build();
|
||||||
|
h.add_events(vec![Event {
|
||||||
|
time: 1i64,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, sd))]),
|
||||||
|
Team::with_members([Member::new("b").with_prior(Gaussian::from_ms(gap, sd))]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::draw(2),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let report = h
|
||||||
|
.converge()
|
||||||
|
.unwrap_or_else(|e| panic!("beta {beta:e}, p_draw {p_draw:e}: {e:?}"));
|
||||||
|
assert!(report.converged);
|
||||||
|
|
||||||
|
let skill = h.current_skill(&"a").unwrap();
|
||||||
|
assert!(
|
||||||
|
skill.mu().is_finite() && skill.sigma().is_finite() && skill.sigma() > 0.0,
|
||||||
|
"beta {beta:e}, p_draw {p_draw:e}: {skill:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ use trueskill_tt::{
|
|||||||
UnknownKeys,
|
UnknownKeys,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn builder(
|
fn builder(policy: UnknownKeys) -> History {
|
||||||
policy: UnknownKeys,
|
|
||||||
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
|
|
||||||
History::builder()
|
History::builder()
|
||||||
.mu(0.0)
|
.mu(0.0)
|
||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.unknown_keys(policy)
|
.unknown_keys(policy)
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 5_000,
|
max_iter: 5_000,
|
||||||
@@ -37,9 +35,7 @@ fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'sta
|
|||||||
|
|
||||||
/// A history where "veteran" and "regular" are well observed and "novice"
|
/// A history where "veteran" and "regular" are well observed and "novice"
|
||||||
/// appears once.
|
/// appears once.
|
||||||
fn fitted(
|
fn fitted(policy: UnknownKeys) -> History {
|
||||||
policy: UnknownKeys,
|
|
||||||
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
|
|
||||||
let mut h = builder(policy);
|
let mut h = builder(policy);
|
||||||
let mut events: Vec<_> = (0..40)
|
let mut events: Vec<_> = (0..40)
|
||||||
.map(|t| round("veteran", "regular", 10.0 + f64::from(t % 3), 5.0))
|
.map(|t| round("veteran", "regular", 10.0 + f64::from(t % 3), 5.0))
|
||||||
@@ -120,6 +116,8 @@ fn swapping_the_teams_negates_the_margin() {
|
|||||||
fn the_predictive_interval_exceeds_the_skill_uncertainty() {
|
fn the_predictive_interval_exceeds_the_skill_uncertainty() {
|
||||||
let h = fitted(UnknownKeys::Prior);
|
let h = fitted(UnknownKeys::Prior);
|
||||||
let skill_gap = h
|
let skill_gap = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)])
|
.posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
|
let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
|
||||||
@@ -148,6 +146,6 @@ fn shape_errors_are_reported() {
|
|||||||
let empty: [&&str; 0] = [];
|
let empty: [&&str; 0] = [];
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h.predict_margin(&[&[&"veteran"], &empty]),
|
h.predict_margin(&[&[&"veteran"], &empty]),
|
||||||
Err(InferenceError::EmptyTeam { team: 1 })
|
Err(InferenceError::EmptyTeam { team: 1, .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-40
@@ -20,13 +20,13 @@ fn unknown_keys_are_reported_not_silently_dropped() {
|
|||||||
let err = h
|
let err = h
|
||||||
.predict_outcome(&[&[&"a"], &[&"ghost"]])
|
.predict_outcome(&[&[&"a"], &[&"ghost"]])
|
||||||
.expect_err("an unknown key must not yield a confident prediction");
|
.expect_err("an unknown key must not yield a confident prediction");
|
||||||
assert_eq!(
|
assert!(
|
||||||
err,
|
matches!(
|
||||||
InferenceError::UnknownKey {
|
&err,
|
||||||
team: 1,
|
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
|
||||||
member: 0,
|
if key == "\"ghost\""
|
||||||
key: "\"ghost\"".to_owned(),
|
),
|
||||||
}
|
"{err:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Every prediction entry point, not just one.
|
// Every prediction entry point, not just one.
|
||||||
@@ -34,7 +34,7 @@ fn unknown_keys_are_reported_not_silently_dropped() {
|
|||||||
h.predict_win_probabilities(&[&[&"a"], &[&"ghost"]])
|
h.predict_win_probabilities(&[&[&"a"], &[&"ghost"]])
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
assert!(h.predict_quality(&[&[&"a"], &[&"ghost"]]).is_err());
|
assert!(h.quality(&[&[&"a"], &[&"ghost"]]).is_err());
|
||||||
assert!(h.predict_ranking(&[&[&"a"], &[&"ghost"]], &[0, 1]).is_err());
|
assert!(h.predict_ranking(&[&[&"a"], &[&"ghost"]], &[0, 1]).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,13 +42,13 @@ fn unknown_keys_are_reported_not_silently_dropped() {
|
|||||||
fn an_entirely_unknown_team_is_an_error() {
|
fn an_entirely_unknown_team_is_an_error() {
|
||||||
let h = history_with(&["a", "b"], 0.0);
|
let h = history_with(&["a", "b"], 0.0);
|
||||||
let err = h.predict_outcome(&[&[&"a"], &[&"x", &"y"]]).unwrap_err();
|
let err = h.predict_outcome(&[&[&"a"], &[&"x", &"y"]]).unwrap_err();
|
||||||
assert_eq!(
|
assert!(
|
||||||
err,
|
matches!(
|
||||||
InferenceError::UnknownKey {
|
&err,
|
||||||
team: 1,
|
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
|
||||||
member: 0,
|
if key == "\"x\""
|
||||||
key: "\"x\"".to_owned(),
|
),
|
||||||
}
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,18 +56,22 @@ fn an_entirely_unknown_team_is_an_error() {
|
|||||||
fn degenerate_team_shapes_are_errors_rather_than_panics() {
|
fn degenerate_team_shapes_are_errors_rather_than_panics() {
|
||||||
let h = history_with(&["a", "b"], 0.0);
|
let h = history_with(&["a", "b"], 0.0);
|
||||||
|
|
||||||
assert_eq!(
|
assert!(matches!(
|
||||||
h.predict_outcome(&[&[&"a"]]).unwrap_err(),
|
h.predict_outcome(&[&[&"a"]]).unwrap_err(),
|
||||||
InferenceError::NotEnoughTeams { got: 1 }
|
InferenceError::NotEnoughTeams { got: 1, .. }
|
||||||
);
|
),);
|
||||||
assert_eq!(
|
// An empty team list cannot infer the key type — nothing in `&[]` names it.
|
||||||
h.predict_outcome(&[]).unwrap_err(),
|
// The annotation is the cost of `predict_*` being generic over the borrowed
|
||||||
InferenceError::NotEnoughTeams { got: 0 }
|
// key, and it only bites on the degenerate call.
|
||||||
);
|
let none: &[&[&str]] = &[];
|
||||||
assert_eq!(
|
assert!(matches!(
|
||||||
|
h.predict_outcome(none).unwrap_err(),
|
||||||
|
InferenceError::NotEnoughTeams { got: 0, .. }
|
||||||
|
),);
|
||||||
|
assert!(matches!(
|
||||||
h.predict_outcome(&[&[&"a"], &[]]).unwrap_err(),
|
h.predict_outcome(&[&[&"a"], &[]]).unwrap_err(),
|
||||||
InferenceError::EmptyTeam { team: 1 }
|
InferenceError::EmptyTeam { team: 1, .. }
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -93,13 +97,10 @@ fn the_outcome_space_is_capped_rather_than_hanging() {
|
|||||||
let refs: Vec<&[&&str]> = too_many.iter().map(Vec::as_slice).collect();
|
let refs: Vec<&[&&str]> = too_many.iter().map(Vec::as_slice).collect();
|
||||||
|
|
||||||
let err = h.predict_outcome(&refs).unwrap_err();
|
let err = h.predict_outcome(&refs).unwrap_err();
|
||||||
assert_eq!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::TooManyTeams {
|
InferenceError::TooManyTeams { got: 8, max, .. } if max == MAX_PREDICTED_TEAMS
|
||||||
got: 8,
|
));
|
||||||
max: MAX_PREDICTED_TEAMS
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// The cheap paths stay available at any size.
|
// The cheap paths stay available at any size.
|
||||||
let wins = h.predict_win_probabilities(&refs).unwrap();
|
let wins = h.predict_win_probabilities(&refs).unwrap();
|
||||||
@@ -282,15 +283,12 @@ fn information_gain_respects_the_entropy_ceiling() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn information_gain_reports_unknown_keys() {
|
fn information_gain_reports_unknown_keys() {
|
||||||
let h = history_with(&["a", "b"], 0.0);
|
let h = history_with(&["a", "b"], 0.0);
|
||||||
assert_eq!(
|
assert!(matches!(
|
||||||
h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
|
&h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
|
||||||
.unwrap_err(),
|
.unwrap_err(),
|
||||||
InferenceError::UnknownKey {
|
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
|
||||||
team: 1,
|
if key == "\"ghost\""
|
||||||
member: 0,
|
));
|
||||||
key: "\"ghost\"".to_owned(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A draw-enabled history has three outcomes to weigh rather than two, so the
|
/// A draw-enabled history has three outcomes to weigh rather than two, so the
|
||||||
@@ -409,7 +407,7 @@ fn prior_reaches_every_prediction_entry_point() {
|
|||||||
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
|
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
|
||||||
let teams: &[&[&&str]] = &[&[&"a"], &[&"ghost"]];
|
let teams: &[&[&&str]] = &[&[&"a"], &[&"ghost"]];
|
||||||
|
|
||||||
assert!(h.predict_quality(teams).is_ok());
|
assert!(h.quality(teams).is_ok());
|
||||||
assert!(h.predict_win_probabilities(teams).is_ok());
|
assert!(h.predict_win_probabilities(teams).is_ok());
|
||||||
assert!(h.predict_outcome(teams).is_ok());
|
assert!(h.predict_outcome(teams).is_ok());
|
||||||
assert!(h.predict_ranking(teams, &[0, 1]).is_ok());
|
assert!(h.predict_ranking(teams, &[0, 1]).is_ok());
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
//! Bounds that any correct implementation must satisfy, swept rather than
|
||||||
|
//! spot-checked.
|
||||||
|
//!
|
||||||
|
//! The crate's docs call the `ln k` ceiling "the sharpest available test of an
|
||||||
|
//! implementation", and record that an early prototype returned 4.77 nats. It
|
||||||
|
//! was violated again — 3.237828 nats against `ln 2` — because the existing
|
||||||
|
//! check sampled one fixture and the violation lives in a specific regime: a
|
||||||
|
//! large ratio between the widest and narrowest performance sigma, where the
|
||||||
|
//! shared prediction grid could not resolve the narrow density and returned
|
||||||
|
//! probabilities greater than one.
|
||||||
|
//!
|
||||||
|
//! A single fixture cannot defend a bound like this. A sweep can.
|
||||||
|
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, GameOptions, Gaussian, InferenceError, Rating, expected_information_gain,
|
||||||
|
};
|
||||||
|
|
||||||
|
type R = Rating<i64, ConstantDrift>;
|
||||||
|
|
||||||
|
/// How many random matchups the ceiling sweep draws.
|
||||||
|
///
|
||||||
|
/// Scaled by build profile rather than fixed. Each sample runs a full inference
|
||||||
|
/// pass per outcome, and that is about **19x** faster in release — measured,
|
||||||
|
/// 20 000 samples take 12.1s released against 23s for 2 000 in debug. `just
|
||||||
|
/// test` runs three debug feature combinations and one release one, so a fixed
|
||||||
|
/// count pays the slow price three times and the fast one once, which is
|
||||||
|
/// exactly backwards.
|
||||||
|
///
|
||||||
|
/// The debug run is here to prove the sweep still compiles and holds on a small
|
||||||
|
/// sample; the release run is the one that actually searches. The violation
|
||||||
|
/// this guards was found at a rate near 1.8%, so even the debug count expects
|
||||||
|
/// tens of hits in the regime.
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
const SAMPLES: usize = 1_000;
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
const SAMPLES: usize = 50_000;
|
||||||
|
|
||||||
|
/// Deterministic LCG, so a failure is reproducible from the printed seed.
|
||||||
|
struct Lcg(u64);
|
||||||
|
|
||||||
|
impl Lcg {
|
||||||
|
fn next_f64(&mut self) -> f64 {
|
||||||
|
self.0 = self
|
||||||
|
.0
|
||||||
|
.wrapping_mul(6_364_136_223_846_793_005)
|
||||||
|
.wrapping_add(1_442_695_040_888_963_407);
|
||||||
|
// Top 53 bits to [0, 1).
|
||||||
|
((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||||
|
lo + (hi - lo) * self.next_f64()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log-uniform, so the sweep spends its samples across magnitudes rather
|
||||||
|
/// than crowding the top of the range — the violations live at small sigma.
|
||||||
|
fn log_uniform(&mut self, lo: f64, hi: f64) -> f64 {
|
||||||
|
let t = self.next_f64();
|
||||||
|
(lo.ln() + t * (hi.ln() - lo.ln())).exp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn information_gain_never_exceeds_the_entropy_of_the_outcome() {
|
||||||
|
let mut rng = Lcg(0x5eed_1234_abcd_ef01);
|
||||||
|
let ceiling = 2.0_f64.ln();
|
||||||
|
let mut evaluated = 0usize;
|
||||||
|
let mut refused = 0usize;
|
||||||
|
|
||||||
|
for i in 0..SAMPLES {
|
||||||
|
let mu_a = rng.in_range(-100.0, 100.0);
|
||||||
|
let mu_b = rng.in_range(-100.0, 100.0);
|
||||||
|
let sigma_a = rng.log_uniform(1e-4, 1e2);
|
||||||
|
let sigma_b = rng.log_uniform(1e-4, 1e2);
|
||||||
|
let beta = rng.log_uniform(1e-4, 1e1);
|
||||||
|
|
||||||
|
let a = R::new(
|
||||||
|
Gaussian::from_ms(mu_a, sigma_a),
|
||||||
|
beta,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
);
|
||||||
|
let b = R::new(
|
||||||
|
Gaussian::from_ms(mu_b, sigma_b),
|
||||||
|
beta,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
);
|
||||||
|
let options = GameOptions {
|
||||||
|
p_draw: 0.0,
|
||||||
|
..GameOptions::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match expected_information_gain(&[&[a], &[b]], &options) {
|
||||||
|
Ok(gain) => {
|
||||||
|
evaluated += 1;
|
||||||
|
assert!(
|
||||||
|
gain.is_finite(),
|
||||||
|
"sample {i}: non-finite gain {gain} \
|
||||||
|
(mu {mu_a}, {mu_b}; sigma {sigma_a:e}, {sigma_b:e}; beta {beta:e})"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
gain >= 0.0,
|
||||||
|
"sample {i}: negative gain {gain} \
|
||||||
|
(mu {mu_a}, {mu_b}; sigma {sigma_a:e}, {sigma_b:e}; beta {beta:e})"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
gain <= ceiling + 1e-9,
|
||||||
|
"sample {i}: gain {gain} exceeds ln 2 = {ceiling} \
|
||||||
|
(mu {mu_a}, {mu_b}; sigma {sigma_a:e}, {sigma_b:e}; beta {beta:e})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Refusing to answer is acceptable; answering wrongly is not.
|
||||||
|
Err(InferenceError::GridTooCoarse { .. }) => refused += 1,
|
||||||
|
Err(e) => panic!("sample {i}: unexpected error {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sweep must actually exercise the function, not pass by refusing
|
||||||
|
// everything.
|
||||||
|
assert!(
|
||||||
|
evaluated * 2 > SAMPLES,
|
||||||
|
"only {evaluated} of {SAMPLES} samples were evaluated ({refused} refused); \
|
||||||
|
the sweep is no longer testing anything"
|
||||||
|
);
|
||||||
|
// And it must still reach the regime where the ceiling was violated —
|
||||||
|
// large sigma ratios, which is exactly where the grid now refuses. Without
|
||||||
|
// this the sweep could drift into only-easy inputs and stop being a guard.
|
||||||
|
assert!(
|
||||||
|
refused > 0,
|
||||||
|
"no sample reached the coarse-grid regime; the sweep no longer covers \
|
||||||
|
the case that produced 3.24 nats"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The regime that produced 3.237828 nats, pinned exactly.
|
||||||
|
#[test]
|
||||||
|
fn the_known_ceiling_violation_no_longer_answers_wrongly() {
|
||||||
|
let a = R::new(
|
||||||
|
Gaussian::from_ms(9.577_887_112_129_012, 0.000_132_507_526_585_134_38),
|
||||||
|
0.000_307_235_559_013_096_2,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
);
|
||||||
|
let b = R::new(
|
||||||
|
Gaussian::from_ms(-14.114_932_828_525_696, 91.586_690_140_921_16),
|
||||||
|
0.000_307_235_559_013_096_2,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
);
|
||||||
|
let options = GameOptions {
|
||||||
|
p_draw: 0.0,
|
||||||
|
..GameOptions::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match expected_information_gain(&[&[a], &[b]], &options) {
|
||||||
|
Ok(gain) => assert!(
|
||||||
|
gain <= 2.0_f64.ln() + 1e-9,
|
||||||
|
"returned {gain}, over the ln 2 ceiling"
|
||||||
|
),
|
||||||
|
Err(InferenceError::GridTooCoarse { needed, max, .. }) => {
|
||||||
|
assert!(needed > max, "needed {needed} should exceed max {max}");
|
||||||
|
}
|
||||||
|
Err(e) => panic!("unexpected error {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! No prediction path may answer from a fit it cannot answer from.
|
||||||
|
//!
|
||||||
|
//! `converge` grew a `NonFiniteResult` guard; nothing stopped a caller from
|
||||||
|
//! ignoring that error and predicting anyway. The three failures that produced
|
||||||
|
//! were each differently wrong: `Ok(NaN)`, a panic out of a `Result`-returning
|
||||||
|
//! method, and `Ok([0.0, 0.0])` — finite, plausible, summing to zero against a
|
||||||
|
//! doc that promises one.
|
||||||
|
//!
|
||||||
|
//! Every test here has a healthy control, so none can pass by everything
|
||||||
|
//! returning `Err`.
|
||||||
|
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
type H = History;
|
||||||
|
|
||||||
|
fn build(beta: f64, prior: Option<Gaussian>, outcome: Outcome) -> H {
|
||||||
|
let mut h: H = History::builder()
|
||||||
|
.beta(beta)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let member = |k: &'static str| match prior {
|
||||||
|
Some(p) => Member::new(k).with_prior(p),
|
||||||
|
None => Member::new(k),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = h.add_events(vec![Event {
|
||||||
|
time: 1,
|
||||||
|
teams: [
|
||||||
|
Team::with_members([member("a")]),
|
||||||
|
Team::with_members([member("b")]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
outcome,
|
||||||
|
}]);
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point-mass priors with `beta(0.0)` on a *ranked* event: `converge` reports
|
||||||
|
/// `NonFiniteResult` and the stored posteriors are `pi: NaN, tau: NaN`.
|
||||||
|
fn nan_poisoned() -> H {
|
||||||
|
let mut h = build(
|
||||||
|
0.0,
|
||||||
|
Some(Gaussian::from_ms(0.0, 0.0)),
|
||||||
|
Outcome::winner(0, 2),
|
||||||
|
);
|
||||||
|
let err = h.converge().expect_err("this fixture must not converge");
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same degenerate parameters on a *scored* event, where inference
|
||||||
|
/// converges cleanly and leaves legitimate point-mass posteriors behind. The
|
||||||
|
/// fit is fine; it is prediction that has nothing to work with.
|
||||||
|
fn degenerate_but_converged() -> H {
|
||||||
|
let mut h = build(
|
||||||
|
0.0,
|
||||||
|
Some(Gaussian::from_ms(0.0, 0.0)),
|
||||||
|
Outcome::scores([1.0, 0.0]),
|
||||||
|
);
|
||||||
|
h.converge().expect("this fixture converges");
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
fn healthy() -> H {
|
||||||
|
let mut h = build(1.0, None, Outcome::winner(0, 2));
|
||||||
|
h.converge().expect("control converges");
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! all_predictions {
|
||||||
|
($h:ident, $f:expr) => {{
|
||||||
|
let teams: &[&[&&'static str]] = &[&[&"a"], &[&"b"]];
|
||||||
|
let f = $f;
|
||||||
|
f("quality", $h.quality(teams).map(|_| ()));
|
||||||
|
f(
|
||||||
|
"predict_win_probabilities",
|
||||||
|
$h.predict_win_probabilities(teams).map(|_| ()),
|
||||||
|
);
|
||||||
|
f("predict_outcome", $h.predict_outcome(teams).map(|_| ()));
|
||||||
|
f(
|
||||||
|
"predict_ranking",
|
||||||
|
$h.predict_ranking(teams, &[0, 1]).map(|_| ()),
|
||||||
|
);
|
||||||
|
f(
|
||||||
|
"expected_information_gain",
|
||||||
|
$h.expected_information_gain(teams).map(|_| ()),
|
||||||
|
);
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_nan_poisoned_fit_is_refused_by_every_prediction_path() {
|
||||||
|
let h = nan_poisoned();
|
||||||
|
|
||||||
|
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
||||||
|
match r {
|
||||||
|
Err(InferenceError::NonFiniteSkill { .. }) => {}
|
||||||
|
other => panic!("{name} answered from a NaN fit: {other:?}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degenerate_performances_are_refused_rather_than_answered_wrongly() {
|
||||||
|
let h = degenerate_but_converged();
|
||||||
|
|
||||||
|
// The fit itself is sound — the posteriors are point masses, not NaN.
|
||||||
|
let skill = h.current_skill("a").expect("a played");
|
||||||
|
assert_eq!(skill.sigma(), 0.0);
|
||||||
|
assert!(skill.mu().is_finite());
|
||||||
|
|
||||||
|
// `quality` previously PANICKED here, out of a method that returns
|
||||||
|
// `Result`: the contrast covariance is exactly singular when beta is zero
|
||||||
|
// and every skill is a point mass.
|
||||||
|
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
||||||
|
match r {
|
||||||
|
Err(InferenceError::NoPerformanceVariance) => {}
|
||||||
|
other => panic!("{name} predicted from a degenerate fit: {other:?}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_control_history_answers_every_prediction() {
|
||||||
|
let h = healthy();
|
||||||
|
|
||||||
|
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
||||||
|
assert!(r.is_ok(), "{name} failed on a healthy history: {r:?}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_probabilities_sum_to_one_on_the_control() {
|
||||||
|
// The promise the `Ok([0.0, 0.0])` case broke. Asserted on the control so
|
||||||
|
// the guard above cannot be "fixed" by making every path error.
|
||||||
|
let h = healthy();
|
||||||
|
let p = h
|
||||||
|
.predict_win_probabilities(&[&[&"a"], &[&"b"]])
|
||||||
|
.expect("control predicts");
|
||||||
|
let total: f64 = p.iter().sum();
|
||||||
|
assert!(
|
||||||
|
(total - 1.0).abs() < 1e-6,
|
||||||
|
"win probabilities sum to {total}"
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-1
@@ -67,7 +67,12 @@ proptest! {
|
|||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
for key in KEYS {
|
for key in KEYS {
|
||||||
for (time, g) in h.learning_curve(key) {
|
// A generated schedule need not touch every key, and an unplayed
|
||||||
|
// key is `None` rather than an empty curve.
|
||||||
|
let Some(curve) = h.learning_curve(key) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for (time, g) in curve {
|
||||||
assert_finite(g, &format!("{key} at t={time}"));
|
assert_finite(g, &format!("{key} at t={time}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-8
@@ -1,4 +1,4 @@
|
|||||||
//! `quality()` beyond two rating groups.
|
//! `quality()` beyond two teams.
|
||||||
//!
|
//!
|
||||||
//! The historical golden (two equal singletons) is asserted in
|
//! The historical golden (two equal singletons) is asserted in
|
||||||
//! `src/lib.rs::tests::test_quality`. These cover the N-group generalisation,
|
//! `src/lib.rs::tests::test_quality`. These cover the N-group generalisation,
|
||||||
@@ -82,14 +82,14 @@ fn uneven_group_sizes_work() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[should_panic(expected = "at least 2 rating groups")]
|
#[should_panic(expected = "at least 2 teams")]
|
||||||
fn single_group_panics_with_clear_message() {
|
fn single_group_panics_with_clear_message() {
|
||||||
let r = rating(25.0, 3.0);
|
let r = rating(25.0, 3.0);
|
||||||
let _ = quality(&[&[r]], BETA);
|
let _ = quality(&[&[r]], BETA);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[should_panic(expected = "at least 2 rating groups")]
|
#[should_panic(expected = "at least 2 teams")]
|
||||||
fn zero_groups_panics_with_clear_message() {
|
fn zero_groups_panics_with_clear_message() {
|
||||||
let _ = quality(&[], BETA);
|
let _ = quality(&[], BETA);
|
||||||
}
|
}
|
||||||
@@ -110,11 +110,8 @@ fn history_predict_quality_supports_three_teams() {
|
|||||||
h.record_winner(&"b", &"c", 2).unwrap();
|
h.record_winner(&"b", &"c", 2).unwrap();
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let q = h.predict_quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap();
|
let q = h.quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap();
|
||||||
assert!(
|
assert!(q.is_finite(), "3-team quality must be finite, got {q}");
|
||||||
q.is_finite(),
|
|
||||||
"3-team predict_quality must be finite, got {q}"
|
|
||||||
);
|
|
||||||
assert!((0.0..=1.0).contains(&q), "out of range: {q}");
|
assert!((0.0..=1.0).contains(&q), "out of range: {q}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,3 +161,52 @@ fn quality_matches_the_reference_implementation() {
|
|||||||
let refs: Vec<&[Gaussian]> = five.iter().map(Vec::as_slice).collect();
|
let refs: Vec<&[Gaussian]> = five.iter().map(Vec::as_slice).collect();
|
||||||
assert!((quality(&refs, beta) - 0.040).abs() < 1e-9);
|
assert!((quality(&refs, beta) - 0.040).abs() < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `quality()` used to compute `det(ata) / det(middle)` in linear space. Both
|
||||||
|
/// are products of `k - 1` diagonal entries, so they leave `f64`'s range long
|
||||||
|
/// before their ratio does — and the ratio is the only thing the answer needs.
|
||||||
|
///
|
||||||
|
/// Measured before the fix: at the crate defaults 150 groups was correct, 200
|
||||||
|
/// returned `0`, and 250 returned `NaN` where the truth is `9.51e-88`. With a
|
||||||
|
/// small beta it bit sooner — `sigma = beta = 1e-3` returned `NaN` at 60 groups
|
||||||
|
/// against a true `1.32e-9`, a value that is entirely ordinary.
|
||||||
|
///
|
||||||
|
/// For `k` single-member groups with equal means the answer has a closed form,
|
||||||
|
/// `(beta / sqrt(beta^2 + sigma^2))^(k-1)`, so this checks against arithmetic
|
||||||
|
/// rather than against a recorded output.
|
||||||
|
#[test]
|
||||||
|
fn quality_matches_its_closed_form_past_the_overflow_point() {
|
||||||
|
for (sigma, beta) in [(25.0 / 3.0, 25.0 / 6.0), (1e-3, 1e-3), (50.0, 25.0 / 6.0)] {
|
||||||
|
let rating = vec![Gaussian::from_ms(25.0, sigma)];
|
||||||
|
for k in [2usize, 50, 60, 150, 200, 250, 300] {
|
||||||
|
let groups: Vec<&[Gaussian]> = (0..k).map(|_| rating.as_slice()).collect();
|
||||||
|
let got = quality(&groups, beta);
|
||||||
|
let expected = (beta / (beta * beta + sigma * sigma).sqrt()).powi(k as i32 - 1);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
got.is_finite(),
|
||||||
|
"sigma {sigma}, beta {beta}, {k} groups: got {got}"
|
||||||
|
);
|
||||||
|
// Subnormal results have no relative precision left to check.
|
||||||
|
if expected > f64::MIN_POSITIVE {
|
||||||
|
let rel = ((got - expected) / expected).abs();
|
||||||
|
assert!(
|
||||||
|
rel < 1e-11,
|
||||||
|
"sigma {sigma}, beta {beta}, {k} groups: got {got:e}, \
|
||||||
|
closed form {expected:e}, rel {rel:e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The overflow was in the intermediates, never in the answer: every value
|
||||||
|
/// above is an ordinary float. This pins the specific case that returned `NaN`
|
||||||
|
/// where the true answer is nine orders of magnitude inside the normal range.
|
||||||
|
#[test]
|
||||||
|
fn a_small_beta_does_not_overflow_at_sixty_groups() {
|
||||||
|
let rating = vec![Gaussian::from_ms(25.0, 1e-3)];
|
||||||
|
let groups: Vec<&[Gaussian]> = (0..60).map(|_| rating.as_slice()).collect();
|
||||||
|
let got = quality(&groups, 1e-3);
|
||||||
|
assert!((got - 1.317_089e-9).abs() / 1.317_089e-9 < 1e-6, "{got:e}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! `HistoryBuilder::default_rating_for`: configuring a *class* of competitors
|
||||||
|
//! rather than one at a time (#53).
|
||||||
|
//!
|
||||||
|
//! Every test carries a control — a key the rule does not match — so none can
|
||||||
|
//! pass by the rule firing for everybody, which would be indistinguishable
|
||||||
|
//! from changing the history defaults.
|
||||||
|
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, Gaussian, History, HistoryBuilder, InferenceError, Member, NullObserver,
|
||||||
|
RatingRule, StartingPoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Pinned: no drift, and a tight prior at a known strength.
|
||||||
|
fn pinned() -> StartingPoint {
|
||||||
|
StartingPoint::new()
|
||||||
|
.prior(Gaussian::from_ms(5.0, 0.5))
|
||||||
|
.drift_scale(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play<R: RatingRule<&'static str>>(
|
||||||
|
h: &mut History<&'static str, i64, ConstantDrift, NullObserver, R>,
|
||||||
|
) {
|
||||||
|
for t in 1..=6 {
|
||||||
|
h.event(t)
|
||||||
|
.team(["layout_a"])
|
||||||
|
.team(["alice"])
|
||||||
|
.scores([3.0, 1.0])
|
||||||
|
.commit()
|
||||||
|
.expect("ingests");
|
||||||
|
}
|
||||||
|
h.converge().expect("converges");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rule_configures_every_matching_key_without_naming_them() {
|
||||||
|
let mut ruled = History::builder()
|
||||||
|
.gamma(0.5)
|
||||||
|
.default_rating_for(|key: &&'static str| key.starts_with("layout_").then(pinned))
|
||||||
|
.build();
|
||||||
|
play(&mut ruled);
|
||||||
|
|
||||||
|
let mut plain = History::builder().gamma(0.5).build();
|
||||||
|
play(&mut plain);
|
||||||
|
|
||||||
|
let layout = ruled.current_skill("layout_a").expect("played");
|
||||||
|
|
||||||
|
// The rule pinned the layout: tight prior, no drift.
|
||||||
|
assert!(
|
||||||
|
layout.sigma() < 0.5,
|
||||||
|
"the layout should stay near its pinned prior, got sigma {}",
|
||||||
|
layout.sigma()
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
layout.sigma(),
|
||||||
|
plain.current_skill("layout_a").unwrap().sigma(),
|
||||||
|
"the rule must actually change the fit"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The control is the *configuration*, not the posterior. Alice's posterior
|
||||||
|
// legitimately moves — she is playing a differently-configured opponent,
|
||||||
|
// and what she learns from beating it depends on how sure the model is
|
||||||
|
// about it. What must not move is what the rule was asked about.
|
||||||
|
let alice = ruled.rating("alice").expect("played");
|
||||||
|
assert_eq!(
|
||||||
|
alice.drift_scale(),
|
||||||
|
1.0,
|
||||||
|
"a non-matching key keeps the default drift"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(alice.prior().mu(), alice.prior().sigma()),
|
||||||
|
{
|
||||||
|
let p = plain.rating("alice").expect("played").prior();
|
||||||
|
(p.mu(), p.sigma())
|
||||||
|
},
|
||||||
|
"a non-matching key keeps the history's prior"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rule_fires_for_a_competitor_first_seen_through_record_winner() {
|
||||||
|
// `record_winner` cannot carry configuration, which is the case a rule
|
||||||
|
// exists for.
|
||||||
|
let mut h = History::builder()
|
||||||
|
.default_rating_for(|key: &&'static str| key.starts_with("bot_").then(pinned))
|
||||||
|
.build();
|
||||||
|
h.record_winner(&"bot_1", &"human", 1).expect("ingests");
|
||||||
|
h.converge().expect("converges");
|
||||||
|
|
||||||
|
assert_eq!(h.rating("bot_1").expect("known").drift_scale(), 0.0);
|
||||||
|
assert_eq!(h.rating("human").expect("known").drift_scale(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_configuration_overrides_a_rule_field_by_field() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.default_rating_for(|_: &&'static str| Some(pinned()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Sets only the prior, so the rule's `drift_scale` must survive.
|
||||||
|
h.register(Member::new("a").with_prior(Gaussian::from_ms(-9.0, 2.0)))
|
||||||
|
.expect("new");
|
||||||
|
// Sets neither: the rule supplies both.
|
||||||
|
h.register(Member::new("b")).expect("new");
|
||||||
|
|
||||||
|
let a = h.rating("a").expect("registered");
|
||||||
|
assert_eq!(a.prior().mu(), -9.0, "explicit prior wins");
|
||||||
|
assert_eq!(a.drift_scale(), 0.0, "the rule's drift_scale survives");
|
||||||
|
|
||||||
|
let b = h.rating("b").expect("registered");
|
||||||
|
assert_eq!(b.prior().mu(), 5.0);
|
||||||
|
assert_eq!(b.drift_scale(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_explicit_declarations_that_disagree_are_still_an_error() {
|
||||||
|
// Precedence resolves rule-vs-explicit. It does not weaken the check
|
||||||
|
// between two explicit declarations, neither of which is more specific.
|
||||||
|
let mut h = History::builder()
|
||||||
|
.default_rating_for(|_: &&'static str| Some(pinned()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let err = h
|
||||||
|
.add_events(vec![
|
||||||
|
event(1, "x", Gaussian::from_ms(1.0, 1.0)),
|
||||||
|
event(2, "x", Gaussian::from_ms(2.0, 1.0)),
|
||||||
|
])
|
||||||
|
.expect_err("two different priors for one competitor");
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::ConflictingCompetitorConfig { .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(time: i64, key: &'static str, prior: Gaussian) -> trueskill_tt::Event<i64, &'static str> {
|
||||||
|
trueskill_tt::Event {
|
||||||
|
time,
|
||||||
|
teams: [
|
||||||
|
trueskill_tt::Team::with_members([Member::new(key).with_prior(prior)]),
|
||||||
|
trueskill_tt::Team::with_members([Member::new("opponent")]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
outcome: trueskill_tt::Outcome::scores([2.0, 1.0]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A named rule type, so the `History<..>` can be written down in a field.
|
||||||
|
struct StaticLayouts;
|
||||||
|
|
||||||
|
impl RatingRule<&'static str> for StaticLayouts {
|
||||||
|
fn starting_point(&self, key: &&'static str) -> Option<StartingPoint> {
|
||||||
|
key.starts_with("layout_").then(pinned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reason this is a trait rather than a bare `Fn` bound: a consumer holds
|
||||||
|
/// its history in application state and has to name the type.
|
||||||
|
struct Ladder {
|
||||||
|
history: History<&'static str, i64, ConstantDrift, NullObserver, StaticLayouts>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_named_rule_type_can_be_stored_in_a_struct_field() {
|
||||||
|
let mut ladder = Ladder {
|
||||||
|
history: HistoryBuilder::default().rating_rule(StaticLayouts).build(),
|
||||||
|
};
|
||||||
|
play(&mut ladder.history);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
ladder
|
||||||
|
.history
|
||||||
|
.current_skill("layout_a")
|
||||||
|
.expect("played")
|
||||||
|
.sigma()
|
||||||
|
< 0.5
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ladder
|
||||||
|
.history
|
||||||
|
.rating("alice")
|
||||||
|
.expect("played")
|
||||||
|
.drift_scale(),
|
||||||
|
1.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_rule_is_the_default_and_costs_nothing_to_spell() {
|
||||||
|
// The whole point of defaulting the parameter: `History<K>` still works.
|
||||||
|
let h: History<String> = History::builder().key_type::<String>().build();
|
||||||
|
assert_eq!(h.competitor_count(), 0);
|
||||||
|
}
|
||||||
@@ -35,8 +35,10 @@ fn ev(a: &str, b: &str, time: i64) -> Event<i64, String> {
|
|||||||
|
|
||||||
/// Ingest each chunk in turn, converging fully after every one.
|
/// Ingest each chunk in turn, converging fully after every one.
|
||||||
fn fit_in_chunks(chunks: Vec<Events>) -> Vec<(String, Gaussian)> {
|
fn fit_in_chunks(chunks: Vec<Events>) -> Vec<(String, Gaussian)> {
|
||||||
let mut h: History<i64, _, _, String> =
|
let mut h: History<String> = History::builder()
|
||||||
History::builder_with_key().convergence(tight()).build();
|
.key_type::<String>()
|
||||||
|
.convergence(tight())
|
||||||
|
.build();
|
||||||
|
|
||||||
for chunk in chunks {
|
for chunk in chunks {
|
||||||
h.add_events(chunk).unwrap();
|
h.add_events(chunk).unwrap();
|
||||||
@@ -150,8 +152,10 @@ fn re_converging_an_unchanged_history_costs_one_iteration() {
|
|||||||
let (early, late) = fixture();
|
let (early, late) = fixture();
|
||||||
let all: Vec<_> = early.into_iter().chain(late).collect();
|
let all: Vec<_> = early.into_iter().chain(late).collect();
|
||||||
|
|
||||||
let mut h: History<i64, _, _, String> =
|
let mut h: History<String> = History::builder()
|
||||||
History::builder_with_key().convergence(tight()).build();
|
.key_type::<String>()
|
||||||
|
.convergence(tight())
|
||||||
|
.build();
|
||||||
h.add_events(all).unwrap();
|
h.add_events(all).unwrap();
|
||||||
let first = h.converge().unwrap();
|
let first = h.converge().unwrap();
|
||||||
assert!(first.converged);
|
assert!(first.converged);
|
||||||
|
|||||||
+22
-14
@@ -6,7 +6,7 @@ fn record_winner_builds_history() {
|
|||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 30,
|
max_iter: 30,
|
||||||
epsilon: 1e-6,
|
epsilon: 1e-6,
|
||||||
@@ -17,24 +17,32 @@ fn record_winner_builds_history() {
|
|||||||
h.record_winner(&"alice", &"bob", 1).unwrap();
|
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let a_idx = h.lookup(&"alice").unwrap();
|
// `lookup` returned an `Index` that nothing public accepted, so the
|
||||||
let b_idx = h.lookup(&"bob").unwrap();
|
// observable claim is the one worth making: two distinct competitors, each
|
||||||
|
// with their own posterior, and the winner ahead.
|
||||||
assert_ne!(a_idx, b_idx);
|
assert_eq!(h.competitor_count(), 2);
|
||||||
|
let alice = h.current_skill("alice").expect("alice played");
|
||||||
|
let bob = h.current_skill("bob").expect("bob played");
|
||||||
|
assert!(alice.mu() > bob.mu());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same key names the same competitor across events, which is what
|
||||||
|
/// interning bought and the only part of it a caller can observe.
|
||||||
#[test]
|
#[test]
|
||||||
fn intern_is_idempotent() {
|
fn a_repeated_key_is_one_competitor() {
|
||||||
let mut h: History = History::builder().build();
|
let mut h: History = History::builder().build();
|
||||||
let a1 = h.intern(&"alice");
|
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||||||
let a2 = h.intern(&"alice");
|
h.record_winner(&"alice", &"carol", 2).unwrap();
|
||||||
assert_eq!(a1, a2);
|
|
||||||
|
assert_eq!(h.competitor_count(), 3);
|
||||||
|
assert_eq!(h.learning_curve("alice").expect("known").len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lookup_returns_none_for_missing() {
|
fn an_unknown_key_is_unknown() {
|
||||||
let h: History = History::builder().build();
|
let h: History = History::builder().build();
|
||||||
assert!(h.lookup(&"nobody").is_none());
|
assert!(h.current_skill("nobody").is_none());
|
||||||
|
assert!(h.learning_curve("nobody").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -43,13 +51,13 @@ fn record_draw_with_p_draw_set() {
|
|||||||
.mu(25.0)
|
.mu(25.0)
|
||||||
.sigma(25.0 / 3.0)
|
.sigma(25.0 / 3.0)
|
||||||
.beta(25.0 / 6.0)
|
.beta(25.0 / 6.0)
|
||||||
.drift(ConstantDrift(25.0 / 300.0))
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
.p_draw(0.25)
|
.p_draw(0.25)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
h.record_draw(&"alice", &"bob", 1).unwrap();
|
h.record_draw(&"alice", &"bob", 1).unwrap();
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
assert!(h.lookup(&"alice").is_some());
|
assert!(h.current_skill("alice").is_some());
|
||||||
assert!(h.lookup(&"bob").is_some());
|
assert!(h.current_skill("bob").is_some());
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-13
@@ -11,7 +11,7 @@ use trueskill_tt::{
|
|||||||
Team,
|
Team,
|
||||||
};
|
};
|
||||||
|
|
||||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
type H = History;
|
||||||
|
|
||||||
const PINNED: Gaussian = Gaussian::from_ms(2.0, 0.5);
|
const PINNED: Gaussian = Gaussian::from_ms(2.0, 0.5);
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ fn history() -> H {
|
|||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift(0.5))
|
.drift(ConstantDrift::new(0.5))
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 20_000,
|
max_iter: 20_000,
|
||||||
epsilon: 1e-13,
|
epsilon: 1e-13,
|
||||||
@@ -94,8 +94,8 @@ fn registering_matches_configuring_on_the_first_event() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for ((k, a), (_, b)) in skills(&configured).into_iter().zip(skills(®istered)) {
|
for ((k, a), (_, b)) in skills(&configured).into_iter().zip(skills(®istered)) {
|
||||||
assert_eq!(a.pi(), b.pi(), "{k} pi");
|
assert_eq!(a.mu(), b.mu(), "{k} mu");
|
||||||
assert_eq!(a.tau(), b.tau(), "{k} tau");
|
assert_eq!(a.variance(), b.variance(), "{k} variance");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ fn registration_reaches_a_competitor_first_seen_through_record_winner() {
|
|||||||
assert_eq!(rating.prior().mu(), PINNED.mu());
|
assert_eq!(rating.prior().mu(), PINNED.mu());
|
||||||
|
|
||||||
// Pinned means pinned: no drift across the two slices.
|
// Pinned means pinned: no drift across the two slices.
|
||||||
let curve = h.learning_curve(&"layout");
|
let curve = h.learning_curve(&"layout").unwrap();
|
||||||
assert!(curve.len() >= 2);
|
assert!(curve.len() >= 2);
|
||||||
let widest = curve
|
let widest = curve
|
||||||
.iter()
|
.iter()
|
||||||
@@ -172,7 +172,13 @@ fn a_weight_on_a_registration_is_rejected() {
|
|||||||
.register(Member::new("layout").with_weight(0.5))
|
.register(Member::new("layout").with_weight(0.5))
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Weight,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,7 +194,7 @@ fn an_invalid_drift_scale_on_a_registration_is_rejected() {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter {
|
||||||
name: "drift_scale",
|
parameter: trueskill_tt::Parameter::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -225,8 +231,8 @@ fn registration_makes_the_fit_order_independent() {
|
|||||||
let forward = build(false);
|
let forward = build(false);
|
||||||
let backward = build(true);
|
let backward = build(true);
|
||||||
for ((k, a), (_, b)) in skills(&forward).into_iter().zip(skills(&backward)) {
|
for ((k, a), (_, b)) in skills(&forward).into_iter().zip(skills(&backward)) {
|
||||||
assert_eq!(a.pi(), b.pi(), "{k} pi");
|
assert_eq!(a.mu(), b.mu(), "{k} mu");
|
||||||
assert_eq!(a.tau(), b.tau(), "{k} tau");
|
assert_eq!(a.variance(), b.variance(), "{k} variance");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,8 +252,8 @@ fn rating_reads_back_what_was_stored() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let r = h.rating(&"layout").unwrap();
|
let r = h.rating(&"layout").unwrap();
|
||||||
assert_eq!(r.drift_scale(), 0.25);
|
assert_eq!(r.drift_scale(), 0.25);
|
||||||
assert_eq!(r.prior().pi(), PINNED.pi());
|
assert_eq!(r.prior().mu(), PINNED.mu());
|
||||||
assert_eq!(r.prior().tau(), PINNED.tau());
|
assert_eq!(r.prior().variance(), PINNED.variance());
|
||||||
|
|
||||||
// A competitor created by an event reports the history defaults.
|
// A competitor created by an event reports the history defaults.
|
||||||
h.record_winner(&"player", &"layout", 1).unwrap();
|
h.record_winner(&"player", &"layout", 1).unwrap();
|
||||||
@@ -280,7 +286,7 @@ mod conflicting_configuration {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::ConflictingCompetitorConfig {
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
field: "drift_scale",
|
field: trueskill_tt::CompetitorField::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -297,7 +303,7 @@ mod conflicting_configuration {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::ConflictingCompetitorConfig {
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
field: "drift_scale",
|
field: trueskill_tt::CompetitorField::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
+3
-3
@@ -9,7 +9,7 @@ fn scored_two_team_one_event_pulls_winner_up() {
|
|||||||
.mu(0.0)
|
.mu(0.0)
|
||||||
.sigma(2.0)
|
.sigma(2.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.score_sigma(1.0)
|
.score_sigma(1.0)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ fn scored_zero_margin_treats_as_tie() {
|
|||||||
.mu(0.0)
|
.mu(0.0)
|
||||||
.sigma(2.0)
|
.sigma(2.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.score_sigma(1.0)
|
.score_sigma(1.0)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ fn scored_three_team_partial_order() {
|
|||||||
.mu(0.0)
|
.mu(0.0)
|
||||||
.sigma(2.0)
|
.sigma(2.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.score_sigma(1.0)
|
.score_sigma(1.0)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! The `Time` generic, exercised end to end.
|
||||||
|
//!
|
||||||
|
//! `History<T: Time, ..>` has always been generic over the time axis, `Untimed`
|
||||||
|
//! has always been exported, and `Drift<T>` is generic specifically so that
|
||||||
|
//! "seasonal or calendar-aware drift is expressible without going through
|
||||||
|
//! `i64`". None of it was reachable: every construction route pinned `T = i64`,
|
||||||
|
//! `HistoryBuilder`'s fields are private, and its `Default` existed only for the
|
||||||
|
//! `i64` instantiation.
|
||||||
|
//!
|
||||||
|
//! Nothing in the repository constructed a non-`i64` history, which is why that
|
||||||
|
//! went unnoticed. This file is the guard against it recurring — it is as much
|
||||||
|
//! about the generic being *exercised* as about any single assertion.
|
||||||
|
|
||||||
|
use trueskill_tt::{ConstantDrift, Drift, History, HistoryBuilder, Time, Untimed};
|
||||||
|
|
||||||
|
/// A domain time type: a season number. Exactly what the `Time` trait exists
|
||||||
|
/// to support, and what a consumer with `chrono` dates would write.
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
struct Season(u16);
|
||||||
|
|
||||||
|
impl Time for Season {
|
||||||
|
fn elapsed_to(&self, later: &Self) -> i64 {
|
||||||
|
i64::from(later.0.saturating_sub(self.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drift that only accumulates between seasons, not within one — the
|
||||||
|
/// calendar-aware case the trait's own docs cite.
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
struct SeasonalDrift {
|
||||||
|
per_season: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drift<Season> for SeasonalDrift {
|
||||||
|
fn variance_delta(&self, from: &Season, to: &Season) -> f64 {
|
||||||
|
self.variance_for_elapsed(from.elapsed_to(to))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn variance_for_elapsed(&self, elapsed: i64) -> f64 {
|
||||||
|
elapsed.max(0) as f64 * self.per_season * self.per_season
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_untimed_history_fits_through_the_builder() {
|
||||||
|
let mut h = History::builder().time_type::<Untimed>().build();
|
||||||
|
for _ in 0..5 {
|
||||||
|
h.record_winner(&"alice", &"bob", Untimed).unwrap();
|
||||||
|
}
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
|
||||||
|
let alice = h.current_skill(&"alice").unwrap();
|
||||||
|
let bob = h.current_skill(&"bob").unwrap();
|
||||||
|
assert!(alice.mu() > bob.mu(), "{alice:?} vs {bob:?}");
|
||||||
|
assert!(alice.sigma().is_finite() && alice.sigma() > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Untimed::elapsed_to` is always 0, so no drift accumulates however many
|
||||||
|
/// events there are. That is the property the type exists for, and it had never
|
||||||
|
/// been checked.
|
||||||
|
#[test]
|
||||||
|
fn untimed_accumulates_no_drift() {
|
||||||
|
fn final_sigma<T: Time + Copy>(time: T, drift: ConstantDrift) -> f64 {
|
||||||
|
let mut h = History::builder().time_type::<T>().drift(drift).build();
|
||||||
|
for _ in 0..8 {
|
||||||
|
h.record_winner(&"a", &"b", time).unwrap();
|
||||||
|
}
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h.current_skill(&"a").unwrap().sigma()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under Untimed the drift setting cannot matter, because elapsed is always 0.
|
||||||
|
let none = final_sigma(Untimed, ConstantDrift::new(0.0));
|
||||||
|
let large = final_sigma(Untimed, ConstantDrift::new(5.0));
|
||||||
|
assert_eq!(
|
||||||
|
none.to_bits(),
|
||||||
|
large.to_bits(),
|
||||||
|
"Untimed must ignore drift entirely: {none} vs {large}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_custom_time_type_and_a_custom_drift_work_together() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.time_type::<Season>()
|
||||||
|
.drift(SeasonalDrift { per_season: 0.5 })
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for season in 1..=4u16 {
|
||||||
|
for _ in 0..3 {
|
||||||
|
h.record_winner(&"veteran", &"rookie", Season(season))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
|
||||||
|
let curve = h.learning_curve(&"veteran").unwrap();
|
||||||
|
assert_eq!(curve.len(), 4, "one point per season: {curve:?}");
|
||||||
|
for (season, g) in &curve {
|
||||||
|
assert!(
|
||||||
|
g.mu().is_finite() && g.sigma() > 0.0,
|
||||||
|
"season {season:?}: {g:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Times come back as the domain type, not as an integer.
|
||||||
|
assert_eq!(curve[0].0, Season(1));
|
||||||
|
assert_eq!(curve[3].0, Season(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seasonal drift must actually widen a gap across seasons — otherwise the
|
||||||
|
/// custom `Drift` is being ignored and the test above would pass regardless.
|
||||||
|
#[test]
|
||||||
|
fn a_custom_drift_is_actually_consulted() {
|
||||||
|
fn sigma_with(per_season: f64) -> f64 {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.time_type::<Season>()
|
||||||
|
.drift(SeasonalDrift { per_season })
|
||||||
|
.build();
|
||||||
|
for season in 1..=6u16 {
|
||||||
|
h.record_winner(&"a", &"b", Season(season)).unwrap();
|
||||||
|
}
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h.current_skill(&"a").unwrap().sigma()
|
||||||
|
}
|
||||||
|
|
||||||
|
let still = sigma_with(0.0);
|
||||||
|
let drifting = sigma_with(2.0);
|
||||||
|
assert!(
|
||||||
|
drifting > still * 1.05,
|
||||||
|
"a drifting fit must be less certain: {drifting} vs {still}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other axis: a custom key type, through the same mechanism.
|
||||||
|
#[test]
|
||||||
|
fn key_type_replaces_builder_with_key() {
|
||||||
|
let mut h = History::builder().key_type::<String>().build();
|
||||||
|
h.record_winner(&"alice".to_string(), &"bob".to_string(), 1)
|
||||||
|
.unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
assert!(h.current_skill("alice").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both axes at once, via the explicit constructor rather than the setters.
|
||||||
|
#[test]
|
||||||
|
fn new_constructs_on_any_axis_directly() {
|
||||||
|
let mut h = HistoryBuilder::<String, Season>::new().build();
|
||||||
|
h.record_winner(&"a".to_string(), &"b".to_string(), Season(7))
|
||||||
|
.unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
assert_eq!(h.learning_curve("a").unwrap()[0].0, Season(7));
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ const BETA: f64 = 1.0;
|
|||||||
const SCORE_SIGMA: f64 = 2.0;
|
const SCORE_SIGMA: f64 = 2.0;
|
||||||
const GAMMA: f64 = 0.5;
|
const GAMMA: f64 = 0.5;
|
||||||
|
|
||||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
type H = History;
|
||||||
|
|
||||||
fn history(gamma: f64) -> H {
|
fn history(gamma: f64) -> H {
|
||||||
History::builder()
|
History::builder()
|
||||||
@@ -24,7 +24,7 @@ fn history(gamma: f64) -> H {
|
|||||||
.sigma(SIGMA0)
|
.sigma(SIGMA0)
|
||||||
.beta(BETA)
|
.beta(BETA)
|
||||||
.score_sigma(SCORE_SIGMA)
|
.score_sigma(SCORE_SIGMA)
|
||||||
.drift(ConstantDrift(gamma))
|
.drift(ConstantDrift::new(gamma))
|
||||||
.unknown_keys(UnknownKeys::Reject)
|
.unknown_keys(UnknownKeys::Reject)
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 20_000,
|
max_iter: 20_000,
|
||||||
@@ -120,7 +120,11 @@ fn a_two_slice_joint_matches_the_exact_posterior() {
|
|||||||
|
|
||||||
// The crate reads each competitor at their latest appearance: a1, b1.
|
// The crate reads each competitor at their latest appearance: a1, b1.
|
||||||
let exact_gap = (cov[2][2] + cov[3][3] - 2.0 * cov[2][3]).sqrt();
|
let exact_gap = (cov[2][2] + cov[3][3] - 2.0 * cov[2][3]).sqrt();
|
||||||
let got = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
let got = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
(got.sigma() - exact_gap).abs() / exact_gap < 1e-9,
|
(got.sigma() - exact_gap).abs() / exact_gap < 1e-9,
|
||||||
"difference: got {} exact {exact_gap}",
|
"difference: got {} exact {exact_gap}",
|
||||||
@@ -128,7 +132,7 @@ fn a_two_slice_joint_matches_the_exact_posterior() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let exact_single = cov[2][2].sqrt();
|
let exact_single = cov[2][2].sqrt();
|
||||||
let got_single = h.posterior_of(&[(&"a", 1.0)]).unwrap();
|
let got_single = h.joint().unwrap().posterior_of(&[(&"a", 1.0)]).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
(got_single.sigma() - exact_single).abs() / exact_single < 1e-9,
|
(got_single.sigma() - exact_single).abs() / exact_single < 1e-9,
|
||||||
"single node: got {} exact {exact_single}",
|
"single node: got {} exact {exact_single}",
|
||||||
@@ -154,6 +158,8 @@ fn competitors_last_seen_in_different_slices_are_comparable() {
|
|||||||
// b last appeared at time 0; a and c at time 20. All three must resolve.
|
// b last appeared at time 0; a and c at time 20. All three must resolve.
|
||||||
for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] {
|
for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] {
|
||||||
let g = h
|
let g = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.posterior_of(&[(&x, 1.0), (&y, -1.0)])
|
.posterior_of(&[(&x, 1.0), (&y, -1.0)])
|
||||||
.unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}"));
|
.unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}"));
|
||||||
assert!(g.sigma() > 0.0 && g.sigma().is_finite());
|
assert!(g.sigma() > 0.0 && g.sigma().is_finite());
|
||||||
@@ -175,7 +181,7 @@ fn means_agree_with_the_marginals() {
|
|||||||
|
|
||||||
for k in ["a", "b", "c"] {
|
for k in ["a", "b", "c"] {
|
||||||
let marginal = h.current_skill(&k).unwrap().mu();
|
let marginal = h.current_skill(&k).unwrap().mu();
|
||||||
let joint = h.posterior_of(&[(&k, 1.0)]).unwrap().mu();
|
let joint = h.joint().unwrap().posterior_of(&[(&k, 1.0)]).unwrap().mu();
|
||||||
assert!(
|
assert!(
|
||||||
(marginal - joint).abs() < 1e-9,
|
(marginal - joint).abs() < 1e-9,
|
||||||
"{k}: marginal {marginal}, joint {joint}"
|
"{k}: marginal {marginal}, joint {joint}"
|
||||||
@@ -197,7 +203,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap()
|
h.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap()
|
||||||
};
|
};
|
||||||
let together = {
|
let together = {
|
||||||
let mut h = history(0.0);
|
let mut h = history(0.0);
|
||||||
@@ -208,7 +217,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap()
|
h.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -234,7 +246,11 @@ fn drift_widens_a_comparison_across_time() {
|
|||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
// b was last seen at time 0; a at time 100.
|
// b was last seen at time 0; a at time 100.
|
||||||
let g = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
let g = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
g.sigma() > previous,
|
g.sigma() > previous,
|
||||||
"gamma={gamma}: sigma {} did not exceed {previous}",
|
"gamma={gamma}: sigma {} did not exceed {previous}",
|
||||||
@@ -257,9 +273,21 @@ fn posterior_of_at_reads_as_of_a_time() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let early = h.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
let early = h
|
||||||
let late = h.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
.joint()
|
||||||
let latest = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
|
.unwrap()
|
||||||
|
.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap();
|
||||||
|
let late = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap();
|
||||||
|
let latest = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Asking as of the final slice is the same as asking for the latest.
|
// Asking as of the final slice is the same as asking for the latest.
|
||||||
assert!((late.mu() - latest.mu()).abs() < 1e-9);
|
assert!((late.mu() - latest.mu()).abs() < 1e-9);
|
||||||
@@ -275,7 +303,12 @@ fn posterior_of_at_reads_as_of_a_time() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// A time before any event has nothing to read.
|
// A time before any event has nothing to read.
|
||||||
assert!(h.posterior_of_at(-1, &[(&"a", 1.0)]).is_err());
|
assert!(
|
||||||
|
h.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of_at(-1, &[(&"a", 1.0)])
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Times between slices resolve to the latest appearance at or before them.
|
/// Times between slices resolve to the latest appearance at or before them.
|
||||||
@@ -289,8 +322,16 @@ fn a_time_between_slices_reads_the_previous_appearance() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
let at_zero = h.posterior_of_at(0, &[(&"a", 1.0)]).unwrap();
|
let at_zero = h
|
||||||
let between = h.posterior_of_at(50, &[(&"a", 1.0)]).unwrap();
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of_at(0, &[(&"a", 1.0)])
|
||||||
|
.unwrap();
|
||||||
|
let between = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of_at(50, &[(&"a", 1.0)])
|
||||||
|
.unwrap();
|
||||||
assert!((at_zero.mu() - between.mu()).abs() < 1e-12);
|
assert!((at_zero.mu() - between.mu()).abs() < 1e-12);
|
||||||
assert!((at_zero.sigma() - between.sigma()).abs() < 1e-12);
|
assert!((at_zero.sigma() - between.sigma()).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//! The traits a consumer needs on the public types, pinned so they cannot be
|
||||||
|
//! removed by accident.
|
||||||
|
//!
|
||||||
|
//! This is written from a consumer's position — deriving `Debug` on a struct
|
||||||
|
//! that *holds* a `History` — because that is the thing that failed. Asserting
|
||||||
|
//! `History: Debug` in isolation would not have caught the generic-bound half:
|
||||||
|
//! `Rating` derives `PartialEq`, but that is only usable if `D: PartialEq`, and
|
||||||
|
//! the crate's own only `Drift` impl did not satisfy it.
|
||||||
|
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, ConvergenceReport, Event, GameOptions, Gaussian, History,
|
||||||
|
HistoryBuilder, InferenceError, Member, Outcome, Rating, Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The reported failure, verbatim: a consumer holding a history in app state.
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[allow(
|
||||||
|
dead_code,
|
||||||
|
reason = "held only so `derive(Debug)` has something to render"
|
||||||
|
)]
|
||||||
|
struct App {
|
||||||
|
history: History,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_struct_holding_a_history_can_derive_debug() {
|
||||||
|
let app = App {
|
||||||
|
history: History::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let rendered = format!("{app:?}");
|
||||||
|
|
||||||
|
// Summarising, not a dump of every skill store — the same choice `Joint`'s
|
||||||
|
// manual `Debug` makes about its n² factorisation.
|
||||||
|
assert!(rendered.contains("competitors"), "{rendered}");
|
||||||
|
assert!(rendered.contains("time_slices"), "{rendered}");
|
||||||
|
assert!(
|
||||||
|
!rendered.contains("SkillStore"),
|
||||||
|
"History's Debug should summarise, not dump: {rendered}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_builder_is_debug_and_clone() {
|
||||||
|
let b: HistoryBuilder = History::builder();
|
||||||
|
let cloned = b.clone();
|
||||||
|
assert!(!format!("{cloned:?}").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_and_input_value_types_are_comparable() {
|
||||||
|
assert_eq!(ConstantDrift::new(0.1), ConstantDrift::new(0.1));
|
||||||
|
assert_ne!(ConstantDrift::new(0.1), ConstantDrift::new(0.2));
|
||||||
|
|
||||||
|
assert_eq!(ConvergenceOptions::default(), ConvergenceOptions::default());
|
||||||
|
assert_eq!(GameOptions::default(), GameOptions::default());
|
||||||
|
|
||||||
|
// `Rating: PartialEq` is only reachable through `D: PartialEq`.
|
||||||
|
assert_eq!(Rating::<i64, ConstantDrift>::default(), Rating::default());
|
||||||
|
assert_ne!(
|
||||||
|
Rating::default(),
|
||||||
|
Rating::<i64, ConstantDrift>::default().with_drift_scale(2.0)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(Member::new("a"), Member::new("a"));
|
||||||
|
assert_ne!(Member::new("a"), Member::new("b"));
|
||||||
|
assert_eq!(
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("a")])
|
||||||
|
);
|
||||||
|
|
||||||
|
let event = || Event {
|
||||||
|
time: 1,
|
||||||
|
teams: [
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
};
|
||||||
|
assert_eq!(event(), event());
|
||||||
|
|
||||||
|
assert_eq!(Gaussian::default(), Gaussian::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_history_is_send_and_sync_and_default() {
|
||||||
|
fn assert_send_sync<X: Send + Sync>() {}
|
||||||
|
assert_send_sync::<History>();
|
||||||
|
assert_send_sync::<InferenceError>();
|
||||||
|
|
||||||
|
let mut h = History::default();
|
||||||
|
let report: ConvergenceReport = h.converge().expect("an empty history converges");
|
||||||
|
assert_eq!(report, report.clone());
|
||||||
|
}
|
||||||
+151
-8
@@ -22,7 +22,7 @@ fn rating() -> R {
|
|||||||
R::new(
|
R::new(
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
25.0 / 6.0,
|
25.0 / 6.0,
|
||||||
ConstantDrift(0.0),
|
ConstantDrift::new(0.0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,13 @@ fn ranked_rejects_a_zero_damping_factor() {
|
|||||||
)
|
)
|
||||||
.expect_err("alpha = 0 must be rejected");
|
.expect_err("alpha = 0 must be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Alpha,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"got {err:?}"
|
"got {err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,7 +71,13 @@ fn ranked_rejects_an_out_of_range_damping_factor() {
|
|||||||
)
|
)
|
||||||
.expect_err("alpha out of (0, 1] must be rejected");
|
.expect_err("alpha out of (0, 1] must be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Alpha,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"alpha={alpha}: got {err:?}"
|
"alpha={alpha}: got {err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -81,7 +93,13 @@ fn scored_rejects_a_bad_damping_factor() {
|
|||||||
)
|
)
|
||||||
.expect_err("alpha = 0 must be rejected");
|
.expect_err("alpha = 0 must be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Alpha,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"got {err:?}"
|
"got {err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -139,7 +157,7 @@ fn ingestion_rejects_a_tie_without_a_draw_probability() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Outcome::scores_with_sigma` documents that a non-positive sigma is
|
/// `Outcome::scores_with_noise` documents that a non-positive sigma is
|
||||||
/// accepted at construction and rejected at ingestion.
|
/// accepted at construction and rejected at ingestion.
|
||||||
#[test]
|
#[test]
|
||||||
fn ingestion_rejects_a_non_positive_per_event_score_sigma() {
|
fn ingestion_rejects_a_non_positive_per_event_score_sigma() {
|
||||||
@@ -152,7 +170,7 @@ fn ingestion_rejects_a_non_positive_per_event_score_sigma() {
|
|||||||
Team::with_members([Member::new("a")]),
|
Team::with_members([Member::new("a")]),
|
||||||
Team::with_members([Member::new("b")]),
|
Team::with_members([Member::new("b")]),
|
||||||
],
|
],
|
||||||
outcome: Outcome::scores_with_sigma([21.0, 9.0], sigma),
|
outcome: Outcome::scores_with_noise([21.0, 9.0], sigma),
|
||||||
}])
|
}])
|
||||||
.expect_err("a non-positive per-event sigma must be rejected");
|
.expect_err("a non-positive per-event sigma must be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -248,10 +266,135 @@ mod builder_parameters {
|
|||||||
};
|
};
|
||||||
let zero = fit(0.0);
|
let zero = fit(0.0);
|
||||||
let positive = fit(25.0 / 6.0);
|
let positive = fit(25.0 / 6.0);
|
||||||
assert!(zero.pi().is_finite() && zero.pi() > 0.0);
|
// `variance` rather than `pi`: the natural parameters are the crate's
|
||||||
|
// internal representation and no longer public. It is the same
|
||||||
|
// quantity inverted, so a finite positive precision is a finite
|
||||||
|
// positive variance.
|
||||||
|
assert!(zero.variance().is_finite() && zero.variance() > 0.0);
|
||||||
assert!(
|
assert!(
|
||||||
(zero.pi() - positive.pi()).abs() > 1e-6,
|
(zero.variance() - positive.variance()).abs() > 1e-6,
|
||||||
"zero beta must not merely be ignored: {zero:?} vs {positive:?}"
|
"zero beta must not merely be ignored: {zero:?} vs {positive:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The constructors below `HistoryBuilder`, which 0.8.0's validation did not
|
||||||
|
/// reach.
|
||||||
|
///
|
||||||
|
/// `sigma`, `beta` and `gamma` all enter inference only as squares, so a
|
||||||
|
/// negative value behaves as its absolute value and the sign vanishes without
|
||||||
|
/// comment. Measured before these guards: `from_ms(25.0, -8.33)` and
|
||||||
|
/// `Rating::new(_, -4.17, _)` returned results bit identical to their positive
|
||||||
|
/// counterparts, and `Rating::new(_, NaN, _)` reached `Game::ranked`, which
|
||||||
|
/// returned `Ok` carrying `Gaussian { pi: NaN, tau: NaN }`.
|
||||||
|
mod constructor_parameters {
|
||||||
|
use trueskill_tt::{ConstantDrift, Gaussian, History, InferenceError, Rating};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "sigma must not be negative")]
|
||||||
|
fn a_negative_sigma_is_rejected_by_from_ms() {
|
||||||
|
let _ = Gaussian::from_ms(25.0, -8.33);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NaN must pass, and that is deliberate: a broken fit produces a NaN
|
||||||
|
/// sigma and `converge` reports it as `NonFiniteResult`. Rejecting it here
|
||||||
|
/// would turn reporting into a panic inside inference.
|
||||||
|
#[test]
|
||||||
|
fn a_nan_sigma_passes_through_from_ms() {
|
||||||
|
let g = Gaussian::from_ms(25.0, f64::NAN);
|
||||||
|
// `sigma()` is NaN exactly when the precision is: it guards `pi <= 0`
|
||||||
|
// (reporting `inf`) and `pi == inf` (reporting `0.0`), so NaN survives
|
||||||
|
// only from a NaN precision.
|
||||||
|
assert!(g.sigma().is_nan());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "beta must be finite and non-negative")]
|
||||||
|
fn a_negative_beta_is_rejected_by_rating_new() {
|
||||||
|
let _ =
|
||||||
|
Rating::<i64, ConstantDrift>::new(Gaussian::default(), -4.17, ConstantDrift::new(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "beta must be finite and non-negative")]
|
||||||
|
fn a_nan_beta_is_rejected_by_rating_new() {
|
||||||
|
let _ = Rating::<i64, ConstantDrift>::new(
|
||||||
|
Gaussian::default(),
|
||||||
|
f64::NAN,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_beta_is_accepted_by_rating_new() {
|
||||||
|
let _ =
|
||||||
|
Rating::<i64, ConstantDrift>::new(Gaussian::default(), 0.0, ConstantDrift::new(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ConstantDrift` rejects at construction now that its field is private.
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "gamma must be finite and non-negative")]
|
||||||
|
fn a_negative_gamma_is_rejected_by_constant_drift_new() {
|
||||||
|
let _ = ConstantDrift::new(-0.0833);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "gamma must be finite and non-negative")]
|
||||||
|
fn a_non_finite_gamma_is_rejected_by_constant_drift_new() {
|
||||||
|
let _ = ConstantDrift::new(f64::NAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamma_reads_back_what_was_given() {
|
||||||
|
assert_eq!(ConstantDrift::new(0.25).gamma(), 0.25);
|
||||||
|
assert_eq!(ConstantDrift::new(0.0).gamma(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `HistoryBuilder::drift` is generic and cannot inspect an arbitrary
|
||||||
|
/// `Drift`, so the check on the variance each competitor accumulates is
|
||||||
|
/// still needed — it is the only thing standing between a custom
|
||||||
|
/// implementation and a NaN fit. `ConstantDrift` can no longer reach it,
|
||||||
|
/// so this uses an implementation that can.
|
||||||
|
#[test]
|
||||||
|
fn a_custom_drift_returning_a_bad_variance_is_rejected_at_convergence() {
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct BadDrift(f64);
|
||||||
|
|
||||||
|
impl trueskill_tt::Drift<i64> for BadDrift {
|
||||||
|
fn variance_delta(&self, _from: &i64, _to: &i64) -> f64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
fn variance_for_elapsed(&self, _elapsed: i64) -> f64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for bad in [f64::NAN, f64::INFINITY, -1.0] {
|
||||||
|
let mut h = History::builder().drift(BadDrift(bad)).build();
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
h.record_winner(&"a", &"b", 5).unwrap();
|
||||||
|
let err = h.converge().unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::DriftVariance,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"drift {bad}: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ordinary drift is untouched.
|
||||||
|
#[test]
|
||||||
|
fn an_ordinary_drift_still_converges() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
|
.build();
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
h.record_winner(&"a", &"b", 5).unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use trueskill_tt::{
|
|||||||
UnknownKeys,
|
UnknownKeys,
|
||||||
};
|
};
|
||||||
|
|
||||||
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
|
type H = History;
|
||||||
|
|
||||||
fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> {
|
fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> {
|
||||||
Event {
|
Event {
|
||||||
@@ -30,12 +30,12 @@ fn base() -> Vec<Event<i64, &'static str>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
|
fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
|
||||||
let mut h: History<i64, _, _, &'static str> = History::builder()
|
let mut h: History = History::builder()
|
||||||
.mu(0.0)
|
.mu(0.0)
|
||||||
.sigma(6.0)
|
.sigma(6.0)
|
||||||
.beta(1.0)
|
.beta(1.0)
|
||||||
.score_sigma(2.0)
|
.score_sigma(2.0)
|
||||||
.drift(ConstantDrift(0.0))
|
.drift(ConstantDrift::new(0.0))
|
||||||
.unknown_keys(policy)
|
.unknown_keys(policy)
|
||||||
.convergence(ConvergenceOptions {
|
.convergence(ConvergenceOptions {
|
||||||
max_iter: 20_000,
|
max_iter: 20_000,
|
||||||
@@ -60,15 +60,30 @@ fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
|
|||||||
fn the_closed_form_matches_an_actual_refit() {
|
fn the_closed_form_matches_an_actual_refit() {
|
||||||
let h = fit(None, UnknownKeys::Reject);
|
let h = fit(None, UnknownKeys::Reject);
|
||||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
let before = h.posterior_of(&target).unwrap().sigma().powi(2);
|
let before = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&target)
|
||||||
|
.unwrap()
|
||||||
|
.sigma()
|
||||||
|
.powi(2);
|
||||||
|
|
||||||
for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] {
|
for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] {
|
||||||
let predicted = h
|
let predicted = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.expected_variance_reduction(&[&[&x], &[&y]], &target)
|
.expected_variance_reduction(&[&[&x], &[&y]], &target)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject);
|
let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject);
|
||||||
let actual = before - after.posterior_of(&target).unwrap().sigma().powi(2);
|
let actual = before
|
||||||
|
- after
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&target)
|
||||||
|
.unwrap()
|
||||||
|
.sigma()
|
||||||
|
.powi(2);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
(predicted - actual).abs() / actual.abs() < 1e-9,
|
(predicted - actual).abs() / actual.abs() < 1e-9,
|
||||||
@@ -84,12 +99,27 @@ fn the_closed_form_matches_an_actual_refit() {
|
|||||||
fn the_outcome_does_not_change_the_reduction() {
|
fn the_outcome_does_not_change_the_reduction() {
|
||||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
let h = fit(None, UnknownKeys::Reject);
|
let h = fit(None, UnknownKeys::Reject);
|
||||||
let before = h.posterior_of(&target).unwrap().sigma().powi(2);
|
let before = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&target)
|
||||||
|
.unwrap()
|
||||||
|
.sigma()
|
||||||
|
.powi(2);
|
||||||
|
|
||||||
let mut seen = Vec::new();
|
let mut seen = Vec::new();
|
||||||
for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] {
|
for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] {
|
||||||
let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject);
|
let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject);
|
||||||
seen.push(before - after.posterior_of(&target).unwrap().sigma().powi(2));
|
seen.push(
|
||||||
|
before
|
||||||
|
- after
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&target)
|
||||||
|
.unwrap()
|
||||||
|
.sigma()
|
||||||
|
.powi(2),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for w in seen.windows(2) {
|
for w in seen.windows(2) {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -107,9 +137,13 @@ fn it_ranks_candidates_by_how_much_they_answer_the_question() {
|
|||||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
|
|
||||||
let direct = h
|
let direct = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.expected_variance_reduction(&[&[&"a"], &[&"b"]], &target)
|
.expected_variance_reduction(&[&[&"a"], &[&"b"]], &target)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let unrelated = h
|
let unrelated = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.expected_variance_reduction(&[&[&"c"], &[&"d"]], &target)
|
.expected_variance_reduction(&[&[&"c"], &[&"d"]], &target)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -128,6 +162,8 @@ fn an_unrelated_unseen_matchup_teaches_nothing_about_the_target() {
|
|||||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
|
|
||||||
let reduction = h
|
let reduction = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
.expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target)
|
.expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -142,7 +178,9 @@ fn shape_errors_are_reported() {
|
|||||||
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h.expected_variance_reduction(&[&[&"a"]], &target),
|
h.joint()
|
||||||
|
.unwrap()
|
||||||
|
.expected_variance_reduction(&[&[&"a"]], &target),
|
||||||
Err(InferenceError::MismatchedShape {
|
Err(InferenceError::MismatchedShape {
|
||||||
expected: 2,
|
expected: 2,
|
||||||
got: 1,
|
got: 1,
|
||||||
@@ -150,7 +188,9 @@ fn shape_errors_are_reported() {
|
|||||||
})
|
})
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
|
h.joint()
|
||||||
|
.unwrap()
|
||||||
|
.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
|
||||||
Err(InferenceError::UnknownKey { .. })
|
Err(InferenceError::UnknownKey { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user