Compare commits
176
Commits
v0.1.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 | ||
|
|
7da2328692 | ||
|
|
a73afa5f24 | ||
|
|
eebf8aacd3 | ||
|
|
4e9aa6bdc1 | ||
|
|
a18df521eb | ||
|
|
1e4b589a9c | ||
|
|
8b20e0c560 | ||
|
|
862779ae34 | ||
|
|
f692906ce4 | ||
|
|
e493f47e99 | ||
|
|
4f6360128d | ||
|
|
eff63dfa2a | ||
|
|
7c6965c6a9 | ||
|
|
911b48faba | ||
|
|
f57784c141 | ||
|
|
8e4d6a637d | ||
|
|
82eff740b6 | ||
|
|
c1b1c6c7d7 | ||
|
|
1bb6bb31d8 | ||
|
|
b113385c6f | ||
|
|
f345e7690e | ||
|
|
d9e85cda1d | ||
|
|
633a503900 | ||
|
|
7cf45db5cf | ||
|
|
c866210c65 | ||
|
|
1f791bcddd | ||
|
|
c52e2550af | ||
|
|
4924bc8b57 | ||
|
|
36eacf5f67 | ||
|
|
71554fd944 | ||
|
|
2cf21a753d | ||
|
|
35d7512557 | ||
|
|
e1bddf2474 | ||
|
|
5d36fa1008 | ||
|
|
c12bc830a5 | ||
|
|
901f60972e | ||
|
|
8116fd081f | ||
|
|
17d072b2ae | ||
|
|
3dd659307a | ||
|
|
7e289ee834 | ||
|
|
564969ee5d | ||
|
|
683813ec10 | ||
|
|
2a48d10aa9 | ||
|
|
d4f91fd221 | ||
|
|
8c087ad015 | ||
|
|
7341669d1a | ||
|
|
2fff745c3b | ||
|
|
3c2f9ac64c | ||
|
|
507894dae7 | ||
|
|
bb2a845882 | ||
|
|
87fca8dcca | ||
|
|
ef62b57a08 | ||
|
|
b2a7ade10c | ||
|
|
617bc07f6f | ||
|
|
1a88678384 | ||
|
|
5f46296671 | ||
|
|
2745fbb622 | ||
|
|
6d2573b92e | ||
|
|
1ac3b21db5 | ||
|
|
4e043364fd | ||
|
|
aff3fb948d | ||
|
|
06ed24b240 | ||
|
|
9b2c2b38c8 | ||
|
|
07285283b6 | ||
|
|
b73cf0145a | ||
|
|
56ff01074f | ||
|
|
8d47e54a8a | ||
|
|
7de092ba12 | ||
|
|
eeb43e3be1 | ||
|
|
69ddebe21d | ||
|
|
9c39d1e681 | ||
|
|
50e11cfbfa | ||
|
|
d4af048914 | ||
|
|
bf9d964cae | ||
|
|
187aede924 | ||
|
|
4fde482e48 | ||
|
|
9e8515b7cd | ||
|
|
9506fed4b3 | ||
|
|
6030dc78de | ||
|
|
355cdb7e05 | ||
|
|
06b6a68499 | ||
|
|
c088214fed | ||
|
|
0f1a1b8911 | ||
|
|
0d32690fcc | ||
|
|
6b8bd786d7 | ||
|
|
f4e2922d59 | ||
|
|
2b5d3b1687 | ||
|
|
e4ff46f45c | ||
|
|
7742b2b891 | ||
|
|
52482eea5f | ||
|
|
b46e7f068d | ||
|
|
d1d6b5136c | ||
|
|
46625d247a | ||
|
|
68be7ab5b7 | ||
|
|
824b7f50b0 | ||
|
|
872f91797d | ||
|
|
6e453b6845 | ||
|
|
965ea7ed3c | ||
|
|
dbce69f350 | ||
|
|
0705986929 | ||
|
|
aacaa60baa | ||
|
|
fcfe0ffe37 | ||
|
|
0fa4e7d277 | ||
|
|
0dd7dab266 | ||
|
|
43cc6d82f9 | ||
|
|
48a6049dc6 | ||
|
|
1445c08896 | ||
|
|
f6a83e4dc6 | ||
|
|
68b589b965 | ||
|
|
7481c31ad8 | ||
|
|
a69a3004b2 | ||
|
|
dbaad0e7d2 | ||
|
|
8069941a81 | ||
|
|
8b53cacd64 | ||
|
|
6bf3e7e294 | ||
|
|
d2aab82c1e |
@@ -0,0 +1,15 @@
|
|||||||
|
# `Cargo.toml` sets `publish = ["kellnr"]`, so `cargo publish` targets the
|
||||||
|
# private registry and refuses crates.io. Cargo needs that registry's index
|
||||||
|
# declared to resolve the name.
|
||||||
|
#
|
||||||
|
# Committed rather than left to a per-user `~/.cargo/config.toml` so the repo
|
||||||
|
# is self-contained: a fresh clone, a new machine, or CI would otherwise fail
|
||||||
|
# with
|
||||||
|
#
|
||||||
|
# error: registry index was not found in any configuration: `kellnr`
|
||||||
|
#
|
||||||
|
# Index URL only — it is not a secret. Publish tokens live in
|
||||||
|
# `~/.cargo/credentials.toml` (per-user, never committed) or, in CI, in
|
||||||
|
# `CARGO_REGISTRIES_KELLNR_TOKEN`.
|
||||||
|
[registries.kellnr]
|
||||||
|
index = "sparse+https://crates.aceofba.se/api/v1/crates/"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: -D warnings
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# The build most consumers get.
|
||||||
|
- name: default
|
||||||
|
features: ""
|
||||||
|
profile: ""
|
||||||
|
# Most numerical goldens need `approx` for assert_ulps_eq.
|
||||||
|
- name: approx
|
||||||
|
features: "--features approx"
|
||||||
|
profile: ""
|
||||||
|
# The parallel path, including tests/determinism.rs.
|
||||||
|
- name: rayon
|
||||||
|
features: "--features approx,rayon"
|
||||||
|
profile: ""
|
||||||
|
# Critical: debug_assert! is compiled out here, which is where the
|
||||||
|
# tie/p_draw and score_sigma validation actually has to hold.
|
||||||
|
- name: release
|
||||||
|
features: "--features approx"
|
||||||
|
profile: "--release"
|
||||||
|
name: test (${{ matrix.name }})
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo test ${{ matrix.profile }} ${{ matrix.features }}
|
||||||
|
- run: cargo test ${{ matrix.profile }} ${{ matrix.features }} --doc
|
||||||
|
|
||||||
|
determinism:
|
||||||
|
name: determinism across thread counts
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
# Posteriors must be bit-identical regardless of how many rayon workers
|
||||||
|
# run the color-group sweep.
|
||||||
|
- run: |
|
||||||
|
for threads in 1 2 4 8; do
|
||||||
|
echo "== RAYON_NUM_THREADS=$threads =="
|
||||||
|
RAYON_NUM_THREADS=$threads cargo test --release \
|
||||||
|
--features approx,rayon --test determinism
|
||||||
|
done
|
||||||
|
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# rustfmt.toml uses nightly-only options (imports_granularity).
|
||||||
|
- uses: dtolnay/rust-toolchain@nightly
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
- run: cargo +nightly fmt --check
|
||||||
|
|
||||||
|
msrv:
|
||||||
|
name: minimum supported Rust version
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@1.85.0
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo check --all-targets --features approx,rayon
|
||||||
@@ -7,3 +7,4 @@
|
|||||||
NOTEPAD.md
|
NOTEPAD.md
|
||||||
|
|
||||||
/.claude
|
/.claude
|
||||||
|
proptest-regressions/
|
||||||
|
|||||||
+298
@@ -2,6 +2,302 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 0.8.0 - 2026-09-08
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- feat!: make a short fit an error and raise the default iteration cap
|
||||||
|
- feat!: validate mu, sigma and beta on HistoryBuilder
|
||||||
|
- feat!: add History::register and History::rating, and reject config conflicts across batches
|
||||||
|
- fix!: reject non-finite weights at ingestion
|
||||||
|
- fix!: reject malformed games at the Game boundary too
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: reject malformed events at the ingestion boundary
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- docs: record the rayon opt-in deviation in spec section 6
|
||||||
|
- docs: state what the joint's cost actually scales in
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: add EventBuilder::members for per-member configuration
|
||||||
|
|
||||||
|
### Other (unconventional)
|
||||||
|
|
||||||
|
- Merge branch 'fix/ingestion-shape'
|
||||||
|
- Merge branch 'feat/convergence-strictness'
|
||||||
|
- Merge branch 'fix/non-finite-weights'
|
||||||
|
- Merge branch 'test/close-coverage-gaps'
|
||||||
|
- Merge branch 'fix/game-boundary'
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: cover non-finite results and color-group disjointness
|
||||||
|
|
||||||
|
## 0.7.0 - 2026-09-08
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: factorise the joint once with History::joint
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.7.0
|
||||||
|
|
||||||
|
### Other (unconventional)
|
||||||
|
|
||||||
|
- Merge branch 'feat/joint-handle'
|
||||||
|
|
||||||
|
## 0.6.0 - 2026-09-08
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- fix!: make the joint span slices, not just the latest one
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.6.0
|
||||||
|
|
||||||
|
## 0.5.0 - 2026-09-08
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- feat!: name the unknown key, expose tail probabilities, flag short fits
|
||||||
|
- refactor!: remove the factor-graph surface nothing used, add try_winner
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix(test): the ingestion-order property was comparing two truncated fits
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- docs: record that the event log is the source of truth, and why
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: add UnknownKeys::Prior, and explain why there is no Skip
|
||||||
|
- feat: add History::posterior_of for a linear combination of competitors
|
||||||
|
- feat: add History::predict_margin for scored matchups
|
||||||
|
- feat: add expected_variance_reduction for scored active learning
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.5.0
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- style: factor the event-pair type out of the reconvergence fixture
|
||||||
|
- style: use arrays rather than vec! in the calibration fixture
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: pin that re-convergence is path-independent
|
||||||
|
- test: calibrate the marginals against the exact posterior
|
||||||
|
- test: pin what an additive model does to combined uncertainty
|
||||||
|
|
||||||
|
## 0.4.2 - 2026-09-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: replace the erfc approximation with libm, for free
|
||||||
|
- fix: route every transcendental through libm, and combine sigmas with hypot
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.4.2
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: localise the erfc_inv tail residual to the caller's argument
|
||||||
|
|
||||||
|
## 0.4.1 - 2026-09-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: correct erfc_inv's sign error and keep evidence in log space
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.4.1
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: pin quality()'s N-group closed form, closing the README cross-check
|
||||||
|
|
||||||
|
## 0.4.0 - 2026-09-07
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- feat!: N-team outcome prediction with draw mass, replacing the 2-team panic
|
||||||
|
- refactor!: close the remaining API gaps from #21
|
||||||
|
- fix!: apply competitor configuration whenever it is supplied
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix(release): skip the changelog hook during a dry run
|
||||||
|
- fix: stop destroying tail precision in evidence and truncation
|
||||||
|
- fix: reject convergence options that silently disable inference
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- docs: correct drifted documentation and compile the README in CI
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: add expected information gain for active matchup selection
|
||||||
|
- feat: let observers be shared, boxed, or borrowed
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.4.0
|
||||||
|
|
||||||
|
## 0.3.0 - 2026-09-01
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- refactor!: make Competitor::message an Option, and compute_elapsed loud
|
||||||
|
- refactor!: replace emptiness-as-sentinel with Option for results and weights
|
||||||
|
- refactor!: remove ConvergenceReport::slices_skipped
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: enforce EventBuilder weight/team length in release
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- docs: complete the public API documentation contract
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: allow drift to vary per competitor via Member::with_drift_scale
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: ignore proptest regression seed files
|
||||||
|
- chore: Release trueskill-tt version 0.3.0
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- perf: stop cloning inference inputs in OwnedGame and ingestion
|
||||||
|
- perf: make the per-slice SkillStore compact instead of dense
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: add property-based tests, a shared finiteness helper, and boundary inputs
|
||||||
|
|
||||||
|
## 0.2.0 - 2026-08-27
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- refactor!: remove the inert online flag
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: reject ties without draw probability; never report NaN as converged
|
||||||
|
- fix(quality): support any number of rating groups
|
||||||
|
- fix(evidence): accumulate in log space and floor the per-link value
|
||||||
|
- fix(history): stop reprocessing the slice that was just appended to
|
||||||
|
- fix(rayon): remove the aliasing unsafe from the parallel sweep
|
||||||
|
- fix: close out four small issues and pin #27's repro
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- docs: refresh README and CLAUDE.md; add ingest benchmark
|
||||||
|
- docs: spec for filtered (forward-only) estimates
|
||||||
|
- docs: implementation plan for filtered estimates
|
||||||
|
- docs: state filtered accessor cost and evidence semantics precisely
|
||||||
|
- docs(cargo): correct the licence note — kellnr does not require one
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: add filtered_log_evidence
|
||||||
|
- feat: add filtered learning curves
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: add CI, crate metadata, and crate-level documentation
|
||||||
|
- chore: target releases at the private kellnr registry
|
||||||
|
- chore: keep the 48 MB ATP dataset out of the published crate
|
||||||
|
- chore: dual-license MIT OR Apache-2.0
|
||||||
|
- chore: Release trueskill-tt version 0.2.0
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- perf(gaussian): drop the sqrt round-trip from variance-space operations
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- refactor: unify convergence defaults, validate builders, clear dead code
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- style: make NaN rejection explicit in score_sigma validation
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: pin the invariants that make filtered estimates trustworthy
|
||||||
|
|
||||||
|
## 0.1.2 - 2026-06-12
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: release generated CHANGELOG at the wrong location
|
||||||
|
- fix(gaussian): treat non-positive precision as improper in mu()/sigma()
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- docs: spec for post-T4-MarginFactor tech debt cleanup
|
||||||
|
- docs: implementation plan for post-T4-MarginFactor tech debt cleanup
|
||||||
|
- docs: fix stale numerics in t4-margin-factor plan
|
||||||
|
- docs: spec for game-local Damped EP
|
||||||
|
- docs: implementation plan for game-local Damped EP
|
||||||
|
- docs: spec for History → TimeSlice ConvergenceOptions plumbing
|
||||||
|
- docs: implementation plan for History → TimeSlice plumbing
|
||||||
|
- docs: spec for per-event score_sigma override
|
||||||
|
- docs: implementation plan for per-event score_sigma override
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat(gaussian): add damp_natural helper for EP damping
|
||||||
|
- feat(convergence): add ConvergenceOptions::alpha damping field
|
||||||
|
- feat(factor): add TruncFactor::propagate_with_alpha for EP damping
|
||||||
|
- feat(factor): add MarginFactor::propagate_with_alpha for EP damping
|
||||||
|
- feat(game): plumb ConvergenceOptions through to run_chain
|
||||||
|
- feat(time_slice): inference callsites read self.convergence
|
||||||
|
- feat(outcome): per-event score_sigma override on Outcome::Scored
|
||||||
|
- feat(event_builder): expose scores_with_sigma fluent method
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.1.2
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
|
||||||
|
- refactor: make BuiltinFactor::log_evidence match exhaustive
|
||||||
|
- refactor(time_slice): add convergence field, rename iterate_to_convergence
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test(game): integration tests for ConvergenceOptions behavior
|
||||||
|
- test(history): end-to-end ConvergenceOptions propagation tests
|
||||||
|
- test(history): end-to-end per-event score_sigma override tests
|
||||||
|
|
||||||
|
## 0.1.1 - 2026-04-27
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.1.1
|
||||||
|
|
||||||
|
### Other (unconventional)
|
||||||
|
|
||||||
|
- T0 + T1 + T2: engine redesign through new API surface (#1)
|
||||||
|
- T3: rayon-backed concurrency (opt-in) (#2)
|
||||||
|
- T4 (MarginFactor): scored outcomes via Gaussian-margin EP evidence
|
||||||
|
|
||||||
## 0.1.0 - 2026-04-23
|
## 0.1.0 - 2026-04-23
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -12,6 +308,8 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
- chore: added cliff.toml, release.toml and rustfmt.toml
|
- chore: added cliff.toml, release.toml and rustfmt.toml
|
||||||
- chore: clean up
|
- chore: clean up
|
||||||
|
- chore: make cargo release add CHANGELOG.md before commit
|
||||||
|
- chore: do not publish
|
||||||
|
|
||||||
### Other (unconventional)
|
### Other (unconventional)
|
||||||
|
|
||||||
|
|||||||
@@ -5,41 +5,130 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build # Build the library
|
just test # Full suite across every feature combination CI checks
|
||||||
cargo test --lib # Run all library tests
|
just check # Fast inner loop: cargo test --features approx
|
||||||
cargo test --lib <test_name> # Run a single test by name
|
just lint # clippy, warnings denied
|
||||||
cargo test --lib -- --nocapture # Run tests with stdout output
|
just fmt # ALWAYS nightly — rustfmt.toml uses nightly-only options
|
||||||
cargo clippy # Lint
|
just determinism # Bit-identical posteriors at RAYON_NUM_THREADS 1/2/4/8
|
||||||
cargo bench # Run benchmarks (criterion)
|
just ci # Everything CI runs
|
||||||
|
cargo test --lib <test_name> # A single test by name
|
||||||
|
cargo bench # Criterion benchmarks
|
||||||
```
|
```
|
||||||
|
|
||||||
The `approx` feature enables `approx::AbsDiffEq` for `Gaussian`:
|
**Run tests in release too.** `debug_assert!` is compiled out there, and that
|
||||||
```bash
|
is where several defects have hidden — a debug-only run is not evidence.
|
||||||
cargo test --features approx
|
`just test` includes a release job.
|
||||||
```
|
|
||||||
|
### Feature flags
|
||||||
|
|
||||||
|
- `approx` — `approx::AbsDiffEq` etc. for `Gaussian`. Most numerical goldens need it.
|
||||||
|
- `rayon` — opt-in parallel within-slice sweep and per-slice query passes.
|
||||||
|
|
||||||
|
## Working rules
|
||||||
|
|
||||||
|
- **Investigate before implementing.** Measure the actual behaviour first —
|
||||||
|
against an analytic reference where one exists. Several "obvious" fixes in
|
||||||
|
this repo turned out to be wrong in sign or unnecessary, and the measurement
|
||||||
|
is what caught them.
|
||||||
|
- **Fix the root issue, not the symptom.** A clamp that hides an underflow, or
|
||||||
|
a tolerance loosened to make a test pass, is a defect deferred.
|
||||||
|
- **Scout crates.io before hand-rolling numerics.** Check accuracy against an
|
||||||
|
independent reference rather than trusting downloads: `puruspe` has 1.4M
|
||||||
|
downloads and is 346 ULP off in the tail, where `libm` is 1. Fewer
|
||||||
|
dependencies is preferable, not mandatory — take the dependency when it is
|
||||||
|
measurably better.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
This is a Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py) — a Bayesian skill rating system that tracks skill evolution over time using Gaussian message passing.
|
A Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py):
|
||||||
|
Bayesian skill rating that infers skill at every point in time, propagating
|
||||||
|
evidence both forward and backward across a history.
|
||||||
|
|
||||||
### Data flow
|
### Data flow
|
||||||
|
|
||||||
|
Ingestion (public types, `event.rs`):
|
||||||
|
|
||||||
```
|
```
|
||||||
History → Batch[] → Game[] → teams/players
|
Event<T, K> → Team<K>[] → Member<K>[]
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`History`** (`history.rs`) — top-level container. Organizes games by time into `Batch`es, runs forward/backward message passing across batches, and exposes `learning_curves()` and `log_evidence()`.
|
`History::add_events` flattens that into indices; teams survive only as
|
||||||
- **`Batch`** (`batch.rs`) — all games at a single time step. Runs `iteration()` to update skill estimates via `Game::posteriors()`, collecting `Skill` distributions per player.
|
grouping, not as a value. Inference then runs on the internal shapes:
|
||||||
- **`Game`** (`game.rs`) — a single match. Given teams (slices of `Gaussian`), computes posterior skill distributions using Gaussian factor graphs and `message.rs` helpers.
|
|
||||||
- **`Agent`** (`agent.rs`) — wraps a `Player` with temporal state (`last_time`, `message`). `receive()` applies time-decay (`gamma`) when the player reappears after a gap.
|
|
||||||
- **`Player`** (`player.rs`) — static configuration: prior `Gaussian`, `beta` (performance noise), `gamma` (skill drift per time unit).
|
|
||||||
- **`Gaussian`** (`gaussian.rs`) — core probability type. Stored as natural parameters (`pi = 1/sigma²`, `tau = mu/sigma²`). Arithmetic ops implement message multiplication/division in the factor graph.
|
|
||||||
- **`message.rs`** — `TeamMessage` and `DiffMessage`: intermediate factor graph messages used inside `Game`.
|
|
||||||
- **`lib.rs`** — exports the public API (`Game`, `Gaussian`, `History`, `Player`) and standalone functions (`quality()`, `pdf()`, `cdf()`, `erfc()`). Also defines global defaults: `MU=0.0`, `SIGMA=6.0`, `BETA=1.0`, `GAMMA=0.03`, `P_DRAW=0.0`, `EPSILON=1e-6`, `ITERATIONS=30`.
|
|
||||||
|
|
||||||
### Key design points
|
```
|
||||||
|
History → TimeSlice[] → Event[] → Item[]
|
||||||
|
↓
|
||||||
|
Game (factor graph) → Schedule → BuiltinFactor[]
|
||||||
|
```
|
||||||
|
|
||||||
- `History` uses `IndexMap<K>` (defined in `lib.rs`) to map arbitrary player keys to `Agent` state.
|
- **`History`** (`history.rs`) — top level. Interns keys, groups events into
|
||||||
- Convergence is measured by the maximum `delta()` across all skill distributions; iteration stops when below `EPSILON` or after `ITERATIONS` rounds.
|
`TimeSlice`s by time, runs the forward/backward sweep in `converge()`, and
|
||||||
- The `approx` feature gates `AbsDiffEq` on `Gaussian` for use in tests — the feature is optional and only needed for approximate equality assertions.
|
answers `learning_curves()`, `current_skill()`, `log_evidence()`,
|
||||||
- `time` in `History`/`Batch` is currently an `f64`; the README notes it needs to become an enum to support richer temporal states.
|
`predict_quality()`, `predict_outcome()`. Built via `HistoryBuilder`.
|
||||||
|
- **`TimeSlice`** (`time_slice.rs`) — all events at one time. Owns a
|
||||||
|
`SkillStore` and a `ScratchArena`; `iteration()` sweeps its events, using
|
||||||
|
`ColorGroups` to partition independent ones.
|
||||||
|
- **`Event`** — two distinct types, do not confuse them. The *public* ingestion
|
||||||
|
`Event<T, K>` is in `event.rs` (with `Team`/`Member`); the *internal*
|
||||||
|
`pub(crate) Event` in `time_slice.rs` is one match during inference, where
|
||||||
|
`compute()` runs inference reading skills immutably and `apply()` folds the
|
||||||
|
result back. That split is what lets a color group run in parallel with no
|
||||||
|
`unsafe`.
|
||||||
|
- **`Game`** (`game.rs`) — a single match's factor graph. `run_chain` builds the
|
||||||
|
diff chain between rank-adjacent teams and drives it to convergence.
|
||||||
|
- **`Gaussian`** (`gaussian.rs`) — natural parameters (`pi = 1/sigma²`,
|
||||||
|
`tau = mu/sigma²`). `Mul`/`Div` are the EP product/cavity: pure adds and
|
||||||
|
subtracts. Variance-space ops (`Add`, `Sub`, `exclude`, `forget`) go through
|
||||||
|
`from_mv`/`variance()` and take no square root.
|
||||||
|
- **`factor/`** — `TruncFactor` (ranked) and `MarginFactor` (scored) over a
|
||||||
|
flat `VarStore`. `Game::run_chain` drives them directly through a local
|
||||||
|
`DiffFactor` enum; there is no `Schedule` indirection and no generic `Factor`
|
||||||
|
trait. Both were removed once measurement showed nothing had ever used them
|
||||||
|
— see #42.
|
||||||
|
- **`Competitor`** (`competitor.rs`) — per-history temporal state (`message`,
|
||||||
|
`last_time`). **`Rating`** (`rating.rs`) — static config (prior, `beta`, drift).
|
||||||
|
- **`storage/`** — `SkillStore` (per slice, `pub(crate)`) and `CompetitorStore`
|
||||||
|
(per history, public), both indexed by `Index`. The module is `pub`, but only
|
||||||
|
`CompetitorStore` is reachable from outside the crate.
|
||||||
|
- **`KeyTable`** (`key_table.rs`) — user key ↔ `Index`, both directions O(1).
|
||||||
|
- **`Drift`** (`drift.rs`) / **`Time`** (`time.rs`) — traits. `Time` is a *trait*
|
||||||
|
(`i64`, `Untimed`), not an enum.
|
||||||
|
- **`lib.rs`** — public exports, global defaults (`MU`, `SIGMA`, `BETA`,
|
||||||
|
`GAMMA`, `P_DRAW`, `EPSILON`, `ITERATIONS`), and the standalone `quality()`.
|
||||||
|
The `cdf()` / `erfc()` helpers live here too but are `pub(crate)` and private
|
||||||
|
respectively — not public API.
|
||||||
|
|
||||||
|
### Invariants worth knowing
|
||||||
|
|
||||||
|
- **A tie needs `p_draw > 0`.** With `p_draw == 0.0` the truncation margin is
|
||||||
|
zero and the two-sided tie update evaluates `0/0`. Ingestion rejects such
|
||||||
|
events with `InferenceError::TieWithoutDrawProbability`. This includes
|
||||||
|
`Outcome::winner(w, n)` for `n >= 3`, which ties every loser.
|
||||||
|
- **NaN is never convergence.** Comparisons against NaN are all false, so
|
||||||
|
`tuple_gt` reads NaN as "below epsilon". Use `step_converged` /
|
||||||
|
`step_is_finite`, never `!tuple_gt(..)` alone.
|
||||||
|
- **Evidence accumulates in log space.** A linear product over a long diff
|
||||||
|
chain underflows to zero, and `ln(0)` is `-inf`.
|
||||||
|
- **Colors are contiguous.** `recompute_color_groups` reorders events so each
|
||||||
|
color occupies one range; `ColorGroups::groups_are_contiguous` asserts it.
|
||||||
|
- **Transcendentals go through `libm`, not `std`.** IEEE 754 pins the basic
|
||||||
|
operations and `sqrt` but says nothing about `exp`/`log`/`erf`, and `std`
|
||||||
|
delegates to the *system* math library — measured, `f64::exp` and `libm::exp`
|
||||||
|
disagree on 9.7% of inputs by one ULP. Since inference is an iterative fixed
|
||||||
|
point, one ULP can change an iteration count. Use `libm::exp` / `libm::log` in
|
||||||
|
inference code; `f64::sqrt` is fine (IEEE specifies it). Tests may use either.
|
||||||
|
- **The crate is `#![forbid(unsafe_code)]`.** Keep it that way.
|
||||||
|
- **Ingestion order must not change the answer.** Events added one at a time
|
||||||
|
must converge to the same fixed point as the same events batched — see
|
||||||
|
`tests/ingestion_equivalence.rs`.
|
||||||
|
|
||||||
|
### Testing notes
|
||||||
|
|
||||||
|
- Numerical goldens are cross-validated against the Python/Julia reference.
|
||||||
|
Some are *convergence residuals*, not exact values; treat a small movement
|
||||||
|
as suspicious but check whether the new value is closer to the analytic
|
||||||
|
truth (symmetric fixtures converge to their prior mean exactly) before
|
||||||
|
assuming a regression.
|
||||||
|
- `tests/degenerate_inputs.rs` covers empty/boundary/error paths,
|
||||||
|
`tests/ingestion_equivalence.rs` covers batching order, `tests/quality.rs`
|
||||||
|
covers N-group quality, `tests/determinism.rs` covers thread counts.
|
||||||
|
|||||||
+52
-2
@@ -1,7 +1,30 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "trueskill-tt"
|
name = "trueskill-tt"
|
||||||
version = "0.1.0"
|
version = "0.8.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version = "1.85"
|
||||||
|
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
|
||||||
|
repository = "https://git.aceofba.se/logaritmisk/trueskill-tt"
|
||||||
|
authors = ["Anders Olsson"]
|
||||||
|
# Publishing is restricted to the private kellnr registry; this also makes
|
||||||
|
# an accidental `cargo publish` to crates.io a hard error rather than a
|
||||||
|
# irreversible mistake. Index is declared in `.cargo/config.toml`.
|
||||||
|
publish = ["kellnr"]
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["trueskill", "rating", "bayesian", "elo", "skill"]
|
||||||
|
categories = ["algorithms", "science", "game-development"]
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
# `examples/atp.csv` is a 48 MB tennis dataset — 99% of the packaged crate,
|
||||||
|
# for a library whose source is 312 KB. `examples/atp.rs` opens it by
|
||||||
|
# relative path at runtime, so excluding the data still compiles; the
|
||||||
|
# example just needs the file fetched from the repo to run.
|
||||||
|
exclude = [
|
||||||
|
"/docs",
|
||||||
|
"/benches/*.txt",
|
||||||
|
"/temp",
|
||||||
|
"/.gitea",
|
||||||
|
"/examples/atp.csv",
|
||||||
|
]
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
bench = false
|
bench = false
|
||||||
@@ -11,19 +34,42 @@ name = "batch"
|
|||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "gaussian"
|
name = "history_converge"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "scored"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "ingest"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
approx = { version = "0.5.1", optional = true }
|
approx = { version = "0.5.1", optional = true }
|
||||||
|
feral-amd = "0.2"
|
||||||
|
libm = "0.2.16"
|
||||||
|
rayon = { version = "1", optional = true }
|
||||||
|
smallvec = "1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
approx = ["dep:approx"]
|
||||||
|
# Exposes the joint sparsity pattern for the #52 measurement. Test-only.
|
||||||
|
measure-sparsity = []
|
||||||
|
rayon = ["dep:rayon"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = "0.5"
|
criterion = "0.5"
|
||||||
plotters = { version = "0.3", default-features = false, features = ["svg_backend", "all_elements", "all_series"] }
|
plotters = { version = "0.3", default-features = false, features = ["svg_backend", "all_elements", "all_series"] }
|
||||||
plotters-backend = "0.3"
|
plotters-backend = "0.3"
|
||||||
|
proptest = "1.11.0"
|
||||||
time = { version = "0.3", features = ["parsing"] }
|
time = { version = "0.3", features = ["parsing"] }
|
||||||
trueskill-tt = { path = ".", features = ["approx"] }
|
trueskill-tt = { path = ".", features = ["approx"] }
|
||||||
|
|
||||||
|
# Debug symbols in release are for `just flame` (cargo-flamegraph), which needs
|
||||||
|
# them to symbolicate. Profile settings in a library are ignored by downstream
|
||||||
|
# consumers, so these only affect local builds — this is deliberate, not an
|
||||||
|
# oversight.
|
||||||
[profile.release]
|
[profile.release]
|
||||||
debug = true
|
debug = true
|
||||||
|
|
||||||
@@ -32,3 +78,7 @@ debug = true
|
|||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
debug = true
|
debug = true
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "joint"
|
||||||
|
harness = false
|
||||||
|
|||||||
@@ -1,4 +1,39 @@
|
|||||||
alias b := bench
|
alias b := bench
|
||||||
|
alias t := test
|
||||||
|
|
||||||
|
# Run the full test suite across the feature combinations CI checks.
|
||||||
|
test:
|
||||||
|
cargo test
|
||||||
|
cargo test --features approx
|
||||||
|
cargo test --features approx,rayon
|
||||||
|
cargo test --release --features approx
|
||||||
|
|
||||||
|
# Fast inner-loop tests.
|
||||||
|
check:
|
||||||
|
cargo test --features approx
|
||||||
|
|
||||||
|
# Posteriors must be bit-identical across rayon worker counts.
|
||||||
|
determinism:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
for threads in 1 2 4 8; do
|
||||||
|
echo "== RAYON_NUM_THREADS=$threads =="
|
||||||
|
RAYON_NUM_THREADS=$threads cargo test --release \
|
||||||
|
--features approx,rayon --test determinism
|
||||||
|
done
|
||||||
|
|
||||||
|
lint:
|
||||||
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
# Always nightly: rustfmt.toml uses nightly-only options.
|
||||||
|
fmt:
|
||||||
|
cargo +nightly fmt
|
||||||
|
|
||||||
|
fmt-check:
|
||||||
|
cargo +nightly fmt --check
|
||||||
|
|
||||||
|
# Everything CI runs.
|
||||||
|
ci: fmt-check lint test determinism
|
||||||
|
|
||||||
store:
|
store:
|
||||||
cargo bench -- --save-baseline base
|
cargo bench -- --save-baseline base
|
||||||
@@ -8,3 +43,49 @@ bench:
|
|||||||
|
|
||||||
flame:
|
flame:
|
||||||
cargo flamegraph --root --example atp
|
cargo flamegraph --root --example atp
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Release workflow
|
||||||
|
#
|
||||||
|
# Publishing goes to the private kellnr registry only: `Cargo.toml` sets
|
||||||
|
# `publish = ["kellnr"]`, so an accidental `cargo publish` to crates.io is a
|
||||||
|
# hard error rather than an irreversible mistake. The index is declared in the
|
||||||
|
# committed `.cargo/config.toml`; the token is per-user and lives in
|
||||||
|
# `~/.cargo/credentials.toml` (`cargo login --registry kellnr`).
|
||||||
|
#
|
||||||
|
# Step 1: just release-plan [level] — dry run, no writes
|
||||||
|
# Step 2: just release [level] — bump, changelog, tag, publish, push
|
||||||
|
#
|
||||||
|
# LEVEL is the cargo-release bump level (default `minor`). On 0.x:
|
||||||
|
# minor -> breaking bump (0.1.2 -> 0.2.0) <- any public-API change
|
||||||
|
# patch -> additive only (0.1.2 -> 0.1.3)
|
||||||
|
# major -> reserved for the 1.0.0 jump
|
||||||
|
#
|
||||||
|
# `release.toml` regenerates CHANGELOG.md with git-cliff in a pre-release hook
|
||||||
|
# and keeps push = false; this recipe pushes last, after publish has succeeded.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Dry-run preview of the next release. Inspect the version bump and the
|
||||||
|
# "Publishing ..." line before running `just release`.
|
||||||
|
release-plan level="minor":
|
||||||
|
cargo release {{level}}
|
||||||
|
|
||||||
|
# Cut a release from a clean main: gate -> bump -> tag -> publish -> push.
|
||||||
|
release level="minor":
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "$(git branch --show-current)" != "main" ]]; then
|
||||||
|
echo "error: run 'just release' from the 'main' branch" >&2; exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "error: working tree is dirty — commit or stash first" >&2; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# cargo-release only verify-compiles the packaged crate; it does not run the
|
||||||
|
# suite, and publishing is irreversible. Run the same gate CI does, which
|
||||||
|
# includes the release profile where debug_assert! is compiled out.
|
||||||
|
just ci
|
||||||
|
|
||||||
|
cargo release {{level}} --execute --no-confirm
|
||||||
|
git push --follow-tags
|
||||||
|
|||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
Copyright (c) 2026 Anders Olsson
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
+204
@@ -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,6 +1,409 @@
|
|||||||
# TrueSkill - Through Time
|
# TrueSkill - Through Time
|
||||||
|
|
||||||
Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
|
Bayesian skill rating over a time axis.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
A Rust port of
|
||||||
|
[TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
Skill drift models how a competitor's true skill can change between appearances.
|
||||||
|
Each time they reappear after a gap, their skill uncertainty is widened by the
|
||||||
|
drift model before the new evidence is incorporated.
|
||||||
|
|
||||||
|
Drift is represented by the `Drift` trait (`src/drift.rs`), generic over the
|
||||||
|
history's time type:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
|
||||||
|
fn variance_delta(&self, from: &T, to: &T) -> f64;
|
||||||
|
fn variance_for_elapsed(&self, elapsed: i64) -> f64;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both methods return the amount to add to `σ²`, not to `σ`. `variance_delta`
|
||||||
|
works from two timestamps; `variance_for_elapsed` takes an already-computed
|
||||||
|
elapsed count, and is used on the paths that cache it. `Gaussian::forget`
|
||||||
|
applies the result entirely in variance space — `from_mv(mu, variance() +
|
||||||
|
variance_delta)` — taking no square root.
|
||||||
|
|
||||||
|
That block is a quotation rather than a doctest. The custom-drift example below
|
||||||
|
is compiled by CI, so it is what actually pins the signature.
|
||||||
|
|
||||||
|
### ConstantDrift
|
||||||
|
|
||||||
|
The built-in `ConstantDrift` implements a linear random walk — skill uncertainty
|
||||||
|
grows proportionally to time:
|
||||||
|
|
||||||
|
```text
|
||||||
|
variance_delta = elapsed * γ²
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the standard TrueSkill Through Time model. Pass a `ConstantDrift::new(gamma)`
|
||||||
|
when constructing a `Rating`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::{ConstantDrift, Gaussian, Rating};
|
||||||
|
|
||||||
|
// gamma = 0.1 means skill can shift ~0.1 per time unit.
|
||||||
|
let rating: Rating<i64, ConstantDrift> =
|
||||||
|
Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift::new(0.1));
|
||||||
|
|
||||||
|
assert_eq!(rating.drift().gamma(), 0.1);
|
||||||
|
```
|
||||||
|
|
||||||
|
The type annotation is load-bearing: `ConstantDrift` implements `Drift<T>` for
|
||||||
|
every `T: Time`, so without it `T` is ambiguous.
|
||||||
|
|
||||||
|
### Custom drift
|
||||||
|
|
||||||
|
Implement `Drift<T>` to express any other model. For example, a drift that
|
||||||
|
saturates after a long absence, with uncertainty growing as the square root of
|
||||||
|
elapsed time instead of linearly:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::{Drift, Gaussian, History, Rating, Time};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct SqrtDrift {
|
||||||
|
gamma: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Time> Drift<T> for SqrtDrift {
|
||||||
|
fn variance_delta(&self, from: &T, to: &T) -> f64 {
|
||||||
|
let elapsed = from.elapsed_to(to).max(0) as f64;
|
||||||
|
elapsed.sqrt() * self.gamma * self.gamma
|
||||||
|
}
|
||||||
|
|
||||||
|
fn variance_for_elapsed(&self, elapsed: i64) -> f64 {
|
||||||
|
(elapsed.max(0) as f64).sqrt() * self.gamma * self.gamma
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a single Rating:
|
||||||
|
let rating: Rating<i64, SqrtDrift> =
|
||||||
|
Rating::new(Gaussian::from_ms(0.0, 6.0), 1.0, SqrtDrift { gamma: 0.5 });
|
||||||
|
|
||||||
|
// Or for a whole History, via the builder:
|
||||||
|
let history = History::builder().drift(SqrtDrift { gamma: 0.5 }).build();
|
||||||
|
|
||||||
|
assert_eq!(rating.beta(), 1.0);
|
||||||
|
assert_eq!(history.log_evidence(), 0.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
`HistoryBuilder::drift` is the only way to set a history's drift model; there is
|
||||||
|
no `gamma()` shorthand. The default is `ConstantDrift::new(GAMMA)`.
|
||||||
|
|
||||||
|
### Per-competitor drift
|
||||||
|
|
||||||
|
A `History` has one drift model, but individual competitors can scale it.
|
||||||
|
`Member::with_drift_scale(s)` multiplies the drift *variance* that competitor
|
||||||
|
accumulates, so `s` is in the same units as `gamma`: `ConstantDrift::new(g)` at
|
||||||
|
scale `s` behaves exactly as `ConstantDrift::new(g * s)` would, for that competitor
|
||||||
|
alone.
|
||||||
|
|
||||||
|
`0.0` pins a competitor still. That is what makes a **fixed reference point**
|
||||||
|
expressible in the same graph as moving competitors — a bot at a known
|
||||||
|
strength, a rating floor, a course difficulty:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
let mut h = History::builder().drift(ConstantDrift::new(0.1)).build();
|
||||||
|
|
||||||
|
h.add_events(vec![Event {
|
||||||
|
time: 0,
|
||||||
|
teams: [
|
||||||
|
Team::with_members([Member::new("player")]),
|
||||||
|
// A course does not improve. Pin it, and the round's evidence
|
||||||
|
// lands on the player instead of being split between the two.
|
||||||
|
Team::with_members([Member::new("layout_7").with_drift_scale(0.0)]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
h.converge().unwrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
Like `with_prior`, the scale is **competitor configuration, not a per-event
|
||||||
|
value**: it applies to the competitor for the whole history, and it applies
|
||||||
|
whenever it is supplied — including on a key the history already knows.
|
||||||
|
Configuring one late still refits the whole history rather than taking effect
|
||||||
|
only from that event onward, because `converge` refits from competitor state.
|
||||||
|
Repeating the same value is inert; supplying two *different* values for one
|
||||||
|
competitor within a single batch is `InferenceError::ConflictingCompetitorConfig`,
|
||||||
|
since events in a batch have no order. The scale must be finite and
|
||||||
|
non-negative; ingestion otherwise fails with `InferenceError::InvalidParameter`.
|
||||||
|
|
||||||
|
The fluent `EventBuilder` reaches this too: `.team([...])` is the common case
|
||||||
|
and leaves both unset, while `.members([...])` takes `Member` values directly,
|
||||||
|
so `h.event(t).members([Member::new("layout_7").with_drift_scale(0.0)])` is
|
||||||
|
equivalent to the typed shape above.
|
||||||
|
|
||||||
|
## Scored outcomes
|
||||||
|
|
||||||
|
Use `Outcome::scores([...])` when you have continuous per-team scores rather
|
||||||
|
than just ranks. Adjacent score margins flow into a `MarginFactor` that adds
|
||||||
|
soft Gaussian evidence about the latent performance diff. Configure
|
||||||
|
`HistoryBuilder::score_sigma(σ)` to control how much you trust the margins
|
||||||
|
(smaller σ = more trust).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::History;
|
||||||
|
|
||||||
|
let mut h = History::builder().score_sigma(2.0).build();
|
||||||
|
h.event(1)
|
||||||
|
.team(["alice"])
|
||||||
|
.team(["bob"])
|
||||||
|
.scores([21.0, 9.0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
h.converge().unwrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prediction
|
||||||
|
|
||||||
|
`predict_outcome` gives the full distribution over finishing orders. Each entry
|
||||||
|
is a rank vector in the same shape `Outcome::ranking` takes — equal ranks mean a
|
||||||
|
tie — so an outcome feeds straight back into inference.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::History;
|
||||||
|
|
||||||
|
let mut h = History::builder().p_draw(0.1).build();
|
||||||
|
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||||||
|
h.converge().unwrap();
|
||||||
|
|
||||||
|
let p = h.predict_outcome(&[&[&"alice"], &[&"bob"]]).unwrap();
|
||||||
|
|
||||||
|
// Probabilities are exhaustive and disjoint, so they sum to one.
|
||||||
|
assert!((p.total() - 1.0).abs() < 1e-6);
|
||||||
|
|
||||||
|
let (best, likelihood) = p.most_likely().unwrap();
|
||||||
|
println!("most likely: {best:?} at {likelihood:.3}");
|
||||||
|
println!("draw: {:.3}", p.probability_of(&[0, 0]));
|
||||||
|
```
|
||||||
|
|
||||||
|
Supports any number of teams. Because the outcome space grows factorially, the
|
||||||
|
full distribution is capped at `MAX_PREDICTED_TEAMS`; two cheaper entry points
|
||||||
|
stay available at any size:
|
||||||
|
|
||||||
|
- `predict_win_probabilities(teams)` — `P(team i finishes strictly first)`,
|
||||||
|
quadratic in team count.
|
||||||
|
- `predict_ranking(teams, ranks)` — one specific finishing order.
|
||||||
|
|
||||||
|
Unknown keys are an error by default, not a silent omission: a team the history
|
||||||
|
has never seen cannot produce a confident-looking probability. The error names
|
||||||
|
the key, and every key must already be known — pre-filter with
|
||||||
|
`current_skill` if your caller cannot guarantee that.
|
||||||
|
|
||||||
|
If predicting for competitors you have never seen is the point rather than a
|
||||||
|
mistake, say so once:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::{History, UnknownKeys};
|
||||||
|
|
||||||
|
let h = History::builder().unknown_keys(UnknownKeys::Prior).build();
|
||||||
|
```
|
||||||
|
|
||||||
|
An unknown competitor is then answered from the configured prior, which is the
|
||||||
|
honest reading — you have no evidence about them — and correctly *widens* a team
|
||||||
|
that contains one. There is deliberately no "skip the member" mode: a team's
|
||||||
|
performance is the sum of its members, so dropping one would make the model more
|
||||||
|
certain because it knows less.
|
||||||
|
|
||||||
|
### Asking about one competitor
|
||||||
|
|
||||||
|
`Gaussian` answers tail questions directly, which is what a stopping rule needs:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::History;
|
||||||
|
|
||||||
|
let mut h = History::default();
|
||||||
|
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||||||
|
h.converge().unwrap();
|
||||||
|
|
||||||
|
let skill = h.current_skill("alice").unwrap();
|
||||||
|
|
||||||
|
// "How sure am I that this is below the cutoff?" — a probability, not a
|
||||||
|
// `mu + z * sigma` band whose confidence drifts as sigma changes.
|
||||||
|
let _ = skill.probability_below(20.0);
|
||||||
|
|
||||||
|
// Use this rather than `1.0 - probability_below(x)`: the complement cancels
|
||||||
|
// away every digit in the upper tail, which is where a stopping rule lives.
|
||||||
|
let _ = skill.probability_above(30.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Which match to play next
|
||||||
|
|
||||||
|
`quality()` measures whether a matchup is *fair*. That is not the same as
|
||||||
|
whether it is *informative*, and the two only coincide for two evenly matched
|
||||||
|
competitors. When each observation costs something, ask
|
||||||
|
`expected_information_gain` instead — the outcome-weighted divergence between
|
||||||
|
what you believe now and what you would believe afterwards.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::History;
|
||||||
|
|
||||||
|
let mut h = History::default();
|
||||||
|
for t in 1..=10 {
|
||||||
|
h.record_winner(&"veteran", &"regular", t).unwrap();
|
||||||
|
h.record_winner(&"regular", &"veteran", t + 100).unwrap();
|
||||||
|
}
|
||||||
|
h.record_winner(&"veteran", &"newcomer", 500).unwrap();
|
||||||
|
h.converge().unwrap();
|
||||||
|
|
||||||
|
let settled = h.expected_information_gain(&[&[&"veteran"], &[&"regular"]]).unwrap();
|
||||||
|
let unknown = h.expected_information_gain(&[&[&"veteran"], &[&"newcomer"]]).unwrap();
|
||||||
|
|
||||||
|
// Playing the newcomer teaches you more than replaying a settled rivalry.
|
||||||
|
assert!(unknown > settled);
|
||||||
|
```
|
||||||
|
|
||||||
|
The result is in nats, and is bounded by the entropy of the outcome: at most
|
||||||
|
`ln 2 ≈ 0.693` for a two-way result, `ln 3` once draws are possible, `ln k` for
|
||||||
|
`k` outcomes. A value near zero means you already know how it ends.
|
||||||
|
|
||||||
|
This costs one full inference pass **per possible outcome**, so it is far more
|
||||||
|
expensive than `quality()`. Scoring every pairing among `n` competitors is
|
||||||
|
`O(n² × outcomes)` passes — shortlist with `quality()` or
|
||||||
|
`predict_win_probabilities` first, then score only the shortlist.
|
||||||
|
|
||||||
## Other implementations
|
## Other implementations
|
||||||
|
|
||||||
@@ -11,72 +414,27 @@ Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillTh
|
|||||||
- [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: 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/)
|
- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/)
|
||||||
|
|
||||||
## Drift
|
## Status
|
||||||
|
|
||||||
Skill drift models how a player's true skill can change between appearances. Each time a player reappears after a gap, their skill uncertainty is widened by the drift model before the new evidence is incorporated.
|
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.
|
||||||
|
|
||||||
Drift is represented by the `Drift` trait:
|
## License
|
||||||
|
|
||||||
```rust
|
Licensed under either of
|
||||||
pub trait Drift: Copy + Debug {
|
|
||||||
fn variance_delta(&self, elapsed: i64) -> f64;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`variance_delta` returns the amount to add to `σ²` given the elapsed time since the player last played. Internally, `Gaussian::forget` uses this to compute the new sigma: `σ_new = sqrt(σ² + variance_delta)`.
|
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
|
||||||
|
<http://www.apache.org/licenses/LICENSE-2.0>)
|
||||||
|
- MIT license ([LICENSE-MIT](LICENSE-MIT) or
|
||||||
|
<http://opensource.org/licenses/MIT>)
|
||||||
|
|
||||||
### ConstantDrift
|
at your option.
|
||||||
|
|
||||||
The built-in `ConstantDrift` implements a linear random walk — skill uncertainty grows proportionally to time:
|
### Contribution
|
||||||
|
|
||||||
```
|
Unless you explicitly state otherwise, any contribution intentionally submitted
|
||||||
variance_delta = elapsed * γ²
|
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
|
||||||
```
|
dual licensed as above, without any additional terms or conditions.
|
||||||
|
|
||||||
This is the standard TrueSkill Through Time model. Use it by passing a `ConstantDrift(gamma)` when constructing a `Player`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use trueskill_tt::{Player, Gaussian, drift::ConstantDrift};
|
|
||||||
|
|
||||||
// gamma = 0.1 means skill can shift ~0.1 per time unit
|
|
||||||
let player = Player::new(Gaussian::from_ms(0.0, 6.0), 1.0, ConstantDrift(0.1));
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom drift
|
|
||||||
|
|
||||||
Implement `Drift` to express any other model. For example, a drift that saturates after a long absence (uncertainty grows with the square root of elapsed time instead of linearly):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use trueskill_tt::drift::Drift;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
struct SqrtDrift {
|
|
||||||
gamma: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drift for SqrtDrift {
|
|
||||||
fn variance_delta(&self, elapsed: i64) -> f64 {
|
|
||||||
(elapsed as f64).sqrt() * self.gamma * self.gamma
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let player = Player::new(Gaussian::from_ms(0.0, 6.0), 1.0, SqrtDrift { gamma: 0.5 });
|
|
||||||
```
|
|
||||||
|
|
||||||
To use a custom drift type with `History`, use the `.drift()` builder method instead of `.gamma()`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let h = History::builder()
|
|
||||||
.drift(SqrtDrift { gamma: 0.5 })
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Todo
|
|
||||||
|
|
||||||
- [x] Implement approx for Gaussian
|
|
||||||
- [x] Add more tests from `TrueSkillThroughTime.jl`
|
|
||||||
- [ ] Add tests for `quality()` (Use [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) as reference)
|
|
||||||
- [ ] Benchmark Batch::iteration()
|
|
||||||
- [ ] Time needs to be an enum so we can have multiple states (see `batch::compute_elapsed()`)
|
|
||||||
- [ ] Add examples (use same TrueSkillThroughTime.(py|jl))
|
|
||||||
- [ ] Add Observer (see [argmin](https://docs.rs/argmin/latest/argmin/core/trait.Observe.html) for inspiration)
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Baseline numbers captured before T0 changes
|
||||||
|
# Hardware: lrrr.local / Apple M5 Pro
|
||||||
|
# Date: 2026-04-24
|
||||||
|
|
||||||
|
Batch::iteration 29.840 µs
|
||||||
|
Gaussian::add 219.58 ps
|
||||||
|
Gaussian::sub 219.41 ps
|
||||||
|
Gaussian::mul 1.568 ns ← hot path; target ≥1.5× improvement
|
||||||
|
Gaussian::div 1.572 ns ← hot path; target ≥1.5× improvement
|
||||||
|
Gaussian::pi 262.89 ps
|
||||||
|
Gaussian::tau 262.47 ps
|
||||||
|
Gaussian::pi_tau_combined 219.40 ps
|
||||||
|
|
||||||
|
# After T0 (2026-04-24, same hardware)
|
||||||
|
|
||||||
|
Batch::iteration 21.253 µs (1.40× — below 3× target; see post-mortem)
|
||||||
|
Gaussian::add 218.62 ps (1.00× — unchanged, Add/Sub use moment form)
|
||||||
|
Gaussian::sub 220.15 ps (1.00×)
|
||||||
|
Gaussian::mul 218.69 ps (7.17× — nat-param: now two f64 adds, no sqrt)
|
||||||
|
Gaussian::div 218.64 ps (7.19× — nat-param: now two f64 subs, no sqrt)
|
||||||
|
Gaussian::pi 263.19 ps (1.00× — now a field read, same cost)
|
||||||
|
Gaussian::tau 263.51 ps (1.00× — now a field read, same cost)
|
||||||
|
Gaussian::pi_tau_combined 219.13 ps (1.00×)
|
||||||
|
|
||||||
|
# Post-mortem: Batch::iteration 1.40× vs. 3× target
|
||||||
|
#
|
||||||
|
# Root cause: the bench has 100 tiny 2-team events. Each event still allocates
|
||||||
|
# ~10 Vecs per iteration (down from ~18). The arena covers teams/diffs/ties/margins
|
||||||
|
# (was 4 Vecs, now 0 new allocs) but the following remain:
|
||||||
|
# - within_priors() returns Vec<Vec<Player<D>>>: 3 Vecs per event (300 total)
|
||||||
|
# - event.outputs() returns Vec<f64>: 1 Vec per event (100 total)
|
||||||
|
# - sort_perm() allocates 2 scratch Vecs: 200 total
|
||||||
|
# - Game::likelihoods = collect() allocates Vec<Vec<Gaussian>>: 4 Vecs (400 total)
|
||||||
|
# Total remaining: ~1000 allocs per iteration call vs. ~1800 before (44% reduction).
|
||||||
|
#
|
||||||
|
# The HashMap → dense Vec win (target 2–4×) benefits the History-level forward/backward
|
||||||
|
# sweep, NOT Batch::iteration in isolation — so this bench doesn't show it.
|
||||||
|
#
|
||||||
|
# To hit ≥3× on Batch::iteration:
|
||||||
|
# - Arena-ify sort_perm (use a stack-fixed array for small n_teams)
|
||||||
|
# - Pass a within_priors output buffer through the arena
|
||||||
|
# - Make Game::likelihoods write into an arena slice rather than allocating
|
||||||
|
# These land in T1 (factor graph) when we redesign Game's internals.
|
||||||
|
|
||||||
|
# After T1 (2026-04-24, same hardware)
|
||||||
|
|
||||||
|
Batch::iteration 23.010 µs (1.08× vs T0 21.253 µs — slight regression)
|
||||||
|
Gaussian::add 231.23 ps (unchanged)
|
||||||
|
Gaussian::sub 235.38 ps (unchanged)
|
||||||
|
Gaussian::mul 234.55 ps (unchanged — nat-param storage)
|
||||||
|
Gaussian::div 233.27 ps (unchanged)
|
||||||
|
Gaussian::pi 272.68 ps (unchanged)
|
||||||
|
Gaussian::tau 272.73 ps (unchanged)
|
||||||
|
Gaussian::pi_tau_combined 234.xx ps (unchanged)
|
||||||
|
|
||||||
|
# Notes:
|
||||||
|
# - Batch::iteration 23.0 µs vs target ≤ 21.5 µs (8% above target).
|
||||||
|
# Root cause: TruncFactor::propagate adds one extra Gaussian mul + div per
|
||||||
|
# diff vs the old inline EP computation. trunc Vec is still a fresh
|
||||||
|
# per-game allocation (borrow checker prevents putting it in the arena
|
||||||
|
# alongside vars). These are addressable in T2.
|
||||||
|
# - arena.team_prior, lhood_lose, lhood_win, inv_buf, sort_buf all reuse
|
||||||
|
# capacity across games (pooled in ScratchArena). sort_perm() allocation
|
||||||
|
# eliminated. message.rs deleted.
|
||||||
|
# - Gaussian operations unchanged vs T0.
|
||||||
|
# - All 53 tests pass. factor graph infrastructure (VarStore, Factor trait,
|
||||||
|
# BuiltinFactor, TruncFactor, EpsilonOrMax schedule) in place for T2.
|
||||||
|
|
||||||
|
# After T2 (2026-04-24, same hardware)
|
||||||
|
|
||||||
|
Batch::iteration 21.36 µs (1.07× vs T1 22.88 µs — 7% improvement)
|
||||||
|
Gaussian::add 218.97 ps (unchanged)
|
||||||
|
Gaussian::sub 218.58 ps (unchanged)
|
||||||
|
Gaussian::mul 218.59 ps (unchanged)
|
||||||
|
Gaussian::div 218.57 ps (unchanged)
|
||||||
|
Gaussian::pi 264.20 ps (unchanged)
|
||||||
|
Gaussian::tau 260.80 ps (unchanged)
|
||||||
|
|
||||||
|
# Notes:
|
||||||
|
# - API-only tier; hot inference path unchanged. The 7% improvement on
|
||||||
|
# Batch::iteration likely comes from the typed add_events(iter) path
|
||||||
|
# being slightly more direct than the nested-Vec path it replaced
|
||||||
|
# (one less layer of composition construction per event).
|
||||||
|
# - Public surface now matches spec Section 4:
|
||||||
|
# record_winner / record_draw / add_events(iter) / event(t).team().commit()
|
||||||
|
# converge() -> Result<ConvergenceReport, InferenceError>
|
||||||
|
# learning_curve(&K) / learning_curves() / current_skill(&K)
|
||||||
|
# log_evidence() / log_evidence_for(&[&K])
|
||||||
|
# predict_quality / predict_outcome
|
||||||
|
# Game::ranked / one_v_one / free_for_all / custom
|
||||||
|
# factors module (pub Factor/Schedule/VarStore/EpsilonOrMax/BuiltinFactor)
|
||||||
|
# - Breaking type renames: Batch→TimeSlice, Player→Rating, Agent→Competitor,
|
||||||
|
# IndexMap→KeyTable.
|
||||||
|
# - Generic over T: Time (default i64), D: Drift<T>, O: Observer<T>,
|
||||||
|
# K: Eq + Hash + Clone (default &'static str).
|
||||||
|
# - Legacy removed: History::convergence(iters, eps, verbose),
|
||||||
|
# HistoryBuilder::gamma(), HistoryBuilder::time(bool), History::time field,
|
||||||
|
# learning_curves_by_index(), nested-Vec public add_events().
|
||||||
|
# - 90 tests green: 68 lib + 10 api_shape + 6 game + 4 record_winner +
|
||||||
|
# 2 equivalence.
|
||||||
|
|
||||||
|
# After T3 (2026-04-24, same hardware)
|
||||||
|
|
||||||
|
Batch::iteration (seq, no rayon) 23.23 µs (matches T2 baseline; no regression)
|
||||||
|
Batch::iteration (rayon, small slice) 24.57 µs (within noise; small workloads pay rayon overhead)
|
||||||
|
Gaussian::add 236.62 ps (unchanged)
|
||||||
|
Gaussian::sub 236.43 ps (unchanged)
|
||||||
|
Gaussian::mul 237.05 ps (unchanged)
|
||||||
|
Gaussian::div 236.07 ps (unchanged)
|
||||||
|
|
||||||
|
# End-to-end history_converge benchmark (Apple M5 Pro, RAYON_NUM_THREADS=auto):
|
||||||
|
# workload seq rayon speedup
|
||||||
|
# 500 events, 100 competitors, 10/slice 4.03 ms 4.24 ms 1.0x
|
||||||
|
# 2000 events, 200 competitors, 20/slice 20.18 ms 19.82 ms 1.0x
|
||||||
|
# 5000 events, 50000 competitors, 1 slice 11.88 ms 9.10 ms 1.3x
|
||||||
|
#
|
||||||
|
# Notes:
|
||||||
|
# - T3's within-slice color-group parallelism only materializes a speedup
|
||||||
|
# when a slice holds many events with disjoint competitor sets. Typical
|
||||||
|
# TrueSkill workloads (tens of events per slice) don't show measurable
|
||||||
|
# benefit from rayon.
|
||||||
|
# - The pre-revert SmallVec experiment hit 2x on the 5000-event workload
|
||||||
|
# but regressed sequential Batch::iteration by 28%. The tradeoff wasn't
|
||||||
|
# worth it for typical workloads — ShipVec<[_; 8]> inline size (1 KB per
|
||||||
|
# Game struct) hurt cache locality on the hot path.
|
||||||
|
# - Cross-slice parallelism (dirty-bit slice skipping per spec Section 5)
|
||||||
|
# is the natural next step for realistic TrueSkill workloads and would
|
||||||
|
# deliver the spec's ~50-500x online-add speedup. Deferred to T4+.
|
||||||
|
# - Determinism verified: tests/determinism.rs asserts bit-identical
|
||||||
|
# posteriors across RAYON_NUM_THREADS={1, 2, 4, 8}.
|
||||||
|
# - Send + Sync bounds added on Time, Drift<T>, Observer<T>, Factor, Schedule.
|
||||||
|
# - Rayon is opt-in via `--features rayon`. Default build is unchanged from T2.
|
||||||
+45
-51
@@ -1,61 +1,55 @@
|
|||||||
use std::collections::HashMap;
|
//! 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, GAMMA, IndexMap, MU, P_DRAW, SIGMA, agent::Agent, batch::Batch, drift::ConstantDrift,
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
gaussian::Gaussian, player::Player,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn criterion_benchmark(criterion: &mut Criterion) {
|
fn criterion_benchmark(criterion: &mut Criterion) {
|
||||||
let mut index = IndexMap::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.get_or_create("a");
|
// 100 events, all at one time, so the history has a single slice.
|
||||||
let b = index.get_or_create("b");
|
let events: Vec<Event<i64, &'static str>> = (0..100)
|
||||||
let c = index.get_or_create("c");
|
.map(|_| Event {
|
||||||
|
time: 1,
|
||||||
let agents = {
|
teams: smallvec![
|
||||||
let mut map = HashMap::new();
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
map.insert(
|
],
|
||||||
a,
|
outcome: Outcome::winner(0, 2),
|
||||||
Agent {
|
})
|
||||||
player: Player::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)),
|
.collect();
|
||||||
..Default::default()
|
h.add_events(events).expect("fixture ingests");
|
||||||
},
|
h
|
||||||
);
|
|
||||||
map.insert(
|
|
||||||
b,
|
|
||||||
Agent {
|
|
||||||
player: Player::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
map.insert(
|
|
||||||
c,
|
|
||||||
Agent {
|
|
||||||
player: Player::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
map
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut composition = Vec::new();
|
criterion.bench_function("slice_sweep_100_events", |b| {
|
||||||
let mut results = Vec::new();
|
b.iter_batched(
|
||||||
let mut weights = Vec::new();
|
build,
|
||||||
|
|mut h| {
|
||||||
for _ in 0..100 {
|
// `converge_partial`, not `converge`: one iteration is
|
||||||
composition.push(vec![vec![a], vec![b]]);
|
// deliberately short of convergence and `converge` reports that
|
||||||
results.push(vec![1.0, 0.0]);
|
// as an error.
|
||||||
weights.push(vec![vec![1.0], vec![1.0]]);
|
let _ = h.converge_partial();
|
||||||
}
|
},
|
||||||
|
criterion::BatchSize::SmallInput,
|
||||||
let mut batch = Batch::new(1, P_DRAW);
|
);
|
||||||
batch.add_events(composition, results, weights, &agents);
|
|
||||||
|
|
||||||
criterion.bench_function("Batch::iteration", |b| {
|
|
||||||
b.iter(|| batch.iteration(0, &agents))
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +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
|
|
||||||
criterion.bench_function("Gaussian::div", |bencher| {
|
|
||||||
bencher.iter(|| g1 / g2);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//! End-to-end History::converge benchmark.
|
||||||
|
//!
|
||||||
|
//! Workload shapes designed to expose rayon's within-slice color-group
|
||||||
|
//! parallelism. Events in the same color group are processed in parallel
|
||||||
|
//! via direct-write with disjoint index sets (no data races). Color groups
|
||||||
|
//! smaller than a threshold fall back to the sequential path to avoid
|
||||||
|
//! rayon overhead on small workloads.
|
||||||
|
//!
|
||||||
|
//! On Apple M5 Pro, the P-core count (6) is the optimal thread count.
|
||||||
|
//! The rayon thread pool is initialised to `min(P-cores, available)` to
|
||||||
|
//! avoid scheduling onto the slower E-cores.
|
||||||
|
//!
|
||||||
|
//! ## Results (Apple M5 Pro, 2026-04-24, after SmallVec revert)
|
||||||
|
//!
|
||||||
|
//! | Workload | Sequential | Parallel | Speedup |
|
||||||
|
//! |---------------------------------------------|------------:|-----------:|--------:|
|
||||||
|
//! | History::converge/500x100@10perslice | 4.03 ms | 4.24 ms | 1.0× |
|
||||||
|
//! | History::converge/2000x200@20perslice | 20.18 ms | 19.82 ms | 1.0× |
|
||||||
|
//! | History::converge/1v1-5000x50000@5000perslice| 11.88 ms | 9.10 ms | 1.3× |
|
||||||
|
//!
|
||||||
|
//! T3 acceptance gate: ≥2× speedup on at least one workload — NOT achieved after revert.
|
||||||
|
//! The SmallVec storage that enabled the 2× gate caused a +28% regression in the
|
||||||
|
//! sequential Batch::iteration benchmark and was reverted. Small workloads still fall
|
||||||
|
//! below the RAYON_THRESHOLD (64 events/color) and run sequentially with near-zero overhead.
|
||||||
|
|
||||||
|
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
fn build_history_1v1(
|
||||||
|
n_events: usize,
|
||||||
|
n_competitors: usize,
|
||||||
|
events_per_slice: usize,
|
||||||
|
seed: u64,
|
||||||
|
) -> History<String> {
|
||||||
|
let mut rng = seed;
|
||||||
|
let mut next = || {
|
||||||
|
rng = rng
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(1442695040888963407);
|
||||||
|
rng
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut h = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 30,
|
||||||
|
epsilon: 1e-6,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(n_events);
|
||||||
|
for ev_i in 0..n_events {
|
||||||
|
let a = (next() as usize) % n_competitors;
|
||||||
|
let mut b = (next() as usize) % n_competitors;
|
||||||
|
while b == a {
|
||||||
|
b = (next() as usize) % n_competitors;
|
||||||
|
}
|
||||||
|
events.push(Event {
|
||||||
|
time: (ev_i as i64 / events_per_slice as i64) + 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
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_converge(c: &mut Criterion) {
|
||||||
|
// Two original task workloads (small per-slice event count;
|
||||||
|
// fall below RAYON_THRESHOLD so sequential path runs — near-zero overhead).
|
||||||
|
c.bench_function("History::converge/500x100@10perslice", |b| {
|
||||||
|
b.iter_batched(
|
||||||
|
|| build_history_1v1(500, 100, 10, 42),
|
||||||
|
|mut h| {
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
},
|
||||||
|
BatchSize::SmallInput,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
c.bench_function("History::converge/2000x200@20perslice", |b| {
|
||||||
|
b.iter_batched(
|
||||||
|
|| build_history_1v1(2000, 200, 20, 42),
|
||||||
|
|mut h| {
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
},
|
||||||
|
BatchSize::SmallInput,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Large single-slice workload: 5000 events, 50000 competitors.
|
||||||
|
// All events in one slice → color-0 gets ~4900 disjoint events, well above
|
||||||
|
// the 64-event RAYON_THRESHOLD. 30 iterations × 1 slice = 30 sweeps, each
|
||||||
|
// parallelised across P-core threads. Shows ≥2× speedup.
|
||||||
|
c.bench_function("History::converge/1v1-5000x50000@5000perslice", |b| {
|
||||||
|
b.iter_batched(
|
||||||
|
|| build_history_1v1(5000, 50000, 5000, 42),
|
||||||
|
|mut h| {
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
},
|
||||||
|
BatchSize::SmallInput,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(benches, bench_converge);
|
||||||
|
criterion_main!(benches);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//! Ingestion cost: one event per call versus one batched call.
|
||||||
|
//!
|
||||||
|
//! The rest of the suite only measures batched construction, which is why a
|
||||||
|
//! quadratic in the incremental path went unnoticed — `record_winner` and
|
||||||
|
//! `event(..).commit()` each ingest a single event, so a caller looping over a
|
||||||
|
//! match feed takes that path.
|
||||||
|
|
||||||
|
use std::hint::black_box;
|
||||||
|
|
||||||
|
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
fn events(n: usize, time: i64) -> Vec<Event<i64, String>> {
|
||||||
|
(0..n)
|
||||||
|
.map(|i| Event {
|
||||||
|
time,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(format!("p{}", 2 * i))]),
|
||||||
|
Team::with_members([Member::new(format!("p{}", 2 * i + 1))]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_ingest(c: &mut Criterion) {
|
||||||
|
let mut group = c.benchmark_group("ingest");
|
||||||
|
|
||||||
|
for n in [250usize, 500, 1000] {
|
||||||
|
group.bench_with_input(BenchmarkId::new("one-at-a-time", n), &n, |b, &n| {
|
||||||
|
b.iter_batched(
|
||||||
|
|| events(n, 0),
|
||||||
|
|evs| {
|
||||||
|
let mut h: History<String> = History::builder().key_type::<String>().build();
|
||||||
|
for ev in evs {
|
||||||
|
h.add_events(std::iter::once(ev)).unwrap();
|
||||||
|
}
|
||||||
|
black_box(h.time_slices_len())
|
||||||
|
},
|
||||||
|
criterion::BatchSize::SmallInput,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("single-batch", n), &n, |b, &n| {
|
||||||
|
b.iter_batched(
|
||||||
|
|| events(n, 0),
|
||||||
|
|evs| {
|
||||||
|
let mut h: History<String> = History::builder().key_type::<String>().build();
|
||||||
|
h.add_events(evs).unwrap();
|
||||||
|
black_box(h.time_slices_len())
|
||||||
|
},
|
||||||
|
criterion::BatchSize::SmallInput,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(benches, bench_ingest);
|
||||||
|
criterion_main!(benches);
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//! Cost of the joint posterior: factorising versus querying.
|
||||||
|
//!
|
||||||
|
//! The split is the whole point of `History::joint`. Factorising is `O(n^3)` in
|
||||||
|
//! the history's appearances and depends only on the fit; a query is `O(n^2)`
|
||||||
|
//! and depends only on the question. `posterior_of_one_shot` pays both every
|
||||||
|
//! time, `joint_query` pays only the second.
|
||||||
|
|
||||||
|
use criterion::{Criterion, criterion_group, criterion_main};
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
/// 30 slices of 8 duels: 480 appearances over 100 competitors.
|
||||||
|
fn fitted() -> History<String> {
|
||||||
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.05))
|
||||||
|
// `max_iter: 30` was here, and this fixture needs more: `converge`
|
||||||
|
// reported `NotConverged { iterations: 30, final_step: (4.5e-4, 0.0) }`
|
||||||
|
// once it stopped returning short fits silently. The benchmark measures
|
||||||
|
// the factorisation, whose cost depends on the fit's *shape* rather
|
||||||
|
// than its exactness — but measuring it on an unconverged fit is still
|
||||||
|
// measuring something nobody would run.
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: trueskill_tt::ITERATIONS,
|
||||||
|
epsilon: 1e-10,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut events: Vec<Event<i64, String>> = Vec::new();
|
||||||
|
let mut k = 0usize;
|
||||||
|
for t in 0..30i64 {
|
||||||
|
for _ in 0..8 {
|
||||||
|
k += 1;
|
||||||
|
events.push(Event {
|
||||||
|
time: t,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(format!("p{}", k % 100))]),
|
||||||
|
Team::with_members([Member::new(format!("p{}", (k + 37) % 100))]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([
|
||||||
|
(k as f64 * 0.3).sin().abs() * 20.0,
|
||||||
|
(k as f64 * 0.3).cos().abs() * 20.0,
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_joint(c: &mut Criterion) {
|
||||||
|
let h = fitted();
|
||||||
|
let a = "p0".to_string();
|
||||||
|
let b = "p1".to_string();
|
||||||
|
let terms = [(&a, 1.0), (&b, -1.0)];
|
||||||
|
|
||||||
|
c.bench_function("joint_factorise_480_appearances", |bencher| {
|
||||||
|
bencher.iter(|| std::hint::black_box(h.joint().unwrap().variables()));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factorise-and-query, the cost the deleted `History::posterior_of`
|
||||||
|
// wrapper paid on every call. Kept as the baseline the cached query below
|
||||||
|
// is measured against.
|
||||||
|
c.bench_function("posterior_of_one_shot_480_appearances", |bencher| {
|
||||||
|
bencher.iter(|| std::hint::black_box(h.joint().unwrap().posterior_of(&terms).unwrap()));
|
||||||
|
});
|
||||||
|
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
c.bench_function("joint_query_480_appearances", |bencher| {
|
||||||
|
bencher.iter(|| std::hint::black_box(joint.posterior_of(&terms).unwrap()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(benches, bench_joint);
|
||||||
|
criterion_main!(benches);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use criterion::{Criterion, criterion_group, criterion_main};
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
fn bench_scored_history(c: &mut Criterion) {
|
||||||
|
c.bench_function("scored_history_60_events_30_iter", |bencher| {
|
||||||
|
bencher.iter(|| {
|
||||||
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.drift(ConstantDrift::new(0.03))
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(60);
|
||||||
|
for i in 0..60 {
|
||||||
|
let a = format!("p{}", i % 20);
|
||||||
|
let b = format!("p{}", (i + 7) % 20);
|
||||||
|
let s_a = (i as f64 * 0.3).sin().abs() * 21.0;
|
||||||
|
let s_b = (i as f64 * 0.3).cos().abs() * 21.0;
|
||||||
|
events.push(Event {
|
||||||
|
time: 1 + (i / 6) as i64,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a)]),
|
||||||
|
Team::with_members([Member::new(b)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([s_a, s_b]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(benches, bench_scored_history);
|
||||||
|
criterion_main!(benches);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
Finished `bench` profile [optimized + debuginfo] target(s) in 0.02s
|
||||||
|
Running benches/scored.rs (target/release/deps/scored-988d1798504ff7d2)
|
||||||
|
Gnuplot not found, using plotters backend
|
||||||
|
Benchmarking scored_history_60_events_30_iter
|
||||||
|
Benchmarking scored_history_60_events_30_iter: Warming up for 3.0000 s
|
||||||
|
Benchmarking scored_history_60_events_30_iter: Collecting 100 samples in estimated 9.7418 s (10k iterations)
|
||||||
|
Benchmarking scored_history_60_events_30_iter: Analyzing
|
||||||
|
scored_history_60_events_30_iter
|
||||||
|
time: [959.36 µs 962.68 µs 966.13 µs]
|
||||||
|
Found 11 outliers among 100 measurements (11.00%)
|
||||||
|
1 (1.00%) low mild
|
||||||
|
5 (5.00%) high mild
|
||||||
|
5 (5.00%) high severe
|
||||||
|
|
||||||
+10
@@ -44,6 +44,11 @@ split_commits = false
|
|||||||
# Assigns commits to groups.
|
# Assigns commits to groups.
|
||||||
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
|
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
|
||||||
commit_parsers = [
|
commit_parsers = [
|
||||||
|
# Must precede the type parsers below: a `feat!`/`fix!`/`refactor!` subject
|
||||||
|
# matches those too, and the first match wins. Without this a breaking
|
||||||
|
# change renders as an ordinary line of its own type.
|
||||||
|
{ message = "^[a-z]+(\\(.+\\))?!:", group = "Breaking Changes" },
|
||||||
|
{ body = "BREAKING CHANGE", group = "Breaking Changes" },
|
||||||
{ message = "^feat", group = "Features" },
|
{ message = "^feat", group = "Features" },
|
||||||
{ message = "^fix", group = "Bug Fixes" },
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
{ message = "^doc", group = "Documentation" },
|
{ message = "^doc", group = "Documentation" },
|
||||||
@@ -53,6 +58,11 @@ commit_parsers = [
|
|||||||
{ message = "^test", group = "Testing" },
|
{ message = "^test", group = "Testing" },
|
||||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||||
{ message = "^chore", group = "Miscellaneous Tasks" },
|
{ message = "^chore", group = "Miscellaneous Tasks" },
|
||||||
|
{ message = "^ci", group = "CI" },
|
||||||
|
# Every branch lands with `--no-ff`, so a release's merge commits outnumber
|
||||||
|
# its real ones and say nothing the merged commits do not. They were
|
||||||
|
# filling an "Other (unconventional)" section with 14 lines of noise.
|
||||||
|
{ message = "^Merge ", skip = true },
|
||||||
{ body = ".*security", group = "Security" },
|
{ body = ".*security", group = "Security" },
|
||||||
{ body = ".*", group = "Other (unconventional)" },
|
{ body = ".*", group = "Other (unconventional)" },
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,593 @@
|
|||||||
|
# History → TimeSlice ConvergenceOptions Plumbing Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Thread `ConvergenceOptions` from `History` through `TimeSlice` to the three `Game::*_with_arena` callsites in `time_slice.rs`, so users who set `HistoryBuilder::convergence(opts)` actually get those options applied to within-game inference (including Damped's `alpha`).
|
||||||
|
|
||||||
|
**Architecture:** `TimeSlice<T>` gains a `convergence: ConvergenceOptions` field set at construction. `History::add_events_with_prior` passes `self.convergence`. The three `Game::*_with_arena` callsites in `time_slice.rs` swap their hardcoded `ConvergenceOptions::default()` for the propagated value. The pre-existing `TimeSlice::convergence` method is renamed to `iterate_to_convergence` to disambiguate from the new field. No new public API on `History` or `HistoryBuilder` — `convergence(opts)` already exists and works.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test --lib`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec reference
|
||||||
|
|
||||||
|
`docs/superpowers/specs/2026-05-08-history-convergence-plumbing-design.md`
|
||||||
|
|
||||||
|
## Pre-flight context for the implementer
|
||||||
|
|
||||||
|
- `HistoryBuilder::convergence(opts)` already exists at `src/history.rs:91`. `History` already stores `convergence: ConvergenceOptions` at `src/history.rs:166`. `History::converge()` already reads `self.convergence.{epsilon, max_iter}` at `src/history.rs:437-447` for the OUTER cross-history loop.
|
||||||
|
- `TimeSlice<T>` is at `src/time_slice.rs:172-180`. Currently has fields `events`, `skills`, `time`, `p_draw`, `arena`, `color_groups`. No convergence field yet.
|
||||||
|
- `TimeSlice::new(time, p_draw)` at `src/time_slice.rs:183-192` is `pub`. Five test callsites use it with `(0i64, 0.0)`. One production callsite in `History::add_events_with_prior` at `src/history.rs:597` uses `(t, self.p_draw)`.
|
||||||
|
- Three callsites in `time_slice.rs` call `Game::*_with_arena` with hardcoded `crate::ConvergenceOptions::default()`:
|
||||||
|
- `Event::iteration_direct` at `src/time_slice.rs:131-169` — does NOT have `&self` access to a TimeSlice. Currently takes `(skills, agents, p_draw, arena)`. Needs to gain a `convergence` parameter.
|
||||||
|
- `TimeSlice::iteration` at `src/time_slice.rs:322-363` — has `&mut self`, so reads `self.convergence` directly.
|
||||||
|
- `TimeSlice::log_evidence` at `src/time_slice.rs:505-540` — has `&self`, so reads `self.convergence` directly.
|
||||||
|
- The rayon path in `sweep_color_groups` at `src/time_slice.rs:376-423` uses a `move` closure capturing `p_draw` by value. The same pattern applies to `convergence` (it's `Copy`, so captures cleanly).
|
||||||
|
- `TimeSlice::convergence` (the **method** at `src/time_slice.rs:447`) shares its name with the new field. Rust technically allows this (different namespaces), but it's a readability hazard — must be renamed. The method is called from 4 test sites in `time_slice.rs` (lines 693, 755, 817, 851). It is NOT called from `history.rs`.
|
||||||
|
- `ConvergenceOptions` is `Copy + Clone + Debug`. Pass by value everywhere.
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Why touched |
|
||||||
|
|---|---|
|
||||||
|
| `src/time_slice.rs` | TimeSlice gains `convergence` field, `new` signature change, rename `convergence` method, three callsites read `self.convergence`, `Event::iteration_direct` gains parameter, rayon closure captures it |
|
||||||
|
| `src/history.rs` | `add_events_with_prior` passes `self.convergence` to `TimeSlice::new`; two integration tests added; alpha doc-comment update happens in `convergence.rs` not here |
|
||||||
|
| `src/convergence.rs` | One-sentence addition to `alpha` doc comment clarifying within-game-only scope |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: TimeSlice gains `convergence` field; signature/rename land atomically
|
||||||
|
|
||||||
|
This task does five things atomically — they cannot land separately because intermediate states won't compile:
|
||||||
|
|
||||||
|
1. Add `pub(crate) convergence: ConvergenceOptions` field to `TimeSlice<T>`.
|
||||||
|
2. Change `TimeSlice::new` signature to take `convergence: ConvergenceOptions` as the third parameter.
|
||||||
|
3. Update the production callsite in `History::add_events_with_prior` (`src/history.rs:597`) to pass `self.convergence`.
|
||||||
|
4. Update the five test callsites in `src/time_slice.rs` (lines 646, 723, 803, 901 — the four with `TimeSlice::new(0i64, 0.0)`, plus the one inside the test module's `iterate_through_color_groups` test if it exists; locate via `grep -n "TimeSlice::new" src/time_slice.rs`).
|
||||||
|
5. Rename the existing `pub(crate) fn convergence` method (at `src/time_slice.rs:447`) to `iterate_to_convergence`. Update its 4 in-file call sites.
|
||||||
|
|
||||||
|
After this task the convergence field is wired but **unused** by inference (Task 2 makes the three Game callsites read it). All existing tests must pass bit-equal because the propagated value still equals `ConvergenceOptions::default()` end-to-end.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/time_slice.rs`
|
||||||
|
- Modify: `src/history.rs:597`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate all `TimeSlice::new` and `convergence`-method callsites**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -n "TimeSlice::new\|\.convergence(" src/time_slice.rs src/history.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 1 production callsite of `TimeSlice::new` in `history.rs`, 5 test callsites in `time_slice.rs`, and 4 method-style `.convergence(` calls in `time_slice.rs` test module. (No `.convergence(` calls in `history.rs` — those are field accesses.)
|
||||||
|
|
||||||
|
Save the line numbers — you'll need them in Step 4 and Step 6.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the `convergence` field to `TimeSlice<T>`**
|
||||||
|
|
||||||
|
In `src/time_slice.rs`, modify the `TimeSlice<T>` struct (currently at `src/time_slice.rs:172-180`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TimeSlice<T: Time = i64> {
|
||||||
|
pub(crate) events: Vec<Event>,
|
||||||
|
pub(crate) skills: SkillStore,
|
||||||
|
pub(crate) time: T,
|
||||||
|
p_draw: f64,
|
||||||
|
pub(crate) convergence: crate::ConvergenceOptions,
|
||||||
|
arena: ScratchArena,
|
||||||
|
pub(crate) color_groups: ColorGroups,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Code won't compile until Step 3.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Change `TimeSlice::new` signature**
|
||||||
|
|
||||||
|
In `src/time_slice.rs`, replace the existing `pub fn new` (currently at `src/time_slice.rs:183-192`) with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
|
||||||
|
Self {
|
||||||
|
events: Vec::new(),
|
||||||
|
skills: SkillStore::new(),
|
||||||
|
time,
|
||||||
|
p_draw,
|
||||||
|
convergence,
|
||||||
|
arena: ScratchArena::new(),
|
||||||
|
color_groups: ColorGroups::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the production callsite in `history.rs`**
|
||||||
|
|
||||||
|
In `src/history.rs:597`, replace:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut time_slice = TimeSlice::new(t, self.p_draw);
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update test callsites of `TimeSlice::new`**
|
||||||
|
|
||||||
|
Run `cargo build --tests` to surface every remaining compile error. Each error is a `TimeSlice::new(time, p_draw)` callsite missing the third argument. The fix: add `crate::ConvergenceOptions::default(),` (inside `src/time_slice.rs` test modules use the path relative to where `ConvergenceOptions` is in scope — if it's not imported in that test mod, add `use crate::ConvergenceOptions;` at the top of the mod and pass `ConvergenceOptions::default()`).
|
||||||
|
|
||||||
|
Example transformation. Before:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut time_slice = TimeSlice::new(0i64, 0.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
After:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply to all 5 test callsites identified in Step 1. Repeat `cargo build --tests` until it succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Rename the `convergence` method to `iterate_to_convergence`**
|
||||||
|
|
||||||
|
In `src/time_slice.rs`, find the method definition at `src/time_slice.rs:447`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) fn convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
|
||||||
|
```
|
||||||
|
|
||||||
|
Rename to:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) fn iterate_to_convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update the 4 call sites (located in Step 1 — `time_slice.rs:693, 755, 817, 851` or wherever your grep found them). At each site, replace `time_slice.convergence(&agents)` with `time_slice.iterate_to_convergence(&agents)`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build and run the full test suite**
|
||||||
|
|
||||||
|
Run: `cargo build && cargo test --lib`
|
||||||
|
|
||||||
|
Expected: all 98 lib tests pass. Bit-equal goldens — the convergence field is wired but the three inference callsites still hardcode `ConvergenceOptions::default()` (Task 2 changes that), and the propagated default equals what was hardcoded before, so behavior is identical.
|
||||||
|
|
||||||
|
If any test fails: investigate. The most likely cause is a missed `TimeSlice::new` callsite or a `.convergence(` call site that needs renaming.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run integration tests**
|
||||||
|
|
||||||
|
Run: `cargo test`
|
||||||
|
|
||||||
|
Expected: all 27 integration tests still pass.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Format and lint**
|
||||||
|
|
||||||
|
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
|
||||||
|
|
||||||
|
Expected: no diff, no warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/time_slice.rs src/history.rs
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
refactor(time_slice): add convergence field, rename iterate_to_convergence
|
||||||
|
|
||||||
|
TimeSlice<T> gains a pub(crate) convergence: ConvergenceOptions field
|
||||||
|
set at construction. TimeSlice::new now takes it as a third parameter
|
||||||
|
(breaking change to the pub constructor, acceptable in 0.1.x).
|
||||||
|
History::add_events_with_prior passes self.convergence so the propagated
|
||||||
|
value reaches every TimeSlice. The pre-existing convergence-the-method
|
||||||
|
is renamed to iterate_to_convergence to disambiguate from the new
|
||||||
|
convergence-the-field.
|
||||||
|
|
||||||
|
The field is wired but not yet read by inference — the three
|
||||||
|
Game::*_with_arena callsites in time_slice.rs still hardcode
|
||||||
|
ConvergenceOptions::default(). Task 2 changes that. Bit-equal because
|
||||||
|
the propagated value equals the hardcoded value end-to-end.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Read `self.convergence` at the three inference callsites
|
||||||
|
|
||||||
|
This task switches the three `Game::*_with_arena` callsites in `time_slice.rs` from hardcoded `ConvergenceOptions::default()` to the propagated `self.convergence` (or for `Event::iteration_direct`, a passed-in parameter). After this task, Damped EP set on `HistoryBuilder` actually reaches the within-game loop.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/time_slice.rs` (only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a `convergence` parameter to `Event::iteration_direct`**
|
||||||
|
|
||||||
|
In `src/time_slice.rs`, modify the existing `iteration_direct` signature (currently at `src/time_slice.rs:131-137`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn iteration_direct<T: Time, D: Drift<T>>(
|
||||||
|
&mut self,
|
||||||
|
skills: &mut SkillStore,
|
||||||
|
agents: &CompetitorStore<T, D>,
|
||||||
|
p_draw: f64,
|
||||||
|
convergence: crate::ConvergenceOptions,
|
||||||
|
arena: &mut ScratchArena,
|
||||||
|
) {
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the body (around `src/time_slice.rs:140-156`), replace both `crate::ConvergenceOptions::default()` arguments with `convergence`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let g = match self.kind {
|
||||||
|
EventKind::Ranked => Game::ranked_with_arena(
|
||||||
|
teams,
|
||||||
|
&result,
|
||||||
|
&self.weights,
|
||||||
|
p_draw,
|
||||||
|
convergence,
|
||||||
|
arena,
|
||||||
|
),
|
||||||
|
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||||
|
teams,
|
||||||
|
&result,
|
||||||
|
&self.weights,
|
||||||
|
score_sigma,
|
||||||
|
convergence,
|
||||||
|
arena,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the rayon path in `sweep_color_groups` (cfg=rayon)**
|
||||||
|
|
||||||
|
In `src/time_slice.rs`, the rayon-feature `sweep_color_groups` (currently at `src/time_slice.rs:376-423`) captures `p_draw` by value into a `move` closure and calls `ev.iteration_direct(skills, agents, p_draw, &mut arena)`. Capture `convergence` the same way and pass it:
|
||||||
|
|
||||||
|
Above the rayon `for_each` at the line `let p_draw = self.p_draw;`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let convergence = self.convergence;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update the call inside the closure (currently `ev.iteration_direct(skills, agents, p_draw, &mut arena);`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ev.iteration_direct(skills, agents, p_draw, convergence, &mut arena);
|
||||||
|
```
|
||||||
|
|
||||||
|
The `else` branch (sequential fallback) at `src/time_slice.rs:417-421` calls `ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);` — also update:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ev.iteration_direct(&mut self.skills, agents, p_draw, self.convergence, &mut self.arena);
|
||||||
|
```
|
||||||
|
|
||||||
|
(Note: this branch reads `self.convergence` directly because no `move` closure is involved here.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the non-rayon path in `sweep_color_groups`**
|
||||||
|
|
||||||
|
In `src/time_slice.rs`, the `#[cfg(not(feature = "rayon"))]` `sweep_color_groups` (currently at `src/time_slice.rs:428-444`) calls `ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);` at `src/time_slice.rs:441`. Replace with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ev.iteration_direct(&mut self.skills, agents, p_draw, self.convergence, &mut self.arena);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `TimeSlice::iteration`'s sequential branch**
|
||||||
|
|
||||||
|
In `src/time_slice.rs`, modify `TimeSlice::iteration` (at `src/time_slice.rs:322-363`). The sequential branch (when `from > 0 || self.color_groups.is_empty()`) has two `Game::*_with_arena` callsites at `src/time_slice.rs:330-346` that hardcode `crate::ConvergenceOptions::default()`. Replace both with `self.convergence`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let g = match event.kind {
|
||||||
|
EventKind::Ranked => Game::ranked_with_arena(
|
||||||
|
teams,
|
||||||
|
&result,
|
||||||
|
&event.weights,
|
||||||
|
self.p_draw,
|
||||||
|
self.convergence,
|
||||||
|
&mut self.arena,
|
||||||
|
),
|
||||||
|
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||||
|
teams,
|
||||||
|
&result,
|
||||||
|
&event.weights,
|
||||||
|
score_sigma,
|
||||||
|
self.convergence,
|
||||||
|
&mut self.arena,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update `TimeSlice::log_evidence`**
|
||||||
|
|
||||||
|
In `src/time_slice.rs`, modify `TimeSlice::log_evidence` (at `src/time_slice.rs:505-540`). The two `Game::*_with_arena` callsites in the inner `run_event` closure at `src/time_slice.rs:519-538` hardcode `crate::ConvergenceOptions::default()`. Replace both with `self.convergence`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
|
||||||
|
let teams = event.within_priors(online, forward, &self.skills, agents);
|
||||||
|
let result = event.outputs();
|
||||||
|
match event.kind {
|
||||||
|
EventKind::Ranked => Game::ranked_with_arena(
|
||||||
|
teams,
|
||||||
|
&result,
|
||||||
|
&event.weights,
|
||||||
|
self.p_draw,
|
||||||
|
self.convergence,
|
||||||
|
arena,
|
||||||
|
)
|
||||||
|
.evidence
|
||||||
|
.ln(),
|
||||||
|
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||||
|
teams,
|
||||||
|
&result,
|
||||||
|
&event.weights,
|
||||||
|
score_sigma,
|
||||||
|
self.convergence,
|
||||||
|
arena,
|
||||||
|
)
|
||||||
|
.evidence
|
||||||
|
.ln(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
(`self.convergence` is `Copy`, so the closure captures it by value naturally without needing a `let` binding outside.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build and run the full test suite — bit-equal regression net**
|
||||||
|
|
||||||
|
Run: `cargo build && cargo test --lib`
|
||||||
|
|
||||||
|
Expected: all 98 lib tests still pass. Bit-equal goldens — every existing test uses `History::default()` or `HistoryBuilder::default()` (which sets `convergence = ConvergenceOptions::default()`), so the propagated value equals what the hardcoded default was. No test exercises a non-default convergence through History today, so no behavior changes.
|
||||||
|
|
||||||
|
If any test fails: investigate. The most likely cause is a stale `crate::ConvergenceOptions::default()` call missed in steps 1-5 — re-grep with `grep -n "ConvergenceOptions::default" src/time_slice.rs` to find any remaining hardcoded sites.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run integration tests**
|
||||||
|
|
||||||
|
Run: `cargo test`
|
||||||
|
|
||||||
|
Expected: all 27 integration tests still pass.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Confirm no `crate::ConvergenceOptions::default()` remains in time_slice.rs**
|
||||||
|
|
||||||
|
Run: `grep -n "ConvergenceOptions::default" src/time_slice.rs`
|
||||||
|
|
||||||
|
Expected: only test-mod hits (in `TimeSlice::new(0i64, 0.0, ConvergenceOptions::default())` callsites from Task 1 step 5). NO production-code hits in `Event::iteration_direct`, `sweep_color_groups`, `TimeSlice::iteration`, or `TimeSlice::log_evidence`.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Format and lint**
|
||||||
|
|
||||||
|
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
|
||||||
|
|
||||||
|
Expected: no diff, no warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/time_slice.rs
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(time_slice): inference callsites read self.convergence
|
||||||
|
|
||||||
|
The three Game::*_with_arena callsites in time_slice.rs (in
|
||||||
|
TimeSlice::iteration's sequential branch, TimeSlice::log_evidence's
|
||||||
|
run_event closure, and Event::iteration_direct via parameter) now use
|
||||||
|
the propagated ConvergenceOptions instead of hardcoded ::default().
|
||||||
|
sweep_color_groups (both rayon and non-rayon paths) forwards
|
||||||
|
self.convergence into Event::iteration_direct.
|
||||||
|
|
||||||
|
Damped EP (alpha < 1.0) and custom max_iter / epsilon set on
|
||||||
|
HistoryBuilder::convergence(opts) now actually reach the within-game
|
||||||
|
inference loop. Bit-equal for users on default options.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Doc-comment update + end-to-end integration tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/convergence.rs` (alpha doc comment)
|
||||||
|
- Modify: `src/history.rs` (two integration tests in the existing `#[cfg(test)] mod tests` block)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `ConvergenceOptions::alpha` doc comment**
|
||||||
|
|
||||||
|
In `src/convergence.rs`, find the existing doc comment on the `alpha` field. Replace it with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// EP damping factor in natural-parameter space: each per-factor
|
||||||
|
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
|
||||||
|
/// undamped (default); `< 1.0` stabilises oscillating fixed-point
|
||||||
|
/// loops at the cost of more iterations. Must be in `(0.0, 1.0]`.
|
||||||
|
///
|
||||||
|
/// Applies only to the within-game EP loop (`run_chain`). The outer
|
||||||
|
/// `History::converge` cross-history sweep is undamped regardless of
|
||||||
|
/// this value — cross-slice damping is a different concept and not
|
||||||
|
/// in scope.
|
||||||
|
pub alpha: f64,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Locate the `#[cfg(test)] mod tests` block in `src/history.rs`**
|
||||||
|
|
||||||
|
Run: `grep -n "#\[cfg(test)\]" src/history.rs`
|
||||||
|
|
||||||
|
Identify the test module (there should be one near the bottom of the file). Read the imports at the top of that module so the new tests can reuse the existing test helpers and scope.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the failing tests**
|
||||||
|
|
||||||
|
Add the following two tests at the end of the test module in `src/history.rs` (just before the module's closing `}`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn history_propagates_convergence_to_inner_run_chain() {
|
||||||
|
use crate::ConvergenceOptions;
|
||||||
|
|
||||||
|
// 4-team ranked game; each event needs more than one inner EP iter
|
||||||
|
// to fully converge.
|
||||||
|
let events_for = |h: &mut crate::History<i64, crate::drift::ConstantDrift,
|
||||||
|
crate::observer::NullObserver, &'static str>| {
|
||||||
|
for &name in &["a", "b", "c", "d"] {
|
||||||
|
h.new_agent(name);
|
||||||
|
}
|
||||||
|
h.event(0)
|
||||||
|
.team(["a"])
|
||||||
|
.team(["b"])
|
||||||
|
.team(["c"])
|
||||||
|
.team(["d"])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut h_capped = crate::History::builder()
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 1,
|
||||||
|
..ConvergenceOptions::default()
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
events_for(&mut h_capped);
|
||||||
|
h_capped.converge().unwrap();
|
||||||
|
|
||||||
|
let mut h_full = crate::History::builder().build();
|
||||||
|
events_for(&mut h_full);
|
||||||
|
h_full.converge().unwrap();
|
||||||
|
|
||||||
|
let curves_capped = h_capped.learning_curves();
|
||||||
|
let curves_full = h_full.learning_curves();
|
||||||
|
|
||||||
|
let mut max_diff: f64 = 0.0;
|
||||||
|
for (key, capped_pts) in curves_capped.iter() {
|
||||||
|
let full_pts = curves_full.get(key).expect("agent missing in full");
|
||||||
|
for (capped, full) in capped_pts.iter().zip(full_pts.iter()) {
|
||||||
|
max_diff = max_diff.max((capped.1.mu() - full.1.mu()).abs());
|
||||||
|
max_diff = max_diff.max((capped.1.sigma() - full.1.sigma()).abs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
max_diff > 1e-6,
|
||||||
|
"max_iter=1 inner loop should differ from default; max_diff={max_diff}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_with_damping_reaches_same_fixed_point_as_undamped() {
|
||||||
|
use crate::ConvergenceOptions;
|
||||||
|
|
||||||
|
let events_for = |h: &mut crate::History<i64, crate::drift::ConstantDrift,
|
||||||
|
crate::observer::NullObserver, &'static str>| {
|
||||||
|
for &name in &["a", "b", "c", "d"] {
|
||||||
|
h.new_agent(name);
|
||||||
|
}
|
||||||
|
h.event(0)
|
||||||
|
.team(["a"])
|
||||||
|
.team(["b"])
|
||||||
|
.team(["c"])
|
||||||
|
.team(["d"])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut h_undamped = crate::History::builder().build();
|
||||||
|
events_for(&mut h_undamped);
|
||||||
|
h_undamped.converge().unwrap();
|
||||||
|
|
||||||
|
let mut h_damped = crate::History::builder()
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
alpha: 0.5,
|
||||||
|
max_iter: 200,
|
||||||
|
..ConvergenceOptions::default()
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
events_for(&mut h_damped);
|
||||||
|
h_damped.converge().unwrap();
|
||||||
|
|
||||||
|
let curves_u = h_undamped.learning_curves();
|
||||||
|
let curves_d = h_damped.learning_curves();
|
||||||
|
|
||||||
|
let mut max_diff: f64 = 0.0;
|
||||||
|
for (key, u_pts) in curves_u.iter() {
|
||||||
|
let d_pts = curves_d.get(key).expect("agent missing in damped");
|
||||||
|
for (u, d) in u_pts.iter().zip(d_pts.iter()) {
|
||||||
|
max_diff = max_diff.max((u.1.mu() - d.1.mu()).abs());
|
||||||
|
max_diff = max_diff.max((u.1.sigma() - d.1.sigma()).abs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
max_diff < 1e-3,
|
||||||
|
"α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the import or method names (e.g. `History::builder()`, `event(...).team(...).commit()`, `learning_curves()`, `new_agent(...)`) don't match what's available in the test module, look at neighboring tests for the exact builder/event-construction pattern in current use and mirror it. The structure (build two Histories, add identical events, compare curves) is the contract; the surface syntax must follow what already works in this test file.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the new tests**
|
||||||
|
|
||||||
|
Run: `cargo test --lib history_propagates_convergence_to_inner_run_chain history_with_damping_reaches_same_fixed_point_as_undamped`
|
||||||
|
|
||||||
|
Expected: 2 passed.
|
||||||
|
|
||||||
|
**Fallback if Test 1 fails** (`max_iter=1` produces the same posteriors as default — meaning the inner loop converges in one iteration on this graph): replace `max_iter: 1` with `max_iter: 0`. With `max_iter = 0` the inner loop body runs zero times, guaranteeing different posteriors than convergence.
|
||||||
|
|
||||||
|
**Fallback if Test 2 fails** (`max_diff` exceeds `1e-3`): raise `max_iter: 200` to `max_iter: 500`. Heavier damping needs more iterations to reach the same fixed point.
|
||||||
|
|
||||||
|
If neither fallback works, STOP and report BLOCKED with the actual `max_diff` and the iteration counts tried.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `cargo test --lib && cargo test`
|
||||||
|
|
||||||
|
Expected: lib count = 100 (was 98), integration count = 27 (unchanged), all passing.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Format and lint**
|
||||||
|
|
||||||
|
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
|
||||||
|
|
||||||
|
Expected: no diff, no warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/convergence.rs src/history.rs
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
test(history): end-to-end ConvergenceOptions propagation tests
|
||||||
|
|
||||||
|
Two integration tests on a 4-team ranked event:
|
||||||
|
- max_iter=1 set on HistoryBuilder produces measurably different
|
||||||
|
posteriors than default, proving the inner loop honors the
|
||||||
|
propagated max_iter
|
||||||
|
- alpha=0.5 with extra iterations reaches the same fixed point as
|
||||||
|
alpha=1.0, proving damping doesn't break correctness on the History
|
||||||
|
path
|
||||||
|
|
||||||
|
Also updates the alpha doc comment to clarify it applies only to the
|
||||||
|
within-game EP loop, not the outer cross-history sweep.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review (writer's note)
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- Spec § "What ships" item 1 (TimeSlice convergence field) → Task 1 step 2 ✓
|
||||||
|
- Spec § "What ships" item 2 (TimeSlice::new signature) → Task 1 step 3 ✓
|
||||||
|
- Spec § "What ships" item 3 (History passes self.convergence) → Task 1 step 4 ✓
|
||||||
|
- Spec § "What ships" item 4 (Event::iteration_direct gains parameter) → Task 2 step 1 ✓
|
||||||
|
- Spec § "What ships" item 4 (callers pass self.convergence) → Task 2 steps 2, 3 ✓
|
||||||
|
- Spec § "What ships" item 5 (TimeSlice::convergence-method reads field) → Task 2 step 4 ✓
|
||||||
|
- Spec § "What ships" item 6 (log_evidence reads field) → Task 2 step 5 ✓
|
||||||
|
- Spec § "What ships" item 7 (test callsite updates) → Task 1 step 5 ✓
|
||||||
|
- Spec § "Design" rename method → Task 1 step 6 ✓
|
||||||
|
- Spec § "Risks" alpha doc-comment update → Task 3 step 1 ✓
|
||||||
|
- Spec § "Testing strategy" §1 (regression net) → Tasks 1 step 7, 2 step 6, 3 step 5 ✓
|
||||||
|
- Spec § "Testing strategy" §2 (history_propagates_convergence) → Task 3 step 3 test 1 ✓
|
||||||
|
- Spec § "Testing strategy" §2 (history_with_damping_reaches_same_fixed_point) → Task 3 step 3 test 2 ✓
|
||||||
|
|
||||||
|
**Out-of-scope items correctly absent:** No new `History`/`HistoryBuilder` methods, no `ConvergenceOptions` split, no `Damped` Schedule impl, no nat-param convergence switch.
|
||||||
|
|
||||||
|
**Type / signature consistency:**
|
||||||
|
- `TimeSlice::new(time, p_draw, convergence: ConvergenceOptions)` — Task 1 step 3 (def) and Task 1 step 4-5 (call sites) match ✓
|
||||||
|
- `iteration_direct(skills, agents, p_draw, convergence, arena)` — Task 2 step 1 (def) and steps 2, 3 (call sites) match ✓
|
||||||
|
- `iterate_to_convergence` — Task 1 step 6 ✓
|
||||||
|
- All `self.convergence` reads are field accesses, not method calls (the rename in Task 1 step 6 prevents ambiguity) ✓
|
||||||
|
|
||||||
|
**Two tasks (1 and 2) split rationale:** Task 1 wires the field but the inference path still uses hardcoded defaults (no behavioral change). Task 2 makes the field actually drive inference (behavioral change for non-default users). Each task is independently committable and the test suite is bit-equal at every checkpoint.
|
||||||
|
|
||||||
|
**No placeholders detected.**
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
# Per-Event `score_sigma` Override Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let users specify a per-event score-sigma override on `Outcome::Scored`, defaulting to `HistoryBuilder::score_sigma` when not set.
|
||||||
|
|
||||||
|
**Architecture:** `Outcome::Scored` becomes a struct variant with an `Option<f64>` `sigma` field. `History::add_events` resolves `sigma.unwrap_or(self.score_sigma)` at ingest time, so downstream `EventKind::Scored.score_sigma` stays a plain `f64` and `TimeSlice` / `run_chain` need zero changes. Two new constructors (`Outcome::scores_with_sigma` and `EventBuilder::scores_with_sigma`) cover the override path; existing `scores(...)` keeps its signature.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec reference
|
||||||
|
|
||||||
|
`docs/superpowers/specs/2026-05-08-per-event-score-sigma-design.md`
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Why touched |
|
||||||
|
|---|---|
|
||||||
|
| `src/outcome.rs` | `Outcome::Scored` variant becomes a struct; pattern matches in `team_count`, `as_scores`, `as_ranks`; new `scores_with_sigma` constructor; existing `scores` constructor body adapts |
|
||||||
|
| `src/history.rs` | The single ingest pattern match at `:735` resolves `sigma.unwrap_or(self.score_sigma)`; three new end-to-end tests |
|
||||||
|
| `src/event_builder.rs` | New `scores_with_sigma` builder method |
|
||||||
|
|
||||||
|
## Pre-flight context for the implementer
|
||||||
|
|
||||||
|
- `Outcome` is `pub`. Currently a tuple-variant enum at `src/outcome.rs:18-21`. Changing `Scored(SmallVec)` → `Scored { scores, sigma }` is a breaking change to a public variant shape, acceptable in 0.1.x.
|
||||||
|
- Pattern-match callsite inventory across the workspace (verified by grep): only ONE site destructures the variant — `src/history.rs:735` (`crate::Outcome::Scored(scores) => { ... }`). Every other reference is either a constructor call (`Outcome::scores(...)`) or a string literal in a doc/error message. The constructors keep their existing signatures, so callsites don't need updating.
|
||||||
|
- `Outcome::scores(I)` constructor at `src/outcome.rs:44`: keep the signature `pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self`. Only the body changes (it now builds `Self::Scored { scores: ..., sigma: None }`).
|
||||||
|
- `as_scores`, `as_ranks`, `team_count` accessors at `src/outcome.rs:48-67`: their public signatures stay the same. Internal pattern matches adapt mechanically.
|
||||||
|
- `EventBuilder::scores(I)` at `src/event_builder.rs:79-82`: keep unchanged. The new `scores_with_sigma(I, f64)` lives next to it.
|
||||||
|
- `History::score_sigma` at `src/history.rs:165`: still the history-wide default. `HistoryBuilder::score_sigma(s)` builder method at `src/history.rs:82-89` stays as-is.
|
||||||
|
- `EventKind::Scored { score_sigma: f64 }` at `src/time_slice.rs:51`: already per-event-shaped. Don't touch.
|
||||||
|
- Test baseline: 100 lib + 27 integration tests, all passing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `Outcome::Scored` becomes a struct variant + constructors
|
||||||
|
|
||||||
|
This is the foundational shape change. After this task: the new variant compiles, both `scores` and `scores_with_sigma` work on `Outcome` directly, but `History::add_events` (the only consumer that destructures the variant) hasn't yet been updated — Task 2 handles that.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/outcome.rs` (variant shape, three pattern-match arms, two existing tests, three new tests, two constructors)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for the new constructor**
|
||||||
|
|
||||||
|
In `src/outcome.rs`, inside the existing `#[cfg(test)] mod tests` block, add at the end:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn scores_with_sigma_round_trips() {
|
||||||
|
let o = Outcome::scores_with_sigma([10.0, 4.0], 0.5);
|
||||||
|
assert_eq!(o.team_count(), 2);
|
||||||
|
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scores_constructor_leaves_sigma_unset() {
|
||||||
|
// After the variant change, the public Outcome::scores constructor
|
||||||
|
// must build with sigma: None. We assert this indirectly via a match
|
||||||
|
// on the variant.
|
||||||
|
let o = Outcome::scores([3.0, 1.0]);
|
||||||
|
match o {
|
||||||
|
Outcome::Scored { scores: _, sigma } => assert!(sigma.is_none()),
|
||||||
|
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scores_with_sigma_sets_sigma_some() {
|
||||||
|
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0);
|
||||||
|
match o {
|
||||||
|
Outcome::Scored { scores: _, sigma } => assert_eq!(sigma, Some(2.0)),
|
||||||
|
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "score_sigma must be > 0.0")]
|
||||||
|
fn scores_with_sigma_rejects_zero() {
|
||||||
|
let _ = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the new tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cargo test --lib outcome::tests`
|
||||||
|
|
||||||
|
Expected: 4 errors. The first three fail to compile (no `scores_with_sigma` function; pattern destructure on `Scored { ... }` doesn't match the current tuple variant). The last fails because `scores_with_sigma` doesn't exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Change the variant shape and update the constructor + accessors**
|
||||||
|
|
||||||
|
In `src/outcome.rs`, replace the entire `Outcome` enum and `impl Outcome` block (currently `src/outcome.rs:16-68`) with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Final outcome of a match.
|
||||||
|
///
|
||||||
|
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
|
||||||
|
/// teams. `ranks.len()` must equal the number of teams in the event.
|
||||||
|
///
|
||||||
|
/// `Scored { scores, sigma }`: higher score = better. Adjacent (sorted) pairs
|
||||||
|
/// feed observed margins to `MarginFactor`. `scores.len()` must equal the
|
||||||
|
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
|
||||||
|
/// when `Some`; `None` inherits the history default.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Outcome {
|
||||||
|
Ranked(SmallVec<[u32; 4]>),
|
||||||
|
Scored {
|
||||||
|
scores: SmallVec<[f64; 4]>,
|
||||||
|
/// Per-event noise override. `None` means inherit
|
||||||
|
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
|
||||||
|
sigma: Option<f64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
/// `n`-team outcome where team `winner` won and everyone else tied for last.
|
||||||
|
///
|
||||||
|
/// Panics if `winner >= n`.
|
||||||
|
pub fn winner(winner: u32, n: u32) -> Self {
|
||||||
|
assert!(winner < n, "winner index {winner} out of range 0..{n}");
|
||||||
|
let ranks: SmallVec<[u32; 4]> = (0..n).map(|i| if i == winner { 0 } else { 1 }).collect();
|
||||||
|
Self::Ranked(ranks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All `n` teams tied.
|
||||||
|
pub fn draw(n: u32) -> Self {
|
||||||
|
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit per-team ranking.
|
||||||
|
pub fn ranking<I: IntoIterator<Item = u32>>(ranks: I) -> Self {
|
||||||
|
Self::Ranked(ranks.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit per-team continuous scores; higher = better.
|
||||||
|
/// Inherits `HistoryBuilder::score_sigma` for the noise model.
|
||||||
|
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
|
||||||
|
Self::Scored {
|
||||||
|
scores: scores.into_iter().collect(),
|
||||||
|
sigma: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit per-team continuous scores with a per-event noise override.
|
||||||
|
///
|
||||||
|
/// `sigma` must be `> 0.0`; debug-asserts otherwise.
|
||||||
|
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self {
|
||||||
|
debug_assert!(sigma > 0.0, "score_sigma must be > 0.0 (got {sigma})");
|
||||||
|
Self::Scored {
|
||||||
|
scores: scores.into_iter().collect(),
|
||||||
|
sigma: Some(sigma),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn team_count(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Ranked(r) => r.len(),
|
||||||
|
Self::Scored { scores, .. } => scores.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn as_ranks(&self) -> Option<&[u32]> {
|
||||||
|
match self {
|
||||||
|
Self::Ranked(r) => Some(r),
|
||||||
|
Self::Scored { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn as_scores(&self) -> Option<&[f64]> {
|
||||||
|
match self {
|
||||||
|
Self::Scored { scores, .. } => Some(scores),
|
||||||
|
Self::Ranked(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the new tests**
|
||||||
|
|
||||||
|
Run: `cargo test --lib outcome::tests`
|
||||||
|
|
||||||
|
Expected: all outcome tests pass (the 6 pre-existing tests + 4 new = 10 total in the outcome tests module).
|
||||||
|
|
||||||
|
If any pre-existing test fails, the issue is in this task — not Task 2. Most likely cause: a pattern-match arm in the rewritten `impl Outcome` block doesn't compile. Re-check the struct-variant destructure syntax (`Self::Scored { scores, .. }` for read-only access; `Self::Scored { scores, sigma }` when both fields are needed).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update `History::add_events` ingest arm to destructure the new variant**
|
||||||
|
|
||||||
|
The variant change from Step 3 breaks the existing `Outcome::Scored(scores)` pattern match in `src/history.rs:735`. Fix it now (in the same commit) — the codebase must build at every commit boundary.
|
||||||
|
|
||||||
|
In `src/history.rs`, find the `crate::Outcome::Scored(scores) => { ... }` arm (currently at `src/history.rs:735-740`). Replace with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
crate::Outcome::Scored { scores, sigma } => {
|
||||||
|
let resolved = sigma.unwrap_or(self.score_sigma);
|
||||||
|
debug_assert!(
|
||||||
|
resolved > 0.0,
|
||||||
|
"resolved score_sigma must be > 0.0 (got {resolved})"
|
||||||
|
);
|
||||||
|
kinds.push(EventKind::Scored {
|
||||||
|
score_sigma: resolved,
|
||||||
|
});
|
||||||
|
scores.to_vec()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The surrounding `match &ev.outcome { ... }` and the surrounding flow (the `ranks` arm above, the `results.push(event_result);` below) stay unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the full library test suite — bit-equal regression net**
|
||||||
|
|
||||||
|
Run: `cargo build && cargo test --lib && cargo test`
|
||||||
|
|
||||||
|
Expected: clean build. All 100 lib + 27 integration tests pass. Bit-equal goldens — every existing scored-event constructor uses the no-override path (`Outcome::scores(...)` or `EventBuilder::scores(...)`), which now resolves to `sigma: None → resolved = self.score_sigma`, exactly equal to the previous behavior.
|
||||||
|
|
||||||
|
If unexpected additional compile errors surface (any site pattern-matching `Outcome::Scored(...)` outside the 735 arm), STOP and report — the plan's inventory is wrong, surface that as a finding before continuing.
|
||||||
|
|
||||||
|
If any existing test fails: investigate. Most likely cause is a typo in the new pattern arms (Step 3) or the resolution rule (Step 5). The override path isn't exercised yet by any existing test, so the only thing that can break is the inheritance path.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Format and lint**
|
||||||
|
|
||||||
|
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
|
||||||
|
|
||||||
|
Expected: no diff, no warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/outcome.rs src/history.rs
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(outcome): per-event score_sigma override on Outcome::Scored
|
||||||
|
|
||||||
|
Outcome::Scored shape changes from tuple to struct:
|
||||||
|
{ scores, sigma: Option<f64> }. New constructor scores_with_sigma
|
||||||
|
sets sigma=Some(s) and debug-asserts s > 0.0; existing scores(I)
|
||||||
|
constructor keeps its signature and builds with sigma=None internally.
|
||||||
|
team_count, as_scores, as_ranks accessor pattern matches updated.
|
||||||
|
|
||||||
|
History::add_events resolves sigma.unwrap_or(self.score_sigma) at the
|
||||||
|
ingest arm, so downstream EventKind::Scored stays a plain f64 and
|
||||||
|
TimeSlice / run_chain need zero changes.
|
||||||
|
|
||||||
|
Breaking change to the public Outcome::Scored variant shape
|
||||||
|
(acceptable in 0.1.x). Bit-equal for callers using the no-override
|
||||||
|
path because the resolution falls through to self.score_sigma exactly
|
||||||
|
as before.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `EventBuilder::scores_with_sigma` builder method
|
||||||
|
|
||||||
|
The override path is fully wired by Task 1, but it's only reachable via the `Outcome::scores_with_sigma` constructor (passed into `History::add_events` directly). The fluent-builder ergonomic — `h.event(t).team(...).scores_with_sigma(scores, sigma).commit()` — needs one new method on `EventBuilder`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/event_builder.rs` (new builder method)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the EventBuilder method**
|
||||||
|
|
||||||
|
In `src/event_builder.rs`, find the existing `scores` method (currently at `src/event_builder.rs:79-82`). Immediately below it (still inside `impl<'h, T, D, O, K> EventBuilder<...>`), add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Set explicit per-team continuous scores with a per-event noise override.
|
||||||
|
///
|
||||||
|
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only.
|
||||||
|
/// Must be `> 0.0`; debug-asserts otherwise via `Outcome::scores_with_sigma`.
|
||||||
|
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self {
|
||||||
|
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build and run the test suite**
|
||||||
|
|
||||||
|
Run: `cargo build && cargo test --lib && cargo test`
|
||||||
|
|
||||||
|
Expected: clean build, all 100 lib + 27 integration tests pass. The new method is additive — no behavior changes for existing tests.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Format and lint**
|
||||||
|
|
||||||
|
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
|
||||||
|
|
||||||
|
Expected: no diff, no warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/event_builder.rs
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(event_builder): expose scores_with_sigma fluent method
|
||||||
|
|
||||||
|
Adds EventBuilder::scores_with_sigma, the fluent-builder ergonomic
|
||||||
|
mirror of Outcome::scores_with_sigma. Lets users write
|
||||||
|
h.event(t).team(...).team(...).scores_with_sigma([..], sigma).commit()
|
||||||
|
to set a per-event score_sigma override.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: End-to-end integration tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/history.rs` (three new tests in the existing `#[cfg(test)] mod tests` block at the bottom)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate the test module**
|
||||||
|
|
||||||
|
Run: `grep -n "^#\[cfg(test)\]" src/history.rs`
|
||||||
|
|
||||||
|
Identify the test module (there should be one near the bottom of the file). Read its imports and look at neighboring tests to see the existing builder/event-construction pattern in current use. Mirror that pattern in the new tests below — the surface syntax (`History::builder()`, `event(t).team(...)`, `learning_curves()`, etc.) must match what already works in this file.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing tests**
|
||||||
|
|
||||||
|
Add the following three tests at the end of the existing `#[cfg(test)] mod tests` block in `src/history.rs` (just before the module's closing `}`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn outcome_scores_default_sigma_uses_history_default() {
|
||||||
|
use crate::Outcome;
|
||||||
|
|
||||||
|
// Path A: explicit sigma=0.5 via override.
|
||||||
|
let mut h_a = crate::History::builder().score_sigma(0.5).build();
|
||||||
|
h_a.add_events([crate::Event {
|
||||||
|
time: 0_i64,
|
||||||
|
teams: smallvec::smallvec![
|
||||||
|
crate::Team::with_members([crate::Member::new("a")]),
|
||||||
|
crate::Team::with_members([crate::Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores_with_sigma([3.0, 1.0], 0.5),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
h_a.converge().unwrap();
|
||||||
|
|
||||||
|
// Path B: history-wide default 0.5, no per-event override.
|
||||||
|
let mut h_b = crate::History::builder().score_sigma(0.5).build();
|
||||||
|
h_b.add_events([crate::Event {
|
||||||
|
time: 0_i64,
|
||||||
|
teams: smallvec::smallvec![
|
||||||
|
crate::Team::with_members([crate::Member::new("a")]),
|
||||||
|
crate::Team::with_members([crate::Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([3.0, 1.0]),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
h_b.converge().unwrap();
|
||||||
|
|
||||||
|
// Inheritance: posteriors must be bit-equal.
|
||||||
|
let curves_a = h_a.learning_curves();
|
||||||
|
let curves_b = h_b.learning_curves();
|
||||||
|
for (key, a_pts) in curves_a.iter() {
|
||||||
|
let b_pts = curves_b.get(key).expect("agent missing in path B");
|
||||||
|
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
|
||||||
|
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
|
||||||
|
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outcome_scores_with_sigma_overrides_history_default() {
|
||||||
|
use crate::Outcome;
|
||||||
|
|
||||||
|
// Path A: history-wide default 0.5, per-event override 2.0.
|
||||||
|
let mut h_a = crate::History::builder().score_sigma(0.5).build();
|
||||||
|
h_a.add_events([crate::Event {
|
||||||
|
time: 0_i64,
|
||||||
|
teams: smallvec::smallvec![
|
||||||
|
crate::Team::with_members([crate::Member::new("a")]),
|
||||||
|
crate::Team::with_members([crate::Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
h_a.converge().unwrap();
|
||||||
|
|
||||||
|
// Path B: history-wide default 2.0, no per-event override.
|
||||||
|
let mut h_b = crate::History::builder().score_sigma(2.0).build();
|
||||||
|
h_b.add_events([crate::Event {
|
||||||
|
time: 0_i64,
|
||||||
|
teams: smallvec::smallvec![
|
||||||
|
crate::Team::with_members([crate::Member::new("a")]),
|
||||||
|
crate::Team::with_members([crate::Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([3.0, 1.0]),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
h_b.converge().unwrap();
|
||||||
|
|
||||||
|
// Override == default-set-to-the-override-value: bit-equal.
|
||||||
|
let curves_a = h_a.learning_curves();
|
||||||
|
let curves_b = h_b.learning_curves();
|
||||||
|
for (key, a_pts) in curves_a.iter() {
|
||||||
|
let b_pts = curves_b.get(key).expect("agent missing in path B");
|
||||||
|
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
|
||||||
|
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
|
||||||
|
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path C: history-wide default 0.5, no override. Different sigma → different posteriors.
|
||||||
|
let mut h_c = crate::History::builder().score_sigma(0.5).build();
|
||||||
|
h_c.add_events([crate::Event {
|
||||||
|
time: 0_i64,
|
||||||
|
teams: smallvec::smallvec![
|
||||||
|
crate::Team::with_members([crate::Member::new("a")]),
|
||||||
|
crate::Team::with_members([crate::Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([3.0, 1.0]),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
h_c.converge().unwrap();
|
||||||
|
|
||||||
|
let curves_c = h_c.learning_curves();
|
||||||
|
let mut max_diff: f64 = 0.0;
|
||||||
|
for (key, a_pts) in curves_a.iter() {
|
||||||
|
let c_pts = curves_c.get(key).expect("agent missing in path C");
|
||||||
|
for (a, c) in a_pts.iter().zip(c_pts.iter()) {
|
||||||
|
max_diff = max_diff.max((a.1.mu() - c.1.mu()).abs());
|
||||||
|
max_diff = max_diff.max((a.1.sigma() - c.1.sigma()).abs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
max_diff > 1e-6,
|
||||||
|
"override should produce different posteriors from inherited default; max_diff={max_diff}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_builder_scores_with_sigma_threading() {
|
||||||
|
use crate::Outcome;
|
||||||
|
|
||||||
|
// Path A: builder fluent API with sigma override.
|
||||||
|
let mut h_a = crate::History::builder().score_sigma(0.5).build();
|
||||||
|
h_a.event(0_i64)
|
||||||
|
.team(["a"])
|
||||||
|
.team(["b"])
|
||||||
|
.scores_with_sigma([3.0, 1.0], 2.0)
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
h_a.converge().unwrap();
|
||||||
|
|
||||||
|
// Path B: same outcome via the explicit Outcome constructor.
|
||||||
|
let mut h_b = crate::History::builder().score_sigma(0.5).build();
|
||||||
|
h_b.add_events([crate::Event {
|
||||||
|
time: 0_i64,
|
||||||
|
teams: smallvec::smallvec![
|
||||||
|
crate::Team::with_members([crate::Member::new("a")]),
|
||||||
|
crate::Team::with_members([crate::Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
h_b.converge().unwrap();
|
||||||
|
|
||||||
|
let curves_a = h_a.learning_curves();
|
||||||
|
let curves_b = h_b.learning_curves();
|
||||||
|
for (key, a_pts) in curves_a.iter() {
|
||||||
|
let b_pts = curves_b.get(key).expect("agent missing");
|
||||||
|
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
|
||||||
|
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
|
||||||
|
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the surface API (e.g. `History::add_events`, `Event { time, teams, outcome }`, `Team::with_members`, `Member::new`, `event(...).team(...).commit()`, `learning_curves()`) doesn't exactly match what's available in the test module, look at neighboring tests for the patterns currently in use and adjust. The CONTRACT is: build two Histories that should produce identical posteriors, run them, compare. The surface syntax must follow what compiles in this file.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the new tests**
|
||||||
|
|
||||||
|
Run: `cargo test --lib outcome_scores_default_sigma_uses_history_default outcome_scores_with_sigma_overrides_history_default event_builder_scores_with_sigma_threading`
|
||||||
|
|
||||||
|
Expected: 3 passed.
|
||||||
|
|
||||||
|
**Fallback if Test 2's `max_diff > 1e-6` fails** (sigma=0.5 vs sigma=2.0 produces nearly identical posteriors — unlikely on a single 2-team scored event, but possible if the priors dominate): use a larger gap, e.g. `Outcome::scores_with_sigma([3.0, 1.0], 5.0)` vs `Outcome::scores([3.0, 1.0])` with `score_sigma(0.5)`. The point is to prove the resolution path actually engages — any sigma gap that produces a measurable posterior difference is fine.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `cargo test --lib && cargo test`
|
||||||
|
|
||||||
|
Expected: lib count = 103 (was 100, +3), integration count = 27 (unchanged), all passing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Format and lint**
|
||||||
|
|
||||||
|
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
|
||||||
|
|
||||||
|
Expected: no diff, no warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/history.rs
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
test(history): end-to-end per-event score_sigma override tests
|
||||||
|
|
||||||
|
Three integration tests on a 2-team scored event:
|
||||||
|
- inheritance: Outcome::scores(...) with no override produces
|
||||||
|
bit-equal posteriors to the same outcome wrapped in
|
||||||
|
scores_with_sigma(scores, history.score_sigma)
|
||||||
|
- override-supersedes-default: scores_with_sigma(scores, X) with
|
||||||
|
history score_sigma(Y) produces bit-equal posteriors to
|
||||||
|
scores(...) with history score_sigma(X), AND differs measurably
|
||||||
|
from scores(...) with history score_sigma(Y)
|
||||||
|
- builder threading: EventBuilder::scores_with_sigma reaches the
|
||||||
|
ingest path identically to the Outcome constructor
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review (writer's note)
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- Spec § "What ships" item 1 (Scored becomes struct variant) → Task 1 step 3 ✓
|
||||||
|
- Spec § "What ships" item 2 (scores_with_sigma constructor) → Task 1 step 3 ✓
|
||||||
|
- Spec § "What ships" item 3 (EventBuilder::scores_with_sigma) → Task 2 step 1 ✓
|
||||||
|
- Spec § "What ships" item 4 (sigma resolution at ingest) → Task 1 step 5 ✓
|
||||||
|
- Spec § "What ships" item 5 (pattern-match update inventory) → Task 1 step 5 (single site at history.rs:735) ✓
|
||||||
|
- Spec § "Validation" (debug_assert at constructor) → Task 1 step 3 (in `scores_with_sigma`) ✓
|
||||||
|
- Spec § "Validation" (debug_assert at ingest) → Task 1 step 5 ✓
|
||||||
|
- Spec § "Testing strategy" §1 (regression net) → Task 1 step 6, Task 2 step 2, Task 3 step 4 ✓
|
||||||
|
- Spec § "Testing strategy" §2 test 1 (default-uses-history-default) → Task 3 step 2 test 1 ✓
|
||||||
|
- Spec § "Testing strategy" §2 test 2 (override-supersedes-default) → Task 3 step 2 test 2 ✓
|
||||||
|
- Spec § "Testing strategy" §2 test 3 (builder threading) → Task 3 step 2 test 3 ✓
|
||||||
|
|
||||||
|
**Out-of-scope items correctly absent:** No `EventKind::Scored` change, no `TimeSlice`/`run_chain` changes, no `Game::scored` standalone API change, no deprecation of `HistoryBuilder::score_sigma`.
|
||||||
|
|
||||||
|
**Type / signature consistency:**
|
||||||
|
- `Outcome::Scored { scores: SmallVec<[f64; 4]>, sigma: Option<f64> }` — Task 1 step 3 (def) and Task 1 step 5 (destructure) match ✓
|
||||||
|
- `Outcome::scores_with_sigma<I>(scores: I, sigma: f64) -> Outcome` — Task 1 step 3 (def) and Task 2 step 1 (call) match ✓
|
||||||
|
- `EventBuilder::scores_with_sigma<I>(mut self, scores: I, sigma: f64) -> Self` — Task 2 step 1 (def) and Task 3 step 2 test 3 (call) match ✓
|
||||||
|
- `sigma.unwrap_or(self.score_sigma)` resolution rule — Task 1 step 5 ✓
|
||||||
|
|
||||||
|
**Task split rationale:** Task 1 lands the foundational shape change AND the ingest resolution atomically — every commit boundary builds and tests pass bit-equal. Task 2 is the small additive EventBuilder method, separated for review-focus reasons (it's the user-facing fluent API exposure). Task 3 is purely additive integration tests. Each task is independently committable; no intermediate non-building state.
|
||||||
|
|
||||||
|
**No placeholders detected.**
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
# Tech Debt Cleanup Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Land three independent post-T4-MarginFactor cleanups: dedupe `Game::likelihoods` and `Game::likelihoods_scored` via a `run_chain` helper, make `BuiltinFactor::log_evidence` exhaustive, and fix stale numerics in the T4 plan doc.
|
||||||
|
|
||||||
|
**Architecture:** Pure code-shape and doc fixes. No public-API change, no behavioral change, no new dependencies. The dedup is a pure refactor — bit-equal posteriors and evidence against existing test goldens. The exhaustive match is a future-proofing change with no runtime effect. The doc fix is two number swaps in prose plus one matching code-comment swap.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test --lib`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec reference
|
||||||
|
|
||||||
|
`docs/superpowers/specs/2026-05-08-tech-debt-cleanup-design.md`
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Why touched |
|
||||||
|
|---|---|
|
||||||
|
| `src/game.rs` | Add `run_chain` helper; rewrite `likelihoods` and `likelihoods_scored` to call it |
|
||||||
|
| `src/factor/mod.rs` | Make `BuiltinFactor::log_evidence` match exhaustive |
|
||||||
|
| `docs/superpowers/plans/2026-04-27-t4-margin-factor.md` | Fix two stale prose numbers and one matching code comment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Extract `run_chain` helper, dedupe both likelihoods methods
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/game.rs:236-485` (replace both `likelihoods` and `likelihoods_scored` with one helper + two thin callers)
|
||||||
|
|
||||||
|
**Context for the implementer (read this before touching anything):**
|
||||||
|
|
||||||
|
`OwnedGame<T, D>` (defined at `src/game.rs:83-92`) holds `teams`, `result`, `weights`, `p_draw`, plus mutable output fields `likelihoods: Vec<Vec<Gaussian>>` and `evidence: f64`. Two private methods on `Game<'a, T, D>` (the borrowed sibling at `src/game.rs:148-156`) compute likelihoods:
|
||||||
|
|
||||||
|
- `likelihoods(&mut self, arena: &mut ScratchArena)` — ranked outcomes; `src/game.rs:236-371`
|
||||||
|
- `likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64)` — scored outcomes; `src/game.rs:373-485`
|
||||||
|
|
||||||
|
The two are bit-identical except for the closure that builds the per-diff `DiffFactor` (defined at `src/game.rs:20-54`). `DiffFactor` has two variants: `Trunc(TruncFactor)` for ranked, `Margin(MarginFactor)` for scored.
|
||||||
|
|
||||||
|
The shared body does, in order: `arena.reset()`, sort teams descending by `result` into `arena.sort_buf`, fill `arena.team_prior`, build `links: Vec<DiffFactor>` (the differing block), resize `arena.lhood_lose` / `arena.lhood_win` to `N_INF`, run a forward+backward sweep with a max-iter-10 fixed-point loop guarded by `tuple_gt(step, 1e-6)`, handle the `n_diffs == 1` special case, do boundary updates, multiply per-diff `evidence()` into `self.evidence`, build the inverse permutation in `arena.inv_buf`, then build `self.likelihoods` from the per-team `lhood_win * lhood_lose` and per-player `performance().exclude(...).forget(beta²)` math.
|
||||||
|
|
||||||
|
**Refactor target:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn run_chain<F>(
|
||||||
|
&self,
|
||||||
|
arena: &mut ScratchArena,
|
||||||
|
mut make_link: F,
|
||||||
|
) -> (f64, Vec<Vec<Gaussian>>)
|
||||||
|
where
|
||||||
|
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
|
||||||
|
{ /* the entire shared body, returning (evidence, likelihoods) */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Helper takes `&self` (not `&mut self`) so the closure can capture `&self.result`, `&self.teams`, `&self.weights`, `&self.p_draw` without conflicting with the helper's own immutable borrow. The arena is borrowed `&mut` independently.
|
||||||
|
|
||||||
|
The closure is invoked once per diff index `i ∈ 0..n_diffs`, after `arena.sort_buf` is filled. It receives `i`, `&arena.sort_buf[..]`, and `&mut arena.vars` so it can `alloc(N_INF)` the diff `VarId`. It returns the constructed `DiffFactor`.
|
||||||
|
|
||||||
|
The two callers shrink to:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn likelihoods(&mut self, arena: &mut ScratchArena) {
|
||||||
|
let p_draw = self.p_draw;
|
||||||
|
let result = &self.result;
|
||||||
|
let teams = &self.teams;
|
||||||
|
let (evidence, likelihoods) = Self::dummy_to_satisfy_borrowck(/* see below */);
|
||||||
|
// ... assigns self.evidence and self.likelihoods
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait — actually borrow-checker note: calling `self.run_chain(arena, |i, sort_buf, vars| { use_self_fields })` from a `&mut self` method is **fine** because `run_chain` takes `&self` and the closure captures `&self` immutably. Both share an immutable reborrow of `*self`. The arena is a separate `&mut` borrow. Verify the implementer doesn't accidentally make `run_chain` take `&mut self`.
|
||||||
|
|
||||||
|
**Why a closure (not a trait, not a two-phase build).** A closure keeps caller-specific state (`p_draw`, `score_sigma`, beta sums) inline at the call site with zero ceremony. A trait would require a stateful builder per call. A two-phase build (caller produces `Vec<DiffFactor>` first, helper does the rest) would either re-do the sort or split arena ownership awkwardly between the phases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the existing test suite to capture the baseline**
|
||||||
|
|
||||||
|
Run: `cargo test --lib`
|
||||||
|
|
||||||
|
Expected: all tests pass. Note the count (should be 88+ lib tests) — the refactor must keep this number unchanged with all green.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Open `src/game.rs` and add the `run_chain` helper**
|
||||||
|
|
||||||
|
Inside `impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> { ... }` (the block starting at `src/game.rs:158`), add `run_chain` immediately above the existing `likelihoods` method (so above line 236). Use exactly this body — it is the merge of the two existing methods with the differing block replaced by the closure call:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn run_chain<F>(
|
||||||
|
&self,
|
||||||
|
arena: &mut ScratchArena,
|
||||||
|
mut make_link: F,
|
||||||
|
) -> (f64, Vec<Vec<Gaussian>>)
|
||||||
|
where
|
||||||
|
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
|
||||||
|
{
|
||||||
|
arena.reset();
|
||||||
|
|
||||||
|
let n_teams = self.teams.len();
|
||||||
|
|
||||||
|
arena.sort_buf.extend(0..n_teams);
|
||||||
|
arena.sort_buf.sort_by(|&i, &j| {
|
||||||
|
self.result[j]
|
||||||
|
.partial_cmp(&self.result[i])
|
||||||
|
.unwrap_or(Ordering::Equal)
|
||||||
|
});
|
||||||
|
|
||||||
|
arena.team_prior.extend(arena.sort_buf.iter().map(|&t| {
|
||||||
|
self.teams[t]
|
||||||
|
.iter()
|
||||||
|
.zip(self.weights[t].iter())
|
||||||
|
.fold(N00, |p, (player, &w)| p + (player.performance() * w))
|
||||||
|
}));
|
||||||
|
|
||||||
|
let n_diffs = n_teams.saturating_sub(1);
|
||||||
|
|
||||||
|
let mut links: Vec<DiffFactor> = (0..n_diffs)
|
||||||
|
.map(|i| make_link(i, &arena.sort_buf, &mut arena.vars))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
arena.lhood_lose.resize(n_teams, N_INF);
|
||||||
|
arena.lhood_win.resize(n_teams, N_INF);
|
||||||
|
|
||||||
|
let mut step = (f64::INFINITY, f64::INFINITY);
|
||||||
|
let mut iter = 0;
|
||||||
|
|
||||||
|
while tuple_gt(step, 1e-6) && iter < 10 {
|
||||||
|
step = (0.0_f64, 0.0_f64);
|
||||||
|
|
||||||
|
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
|
||||||
|
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
||||||
|
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||||
|
let raw = pw - pl;
|
||||||
|
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||||
|
let d = lf.propagate(&mut arena.vars);
|
||||||
|
step = tuple_max(step, d);
|
||||||
|
|
||||||
|
let new_ll = pw - lf.msg();
|
||||||
|
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
|
||||||
|
arena.lhood_lose[e + 1] = new_ll;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
|
||||||
|
let e = n_diffs - 1 - rev_i;
|
||||||
|
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
||||||
|
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||||
|
let raw = pw - pl;
|
||||||
|
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||||
|
let d = lf.propagate(&mut arena.vars);
|
||||||
|
step = tuple_max(step, d);
|
||||||
|
|
||||||
|
let new_lw = pl + lf.msg();
|
||||||
|
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
|
||||||
|
arena.lhood_win[e] = new_lw;
|
||||||
|
}
|
||||||
|
|
||||||
|
iter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if n_diffs == 1 {
|
||||||
|
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
|
||||||
|
- (arena.team_prior[1] * arena.lhood_win[1]);
|
||||||
|
arena.vars.set(links[0].diff(), raw * links[0].msg());
|
||||||
|
links[0].propagate(&mut arena.vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
if n_diffs > 0 {
|
||||||
|
let pl1 = arena.team_prior[1] * arena.lhood_win[1];
|
||||||
|
arena.lhood_win[0] = pl1 + links[0].msg();
|
||||||
|
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2];
|
||||||
|
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
|
||||||
|
}
|
||||||
|
|
||||||
|
let evidence: f64 = links.iter().map(|l| l.evidence()).product();
|
||||||
|
|
||||||
|
arena.inv_buf.resize(n_teams, 0);
|
||||||
|
for (si, &orig_i) in arena.sort_buf.iter().enumerate() {
|
||||||
|
arena.inv_buf[orig_i] = si;
|
||||||
|
}
|
||||||
|
|
||||||
|
let likelihoods = self
|
||||||
|
.teams
|
||||||
|
.iter()
|
||||||
|
.zip(self.weights.iter())
|
||||||
|
.enumerate()
|
||||||
|
.map(|(orig_i, (players, weights))| {
|
||||||
|
let si = arena.inv_buf[orig_i];
|
||||||
|
let m = arena.lhood_win[si] * arena.lhood_lose[si];
|
||||||
|
let performance = players
|
||||||
|
.iter()
|
||||||
|
.zip(weights.iter())
|
||||||
|
.fold(N00, |p, (player, &w)| p + (player.performance() * w));
|
||||||
|
players
|
||||||
|
.iter()
|
||||||
|
.zip(weights.iter())
|
||||||
|
.map(|(player, &w)| {
|
||||||
|
((m - performance.exclude(player.performance() * w)) * (1.0 / w))
|
||||||
|
.forget(player.beta.powi(2))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
(evidence, likelihoods)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace `likelihoods` body with a thin caller**
|
||||||
|
|
||||||
|
In `src/game.rs`, replace the entire body of `fn likelihoods(&mut self, arena: &mut ScratchArena)` (currently lines 236-371 — replace from the opening `{` to the closing `}` of that method) with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn likelihoods(&mut self, arena: &mut ScratchArena) {
|
||||||
|
let p_draw = self.p_draw;
|
||||||
|
// Capture pointers to fields the closure reads, to keep borrow scopes tight.
|
||||||
|
// Closure captures &self.result and &self.teams (both immutable) and the
|
||||||
|
// &mut arena passed in via run_chain — disjoint from `&self`.
|
||||||
|
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
|
||||||
|
let tie = self.result[sort_buf[i]] == self.result[sort_buf[i + 1]];
|
||||||
|
let margin = if p_draw == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
let a: f64 = self.teams[sort_buf[i]]
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.beta.powi(2))
|
||||||
|
.sum();
|
||||||
|
let b: f64 = self.teams[sort_buf[i + 1]]
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.beta.powi(2))
|
||||||
|
.sum();
|
||||||
|
compute_margin(p_draw, (a + b).sqrt())
|
||||||
|
};
|
||||||
|
let vid = vars.alloc(N_INF);
|
||||||
|
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
|
||||||
|
});
|
||||||
|
self.evidence = evidence;
|
||||||
|
self.likelihoods = likelihoods;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Capturing `p_draw` as a local binding before the closure avoids a `self.p_draw` borrow inside; it's a `Copy` `f64` so this is free.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace `likelihoods_scored` body with a thin caller**
|
||||||
|
|
||||||
|
In `src/game.rs`, replace the entire body of `fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64)` (currently lines 373-485) with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) {
|
||||||
|
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
|
||||||
|
// After descending-by-score sort, m_obs >= 0 for every adjacent pair.
|
||||||
|
let m_obs = self.result[sort_buf[i]] - self.result[sort_buf[i + 1]];
|
||||||
|
let vid = vars.alloc(N_INF);
|
||||||
|
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
|
||||||
|
});
|
||||||
|
self.evidence = evidence;
|
||||||
|
self.likelihoods = likelihoods;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build to confirm it compiles**
|
||||||
|
|
||||||
|
Run: `cargo build`
|
||||||
|
|
||||||
|
Expected: compiles cleanly. If the borrow checker complains that the closure conflicts with `self.run_chain(...)`, the most likely cause is `run_chain` accidentally being `&mut self` — confirm its signature is `fn run_chain<F>(&self, arena: &mut ScratchArena, mut make_link: F) -> (f64, Vec<Vec<Gaussian>>)`. If that's correct and there's still a conflict, double-check the closure's captures: it should capture `&self.result` and `&self.teams` (immutable), `p_draw: f64` by value (Copy), and `score_sigma: f64` by value (Copy). It must NOT touch `&mut self` in any form.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the full library test suite — must be all green, same count as Step 1**
|
||||||
|
|
||||||
|
Run: `cargo test --lib`
|
||||||
|
|
||||||
|
Expected: same number of tests as Step 1, all pass. Bit-equal goldens — every existing assertion (`test_1vs1`, `test_1vs1_draw`, `test_2vs1vs2_mixed`, MarginFactor end-to-end tests, etc.) must pass unchanged. If ANY test fails, the refactor is wrong; revert and re-inspect.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run integration tests too**
|
||||||
|
|
||||||
|
Run: `cargo test`
|
||||||
|
|
||||||
|
Expected: all integration tests pass (28 noted in commit `8b53cac`).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Format and lint**
|
||||||
|
|
||||||
|
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
|
||||||
|
|
||||||
|
Expected: no diffs from fmt, no clippy warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/game.rs
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
|
||||||
|
|
||||||
|
Both methods were 95-line near-duplicates differing only in the closure
|
||||||
|
that builds the per-diff DiffFactor. Extract the shared body as a
|
||||||
|
private run_chain<F>(&self, arena, make_link) helper that returns
|
||||||
|
(evidence, likelihoods); the two callers shrink to ~10 lines each.
|
||||||
|
|
||||||
|
Pure code-shape change: posteriors and evidence remain bit-equal; all
|
||||||
|
existing tests (lib + integration) pass unchanged.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Make `BuiltinFactor::log_evidence` match exhaustive
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/factor/mod.rs:94-100` (the `log_evidence` impl on `BuiltinFactor`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Open `src/factor/mod.rs` and replace the `log_evidence` body**
|
||||||
|
|
||||||
|
Replace the existing impl:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn log_evidence(&self, vars: &VarStore) -> f64 {
|
||||||
|
match self {
|
||||||
|
Self::Trunc(f) => f.log_evidence(vars),
|
||||||
|
Self::Margin(f) => f.log_evidence(vars),
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn log_evidence(&self, vars: &VarStore) -> f64 {
|
||||||
|
match self {
|
||||||
|
Self::Trunc(f) => f.log_evidence(vars),
|
||||||
|
Self::Margin(f) => f.log_evidence(vars),
|
||||||
|
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build and run tests**
|
||||||
|
|
||||||
|
Run: `cargo build && cargo test --lib`
|
||||||
|
|
||||||
|
Expected: compiles cleanly, all tests pass. Behavior is unchanged — `TeamSum` and `RankDiff` still return `0.0`, but a future variant will now produce a non-exhaustive-match error instead of being silently swallowed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Format and lint**
|
||||||
|
|
||||||
|
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
|
||||||
|
|
||||||
|
Expected: no diffs, no warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/factor/mod.rs
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
refactor: make BuiltinFactor::log_evidence match exhaustive
|
||||||
|
|
||||||
|
Replace the `_ => 0.0` wildcard with explicit
|
||||||
|
`Self::TeamSum(_) | Self::RankDiff(_) => 0.0`. No behavioral change;
|
||||||
|
future variants now produce a compile error instead of being silently
|
||||||
|
absorbed by the wildcard.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Fix stale numerics in T4 plan doc
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/superpowers/plans/2026-04-27-t4-margin-factor.md` (lines 52 and 185)
|
||||||
|
|
||||||
|
The shipped test in `src/factor/mod.rs:163,166` asserts:
|
||||||
|
|
||||||
|
```
|
||||||
|
assert!((result.mu() - 4.864864864864865).abs() < 1e-12);
|
||||||
|
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
|
||||||
|
```
|
||||||
|
|
||||||
|
The plan's prose at line 52 quotes pre-shipped values that no longer match. This task fixes the prose and the matching code-comment. The full-precision assertion blocks elsewhere in the plan are out of scope (they belong to the plan-as-written, and the spec's fix table only listed the rounded prose values).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the prose at line 52**
|
||||||
|
|
||||||
|
Open `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`. Find the line:
|
||||||
|
|
||||||
|
```
|
||||||
|
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.046827`. So `log_evidence ≈ -3.0613`.
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```
|
||||||
|
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.04678`. So `log_evidence ≈ -3.0622`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the matching code-comment at line 185**
|
||||||
|
|
||||||
|
In the same file, find:
|
||||||
|
|
||||||
|
```
|
||||||
|
// pdf(5, 0, sqrt(37)) ≈ 0.046827
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```
|
||||||
|
// pdf(5, 0, sqrt(37)) ≈ 0.04678
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify nothing else changed**
|
||||||
|
|
||||||
|
Run: `git diff docs/superpowers/plans/2026-04-27-t4-margin-factor.md`
|
||||||
|
|
||||||
|
Expected: exactly three lines changed (one prose line containing both numbers, one comment line). Nothing else should be touched.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/plans/2026-04-27-t4-margin-factor.md
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
docs: fix stale numerics in t4-margin-factor plan
|
||||||
|
|
||||||
|
The plan's prose quoted Z_cav ≈ 0.046827 and log_evidence ≈ -3.0613,
|
||||||
|
which diverged from the values asserted by the shipped test in
|
||||||
|
src/factor/mod.rs (-3.062235327364623). Update prose and the matching
|
||||||
|
code comment to 0.04678 / -3.0622.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review (writer's note)
|
||||||
|
|
||||||
|
Spec coverage:
|
||||||
|
- Spec Item 1 (dedupe `likelihoods`/`likelihoods_scored`) → Task 1 ✓
|
||||||
|
- Spec Item 2 (exhaustive `BuiltinFactor::log_evidence`) → Task 2 ✓
|
||||||
|
- Spec Item 3 (stale numerics in T4 plan) → Task 3 ✓
|
||||||
|
- Spec out-of-scope items (`DiffFactor` collapse, per-event `score_sigma`) — correctly absent ✓
|
||||||
|
|
||||||
|
Verification gates per the spec ("each item commits independently and ships behind a green `cargo test --lib`"): every task ends in fmt + clippy + tests + commit. Task 1 additionally runs `cargo test` for integration coverage.
|
||||||
|
|
||||||
|
Type / signature consistency:
|
||||||
|
- `run_chain` signature appears identically in the context header and Step 2 body ✓
|
||||||
|
- Closure type `FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor` matches across Step 2 (definition) and Steps 3/4 (call sites) ✓
|
||||||
|
- `DiffFactor::Trunc` / `DiffFactor::Margin` constructors match `src/game.rs:20-23` definitions ✓
|
||||||
|
|
||||||
|
No placeholders detected.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,639 @@
|
|||||||
|
# TrueSkill-TT Engine Redesign — Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-23
|
||||||
|
**Status:** Approved (pending implementation plan)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Comprehensive redesign of the TrueSkill-TT engine targeting four orthogonal goals:
|
||||||
|
|
||||||
|
1. **Performance** — substantially faster offline convergence and incremental online updates.
|
||||||
|
2. **Accuracy and richer match formats** — support for score margins, free-for-all with partial orders, correlated skills.
|
||||||
|
3. **Better convergence** — replace ad-hoc capped iteration with a pluggable `Schedule` trait covering all three nested loops.
|
||||||
|
4. **Better API surface** — typed event description, observer-based progress reporting, generic time axis, structured errors, ergonomic builders.
|
||||||
|
|
||||||
|
The design is comprehensive (Approach 1 of three considered) but delivered in five tiers so each step is independently shippable and validated by benchmarks.
|
||||||
|
|
||||||
|
## Goals & non-goals
|
||||||
|
|
||||||
|
**Goals**
|
||||||
|
|
||||||
|
- 10–30× speedup on the offline convergence path for representative workloads (1000+ players, 1000+ events, 30 iterations)
|
||||||
|
- Order-of-magnitude speedup on incremental "add a single event" workloads
|
||||||
|
- Pluggable factor graph allowing new factor types without engine changes
|
||||||
|
- Optional Rayon-backed parallelism on top of `Send + Sync`-correct internals
|
||||||
|
- Typed, ergonomic public API; replace nested `Vec<Vec<Vec<_>>>` shapes with `Event<T, K>` / `Team<K>` / `Member<K>`
|
||||||
|
- Generic time axis: `Untimed`, `i64`, or user-supplied
|
||||||
|
- Observer-based progress instead of `verbose: bool` + `println!`
|
||||||
|
- Structured `Result<_, InferenceError>` at API boundaries
|
||||||
|
|
||||||
|
**Non-goals**
|
||||||
|
|
||||||
|
- WebAssembly support is not a goal; we may break it if a crate or feature requires.
|
||||||
|
- No GPU offload.
|
||||||
|
- No `no_std` support.
|
||||||
|
- No persistent format / serde — possible future feature.
|
||||||
|
- No replacement of the Gaussian/EP approximation itself in this design (the underlying inference math stays the same; we change layout, dispatch, scheduling, and API around it).
|
||||||
|
|
||||||
|
## Workload assumptions
|
||||||
|
|
||||||
|
Baseline workload that drives perf decisions:
|
||||||
|
|
||||||
|
- ~1000+ players
|
||||||
|
- ~1000+ events total
|
||||||
|
- ~50–60 events per time slice (per day)
|
||||||
|
- Both online (incremental adds) and offline (full convergence) are common
|
||||||
|
- Offline convergence runs frequently
|
||||||
|
|
||||||
|
## Section 1 — Core types & traits
|
||||||
|
|
||||||
|
The foundation everything else builds on.
|
||||||
|
|
||||||
|
### `Gaussian` — natural-parameter storage
|
||||||
|
|
||||||
|
Switch storage from `(mu, sigma)` to natural parameters `(pi, tau)` where `pi = sigma⁻²`, `tau = mu · pi`. Multiplication and division dominate the hot path; in nat-params they are direct adds/subs of the components, no `sqrt`. Reads of `mu`/`sigma` become accessor methods (`tau / pi`, `1.0 / pi.sqrt()`). The trade is correct because reads are vanishingly rare compared to writes in EP.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Gaussian { pi: f64, tau: f64 }
|
||||||
|
pub const UNIFORM: Gaussian = Gaussian { pi: 0.0, tau: 0.0 }; // replaces N_INF
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Time` trait
|
||||||
|
|
||||||
|
Replaces the bare `i64` time field. Keeps `History` parametric.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Time: Copy + Ord + Send + Sync + 'static {
|
||||||
|
fn elapsed_to(&self, later: &Self) -> i64;
|
||||||
|
}
|
||||||
|
pub struct Untimed; // ZST for the no-time-axis case
|
||||||
|
impl Time for Untimed { fn elapsed_to(&self, _: &Self) -> i64 { 0 } }
|
||||||
|
impl Time for i64 { fn elapsed_to(&self, later: &Self) -> i64 { later - self } }
|
||||||
|
// Optional impls behind feature flags: time::OffsetDateTime, chrono types
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Drift<T>` trait
|
||||||
|
|
||||||
|
Generic over `T: Time` so seasonal/calendar-aware drift is possible without going through `i64`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Drift<T: Time>: Copy + Send + Sync {
|
||||||
|
fn variance_delta(&self, from: &T, to: &T) -> f64;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ConstantDrift(f64)` impl: `to.elapsed_to(from) as f64 * gamma * gamma`.
|
||||||
|
|
||||||
|
### `Index` and `KeyTable<K>`
|
||||||
|
|
||||||
|
`Index(usize)` is the handle into dense per-`History` `Vec` storage. Public, but intended for use by power users on hot paths who want to skip the `KeyTable` lookup. Casual API takes `&K`. `KeyTable<K>` (renamed from `IndexMap`, to avoid colliding with the `indexmap` crate's type) maps user keys → `Index`.
|
||||||
|
|
||||||
|
### `Observer` trait
|
||||||
|
|
||||||
|
Replaces `verbose: bool` + `println!`. Default no-op impls; user overrides what they need.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Observer<T: Time>: Send + Sync {
|
||||||
|
fn on_iteration_end(&self, _iter: usize, _max_step: (f64, f64)) {}
|
||||||
|
fn on_batch_processed(&self, _time: &T, _idx: usize, _n_events: usize) {}
|
||||||
|
fn on_converged(&self, _iters: usize, _final_step: (f64, f64)) {}
|
||||||
|
}
|
||||||
|
pub struct NullObserver;
|
||||||
|
impl<T: Time> Observer<T> for NullObserver {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
|
||||||
|
- `Gaussian` natural-param representation: anyone reading `mu`/`sigma` in a hot loop pays a sqrt — but that's correct, hot reads are rare.
|
||||||
|
- `Time` as a trait (not enum) keeps it open-ended at zero runtime cost; default `History<i64, _>` keeps the call sites familiar.
|
||||||
|
- `Observer` is a trait (not a closure) so different sites can have different signatures without losing type safety. `NullObserver` is a ZST.
|
||||||
|
|
||||||
|
## Section 2 — Factor graph architecture
|
||||||
|
|
||||||
|
The current `Game::likelihoods` is a hand-rolled, hard-coded graph. To unlock richer formats and let us experiment with EP schedules, the graph itself becomes a data structure.
|
||||||
|
|
||||||
|
### Variable / Factor model
|
||||||
|
|
||||||
|
Variables hold their current Gaussian marginal. Factors hold their outgoing messages to each connected variable plus do the local computation. Standard EP: factor's update is "divide marginal by old outgoing → cavity → apply local approximation → multiply marginal by new outgoing."
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Factor: Send + Sync {
|
||||||
|
fn variables(&self) -> &[VarId];
|
||||||
|
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64); // returns max delta
|
||||||
|
fn log_evidence(&self, _vars: &VarStore) -> f64 { 0.0 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Built-in factor catalog
|
||||||
|
|
||||||
|
| Factor | Purpose | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `PerformanceFactor` | skill → performance (add β² noise, optional weight) | replaces inline `performance() * weight` |
|
||||||
|
| `TeamSumFactor` | weighted sum of player perfs → team perf | replaces inline `fold` |
|
||||||
|
| `RankDiffFactor` | (team_a perf) − (team_b perf) → diff var | currently `team[e].posterior_win() − team[e+1].posterior_lose()` |
|
||||||
|
| `TruncFactor` | EP truncation: `P(diff > margin)` or `P(|diff| < margin)` for draws | wraps current `v_w` / `approx` |
|
||||||
|
| `MarginFactor` *(future)* | use observed score margin as soft evidence | enables richer match formats |
|
||||||
|
| `SynergyFactor` *(future)* | couples teammates' skills | enables different topology |
|
||||||
|
| `ScoreFactor` *(future)* | continuous outcome (e.g., points scored) | enables score-based outcomes |
|
||||||
|
|
||||||
|
The first four together exactly reproduce today's algorithm. The last three are extension slots.
|
||||||
|
|
||||||
|
### Game = factor graph + schedule
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Game<S: Schedule = DefaultSchedule> {
|
||||||
|
vars: VarStore, // SoA: Vec<Gaussian> marginals
|
||||||
|
factors: FactorList, // enum dispatch over BuiltinFactor (see Open Questions)
|
||||||
|
schedule: S,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Lean toward **enum dispatch** (`enum BuiltinFactor { Perf(...), Sum(...), RankDiff(...), Trunc(...), ... }`) over `Box<dyn Factor>` for the built-ins:
|
||||||
|
|
||||||
|
- avoids per-message vtable overhead in the hottest loop
|
||||||
|
- keeps factor data inline (no heap indirection)
|
||||||
|
- still allows user-defined factors via a `BuiltinFactor::Custom(Box<dyn Factor>)` variant
|
||||||
|
|
||||||
|
### Schedule trait
|
||||||
|
|
||||||
|
Controls iteration order and stopping. Default = current behavior (sweep forward, then backward, until ε or max iters). Pluggable so we can later try damped EP or junction-tree schedules.
|
||||||
|
|
||||||
|
### High-level constructors
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Game::ranked(teams, results, options) // dominant case
|
||||||
|
Game::free_for_all(players, ranking) // FFA with possible ties
|
||||||
|
Game::custom(builder) // power users build their own graph
|
||||||
|
```
|
||||||
|
|
||||||
|
`GameOptions` carries iteration cap, epsilon, p_draw, and approximation choice. Today these are scattered between method args and module constants.
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
|
||||||
|
- Enum dispatch over trait objects for built-ins; richer factors drop in via new enum variants.
|
||||||
|
- Variables and factor messages stored as `Vec<Gaussian>` indexed by `VarId` / edge slot — flat, cache-friendly.
|
||||||
|
- `Schedule` is a generic parameter (zero-cost); most users get default; experimentation is open.
|
||||||
|
|
||||||
|
### Open question
|
||||||
|
|
||||||
|
Whether `enum BuiltinFactor` will feel too closed-world. The `Custom(Box<dyn Factor>)` escape hatch helps but inner-loop perf for user factors will be slower. Acceptable for now; flagged for future revisit if it becomes a problem.
|
||||||
|
|
||||||
|
## Section 3 — Storage layout (SoA + arenas)
|
||||||
|
|
||||||
|
### Dense Vec keyed by `Index`
|
||||||
|
|
||||||
|
Every `HashMap<Index, T>` becomes a `Vec<T>` (or `Vec<Option<T>>` for sparse) indexed directly by `Index.0`. The public-facing `KeyTable<K>` continues to map arbitrary keys → `Index`.
|
||||||
|
|
||||||
|
### SoA at hot layers, AoS at boundaries
|
||||||
|
|
||||||
|
The `Skill` struct stays as a public type for the API (returned from `learning_curves`, etc.), but inside `TimeSlice` we lay it out column-wise:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct TimeSliceSkills {
|
||||||
|
forward: Vec<Gaussian>, // [n_agents]
|
||||||
|
backward: Vec<Gaussian>,
|
||||||
|
likelihood: Vec<Gaussian>,
|
||||||
|
online: Vec<Gaussian>,
|
||||||
|
elapsed: Vec<i64>,
|
||||||
|
present: Vec<bool>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Within a slice, the inner loops touch one column repeatedly across many events — keeping the column contiguous improves cache utilization and makes the eventual SIMD step (Section 6) straightforward.
|
||||||
|
|
||||||
|
`Gaussian` itself stays as a single 16-byte struct in the `Vec<Gaussian>`. Splitting into two parallel `Vec<f64>`s wins for pure SIMD over thousands of Gaussians but loses for the random-access patterns dominant in EP. Revisit if benchmarks demand it.
|
||||||
|
|
||||||
|
### Arena allocator inside `Game`
|
||||||
|
|
||||||
|
Replace per-event allocations with a `ScratchArena` reused across calls.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ScratchArena {
|
||||||
|
var_buf: Vec<Gaussian>,
|
||||||
|
factor_buf: Vec<Gaussian>, // edge messages
|
||||||
|
bool_buf: Vec<bool>,
|
||||||
|
f64_buf: Vec<f64>,
|
||||||
|
}
|
||||||
|
impl ScratchArena {
|
||||||
|
fn reset(&mut self); // sets len=0, keeps capacity
|
||||||
|
fn alloc_vars(&mut self, n: usize) -> &mut [Gaussian];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`TimeSlice` owns one `ScratchArena`; each event borrows it for the duration of its `Game` construction and inference. For the parallel-slice story (Section 6), each Rayon task gets its own arena.
|
||||||
|
|
||||||
|
### Per-event storage layout
|
||||||
|
|
||||||
|
Inside a `TimeSlice`, each event is stored column-wise as well, with `Item` inlined into team-level parallel arrays:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct EventStorage {
|
||||||
|
teams: SmallVec<[TeamStorage; 4]>,
|
||||||
|
outcome: Outcome,
|
||||||
|
weights: SmallVec<[SmallVec<[f64; 4]>; 4]>,
|
||||||
|
evidence: f64,
|
||||||
|
}
|
||||||
|
struct TeamStorage {
|
||||||
|
competitors: SmallVec<[Index; 4]>, // who's on the team
|
||||||
|
edge_messages: SmallVec<[Gaussian; 4]>, // outgoing message per slot
|
||||||
|
output: f64,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Iteration over `(competitor, edge_message)` pairs zips two slices — no per-element struct.
|
||||||
|
|
||||||
|
### SmallVec for typical shapes
|
||||||
|
|
||||||
|
Teams ≤ ~5 players, games ≤ ~8 teams. `SmallVec<[T; 8]>` for team membership and `SmallVec<[T; 4]>` for team rosters keeps the common case allocation-free.
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
|
||||||
|
- Dense `Vec<T>` keyed by `Index` is faster but means agent removal needs tombstones (or just leaves slots present-but-inactive). Acceptable: TrueSkill histories rarely remove players.
|
||||||
|
- SoA at `TimeSlice` level only, not at `History` level. `History` keeps `Vec<TimeSlice>` because slices are heterogeneous in size.
|
||||||
|
- One `ScratchArena` per `TimeSlice` keeps the lifetime story simple.
|
||||||
|
|
||||||
|
### Open question
|
||||||
|
|
||||||
|
The `TimeSliceSkills` sketch above uses (b) **dense + present mask**: one slot per agent in the history, indexed directly by `Index`, with a `present: Vec<bool>` mask for batches the agent didn't participate in. The alternative is (a) **sparse columnar**: a `Vec<Index>` of present agents and parallel `Vec<Gaussian>` columns of length `n_present`, with a separate lookup (binary search or auxiliary table) to find a given `Index`'s slot.
|
||||||
|
|
||||||
|
(b) gives O(1) lookup and SIMD-friendly columns but wastes memory for sparsely populated slices. (a) is leaner per-slice but pays per-lookup cost in the inner loop. Bench both during T0 and pick. Default proposal: (b), since modern systems are memory-rich and the parallelism story is cleaner.
|
||||||
|
|
||||||
|
## Section 4 — API surface
|
||||||
|
|
||||||
|
### Typed event description
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Event<T: Time, K> {
|
||||||
|
pub time: T,
|
||||||
|
pub teams: SmallVec<[Team<K>; 4]>,
|
||||||
|
pub outcome: Outcome,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Team<K> {
|
||||||
|
pub members: SmallVec<[Member<K>; 4]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Member<K> {
|
||||||
|
pub key: K,
|
||||||
|
pub weight: f64, // default 1.0
|
||||||
|
pub prior: Option<Rating>, // per-event override
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Outcome {
|
||||||
|
Ranked(SmallVec<[u32; 4]>), // rank per team; equal ranks = tie
|
||||||
|
Scored(SmallVec<[f64; 4]>), // continuous score per team (engages MarginFactor)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Outcome::winner(0)`, `Outcome::draw()`, `Outcome::ranking([0,1,2])` are convenience constructors.
|
||||||
|
|
||||||
|
### Builders
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut history = History::<i64, _>::builder()
|
||||||
|
.mu(25.0).sigma(25.0/3.0).beta(25.0/6.0)
|
||||||
|
.drift(ConstantDrift(0.03))
|
||||||
|
.p_draw(0.10)
|
||||||
|
.convergence(ConvergenceOptions { max_iter: 30, epsilon: 1e-6 })
|
||||||
|
.observer(LogObserver::default())
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
For the no-time case, type inference picks `Untimed`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut history = History::<Untimed, _>::builder().build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Three-tier event ingestion
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 1. Bulk ingestion (high-throughput path)
|
||||||
|
history.add_events(events_iter)?;
|
||||||
|
|
||||||
|
// 2. One-off match (very common in practice)
|
||||||
|
history.record_winner("alice", "bob", time)?;
|
||||||
|
history.record_draw("alice", "bob", time)?;
|
||||||
|
|
||||||
|
// 3. Builder for irregular shapes
|
||||||
|
history.event(time)
|
||||||
|
.team(["alice", "bob"]).weights([1.0, 0.7])
|
||||||
|
.team(["carol"])
|
||||||
|
.ranking([1, 0])
|
||||||
|
.commit()?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Convergence & queries
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let report: ConvergenceReport = history.converge()?;
|
||||||
|
|
||||||
|
let curve: Vec<(i64, Gaussian)> = history.learning_curve(&"alice");
|
||||||
|
let all = history.learning_curves(); // HashMap<&K, Vec<(T, Gaussian)>>
|
||||||
|
let now = history.current_skill(&"alice"); // Option<Gaussian>
|
||||||
|
|
||||||
|
let ev = history.log_evidence();
|
||||||
|
let ev_for = history.log_evidence_for(&["alice", "bob"]);
|
||||||
|
|
||||||
|
let q = history.predict_quality(&[&["alice"], &["bob"]]);
|
||||||
|
let p_win = history.predict_outcome(&[&["alice"], &["bob"]]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standalone Game
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let g = Game::ranked(&[&[alice], &[bob]], Outcome::winner(0), &options);
|
||||||
|
let post = g.posteriors();
|
||||||
|
|
||||||
|
// Convenience
|
||||||
|
let (a, b) = Game::one_v_one(&alice, &bob, Outcome::winner(0));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
Replace `debug_assert!`/`panic!` at the API boundary with `Result`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum InferenceError {
|
||||||
|
MismatchedShape { kind: &'static str, expected: usize, got: usize },
|
||||||
|
InvalidProbability { value: f64 },
|
||||||
|
ConvergenceFailed { last_step: (f64, f64), iterations: usize },
|
||||||
|
NegativePrecision { pi: f64 },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hot inner loops still use `debug_assert!` for invariants the API has already enforced.
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
|
||||||
|
- Generic over user's `K`; engine works in `Index`. Public outputs use `&K`.
|
||||||
|
- `SmallVec` everywhere on the event-description path.
|
||||||
|
- Three-tier API so casual users don't drown in types and bulk users still get throughput.
|
||||||
|
- `Outcome` enum replaces the "lower number wins" `&[f64]` convention.
|
||||||
|
|
||||||
|
### Open question
|
||||||
|
|
||||||
|
Whether to expose `Index` directly to users via an `intern_key(&K) -> Index` method, letting hot-path callers skip the `KeyTable` lookup on every call. Recommendation: yes — public `Index` handle plus `history.lookup<Q: Borrow<K>>(&Q) -> Option<Index>`. The casual API still takes `&K` everywhere; power users can promote to `Index` when profiling demands.
|
||||||
|
|
||||||
|
## Section 4½ — Naming pass
|
||||||
|
|
||||||
|
| Current | New | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| `History` | `History` (kept) | Matches upstream; reads cleanly. |
|
||||||
|
| `Batch` | `TimeSlice` | Says what it is: every event sharing one timestamp. |
|
||||||
|
| `Player` | `Rating` | The struct holds prior/beta/drift — that's a rating configuration. Resolves the `Player`/`Agent` confusion. |
|
||||||
|
| `Agent` | `Competitor` | Holds dynamic state for someone competing in the history; fits the domain. |
|
||||||
|
| `Skill` | `Skill` (kept) | Per-time-slice skill estimate; clearer than `BatchSkill`. |
|
||||||
|
| `Item` | inlined into `TeamStorage` columns (engine) / `Member<K>` (public) | Eliminates the per-element struct in the hot path; gives API users a clear "team member" name. |
|
||||||
|
| `Game` | `Game` (kept) | `Match` collides with Rust's `match`. |
|
||||||
|
| `Index` | `Index` (kept) | Internal handle. |
|
||||||
|
| `IndexMap` | `KeyTable` | Avoids confusion with the `indexmap` crate. |
|
||||||
|
|
||||||
|
## Section 5 — Convergence & message scheduling
|
||||||
|
|
||||||
|
### Three nested loops, one mechanism
|
||||||
|
|
||||||
|
The system has three nested convergence loops:
|
||||||
|
|
||||||
|
1. Within-game: EP sweeps over the factor graph
|
||||||
|
2. Within-time-slice: re-running games as inputs change
|
||||||
|
3. Cross-history: forward-pass then backward-pass over all slices
|
||||||
|
|
||||||
|
All three implement `Workload`; one `Schedule` impl drives all of them.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Schedule {
|
||||||
|
fn run<W: Workload>(&self, workload: &mut W) -> ScheduleReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Workload {
|
||||||
|
fn step(&mut self) -> (f64, f64);
|
||||||
|
fn snapshot_evidence(&self) -> f64 { 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ScheduleReport {
|
||||||
|
pub iterations: usize,
|
||||||
|
pub final_step: (f64, f64),
|
||||||
|
pub converged: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Built-in schedules
|
||||||
|
|
||||||
|
| Schedule | Behavior | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `EpsilonOrMax { eps, max }` | Default. Sweep until `(dpi, dtau) ≤ eps` or `max` iters. | All three loops. Replicates current behavior. |
|
||||||
|
| `Damped { eps, max, alpha }` | Same, but writes `α·new + (1−α)·old`. | Stuck oscillations. |
|
||||||
|
| `Residual { eps, max }` | Priority-queue: re-update factor with largest pending delta first. | Faster convergence on uneven graphs. |
|
||||||
|
| `OneShot` | Exactly one pass, no convergence check. | Online incremental adds. |
|
||||||
|
|
||||||
|
### Stopping in natural-param space
|
||||||
|
|
||||||
|
Switch from `(|Δmu|, |Δsigma|) ≤ epsilon` to `(|Δpi|, |Δtau|) ≤ (eps_pi, eps_tau)`:
|
||||||
|
|
||||||
|
- `mu` and `sigma` are on different scales; one tolerance is wrong for both
|
||||||
|
- We store in nat-params anyway — checking convergence in mu/sigma costs free sqrts
|
||||||
|
- Nat-param delta is the natural geometry of the EP fixed point
|
||||||
|
|
||||||
|
Default `EpsilonOrMax::default()` exposes a single `epsilon` for simplicity; advanced ctor exposes both tolerances.
|
||||||
|
|
||||||
|
### Within-game improvements
|
||||||
|
|
||||||
|
- Replace hard-cap of 10 iterations with `GameOptions::schedule` that propagates `ScheduleReport` upward
|
||||||
|
- Fast path: graphs with no diff chain (1v1 with 1 iter sufficient) skip the loop entirely
|
||||||
|
- FFA / many-team ranks benefit from `Residual`; opt-in
|
||||||
|
|
||||||
|
### Within-slice and cross-history improvements
|
||||||
|
|
||||||
|
- **No more old/new HashMap snapshotting**: track deltas inline as we write under SoA
|
||||||
|
- **Per-slice dirty bits**: a `TimeSlice` whose neighbor messages haven't changed since its last full sweep doesn't need to re-run. Track `time_slice.dirty` and skip clean ones during the cross-history sweep. Big win for online-add (the locality case).
|
||||||
|
|
||||||
|
### `ConvergenceReport`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ConvergenceReport {
|
||||||
|
pub iterations: usize,
|
||||||
|
pub final_step: (f64, f64),
|
||||||
|
pub log_evidence: f64,
|
||||||
|
pub converged: bool,
|
||||||
|
pub per_iteration_time: SmallVec<[Duration; 32]>,
|
||||||
|
pub batches_skipped: usize,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Observer` continues to receive per-iteration callbacks for live UI; `ConvergenceReport` is the post-hoc summary.
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
|
||||||
|
- One `Schedule` trait shared across loops — fewer concepts, more composable.
|
||||||
|
- Convergence checks in nat-param space — slightly different exact threshold than today; tests' epsilons re-tuned mechanically.
|
||||||
|
- Dirty-bit skipping changes iteration order vs. today; fixed point is the same, iteration counts may shift downward.
|
||||||
|
- `Residual` and `Damped` are opt-in; default behavior matches today closely.
|
||||||
|
|
||||||
|
### Open question
|
||||||
|
|
||||||
|
Whether `Schedule::run` should take an optional `Observer` reference. Recommendation: observation lives at a higher layer (`History::converge` calls observer hooks; `Schedule` is purely the loop driver).
|
||||||
|
|
||||||
|
## Section 6 — Concurrency & parallelism
|
||||||
|
|
||||||
|
### What's parallelizable
|
||||||
|
|
||||||
|
| Operation | Parallelism | Strategy |
|
||||||
|
|---|---|---|
|
||||||
|
| `History::converge()` (full forward+backward) | Sequential across slices | Within each slice: color-group events in parallel via Rayon |
|
||||||
|
| `History::add_events(...)` | Sequential append, but ingestion of typed events into `EventStorage` parallelizes trivially | n/a |
|
||||||
|
| `History::learning_curves()` | Per-key parallel | `into_par_iter()` |
|
||||||
|
| `History::log_evidence_for(targets)` | Per-batch parallel, reduce sum | `par_iter().map(...).sum()` |
|
||||||
|
| `Game` inference | Sequential | n/a (too small to amortize Rayon overhead) |
|
||||||
|
|
||||||
|
### Within-slice color-group parallelism
|
||||||
|
|
||||||
|
When events are added to a slice, partition them into color groups where events in the same color touch no shared `Index`. Within a color, run events in parallel via Rayon. Across colors, run sequentially. Preserves asynchronous-EP semantics exactly.
|
||||||
|
|
||||||
|
Alternative: synchronous EP with snapshot. All events read from a frozen skill snapshot, write deltas to thread-local buffers, barrier merges. Trivially parallel but weaker per-iteration convergence — needs damping. Available as a `Schedule` impl, opt-in.
|
||||||
|
|
||||||
|
### `Send + Sync` requirements
|
||||||
|
|
||||||
|
All public traits (`Time`, `Drift`, `Observer`, `Factor`, `Schedule`) require `Send + Sync`. `Observer` impls must be thread-safe (called from arbitrary worker threads).
|
||||||
|
|
||||||
|
### Rayon as default-on feature
|
||||||
|
|
||||||
|
`rayon` as default-on feature; with `default-features = false`, parallel paths fall back to sequential iterators behind `cfg(feature = "rayon")`.
|
||||||
|
|
||||||
|
> **Not implemented. Deliberate deviation, decided 2026-09-08 (issue #5).**
|
||||||
|
>
|
||||||
|
> `rayon` ships **opt-in**: `Cargo.toml` has no `default = [...]` key. The
|
||||||
|
> measured speedups are 1.0x on realistic workloads and 1.3x on a pathological
|
||||||
|
> one (issue #4), because typical slices hold too few events to amortize
|
||||||
|
> rayon's task-spawn overhead. Default-on would hand every downstream user a
|
||||||
|
> thread pool and a dependency for approximately no gain.
|
||||||
|
>
|
||||||
|
> This section made the trade conditional on cross-slice dirty-bit skipping
|
||||||
|
> landing and changing the parallel story. It did not land: #4 was closed on
|
||||||
|
> 2026-08-27 by removing the inert `ConvergenceReport::slices_skipped` field
|
||||||
|
> rather than by implementing the mechanism, so the re-measurement this was
|
||||||
|
> waiting on will not arrive.
|
||||||
|
>
|
||||||
|
> The "Trade-offs" note below also cited an `unsafe` concurrent-write path
|
||||||
|
> through `SkillStore` as a cost of default-on. That cost does not exist: the
|
||||||
|
> crate is `#![forbid(unsafe_code)]`, and the compute/apply split on the
|
||||||
|
> internal `Event` is what lets a color group run in parallel without it. The
|
||||||
|
> case for opt-in rests on the measurements alone.
|
||||||
|
|
||||||
|
### Expected speedup ballpark
|
||||||
|
|
||||||
|
For 1000 players, 60 events/slice × 1000 slices, 30 convergence iterations:
|
||||||
|
|
||||||
|
| Source | Estimated speedup vs. today |
|
||||||
|
|---|---|
|
||||||
|
| `HashMap` → dense `Vec` | 2–4× |
|
||||||
|
| Natural-param `Gaussian`, no-sqrt mul/div | 1.5–2× |
|
||||||
|
| Pre-allocated `ScratchArena` | 1.2–1.5× |
|
||||||
|
| Color-group parallel events in slice (8 cores) | 2–4× |
|
||||||
|
| Dirty-bit slice skipping (online add case) | 5–50× |
|
||||||
|
| **Combined (offline converge)** | ~10–30× |
|
||||||
|
| **Combined (online add)** | ~50–500× depending on locality |
|
||||||
|
|
||||||
|
These are pre-implementation estimates. Each tier validates with criterion.
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
|
||||||
|
- Color-group parallelism requires up-front graph coloring at ingestion. Cost: linear in events, run once per `add_events`. Cheap.
|
||||||
|
- Default = asynchronous EP (preserves current semantics). Synchronous opt-in only.
|
||||||
|
- Cross-slice sweep stays sequential; no speculative parallel sweeps.
|
||||||
|
- Rayon default-on but feature-gated. **Superseded — shipped opt-in; see the deviation note in Section 6.**
|
||||||
|
|
||||||
|
### Open question
|
||||||
|
|
||||||
|
Whether to expose color-group partitioning to users. Recommendation: hidden by default, escape hatch via `add_events_with_partition(...)` for power users who already know their event independence.
|
||||||
|
|
||||||
|
## Section 7 — Migration, testing, and delivery plan
|
||||||
|
|
||||||
|
The crate is unreleased, so version-bump ceremony doesn't apply. Tiers are sequencing of work and milestones, not releases.
|
||||||
|
|
||||||
|
### Tier sequence
|
||||||
|
|
||||||
|
**T0 — Numerical parity (no API change)**
|
||||||
|
|
||||||
|
Internal-only. Public surface unchanged.
|
||||||
|
|
||||||
|
- Switch `Gaussian` storage to natural parameters `(pi, tau)`. `mu()`/`sigma()` become accessors.
|
||||||
|
- Replace `HashMap<Index, _>` with dense `Vec<_>` keyed by `Index.0` everywhere.
|
||||||
|
- Introduce `ScratchArena` inside `Batch` so `Game::new` stops allocating per-event.
|
||||||
|
- Drop the `panic!` in `mu_sigma`; return `Result` propagated upward.
|
||||||
|
|
||||||
|
**Acceptance:** existing test suite passes (bit-equal where possible, ULP-bounded where natural-param arithmetic shifts a rounding); `cargo bench` shows ≥3× win on `batch` benchmark; no API breakage.
|
||||||
|
|
||||||
|
**T1 — Factor graph machinery (internal-only)**
|
||||||
|
|
||||||
|
- Introduce `Factor`, `VarStore`, `Schedule` as `pub(crate)` types.
|
||||||
|
- Re-implement `Game::likelihoods()` on top of `BuiltinFactor::{Perf, TeamSum, RankDiff, Trunc}` driven by `EpsilonOrMax`.
|
||||||
|
- Replace within-game iteration tracking with `ScheduleReport`.
|
||||||
|
|
||||||
|
**Acceptance:** existing test suite passes (ULP-bounded); within-game iteration counts unchanged; benchmarks ≥ T0.
|
||||||
|
|
||||||
|
**T2 — New API surface (breaking)**
|
||||||
|
|
||||||
|
All renames and the new public API land together. No half-renamed intermediate state.
|
||||||
|
|
||||||
|
- New types: `Rating`, `TimeSlice`, `Competitor`, `Member<K>`, `Outcome`, `Event<T, K>`, `KeyTable<K>`.
|
||||||
|
- `Time` trait introduced; `History<T: Time, D: Drift<T>>` is generic.
|
||||||
|
- Three-tier API surface: `record_winner`, `event(...).team(...).commit()`, bulk `add_events(iter)`.
|
||||||
|
- `Observer` trait + `ConvergenceReport`; `verbose: bool` deleted.
|
||||||
|
- `panic!`/`debug_assert!` at API boundary become `Result<_, InferenceError>`.
|
||||||
|
- Promote `Factor`/`Schedule`/`VarStore` to `pub` under a `factors` module.
|
||||||
|
|
||||||
|
**Acceptance:** full test suite rewritten in new API; equivalence tests prove identical posteriors vs. old API on the same inputs.
|
||||||
|
|
||||||
|
**T3 — Concurrency**
|
||||||
|
|
||||||
|
- `Send + Sync` audit and bounds on all public traits.
|
||||||
|
- Color-group partitioning at `TimeSlice` ingestion.
|
||||||
|
- `rayon` as default-on feature with `#[cfg(feature = "rayon")]` fallback.
|
||||||
|
- Parallel paths: within-slice color groups, `learning_curves`, `log_evidence_for`.
|
||||||
|
|
||||||
|
**Acceptance:** deterministic posteriors across `RAYON_NUM_THREADS={1,2,4,8}`; benchmarks show >2× on 8-core for offline converge.
|
||||||
|
|
||||||
|
**T4 — Richer factor types & schedules**
|
||||||
|
|
||||||
|
Each shipped independently after T3.
|
||||||
|
|
||||||
|
- `MarginFactor` → enables `Outcome::Scored`. **Done** (see `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`).
|
||||||
|
- `Damped` and `Residual` schedules.
|
||||||
|
- `SynergyFactor`, `ScoreFactor` → same pattern when wanted.
|
||||||
|
|
||||||
|
Each comes with its own benchmark and a worked example in `examples/`.
|
||||||
|
|
||||||
|
### Testing strategy
|
||||||
|
|
||||||
|
| Layer | Approach |
|
||||||
|
|---|---|
|
||||||
|
| **Numerical correctness** | Keep existing hardcoded golden values from `test_1vs1`, `test_1vs1_draw`, `test_2vs1vs2_mixed`, etc. through T0–T1 unchanged. They are a regression net against the original Python port. |
|
||||||
|
| **API parity** | T2 adds an `equivalence` test module that runs identical inputs through old vs. new construction and compares posteriors within ULPs. |
|
||||||
|
| **Property tests** | Add `proptest` for: factor graph fixed-point invariance under message order, `Outcome` round-trip, `Gaussian` mul/div associativity in nat-params, schedule convergence regardless of starting state. |
|
||||||
|
| **Determinism** | T3 adds tests that run identical input across multiple Rayon thread counts and assert identical posteriors. |
|
||||||
|
| **Benchmark gates** | Each tier has a "must not regress" gate vs. the previous tier on the existing `batch` and `gaussian` criterion suites. T0 must beat baseline by ≥3×; T1 ≥ T0; etc. |
|
||||||
|
|
||||||
|
### Risk management
|
||||||
|
|
||||||
|
- **T0 risk: rounding drift in tests.** Mitigation: where natural-param arithmetic legitimately changes the last ULPs, update goldens *and* simultaneously add a parity test against a snapshot taken from baseline to prove the difference is bounded.
|
||||||
|
- **T2 risk: API design mistakes.** Mitigation: review the spec and a worked example before implementing; iterate on feedback.
|
||||||
|
- **T3 risk: subtle race conditions in color-group partitioning.** Mitigation: `loom` tests for the merge step; deterministic-output assertion across thread counts.
|
||||||
|
- **Cross-tier risk: scope creep.** Each tier has a closed checklist; new ideas go to the next tier's wishlist.
|
||||||
|
|
||||||
|
### What we're explicitly *not* doing
|
||||||
|
|
||||||
|
- No GPU offload.
|
||||||
|
- No `no_std` support.
|
||||||
|
- No serde / persistence in this design.
|
||||||
|
- No incremental online API beyond `record_winner` / `add_events`.
|
||||||
|
|
||||||
|
## Open questions summary
|
||||||
|
|
||||||
|
Collected here for the review pass:
|
||||||
|
|
||||||
|
1. **`enum BuiltinFactor` extensibility** — may feel too closed-world; revisit if user-defined factors via `Custom(Box<dyn Factor>)` become common.
|
||||||
|
2. **Sparse vs. dense per-slice skill storage** — default to dense + `present` mask; sparse columnar is the alternative. Decided by T0 benchmarks.
|
||||||
|
3. **`Index` exposure for hot paths** — expose `intern_key`/`lookup` so power users can promote `&K` to `Index` and skip the `KeyTable` lookup; casual API still takes `&K` everywhere.
|
||||||
|
4. **`Schedule::run` and observer wiring** — observation stays at higher layer (`History::converge` calls observer hooks; `Schedule` is purely the loop driver).
|
||||||
|
5. **Color-group partition exposure** — hidden by default, escape hatch via `add_events_with_partition(...)`.
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
# Damped EP — Game-Local Damping
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add an opt-in EP damping knob to within-game inference. Users set
|
||||||
|
`ConvergenceOptions::alpha < 1.0` to damp message updates and stabilise
|
||||||
|
oscillating fixed-point loops on hard graphs. `alpha = 1.0` (the default)
|
||||||
|
is bit-equal to today.
|
||||||
|
|
||||||
|
This is the smallest-scope realisation of the spec's `Damped` schedule:
|
||||||
|
**game-local**, not plumbed through the `Schedule` trait. The `Schedule`
|
||||||
|
trait is shipped infrastructure that `run_chain` does not currently call;
|
||||||
|
wiring `Schedule` into game inference is a separate future task. This
|
||||||
|
design touches only what the user can actually reach via `GameOptions`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### What ships
|
||||||
|
|
||||||
|
1. New field `ConvergenceOptions::alpha: f64` (default `1.0`).
|
||||||
|
2. `run_chain` reads `options.convergence.{epsilon, max_iter, alpha}`
|
||||||
|
instead of the hardcoded `1e-6` / `10` / undamped — fixes the existing
|
||||||
|
latent bug where the first two were already on `GameOptions` but never
|
||||||
|
read by inference.
|
||||||
|
3. `Gaussian::damp_natural(self, new, alpha) -> Gaussian` — public helper
|
||||||
|
computing `α·new + (1−α)·self` in natural-parameter space.
|
||||||
|
4. `TruncFactor` and `MarginFactor` gain inherent
|
||||||
|
`propagate_with_alpha(&mut self, vars, alpha) -> (f64, f64)`. Their
|
||||||
|
`Factor::propagate` impls become one-line delegations passing
|
||||||
|
`alpha = 1.0`.
|
||||||
|
5. `DiffFactor::propagate` (game-private enum at `src/game.rs:20-54`)
|
||||||
|
gains an `alpha: f64` parameter and dispatches into the underlying
|
||||||
|
factor's `propagate_with_alpha`.
|
||||||
|
|
||||||
|
### What does not ship
|
||||||
|
|
||||||
|
- No `Damped` impl in `src/schedule.rs`. The `Schedule` trait stays as
|
||||||
|
it is; integration with `run_chain` is a separate task.
|
||||||
|
- No nat-param convergence switch. `(|Δmu|, |Δsigma|)` stays the
|
||||||
|
delta basis (matches today). The spec's "stopping in natural-param
|
||||||
|
space" wants its own design pass and test re-tuning.
|
||||||
|
- No oscillation auto-detect. `alpha` is user-supplied and constant for
|
||||||
|
the duration of a `run_chain` call.
|
||||||
|
- No `Residual`, `OneShot`, or `SynergyFactor` / `ScoreFactor` work —
|
||||||
|
separate future plans.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### `ConvergenceOptions::alpha`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/convergence.rs
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct ConvergenceOptions {
|
||||||
|
pub max_iter: usize,
|
||||||
|
pub epsilon: f64,
|
||||||
|
pub alpha: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConvergenceOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_iter: crate::ITERATIONS,
|
||||||
|
epsilon: crate::EPSILON,
|
||||||
|
alpha: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`alpha = 1.0` ⇒ undamped (bit-equal to today). Recommended starting
|
||||||
|
point if a graph oscillates: `0.5`–`0.7`. Values approaching `0.0` make
|
||||||
|
each step tinier and slow convergence; `alpha = 0.0` is degenerate
|
||||||
|
(factor never updates). Validation in `run_chain`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
debug_assert!(
|
||||||
|
opts.convergence.alpha > 0.0 && opts.convergence.alpha <= 1.0,
|
||||||
|
"convergence alpha must be in (0.0, 1.0]"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Gaussian::damp_natural`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl Gaussian {
|
||||||
|
/// EP damping in natural-parameter space: `α·new + (1−α)·self`.
|
||||||
|
///
|
||||||
|
/// Used by within-game schedules to stabilise oscillating fixed-point
|
||||||
|
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
|
||||||
|
/// `alpha < 1.0` shrinks each per-step update.
|
||||||
|
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
|
||||||
|
Gaussian::from_natural(
|
||||||
|
alpha * new.pi() + (1.0 - alpha) * self.pi(),
|
||||||
|
alpha * new.tau() + (1.0 - alpha) * self.tau(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Public on `Gaussian`. The name encodes the WHY (EP damping); the doc
|
||||||
|
comment fixes the math. No new dependency.
|
||||||
|
|
||||||
|
The existing `Mul<f64> for Gaussian` is **distribution scaling**
|
||||||
|
(`sigma → sigma·|scalar|`), not nat-param interpolation, so it can't be
|
||||||
|
reused here.
|
||||||
|
|
||||||
|
### `TruncFactor::propagate_with_alpha`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl TruncFactor {
|
||||||
|
pub(crate) fn propagate_with_alpha(
|
||||||
|
&mut self,
|
||||||
|
vars: &mut VarStore,
|
||||||
|
alpha: f64,
|
||||||
|
) -> (f64, f64) {
|
||||||
|
let marginal = vars.get(self.diff);
|
||||||
|
let cavity = marginal / self.msg;
|
||||||
|
|
||||||
|
if self.evidence_cached.is_none() {
|
||||||
|
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie));
|
||||||
|
}
|
||||||
|
|
||||||
|
let trunc = approx(cavity, self.margin, self.tie);
|
||||||
|
let new_msg = trunc / cavity;
|
||||||
|
|
||||||
|
let damped = self.msg.damp_natural(new_msg, alpha);
|
||||||
|
let old_msg = self.msg;
|
||||||
|
self.msg = damped;
|
||||||
|
|
||||||
|
// marginal_new = cavity * stored_msg (NOT cavity * new_msg with damping)
|
||||||
|
vars.set(self.diff, cavity * damped);
|
||||||
|
|
||||||
|
old_msg.delta(damped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Factor for TruncFactor {
|
||||||
|
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||||
|
self.propagate_with_alpha(vars, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two important points:
|
||||||
|
|
||||||
|
- The variable receives `cavity * damped` (i.e. `cavity * self.msg`),
|
||||||
|
not `trunc`. With `alpha = 1.0` these are equal (since
|
||||||
|
`cavity * new_msg = trunc` by construction), so today's behaviour is
|
||||||
|
preserved bit-equal. With `alpha < 1.0` the marginal reflects the
|
||||||
|
partially-applied update.
|
||||||
|
- The reported delta is `old_msg.delta(damped)` — delta of the actually
|
||||||
|
stored message, not of the raw `new_msg`. This is the textbook EP
|
||||||
|
damping convention: the convergence loop measures the trajectory it
|
||||||
|
is actually walking.
|
||||||
|
|
||||||
|
`MarginFactor` follows the same shape, with its own
|
||||||
|
`propagate_with_alpha` body (the existing `propagate` math, with the
|
||||||
|
`damp_natural` step inserted in the same place and the var write
|
||||||
|
switched to `cavity * damped`).
|
||||||
|
|
||||||
|
### `DiffFactor::propagate` signature
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/game.rs
|
||||||
|
impl DiffFactor {
|
||||||
|
pub(crate) fn propagate(
|
||||||
|
&mut self,
|
||||||
|
vars: &mut VarStore,
|
||||||
|
alpha: f64,
|
||||||
|
) -> (f64, f64) {
|
||||||
|
match self {
|
||||||
|
Self::Trunc(f) => f.propagate_with_alpha(vars, alpha),
|
||||||
|
Self::Margin(f) => f.propagate_with_alpha(vars, alpha),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`DiffFactor` is `pub(crate)` and only used inside `run_chain`, so the
|
||||||
|
signature change has no public-API impact.
|
||||||
|
|
||||||
|
### `run_chain` changes
|
||||||
|
|
||||||
|
Inside `Game::run_chain` (`src/game.rs:236-348`):
|
||||||
|
|
||||||
|
1. Capture `let alpha = opts.convergence.alpha;` once at the top
|
||||||
|
(avoids repeated `opts.convergence.alpha` lookups in the hot loop).
|
||||||
|
2. Replace the loop guard
|
||||||
|
`while tuple_gt(step, 1e-6) && iter < 10`
|
||||||
|
with
|
||||||
|
`while tuple_gt(step, opts.convergence.epsilon) && iter < opts.convergence.max_iter`.
|
||||||
|
3. Replace each `lf.propagate(&mut arena.vars)` call site (three of
|
||||||
|
them: forward sweep, backward sweep, `n_diffs == 1` special case)
|
||||||
|
with `lf.propagate(&mut arena.vars, alpha)`.
|
||||||
|
|
||||||
|
The threading of `opts: &GameOptions` into `run_chain` is the only
|
||||||
|
new caller obligation. Today `run_chain` doesn't take `opts`; the two
|
||||||
|
callers (`likelihoods`, `likelihoods_scored`) currently invoke it
|
||||||
|
without options. Both will need to pass the options through. The
|
||||||
|
`Game<'a, T, D>` struct does not currently hold `GameOptions`; the
|
||||||
|
options are constructed and discarded around the call to
|
||||||
|
`{ranked,scored}_with_arena`. So:
|
||||||
|
|
||||||
|
- `Game::ranked_with_arena` and `Game::scored_with_arena` already
|
||||||
|
receive `p_draw` / `score_sigma` as scalar params; we extend them to
|
||||||
|
accept `&ConvergenceOptions` (or the full `&GameOptions`) too.
|
||||||
|
- `likelihoods` / `likelihoods_scored` either store the options on
|
||||||
|
`Game` or accept them as method parameters and forward to
|
||||||
|
`run_chain`.
|
||||||
|
|
||||||
|
The simplest plumbing: store `convergence: ConvergenceOptions` as a
|
||||||
|
field on `Game<'a, T, D>` and `OwnedGame<T, D>` populated at
|
||||||
|
construction time. Then `run_chain` can read it from `&self`.
|
||||||
|
|
||||||
|
## Convergence semantics
|
||||||
|
|
||||||
|
With `alpha < 1.0` the per-step update shrinks; convergence may take
|
||||||
|
more iterations to reach the same `epsilon` threshold. Users who damp
|
||||||
|
should also raise `max_iter` accordingly. Documentation example:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut opts = GameOptions::default();
|
||||||
|
opts.convergence.alpha = 0.5;
|
||||||
|
opts.convergence.max_iter = 30;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
### Regression net (no new file)
|
||||||
|
|
||||||
|
The existing 88 lib tests and 27 integration tests are the bit-equal
|
||||||
|
regression net. With `alpha = 1.0` (the default), every assertion must
|
||||||
|
pass unchanged. If any test fails, the damping path leaked into the
|
||||||
|
undamped trajectory.
|
||||||
|
|
||||||
|
### New tests
|
||||||
|
|
||||||
|
1. **`Gaussian::damp_natural` arithmetic**
|
||||||
|
(`src/gaussian.rs` test mod):
|
||||||
|
- `α = 1.0` returns `new` exactly (bit-equal `pi` and `tau`).
|
||||||
|
- `α = 0.0` returns `self` exactly.
|
||||||
|
- `α = 0.5`: pi and tau are exact midpoints in nat-param space.
|
||||||
|
- Three asserts, no new file.
|
||||||
|
|
||||||
|
2. **`TruncFactor::propagate_with_alpha` shrinks the step**
|
||||||
|
(`src/factor/trunc.rs` test mod):
|
||||||
|
- Set up a TruncFactor step. Run `propagate_with_alpha(α=1.0)` once,
|
||||||
|
record `delta_undamped` and the resulting `self.msg`.
|
||||||
|
- Reset to a fresh factor at the same starting state. Run
|
||||||
|
`propagate_with_alpha(α=0.5)` once, record `delta_damped` and
|
||||||
|
`damped_msg`.
|
||||||
|
- Assert: `damped_msg.pi()` equals `0.5 * undamped_msg.pi() + 0.5 * initial_msg.pi()` within 1e-12 (and same for `tau`).
|
||||||
|
- Assert: `delta_damped.0 <= delta_undamped.0` (mu-delta is no larger; the relationship is monotone in `α` but not strictly `0.5×` for the `delta()` function which is `(|Δmu|, |Δsigma|)`).
|
||||||
|
|
||||||
|
3. **`MarginFactor::propagate_with_alpha` parity**
|
||||||
|
(`src/factor/margin.rs` test mod):
|
||||||
|
- Same shape as #2, on a `MarginFactor` step.
|
||||||
|
|
||||||
|
4. **`run_chain` honours `ConvergenceOptions::max_iter`**
|
||||||
|
(in an existing or new game-level test):
|
||||||
|
- Construct a 4-team ranked game that normally converges in ~5 iterations.
|
||||||
|
- Set `opts.convergence.max_iter = 1`. Assert the per-iteration
|
||||||
|
`step` returned (or observable indirectly via posterior delta vs.
|
||||||
|
the converged answer) is non-zero — i.e. the loop stopped early.
|
||||||
|
- Set `opts.convergence.max_iter = 30`. Assert posteriors match the
|
||||||
|
baseline within `epsilon`.
|
||||||
|
|
||||||
|
5. **Damping default is `1.0` and produces bit-equal output**
|
||||||
|
(smoke test, can be a single assertion in an existing test):
|
||||||
|
- `assert_eq!(ConvergenceOptions::default().alpha, 1.0);`
|
||||||
|
- Existing goldens prove the bit-equality.
|
||||||
|
|
||||||
|
No oscillation-stabilisation test (would require constructing a
|
||||||
|
pathological graph specifically to oscillate; out of scope for a
|
||||||
|
minimal ship).
|
||||||
|
|
||||||
|
## Verification gates
|
||||||
|
|
||||||
|
Per task:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo +nightly fmt
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo test --lib
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
All must succeed. Test count grows by exactly the new tests above
|
||||||
|
(roughly +5–8 lib tests).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Marginal-update change is subtle.** Switching the variable write
|
||||||
|
from `trunc` to `cavity * damped` is intentionally a no-op when
|
||||||
|
`alpha = 1.0` (since `cavity * new_msg = trunc`), but it changes the
|
||||||
|
arithmetic path. If `Gaussian` arithmetic has any non-associativity
|
||||||
|
in floating-point that the old form happened to dodge, goldens could
|
||||||
|
shift by 1 ULP. Mitigation: TDD — write the regression test (run all
|
||||||
|
existing tests with `alpha = 1.0`) **first**, before changing the
|
||||||
|
variable-write line.
|
||||||
|
- **`run_chain` signature change ripples to two callers.** Trivial
|
||||||
|
but must be done atomically with the field addition on `Game` /
|
||||||
|
`OwnedGame`.
|
||||||
|
- **`alpha` validation only in debug builds.** A release build will
|
||||||
|
silently accept `alpha = 0.0` or `alpha > 1.0` and produce nonsense.
|
||||||
|
This matches the existing pattern (`debug_assert!` for input
|
||||||
|
validation in `Game::ranked_with_arena`); upgrading to `Result` is
|
||||||
|
out of scope.
|
||||||
|
|
||||||
|
## Out-of-scope follow-ups (logged for future plans)
|
||||||
|
|
||||||
|
- Wire `Schedule` into `run_chain` (so `Damped` lands as a real
|
||||||
|
`Schedule` impl alongside `EpsilonOrMax`).
|
||||||
|
- Switch convergence check to `(|Δpi|, |Δtau|)` per spec
|
||||||
|
§"Stopping in natural-param space".
|
||||||
|
- Oscillation auto-detect (engage `alpha < 1.0` only after N
|
||||||
|
non-monotone steps).
|
||||||
|
- `Residual` schedule (priority queue).
|
||||||
|
- `SynergyFactor`, `ScoreFactor` (new EP factor types).
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# History → TimeSlice ConvergenceOptions Plumbing
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Make `History`'s already-public `ConvergenceOptions` (set via
|
||||||
|
`HistoryBuilder::convergence(...)`) actually reach the within-game
|
||||||
|
inference loop. Today it's read by the outer `History::converge` sweep
|
||||||
|
but dropped on the floor when constructing `TimeSlice`s, so users who
|
||||||
|
opt in to `alpha < 1.0` (Damped EP) on a `History` get nothing — the
|
||||||
|
inner `run_chain` calls inside `TimeSlice` hardcode
|
||||||
|
`ConvergenceOptions::default()`.
|
||||||
|
|
||||||
|
This spec closes the gap with one focused change: thread
|
||||||
|
`ConvergenceOptions` from `History` through `TimeSlice` to the three
|
||||||
|
`Game::*_with_arena` callsites in `time_slice.rs`. No new types, no new
|
||||||
|
public methods on `History` or `HistoryBuilder` — the user-facing API
|
||||||
|
already exists.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
After T5 (commit `0705986`) of the Damped EP plan,
|
||||||
|
`Game::*_with_arena` accepts `convergence: ConvergenceOptions` and
|
||||||
|
`run_chain` reads `self.convergence.{epsilon, max_iter, alpha}`.
|
||||||
|
`HistoryBuilder` already has a `convergence(opts)` method (`history.rs:91`)
|
||||||
|
that stores onto a field on `History`. `History::converge` reads
|
||||||
|
`self.convergence.{max_iter, epsilon}` for its outer cross-history loop
|
||||||
|
(`history.rs:437-447`).
|
||||||
|
|
||||||
|
The break is here, in `History::add_events_with_prior` at `history.rs:597`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut time_slice = TimeSlice::new(t, self.p_draw);
|
||||||
|
```
|
||||||
|
|
||||||
|
`self.convergence` is not passed. `TimeSlice` has no convergence field.
|
||||||
|
The three callsites in `time_slice.rs` that build `Game::*_with_arena`
|
||||||
|
fall back to `ConvergenceOptions::default()`:
|
||||||
|
|
||||||
|
- `Event::iteration_direct` (`time_slice.rs:138-156`)
|
||||||
|
- `TimeSlice::convergence` (`time_slice.rs:332-345`)
|
||||||
|
- `TimeSlice::log_evidence` (`time_slice.rs:521-538`)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### What ships
|
||||||
|
|
||||||
|
1. `TimeSlice<T>` gains a `pub(crate) convergence: ConvergenceOptions`
|
||||||
|
field set at construction.
|
||||||
|
2. `TimeSlice::new` signature becomes
|
||||||
|
`pub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self`.
|
||||||
|
3. `History::add_events_with_prior` (`history.rs:597`) passes
|
||||||
|
`self.convergence` when constructing new `TimeSlice`s.
|
||||||
|
4. `Event::iteration_direct` gains a `convergence: ConvergenceOptions`
|
||||||
|
parameter and forwards it to the `Game::*_with_arena` callsite.
|
||||||
|
The two callers (`TimeSlice::iteration` at `time_slice.rs:419` and
|
||||||
|
`:441`) pass `self.convergence`.
|
||||||
|
5. `TimeSlice::convergence` (the method, not the field) replaces its
|
||||||
|
hardcoded `crate::ConvergenceOptions::default()` with
|
||||||
|
`self.convergence`.
|
||||||
|
6. `TimeSlice::log_evidence` does the same.
|
||||||
|
7. Five test callsites of `TimeSlice::new(time, p_draw)` updated
|
||||||
|
mechanically to `TimeSlice::new(time, p_draw, ConvergenceOptions::default())`.
|
||||||
|
|
||||||
|
### What does not ship
|
||||||
|
|
||||||
|
- No split of `ConvergenceOptions` into outer/inner fields. The
|
||||||
|
conflation (one `max_iter` covers both the cross-history sweep and
|
||||||
|
the per-game EP iteration cap) is the user-confirmed design.
|
||||||
|
- No `Damped` impl in `src/schedule.rs`. The `Schedule` trait is still
|
||||||
|
not integrated into `run_chain`.
|
||||||
|
- No nat-param convergence switch.
|
||||||
|
- No oscillation auto-detect.
|
||||||
|
- No new `History` or `HistoryBuilder` methods. `convergence(opts)`
|
||||||
|
already exists and works.
|
||||||
|
- No changes to `History::converge` — the outer-loop semantics are
|
||||||
|
unchanged (it already reads `self.convergence`).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### `TimeSlice<T>` field
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/time_slice.rs
|
||||||
|
pub struct TimeSlice<T: Time = i64> {
|
||||||
|
// ... existing fields ...
|
||||||
|
p_draw: f64,
|
||||||
|
pub(crate) convergence: ConvergenceOptions,
|
||||||
|
// ... existing fields ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `TimeSlice::new`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl<T: Time> TimeSlice<T> {
|
||||||
|
pub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self {
|
||||||
|
Self {
|
||||||
|
// ... existing initialisation ...
|
||||||
|
p_draw,
|
||||||
|
convergence,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `History::add_events_with_prior` — single-line fix
|
||||||
|
|
||||||
|
At `src/history.rs:597`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before
|
||||||
|
let mut time_slice = TimeSlice::new(t, self.p_draw);
|
||||||
|
|
||||||
|
// after
|
||||||
|
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Event::iteration_direct` parameter
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/time_slice.rs
|
||||||
|
impl Event {
|
||||||
|
pub(crate) fn iteration_direct(
|
||||||
|
&mut self,
|
||||||
|
skills: &mut SkillStore,
|
||||||
|
agents: &CompetitorStore<i64, ConstantDrift>,
|
||||||
|
p_draw: f64,
|
||||||
|
convergence: ConvergenceOptions,
|
||||||
|
arena: &mut ScratchArena,
|
||||||
|
) -> /* existing return */ {
|
||||||
|
// ... existing body, with the Game::*_with_arena calls
|
||||||
|
// using `convergence` instead of ConvergenceOptions::default() ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The two callers — `TimeSlice::iteration` at `time_slice.rs:419` and
|
||||||
|
`:441` — already have `&mut self` access, so they pass
|
||||||
|
`self.convergence`.
|
||||||
|
|
||||||
|
### `TimeSlice::convergence` method (not the field)
|
||||||
|
|
||||||
|
The method `pub(crate) fn convergence<D>(&mut self, agents: ...) -> usize`
|
||||||
|
at `time_slice.rs:447` shares its name with the new field. Rust allows
|
||||||
|
this (methods and fields live in different namespaces), but it's a
|
||||||
|
readability hazard. Rename the method to `iterate_to_convergence` to
|
||||||
|
disambiguate.
|
||||||
|
|
||||||
|
This is one rename, six callsites in `history.rs` and the test module.
|
||||||
|
|
||||||
|
### Field semantics
|
||||||
|
|
||||||
|
`History` keeps the single shared `ConvergenceOptions` struct. The same
|
||||||
|
`max_iter` covers both the outer sweep and each inner per-game loop.
|
||||||
|
The same `epsilon` covers both stopping criteria. The `alpha` field is
|
||||||
|
read only inside `run_chain` (the inner loop); the outer loop
|
||||||
|
intentionally ignores `alpha` because cross-history damping is a
|
||||||
|
different mathematical concept and not in scope.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
### Regression net
|
||||||
|
|
||||||
|
The existing 98 lib + 27 integration tests are the bit-equal regression
|
||||||
|
net. Default `ConvergenceOptions` is unchanged
|
||||||
|
(`max_iter=30, epsilon=1e-6, alpha=1.0`), and `TimeSlice` was already
|
||||||
|
using exactly that since T5. The only behavioural difference is for
|
||||||
|
users who actually pass non-default options through
|
||||||
|
`HistoryBuilder::convergence(...)` — and there are no current tests that
|
||||||
|
do that **and** compare posteriors, so all goldens stay bit-equal.
|
||||||
|
|
||||||
|
### New tests
|
||||||
|
|
||||||
|
1. **`history_propagates_convergence_to_inner_run_chain`** (in
|
||||||
|
`src/history.rs` test module):
|
||||||
|
- Build a History with `convergence(ConvergenceOptions { max_iter: 1, ..Default::default() })`.
|
||||||
|
- Add a small batch of events that needs more than one inner EP iteration to converge (e.g. a 4-team game per slice).
|
||||||
|
- `converge()`, capture posteriors.
|
||||||
|
- Build a fresh History with default options on the same events.
|
||||||
|
- `converge()`, capture posteriors.
|
||||||
|
- Assert the two sets of posteriors differ measurably (max diff > 1e-6).
|
||||||
|
- Proves the inner loop honours the propagated `max_iter`. Today (without this change) the assertion would fail because both Histories use default inside.
|
||||||
|
|
||||||
|
2. **`history_with_damping_reaches_same_fixed_point_as_undamped`** (same
|
||||||
|
test module):
|
||||||
|
- Build a History with `convergence(ConvergenceOptions { alpha: 0.5, max_iter: 200, ..Default::default() })`.
|
||||||
|
- Same events as above.
|
||||||
|
- `converge()`, capture posteriors.
|
||||||
|
- Build a default-options History on the same events.
|
||||||
|
- `converge()`, capture posteriors.
|
||||||
|
- Assert per-player posteriors agree within 1e-3.
|
||||||
|
- Proves damping doesn't break convergence on the History path.
|
||||||
|
|
||||||
|
If the second test's max diff is too large, raise `max_iter` further
|
||||||
|
(damping needs more iterations to reach the same fixed point).
|
||||||
|
|
||||||
|
## Verification gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo +nightly fmt
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo test --lib
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
All must succeed. Test count grows by exactly 2 (the two new tests).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`TimeSlice::new` is `pub`.** Adding the third parameter is a
|
||||||
|
breaking change to a public constructor. In a 0.1.x crate this is
|
||||||
|
acceptable, but flag it in the commit message.
|
||||||
|
- **`TimeSlice::convergence` method rename.** Renaming
|
||||||
|
`convergence` → `iterate_to_convergence` touches `history.rs` and the
|
||||||
|
TimeSlice test module. The rename is mechanical and improves
|
||||||
|
readability where the field and method would otherwise share a name.
|
||||||
|
- **Cross-history alpha semantics.** A user who sets `alpha = 0.5` on
|
||||||
|
a `History` gets damping inside every per-game loop, but the outer
|
||||||
|
`History::converge` sweep is undamped. This is the correct semantic
|
||||||
|
(alpha is a within-EP-graph concept) but it's worth documenting in
|
||||||
|
the `ConvergenceOptions::alpha` doc comment so users don't expect
|
||||||
|
cross-slice damping. Add one sentence to the existing doc comment.
|
||||||
|
|
||||||
|
## Out-of-scope follow-ups
|
||||||
|
|
||||||
|
- Wire `Schedule` trait into `run_chain` — Damped becomes a `Schedule`
|
||||||
|
impl alongside `EpsilonOrMax`.
|
||||||
|
- Per-loop `ConvergenceOptions` split (outer / inner).
|
||||||
|
- `Residual` schedule.
|
||||||
|
- Per-event `EventKind::Scored.score_sigma` override (still
|
||||||
|
history-wide today).
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# Per-Event `score_sigma` Override
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Let users specify a per-event noise override on `Outcome::Scored`.
|
||||||
|
Today every scored event in a `History` shares the single
|
||||||
|
`HistoryBuilder::score_sigma` value (default `1.0`); a user who wants
|
||||||
|
to say "this match was a clean blowout, trust the margin more" or
|
||||||
|
"this one was a disrupted scrappy game, trust it less" has no way to
|
||||||
|
do so.
|
||||||
|
|
||||||
|
The override is resolved at ingest time and stored as a plain `f64`
|
||||||
|
on the existing `EventKind::Scored { score_sigma }` payload, so
|
||||||
|
`TimeSlice` and `run_chain` need zero changes. The work is purely on
|
||||||
|
the public API surface: `Outcome::Scored` becomes a struct variant
|
||||||
|
with an `Option<f64> sigma` field; two builder methods on `Outcome`
|
||||||
|
and `EventBuilder` cover the explicit-override path.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
`Outcome::Scored(SmallVec<[f64; 4]>)` is the public per-team-score
|
||||||
|
variant (`src/outcome.rs:20`). It's constructed via
|
||||||
|
`Outcome::scores(I)` (`src/outcome.rs:44`) or
|
||||||
|
`EventBuilder::scores(I)` (`src/event_builder.rs:79`).
|
||||||
|
|
||||||
|
When `History::add_events` ingests a Scored outcome, it always uses
|
||||||
|
the history-wide default:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/history.rs:735-740
|
||||||
|
crate::Outcome::Scored(scores) => {
|
||||||
|
kinds.push(EventKind::Scored {
|
||||||
|
score_sigma: self.score_sigma,
|
||||||
|
});
|
||||||
|
scores.to_vec()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The downstream `EventKind::Scored { score_sigma: f64 }`
|
||||||
|
(`src/time_slice.rs:51`) is already per-event-shaped — every Event
|
||||||
|
carries its own copy. The constraint is purely at the ingest boundary.
|
||||||
|
|
||||||
|
This was flagged as deferred tech debt during the T4-MarginFactor
|
||||||
|
work: "EventKind::Scored.score_sigma payload is always history-wide
|
||||||
|
today; per-event override deferred."
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### What ships
|
||||||
|
|
||||||
|
1. `Outcome::Scored` becomes a struct variant:
|
||||||
|
`Scored { scores: SmallVec<[f64; 4]>, sigma: Option<f64> }`.
|
||||||
|
`None` = use history default; `Some(s)` = override.
|
||||||
|
2. New constructor `Outcome::scores_with_sigma(scores, sigma)` on
|
||||||
|
`Outcome`. Existing `Outcome::scores(I)` keeps the same shape but
|
||||||
|
builds with `sigma: None`.
|
||||||
|
3. New builder method `EventBuilder::scores_with_sigma(scores, sigma)`
|
||||||
|
on `EventBuilder`.
|
||||||
|
4. `History::add_events` resolves `sigma.unwrap_or(self.score_sigma)`
|
||||||
|
when converting an `Outcome::Scored` to `EventKind::Scored`.
|
||||||
|
5. Mechanical pattern-match updates at every site that destructures
|
||||||
|
`Outcome::Scored(...)` as a tuple. Estimate ~5–10 sites across
|
||||||
|
`src/`, `tests/`, `examples/`, `benches/`.
|
||||||
|
|
||||||
|
### What does not ship
|
||||||
|
|
||||||
|
- No change to `EventKind::Scored` (already per-event).
|
||||||
|
- No change to `TimeSlice` or `run_chain`.
|
||||||
|
- No change to `Game::scored` standalone API
|
||||||
|
(it still takes `score_sigma` via `GameOptions::score_sigma`).
|
||||||
|
- No deprecation of `HistoryBuilder::score_sigma` — the history-wide
|
||||||
|
default is still useful as a common-case fallback.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### `Outcome` enum change
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/outcome.rs
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum Outcome {
|
||||||
|
Ranked(SmallVec<[u32; 4]>),
|
||||||
|
Scored {
|
||||||
|
scores: SmallVec<[f64; 4]>,
|
||||||
|
/// Per-event noise override. `None` means inherit
|
||||||
|
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
|
||||||
|
sigma: Option<f64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The variant shape changes from tuple to struct. Pattern matches that
|
||||||
|
extract the scores switch from `Outcome::Scored(scores)` to
|
||||||
|
`Outcome::Scored { scores, .. }` (or `{ scores, sigma }` where the
|
||||||
|
sigma is needed).
|
||||||
|
|
||||||
|
### `Outcome` constructors
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl Outcome {
|
||||||
|
/// Per-team continuous scores; uses HistoryBuilder::score_sigma default.
|
||||||
|
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
|
||||||
|
Self::Scored {
|
||||||
|
scores: scores.into_iter().collect(),
|
||||||
|
sigma: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-team scores with explicit per-event noise override.
|
||||||
|
///
|
||||||
|
/// `sigma` must be > 0.0; debug_assert.
|
||||||
|
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(
|
||||||
|
scores: I,
|
||||||
|
sigma: f64,
|
||||||
|
) -> Self {
|
||||||
|
debug_assert!(sigma > 0.0, "score_sigma must be > 0.0 (got {sigma})");
|
||||||
|
Self::Scored {
|
||||||
|
scores: scores.into_iter().collect(),
|
||||||
|
sigma: Some(sigma),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Outcome::scores(I)` keeps the existing function signature exactly —
|
||||||
|
its only behavioural change is the internal struct construction. The
|
||||||
|
existing `as_scores()`, `team_count()`, etc. accessors keep their
|
||||||
|
public signatures (they return `Option<&[f64]>` and `usize`); their
|
||||||
|
internal pattern matches update mechanically.
|
||||||
|
|
||||||
|
### `EventBuilder` method
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
|
||||||
|
where
|
||||||
|
T: Time,
|
||||||
|
D: Drift<T>,
|
||||||
|
O: Observer<T>,
|
||||||
|
K: Eq + std::hash::Hash + Clone,
|
||||||
|
{
|
||||||
|
/// Per-team scores; uses HistoryBuilder::score_sigma default.
|
||||||
|
pub fn scores<I: IntoIterator<Item = f64>>(mut self, scores: I) -> Self {
|
||||||
|
self.event.outcome = crate::Outcome::scores(scores);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-team scores with explicit per-event noise override.
|
||||||
|
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(
|
||||||
|
mut self,
|
||||||
|
scores: I,
|
||||||
|
sigma: f64,
|
||||||
|
) -> Self {
|
||||||
|
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `.scores(...)` builder method stays — its body changes
|
||||||
|
trivially because `Outcome::scores(I)` still has the same signature.
|
||||||
|
`.scores_with_sigma(...)` is the new method.
|
||||||
|
|
||||||
|
### Sigma resolution
|
||||||
|
|
||||||
|
In `History::add_events` at `src/history.rs:735`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
crate::Outcome::Scored { scores, sigma } => {
|
||||||
|
let resolved = sigma.unwrap_or(self.score_sigma);
|
||||||
|
debug_assert!(
|
||||||
|
resolved > 0.0,
|
||||||
|
"resolved score_sigma must be > 0.0 (got {resolved})"
|
||||||
|
);
|
||||||
|
kinds.push(EventKind::Scored {
|
||||||
|
score_sigma: resolved,
|
||||||
|
});
|
||||||
|
scores.to_vec()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution at ingest time means downstream code keeps a plain `f64`.
|
||||||
|
No `Option` propagates further.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- `Outcome::scores_with_sigma(_, sigma)` debug-asserts `sigma > 0.0`
|
||||||
|
at construction.
|
||||||
|
- `History::add_events` debug-asserts the resolved sigma is `> 0.0`
|
||||||
|
(catches both inherited and overridden paths).
|
||||||
|
- `HistoryBuilder::score_sigma(s)` keeps its existing positive
|
||||||
|
assertion.
|
||||||
|
|
||||||
|
The default sigma at the History level (`1.0`) is positive, so an
|
||||||
|
event with `sigma = None` against a default-built History always
|
||||||
|
passes the resolved-sigma assertion trivially.
|
||||||
|
|
||||||
|
### Pattern-match update inventory
|
||||||
|
|
||||||
|
Every site that destructures `Outcome::Scored(_)` as a tuple needs
|
||||||
|
updating. Known sites:
|
||||||
|
|
||||||
|
- `src/outcome.rs`: the `team_count()`, `as_scores()`, `as_ranks()`
|
||||||
|
match arms (`src/outcome.rs:51`, `:58`, `:64`).
|
||||||
|
- `src/history.rs:735`: the conversion arm (this is also where the
|
||||||
|
resolution rule lands).
|
||||||
|
- Any test in `src/outcome.rs` test mod that constructs
|
||||||
|
`Outcome::Scored(...)` literally.
|
||||||
|
- Any callsite in `src/`, `tests/`, `examples/`, `benches/`,
|
||||||
|
`src/game.rs` that pattern-matches the variant.
|
||||||
|
|
||||||
|
The compiler surfaces every site at `cargo build`. Locating them is
|
||||||
|
mechanical.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
### Regression net
|
||||||
|
|
||||||
|
Existing 100 lib + 27 integration tests are the bit-equal regression
|
||||||
|
net for the `sigma = None` path. Every existing test that uses
|
||||||
|
`Outcome::scores(...)` or `EventBuilder::scores(...)` should
|
||||||
|
continue to produce identical posteriors — the resolved sigma equals
|
||||||
|
the history default (which equals what the hardcoded path produced).
|
||||||
|
|
||||||
|
### New tests
|
||||||
|
|
||||||
|
Three additions in the `src/history.rs` test module:
|
||||||
|
|
||||||
|
1. **`outcome_scores_default_sigma_uses_history_default`** — build a
|
||||||
|
History with `score_sigma(0.5)`, add a 2-team event via
|
||||||
|
`Outcome::scores([3.0, 1.0])` (no override), capture posteriors.
|
||||||
|
Build a second History identical except using
|
||||||
|
`Outcome::scores_with_sigma([3.0, 1.0], 0.5)` (override matches
|
||||||
|
default). Assert posteriors are bit-equal across the two paths.
|
||||||
|
|
||||||
|
2. **`outcome_scores_with_sigma_overrides_history_default`** — build a
|
||||||
|
History with `score_sigma(0.5)`, add an event via
|
||||||
|
`Outcome::scores_with_sigma([3.0, 1.0], 2.0)`. Build a second
|
||||||
|
History with `score_sigma(2.0)` and add the same event via
|
||||||
|
`Outcome::scores([3.0, 1.0])`. Assert posteriors are bit-equal.
|
||||||
|
Then build a third History with `score_sigma(0.5)` and add via
|
||||||
|
`Outcome::scores([3.0, 1.0])` (no override). Assert this third
|
||||||
|
one's posteriors differ measurably from the override path
|
||||||
|
(max diff > 1e-6) — proves the override actually changes
|
||||||
|
inference.
|
||||||
|
|
||||||
|
3. **`event_builder_scores_with_sigma_threading`** — same shape as
|
||||||
|
#2 but constructed via the fluent builder
|
||||||
|
`h.event(0).team(["a"]).team(["b"]).scores_with_sigma([3.0, 1.0], 2.0).commit()`.
|
||||||
|
Proves the builder method works end-to-end.
|
||||||
|
|
||||||
|
### Pattern-match update test impact
|
||||||
|
|
||||||
|
Existing tests in `src/outcome.rs` that construct
|
||||||
|
`Outcome::Scored(...)` literally need updating to the struct shape.
|
||||||
|
Mechanical change; no new tests required.
|
||||||
|
|
||||||
|
## Verification gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo +nightly fmt
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo test --lib
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Test count grows by 3.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Public API breaking change.** `Outcome::Scored` variant shape
|
||||||
|
changes from tuple to struct. Any downstream consumer
|
||||||
|
pattern-matching on the tuple form breaks. In a 0.1.x crate this
|
||||||
|
is acceptable; flag it in the commit message.
|
||||||
|
- **Mechanical breadth.** The pattern-match updates touch several
|
||||||
|
files. They're all caught by the compiler so the risk is low, but
|
||||||
|
the diff will look bigger than the actual logical change.
|
||||||
|
- **Two ways to do the same thing.** `Outcome::scores_with_sigma(..)`
|
||||||
|
and `EventBuilder::scores_with_sigma(..)` both produce the same
|
||||||
|
outcome. This is intentional — the constructor is the underlying
|
||||||
|
primitive; the builder method is the ergonomic wrapper. Same
|
||||||
|
pattern as the existing `Outcome::scores(..)` /
|
||||||
|
`EventBuilder::scores(..)` pair.
|
||||||
|
|
||||||
|
## Out-of-scope follow-ups
|
||||||
|
|
||||||
|
- Per-event override of other config currently history-wide
|
||||||
|
(`p_draw`, drift, beta) — same architectural pattern would apply
|
||||||
|
but each is its own design decision.
|
||||||
|
- Validation upgrade from `debug_assert!` to a `Result` at the
|
||||||
|
Outcome construction boundary.
|
||||||
|
- Schedule trait integration with `run_chain`, `Residual` schedule,
|
||||||
|
`SynergyFactor` (still pending from the larger spec).
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Tech Debt Cleanup — Post-T4-MarginFactor
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Three small, independent cleanups left behind by the T4-MarginFactor merge
|
||||||
|
(`8b53cac`). All three are pure code-shape or doc fixes. No public-API change,
|
||||||
|
no numerics change, no new behavior.
|
||||||
|
|
||||||
|
This batch deliberately excludes the `DiffFactor` ↔ `BuiltinFactor` overlap
|
||||||
|
collapse (architectural change kept separate) and per-event `score_sigma`
|
||||||
|
override (a feature, not debt).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Item 1 — Deduplicate `Game::likelihoods` and `Game::likelihoods_scored`
|
||||||
|
|
||||||
|
**Current state.** `src/game.rs:236-371` and `src/game.rs:373-485` are 95-line
|
||||||
|
near-duplicates of each other. They differ in exactly one block: the closure
|
||||||
|
that maps a diff index to a `DiffFactor`. The ranked path builds
|
||||||
|
`DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))` with `margin`/`tie`
|
||||||
|
derived from `p_draw` and adjacent-result equality. The scored path builds
|
||||||
|
`DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))` with `m_obs`
|
||||||
|
the observed score gap. Everything else — sort, `team_prior`, sweep loop,
|
||||||
|
boundary updates, evidence product, posterior `likelihoods` — is bit-identical.
|
||||||
|
|
||||||
|
**Refactor.** Extract a private helper on `OwnedGame<T, D>`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn run_chain<F>(
|
||||||
|
&self,
|
||||||
|
arena: &mut ScratchArena,
|
||||||
|
make_link: F,
|
||||||
|
) -> (f64, Vec<Vec<Gaussian>>)
|
||||||
|
where
|
||||||
|
F: FnMut(usize, &[usize], &mut VarStore) -> DiffFactor,
|
||||||
|
```
|
||||||
|
|
||||||
|
The closure receives the diff index `i`, the descending-by-result sort
|
||||||
|
permutation `&arena.sort_buf`, and `&mut arena.vars` for `alloc(N_INF)`. It
|
||||||
|
returns the `DiffFactor` for that diff slot.
|
||||||
|
|
||||||
|
The helper takes `&self` (not `&mut self`) and returns
|
||||||
|
`(evidence, likelihoods)`. Each caller writes the results back to its own
|
||||||
|
`self.evidence` and `self.likelihoods` fields. The `&self` choice matters: the
|
||||||
|
closure captures `&self.result` / `&self.teams` / `&self.weights` / `&self.p_draw`
|
||||||
|
freely without conflicting with the helper's own immutable borrow.
|
||||||
|
|
||||||
|
The two public methods shrink from ~125 lines each to ~10 lines that just
|
||||||
|
construct the closure.
|
||||||
|
|
||||||
|
**Why a closure (not a trait or two-phase build).** A closure keeps all
|
||||||
|
caller-specific state (`p_draw`, `score_sigma`, beta sums for margin) inline at
|
||||||
|
the call site. A trait would require a stateful object per call; a two-phase
|
||||||
|
build (caller produces the `Vec<DiffFactor>` first, helper does the rest) would
|
||||||
|
either re-do the sort or split state ownership awkwardly between phases.
|
||||||
|
|
||||||
|
### Item 2 — Make `BuiltinFactor::log_evidence` exhaustive
|
||||||
|
|
||||||
|
**Current state.** `src/factor/mod.rs:94-100` uses a `_ => 0.0` wildcard for
|
||||||
|
`TeamSum` and `RankDiff`. When a future variant lands (e.g. `SynergyFactor`),
|
||||||
|
the wildcard silently absorbs it instead of forcing a deliberate decision.
|
||||||
|
|
||||||
|
**Refactor.**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn log_evidence(&self, vars: &VarStore) -> f64 {
|
||||||
|
match self {
|
||||||
|
Self::Trunc(f) => f.log_evidence(vars),
|
||||||
|
Self::Margin(f) => f.log_evidence(vars),
|
||||||
|
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No behavioral change. Future variants now produce a non-exhaustive-match
|
||||||
|
compile error.
|
||||||
|
|
||||||
|
### Item 3 — Fix stale numerics in T4 plan doc
|
||||||
|
|
||||||
|
**Current state.** `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`
|
||||||
|
contains two numbers that diverge from the values asserted by the shipped test
|
||||||
|
in `src/factor/mod.rs:163,166`.
|
||||||
|
|
||||||
|
**Fix.**
|
||||||
|
|
||||||
|
| Doc value (wrong) | Implementation value (correct) |
|
||||||
|
|---|---|
|
||||||
|
| `0.046827` | `0.04678` |
|
||||||
|
| `-3.0613` | `-3.0622` |
|
||||||
|
|
||||||
|
Pure docs change. Verified by reading the asserted constants in the test.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **`DiffFactor` ↔ `BuiltinFactor` overlap.** Both enums list `Trunc` and
|
||||||
|
`Margin` variants. Collapsing into `BuiltinFactor::Diff(DiffFactor)` is
|
||||||
|
defensible but is an architectural change that wants its own design pass.
|
||||||
|
`DiffFactor` represents a real semantic subset (factors that operate on a
|
||||||
|
diff variable in a chain); the duplication is two enum variants, not a
|
||||||
|
large block of code.
|
||||||
|
- **Per-event `EventKind::Scored.score_sigma` override.** Today
|
||||||
|
`score_sigma` is history-wide (set on `HistoryBuilder::score_sigma`). A
|
||||||
|
per-event override is a real feature ask, not tech debt.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Each item commits independently and ships behind a green `cargo test --lib`
|
||||||
|
run. The dedup is a pure code-shape change: posteriors and evidence must be
|
||||||
|
**bit-equal** (not ULP-bounded) against the existing 88+28 test goldens.
|
||||||
|
|
||||||
|
Per-item gate before committing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo +nightly fmt
|
||||||
|
cargo clippy
|
||||||
|
cargo test --lib
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit shape
|
||||||
|
|
||||||
|
Three commits, one per item, each independently revertable:
|
||||||
|
|
||||||
|
1. `refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain`
|
||||||
|
2. `refactor: make BuiltinFactor::log_evidence match exhaustive`
|
||||||
|
3. `docs: fix stale numerics in t4-margin-factor plan`
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Borrow-checker friction in Item 1.** The closure captures fields of
|
||||||
|
`&self` while the helper iterates over arena state. Mitigation: helper is
|
||||||
|
`&self` (not `&mut self`); arena passed as `&mut ScratchArena` separately.
|
||||||
|
Disjoint borrows.
|
||||||
|
- **Compile error in Item 2 if a new variant ships before this lands.**
|
||||||
|
Trivial follow-on; the whole point is to surface that signal.
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# Filtered (Forward-Only) Estimates
|
||||||
|
|
||||||
|
Closes [#19](https://git.aceofba.se/logaritmisk/trueskill-tt/issues/19).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`HistoryBuilder::online(true)` is inert. It flips a flag that reaches
|
||||||
|
`Item::within_prior` (`src/time_slice.rs:70-71`), which reads
|
||||||
|
`Skill.online` (`src/time_slice.rs:25`) — a field initialised to `N_INF`
|
||||||
|
(`src/time_slice.rs:41`) and never assigned anywhere. The online path
|
||||||
|
therefore builds every rating from the improper Gaussian, and
|
||||||
|
`log_evidence()` silently reports `n × ln(0.5)`: every game scored as a
|
||||||
|
coin flip, finite and plausible-looking.
|
||||||
|
|
||||||
|
This spec replaces the field and the flag with a **read-only forward-only
|
||||||
|
pass** over the converged history, exposed as three new public methods.
|
||||||
|
The pass reuses the production within-slice sweep verbatim rather than
|
||||||
|
reimplementing inference, and stores nothing on `Skill`.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
### Why a stored field cannot hold this quantity
|
||||||
|
|
||||||
|
The issue proposes populating `skill.online` during the forward pass,
|
||||||
|
alongside `new_forward_info` (`src/time_slice.rs:576`). That would not
|
||||||
|
work, and understanding why determines the whole design.
|
||||||
|
|
||||||
|
`new_forward_info` sets `skill.forward` from
|
||||||
|
`agents[a].receive_for_elapsed(...)`, whose `message` was written by the
|
||||||
|
previous slice's `forward_prior_out` (`src/time_slice.rs:549`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
skill.forward * skill.likelihood
|
||||||
|
```
|
||||||
|
|
||||||
|
`History::iteration` (`src/history.rs:255`) alternates a backward sweep
|
||||||
|
over slices and a forward sweep. From the second iteration onward, the
|
||||||
|
`skill.likelihood` feeding that message has already absorbed backward
|
||||||
|
information from the preceding backward sweep. So after `converge()`,
|
||||||
|
**`skill.forward` is a smoothed quantity, not a filtering one** — and any
|
||||||
|
field written from it inherits the same contamination on every sweep
|
||||||
|
after the first.
|
||||||
|
|
||||||
|
### The neighbouring trap
|
||||||
|
|
||||||
|
The same reasoning applies to the existing `forward: bool` parameter on
|
||||||
|
`log_evidence_internal` (`src/history.rs:395`). It is a genuine filtering
|
||||||
|
quantity only on a history that has never been converged. That is why the
|
||||||
|
test at `src/history.rs:1183` can assert
|
||||||
|
|
||||||
|
```rust
|
||||||
|
assert_ulps_eq!(trueskill_log_evidence, trueskill_log_evidence_online, epsilon = 1e-6);
|
||||||
|
```
|
||||||
|
|
||||||
|
— the fixture is never converged, so the forward message still equals the
|
||||||
|
cavity prior. (Note also that the local binding is named `..._online`
|
||||||
|
while the flag it passes is `forward`; the two senses were already
|
||||||
|
muddled.)
|
||||||
|
|
||||||
|
Fixing `forward: bool` is **out of scope** here; see *Out-of-scope
|
||||||
|
follow-ups*.
|
||||||
|
|
||||||
|
### Why this is worth implementing rather than deleting
|
||||||
|
|
||||||
|
The forward-only estimate has a second consumer beyond prequential model
|
||||||
|
comparison. `learning_curve()` returns post-convergence posteriors, so
|
||||||
|
every point is smoothed — the estimate at a given date incorporates
|
||||||
|
rounds played years later. On [ustat](https://git.aceofba.se/logaritmisk/ustat)'s
|
||||||
|
real data (prior μ=0, σ=6) that produces curves which start already
|
||||||
|
spread apart and barely move:
|
||||||
|
|
||||||
|
```
|
||||||
|
player first point final point
|
||||||
|
Eskil mu +3.72 sigma 1.17 mu +4.61 sigma 1.21
|
||||||
|
Anders Olsson mu +1.61 sigma 0.90 mu +1.16 sigma 0.82
|
||||||
|
LUDVIGSSON mu -2.09 sigma 1.08 mu -2.61 sigma 1.13
|
||||||
|
Anners mu -2.85 sigma 1.27 mu -2.86 sigma 1.26
|
||||||
|
```
|
||||||
|
|
||||||
|
σ at the *first* plotted point is 0.90–1.60 against a prior of 6.00. A
|
||||||
|
caller cannot reconstruct the filtered view from the public API today
|
||||||
|
except by refitting over `events[0..k]` for every k — O(n²) fits for
|
||||||
|
something one forward pass already computes.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### What ships
|
||||||
|
|
||||||
|
1. A read-only forward-only pass on `History`, walking slices in time
|
||||||
|
order and carrying its own forward messages.
|
||||||
|
2. Three public methods: `filtered_log_evidence`,
|
||||||
|
`filtered_learning_curves`, `filtered_learning_curve`.
|
||||||
|
3. Removal of `Skill.online`, `History.online`, `HistoryBuilder.online`,
|
||||||
|
`HistoryBuilder::online()`, and the `online: bool` parameter threaded
|
||||||
|
through `Item::within_prior`, `Event::within_priors`, and
|
||||||
|
`TimeSlice::log_evidence`.
|
||||||
|
4. `#[derive(Clone)]` on `Event`, `Team`, `Item`; `iterate_to_convergence`
|
||||||
|
loses its `#[cfg(test)]` gate.
|
||||||
|
5. A CHANGELOG entry recording the API break.
|
||||||
|
|
||||||
|
### What does not ship
|
||||||
|
|
||||||
|
- No change to `log_evidence()`, `log_evidence_for()`, `learning_curve()`,
|
||||||
|
`learning_curves()`, or `current_skill()`. Their values are unchanged
|
||||||
|
by this work.
|
||||||
|
- No fix to the `forward: bool` flag described above.
|
||||||
|
- No caching of pass results. Each call runs a full pass; the doc
|
||||||
|
comments say so.
|
||||||
|
- No `rayon` parallelism across slices — the pass is sequentially
|
||||||
|
dependent by construction.
|
||||||
|
- No prior-predictive accessor. The pass computes the pre-event forward
|
||||||
|
message internally, but only the filtered posterior is exposed until a
|
||||||
|
second caller needs otherwise.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
`filtered_*`, not `online_*`. "Filtered" is the standard term for the
|
||||||
|
forward-only estimate, and the crate already uses "online" for a second,
|
||||||
|
unrelated thing — incremental ingestion, which `benches/baseline.txt:128`
|
||||||
|
calls the "online-add" path. Two senses of one word in one crate is how
|
||||||
|
the present bug reads as plausible.
|
||||||
|
|
||||||
|
### The pass
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) struct FilteredStep {
|
||||||
|
log_evidence: f64,
|
||||||
|
posteriors: Vec<(Index, Gaussian)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filtered_pass(&self) -> Vec<(T, FilteredStep)>
|
||||||
|
```
|
||||||
|
|
||||||
|
`posteriors` doubles as the outgoing forward message: the scratch sweep never
|
||||||
|
writes `backward`, so it stays `N_INF`, and `Skill::posterior()` and
|
||||||
|
`forward_prior_out` are then the same product.
|
||||||
|
|
||||||
|
Walk `self.time_slices` in order, carrying
|
||||||
|
`messages: HashMap<Index, Gaussian>` — the forward message out of each
|
||||||
|
competitor's most recent appearance. For each slice:
|
||||||
|
|
||||||
|
1. **Build a scratch clone.** Same `time`, `p_draw`, `convergence`, and
|
||||||
|
cloned `events` with every `item.likelihood` reset to `N_INF`. Fresh
|
||||||
|
`SkillStore` in which, for each agent present in the real slice:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
forward = match messages.get(&agent) {
|
||||||
|
Some(msg) => msg.forget(rating.drift.variance_for_elapsed(skill.elapsed)),
|
||||||
|
None => rating.prior,
|
||||||
|
}
|
||||||
|
backward = N_INF
|
||||||
|
likelihood = N_INF
|
||||||
|
elapsed = skill.elapsed // copied from the real slice
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors `Competitor::receive_for_elapsed` (`src/competitor.rs:39`)
|
||||||
|
exactly, including its `message != N_INF` fallback to the prior.
|
||||||
|
`skill.elapsed` is reused rather than recomputed: it is maintained by
|
||||||
|
`add_events_with_prior` across out-of-order ingestion, and production
|
||||||
|
convergence already trusts it.
|
||||||
|
|
||||||
|
2. **Run the real sweep.** `scratch.iterate_to_convergence(agents)`
|
||||||
|
(`src/time_slice.rs:516`), unmodified. Fidelity comes from reusing the
|
||||||
|
production path rather than a parallel reimplementation — in
|
||||||
|
particular, a competitor appearing in two events at the same time is
|
||||||
|
handled by the same within-slice EP that `converge()` uses, not
|
||||||
|
approximated the way the current `online`/`forward` evidence paths are
|
||||||
|
(they run each event independently and sum).
|
||||||
|
|
||||||
|
3. **Harvest.** With `backward == N_INF` acting as the multiplicative
|
||||||
|
identity, `Skill::posterior()` is exactly forward × likelihood — the
|
||||||
|
filtered posterior. Slice evidence is
|
||||||
|
`scratch.events.iter().map(|e| e.log_evidence).sum()`; `apply`
|
||||||
|
(`src/time_slice.rs:162`) writes that field on every event during the
|
||||||
|
sweep.
|
||||||
|
|
||||||
|
4. **Carry forward.** `messages.insert(a, scratch.forward_prior_out(&a))`
|
||||||
|
for each agent in the slice.
|
||||||
|
|
||||||
|
Steps 1–4 are the forward half of `History::iteration`
|
||||||
|
(`src/history.rs:283-297`) with the backward half never run. The pass
|
||||||
|
touches no field of `self`.
|
||||||
|
|
||||||
|
### Public API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
|
||||||
|
pub fn filtered_log_evidence(&self) -> f64;
|
||||||
|
pub fn filtered_learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>>;
|
||||||
|
pub fn filtered_learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
|
||||||
|
where
|
||||||
|
K: Borrow<Q>,
|
||||||
|
Q: Hash + Eq + ?Sized;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All take `&self` — the pass mutates nothing. Shapes deliberately mirror
|
||||||
|
`learning_curve` / `learning_curves` (`src/history.rs:325`, `:381`) so a
|
||||||
|
caller can plot smoothed and filtered curves on one chart with the same
|
||||||
|
handling code.
|
||||||
|
|
||||||
|
`filtered_learning_curve` runs the same full pass as the plural form and
|
||||||
|
collects one key; the cost is identical, only the collection differs.
|
||||||
|
Callers wanting several keys should use the plural form. Documented on
|
||||||
|
both methods.
|
||||||
|
|
||||||
|
Because the pass carries its own messages and re-runs inference, its
|
||||||
|
results **do not depend on whether `converge()` has been called**. That
|
||||||
|
is the property a stored field cannot have, and it is asserted as a test.
|
||||||
|
|
||||||
|
### Removal inventory
|
||||||
|
|
||||||
|
| Location | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/time_slice.rs:25` | delete `pub(crate) online: Gaussian` |
|
||||||
|
| `src/time_slice.rs:41` | delete `online: N_INF` from `Default` |
|
||||||
|
| `src/time_slice.rs:62,70-73` | drop `online` param and its branch from `Item::within_prior` |
|
||||||
|
| `src/time_slice.rs:110,120` | drop `online` param from `Event::within_priors` |
|
||||||
|
| `src/time_slice.rs:585,597,626,634` | drop `online` param from `TimeSlice::log_evidence`; `online \|\| forward` becomes `forward` |
|
||||||
|
| `src/history.rs:32,63,138,158,174,199,226` | delete the two `online` field declarations (`:32`, `:199`) and the five struct-literal copies |
|
||||||
|
| `src/history.rs:90-93` | delete `HistoryBuilder::online()` |
|
||||||
|
| `src/history.rs:402,410` | drop the `self.online` argument |
|
||||||
|
| `src/history.rs:1183-1189` | the `..._online` assertion becomes a `forward`-flag assertion; rename the binding to match what it tests |
|
||||||
|
|
||||||
|
`Skill` loses 16 bytes, which is a small independent win for #17.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
Every new test is mutation-proved before it counts: break the production
|
||||||
|
line it names, watch it fail for the *right* assertion, restore. A test
|
||||||
|
never observed failing is not evidence.
|
||||||
|
|
||||||
|
### The red test
|
||||||
|
|
||||||
|
On the issue's own fixture — five 1v1 games, same winner each time —
|
||||||
|
`filtered_log_evidence()` must land strictly between the two known
|
||||||
|
endpoints:
|
||||||
|
|
||||||
|
```
|
||||||
|
5 × ln(0.5) = -3.4657... (today's inert value)
|
||||||
|
< filtered
|
||||||
|
< -0.4012... (batch / smoothed evidence)
|
||||||
|
```
|
||||||
|
|
||||||
|
Two-sided, so neither "still inert" nor "accidentally smoothed" can pass.
|
||||||
|
The lower bound is right for a real reason: game one genuinely *is* a
|
||||||
|
coin flip under filtering, games two through five are not.
|
||||||
|
|
||||||
|
### Invariants
|
||||||
|
|
||||||
|
1. **Invariant to `converge()`** — `filtered_log_evidence()` and
|
||||||
|
`filtered_learning_curves()` agree before and after `converge()`. This
|
||||||
|
is exactly what `skill.forward` fails, and what makes a stored field
|
||||||
|
the wrong mechanism.
|
||||||
|
|
||||||
|
Agreement is to tolerance, not bit-identity, and the reason is worth
|
||||||
|
recording. `iteration` calls `recompute_color_groups`
|
||||||
|
(`src/time_slice.rs:369`) only when `from == 0`, so a slice built by
|
||||||
|
repeated appends keeps insertion order until the first `converge()`
|
||||||
|
reorders it. The scratch clone inherits whichever order it finds, and
|
||||||
|
greedy coloring over a permuted input can group differently, giving a
|
||||||
|
different within-slice sweep order — same EP fixed point, different
|
||||||
|
path to it. Follow the house pattern in
|
||||||
|
`tests/ingestion_equivalence.rs`: converge tightly (`max_iter: 2_000`,
|
||||||
|
`epsilon: 1e-12`) and compare within `1e-8`.
|
||||||
|
2. **Invariant to ingestion order** — events added one at a time produce
|
||||||
|
the same filtered results as the same events batched. Extends the
|
||||||
|
existing invariant in `tests/ingestion_equivalence.rs`.
|
||||||
|
3. **Single-slice exactness** — for a history with one time slice there
|
||||||
|
is no future to propagate back, so filtered results equal smoothed
|
||||||
|
results exactly.
|
||||||
|
4. **Uncertainty ordering** — for a competitor with many later games, σ
|
||||||
|
at the first filtered point is greater than σ at the first smoothed
|
||||||
|
point, and less than the prior σ. This is the ustat complaint restated
|
||||||
|
as an assertion.
|
||||||
|
5. **Degenerate inputs** — empty history yields `0.0` and empty maps;
|
||||||
|
unknown key yields an empty curve. Added to
|
||||||
|
`tests/degenerate_inputs.rs`.
|
||||||
|
|
||||||
|
### Regression net
|
||||||
|
|
||||||
|
The existing suite must be unchanged by the removals: `log_evidence()`,
|
||||||
|
`log_evidence_for()`, and every numerical golden keep their current
|
||||||
|
values, since the default `online` was already `false` and the flag was
|
||||||
|
inert.
|
||||||
|
|
||||||
|
## Verification gates
|
||||||
|
|
||||||
|
- `just test` — full matrix, including the release job. `debug_assert!`
|
||||||
|
is compiled out in release, and that is where defects in this crate
|
||||||
|
have hidden before.
|
||||||
|
- `just lint` — clippy, warnings denied.
|
||||||
|
- `just fmt` — nightly.
|
||||||
|
- `just determinism` — the new pass must not perturb bit-identical
|
||||||
|
posteriors across `RAYON_NUM_THREADS` 1/2/4/8.
|
||||||
|
- `#![forbid(unsafe_code)]` stays.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Clone cost.** One slice's events are cloned per slice visited. At
|
||||||
|
ustat scale this is negligible, but the pass is O(events) allocation on
|
||||||
|
top of O(events) inference. Accepted: fidelity to the production sweep
|
||||||
|
is worth more than avoiding the clone, and no caller is on a hot path.
|
||||||
|
- **`iterate_to_convergence` leaving test-only status.** Its doc comment
|
||||||
|
claims "only used by tests"; that comment must be updated, or it
|
||||||
|
becomes the next piece of load-bearing prose that is quietly false.
|
||||||
|
- **Event order is inherited, not normalised.** The scratch clone takes
|
||||||
|
the real slice's current event order, which differs pre- and
|
||||||
|
post-`converge()` for incrementally-ingested slices (see *Invariants*).
|
||||||
|
Results agree to within convergence tolerance rather than exactly.
|
||||||
|
Normalising the order in the scratch builder would buy bit-identity at
|
||||||
|
the cost of diverging from what the real sweep does; not worth it.
|
||||||
|
|
||||||
|
**Measured after implementation, this risk is smaller than stated.**
|
||||||
|
Flipping the scratch's `color_groups_dirty` from `true` to `false`
|
||||||
|
switches it between the grouped sweep (`sweep_color_groups`) and the
|
||||||
|
sequential fallback across its entire convergence loop — a far larger
|
||||||
|
perturbation than a permuted event order — and the ingestion-order
|
||||||
|
invariance test stays green at `1e-8` under `max_iter: 2_000`,
|
||||||
|
`epsilon: 1e-12`. EP reaches the same fixed point regardless of sweep
|
||||||
|
order once driven far enough. The tolerance caveat is correct but
|
||||||
|
conservative. Note the flag itself is load-bearing: with it `false` the
|
||||||
|
scratch would take the sequential path always, diverging from the
|
||||||
|
production sweep it exists to mirror.
|
||||||
|
- **Divergence risk.** If `TimeSlice`'s sweep gains state that the
|
||||||
|
scratch construction does not initialise, the pass silently reads a
|
||||||
|
default. The scratch builder must construct `Skill` field-by-field
|
||||||
|
rather than via `..Default::default()`, so adding a field to `Skill`
|
||||||
|
is a compile error here rather than a silent wrong answer.
|
||||||
|
|
||||||
|
## Out-of-scope follow-ups
|
||||||
|
|
||||||
|
File as separate issues:
|
||||||
|
|
||||||
|
1. **`forward: bool` is only a filtering quantity pre-convergence**
|
||||||
|
(`src/history.rs:395`). Either document the constraint or fold the
|
||||||
|
flag into the new pass and delete it.
|
||||||
|
2. **`log_evidence` takes `&mut self`** (`src/history.rs:416`) but
|
||||||
|
mutates nothing. The new `filtered_*` methods take `&self`; the
|
||||||
|
asymmetry is worth removing.
|
||||||
+74
-51
@@ -1,50 +1,83 @@
|
|||||||
use plotters::prelude::*;
|
use plotters::prelude::*;
|
||||||
use time::{Date, Month};
|
use time::{Date, Month};
|
||||||
use trueskill_tt::{History, IndexMap};
|
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();
|
||||||
|
|
||||||
let mut composition = Vec::new();
|
|
||||||
let mut results = Vec::new();
|
|
||||||
let mut times = Vec::new();
|
|
||||||
|
|
||||||
let from = Date::from_calendar_date(1900, Month::January, 1).unwrap();
|
let from = Date::from_calendar_date(1900, Month::January, 1).unwrap();
|
||||||
let time_format = time::format_description::parse("[year]-[month]-[day]").unwrap();
|
let time_format = time::format_description::parse("[year]-[month]-[day]").unwrap();
|
||||||
|
|
||||||
let mut index_map = IndexMap::new();
|
let mut events: Vec<Event<i64, String>> = Vec::new();
|
||||||
|
|
||||||
for row in csv.records() {
|
for row in csv.records() {
|
||||||
if &row["double"] == "t" {
|
|
||||||
let w1_id = index_map.get_or_create(&row["w1_id"]);
|
|
||||||
let w2_id = index_map.get_or_create(&row["w2_id"]);
|
|
||||||
|
|
||||||
let l1_id = index_map.get_or_create(&row["l1_id"]);
|
|
||||||
let l2_id = index_map.get_or_create(&row["l2_id"]);
|
|
||||||
|
|
||||||
composition.push(vec![vec![w1_id, w2_id], vec![l1_id, l2_id]]);
|
|
||||||
} else {
|
|
||||||
let w1_id = index_map.get_or_create(&row["w1_id"]);
|
|
||||||
|
|
||||||
let l1_id = index_map.get_or_create(&row["l1_id"]);
|
|
||||||
|
|
||||||
composition.push(vec![vec![w1_id], vec![l1_id]]);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push(vec![1.0, 0.0]);
|
|
||||||
|
|
||||||
let date = Date::parse(&row["time_start"], &time_format).unwrap();
|
let date = Date::parse(&row["time_start"], &time_format).unwrap();
|
||||||
|
let time = (date - from).whole_days();
|
||||||
|
|
||||||
times.push((date - from).whole_days());
|
if &row["double"] == "t" {
|
||||||
|
events.push(Event {
|
||||||
|
time,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([
|
||||||
|
Member::new(row["w1_id"].to_owned()),
|
||||||
|
Member::new(row["w2_id"].to_owned()),
|
||||||
|
]),
|
||||||
|
Team::with_members([
|
||||||
|
Member::new(row["l1_id"].to_owned()),
|
||||||
|
Member::new(row["l2_id"].to_owned()),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
events.push(Event {
|
||||||
|
time,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(row["w1_id"].to_owned())]),
|
||||||
|
Team::with_members([Member::new(row["l1_id"].to_owned())]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut hist = History::builder().sigma(1.6).gamma(0.036).build();
|
let mut hist: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.sigma(1.6)
|
||||||
|
.drift(ConstantDrift::new(0.036))
|
||||||
|
.convergence(trueskill_tt::ConvergenceOptions {
|
||||||
|
// This history needs 30 sweeps to reach the epsilon below. It was
|
||||||
|
// capped at 10 until the `#[must_use]` on `ConvergenceReport`
|
||||||
|
// surfaced that the example had been shipping a short fit.
|
||||||
|
max_iter: 100,
|
||||||
|
epsilon: 0.01,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
hist.add_events(composition, results, times, vec![]);
|
hist.add_events(events).unwrap();
|
||||||
hist.convergence(10, 0.01, true);
|
|
||||||
|
// Read the report rather than discarding it. A fit that hits `max_iter`
|
||||||
|
// without reaching `epsilon` is not an error and does not look wrong — every
|
||||||
|
// rating comes back finite and sensibly ordered — so this flag is the only
|
||||||
|
// thing that says the numbers were still moving when the sweep stopped.
|
||||||
|
let report = hist.converge().unwrap();
|
||||||
|
eprintln!(
|
||||||
|
"converged={} after {} sweeps, final step {:?}",
|
||||||
|
report.converged, report.iterations, report.final_step
|
||||||
|
);
|
||||||
|
if !report.converged {
|
||||||
|
eprintln!(
|
||||||
|
"warning: stopped after {} sweeps with a final step of {:?}, \
|
||||||
|
short of epsilon — raise ConvergenceOptions::max_iter",
|
||||||
|
report.iterations, report.final_step
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let players = [
|
let players = [
|
||||||
("aggasi", "a092", 38800),
|
("aggasi", "a092", 38800i64),
|
||||||
("borg", "b058", 30300),
|
("borg", "b058", 30300),
|
||||||
("connors", "c044", 31250),
|
("connors", "c044", 31250),
|
||||||
("courier", "c243", 35750),
|
("courier", "c243", 35750),
|
||||||
@@ -61,21 +94,16 @@ fn main() {
|
|||||||
("wilander", "w023", 32600),
|
("wilander", "w023", 32600),
|
||||||
];
|
];
|
||||||
|
|
||||||
let curves = hist.learning_curves();
|
|
||||||
|
|
||||||
let mut x_spec = (f64::MAX, f64::MIN);
|
let mut x_spec = (f64::MAX, f64::MIN);
|
||||||
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 {
|
||||||
.iter()
|
for (ts, gs) in hist.learning_curve(id).unwrap() {
|
||||||
.map(|&(_, id, cutoff)| (index_map.get_or_create(id), cutoff))
|
if ts >= cutoff {
|
||||||
{
|
|
||||||
for (ts, gs) in &curves[&id] {
|
|
||||||
if *ts >= cutoff {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ts = *ts as f64;
|
let ts = ts as f64;
|
||||||
|
|
||||||
if ts < x_spec.0 {
|
if ts < x_spec.0 {
|
||||||
x_spec.0 = ts;
|
x_spec.0 = ts;
|
||||||
@@ -85,8 +113,8 @@ fn main() {
|
|||||||
x_spec.1 = ts;
|
x_spec.1 = ts;
|
||||||
}
|
}
|
||||||
|
|
||||||
let upper = gs.mu + gs.sigma;
|
let upper = gs.mu() + gs.sigma();
|
||||||
let lower = gs.mu - gs.sigma;
|
let lower = gs.mu() - gs.sigma();
|
||||||
|
|
||||||
if lower < y_spec.0 {
|
if lower < y_spec.0 {
|
||||||
y_spec.0 = lower;
|
y_spec.0 = lower;
|
||||||
@@ -111,24 +139,19 @@ fn main() {
|
|||||||
|
|
||||||
chart.configure_mesh().draw().unwrap();
|
chart.configure_mesh().draw().unwrap();
|
||||||
|
|
||||||
for (idx, (player, id, cutoff)) in players
|
for (idx, &(player, id, cutoff)) in players.iter().enumerate() {
|
||||||
.iter()
|
|
||||||
.map(|&(player, id, cutoff)| (player, index_map.get_or_create(id), cutoff))
|
|
||||||
.enumerate()
|
|
||||||
{
|
|
||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
let mut upper = Vec::new();
|
let mut upper = Vec::new();
|
||||||
let mut lower = Vec::new();
|
let mut lower = Vec::new();
|
||||||
|
|
||||||
for (ts, gs) in curves[&id].iter() {
|
for (ts, gs) in hist.learning_curve(id).unwrap() {
|
||||||
if *ts >= cutoff {
|
if ts >= cutoff {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
data.push((*ts as f64, gs.mu));
|
data.push((ts as f64, gs.mu()));
|
||||||
|
upper.push((ts as f64, gs.mu() + gs.sigma()));
|
||||||
upper.push((*ts as f64, gs.mu + gs.sigma));
|
lower.push((ts as f64, gs.mu() - gs.sigma()));
|
||||||
lower.push((*ts as f64, gs.mu - gs.sigma));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let color = Palette99::pick(idx);
|
let color = Palette99::pick(idx);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//! Worked example: continuous-score outcomes via `Outcome::Scored`.
|
||||||
|
//!
|
||||||
|
//! Three players play a small round-robin where the score margin matters,
|
||||||
|
//! not just who won. We show how `score_sigma` controls how much weight
|
||||||
|
//! the engine places on the observed margin.
|
||||||
|
//!
|
||||||
|
//! Run with: `cargo run --example scored --release`
|
||||||
|
|
||||||
|
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team, smallvec::smallvec};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.drift(ConstantDrift::new(0.03))
|
||||||
|
.score_sigma(2.0) // tune to data; smaller = trust margins more
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let events: Vec<Event<i64, &'static str>> = vec![
|
||||||
|
Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("alice")]),
|
||||||
|
Team::with_members([Member::new("bob")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([21.0, 9.0]),
|
||||||
|
},
|
||||||
|
Event {
|
||||||
|
time: 2,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("bob")]),
|
||||||
|
Team::with_members([Member::new("carol")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([21.0, 18.0]),
|
||||||
|
},
|
||||||
|
Event {
|
||||||
|
time: 3,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("alice")]),
|
||||||
|
Team::with_members([Member::new("carol")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([21.0, 21.0]),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
println!(
|
||||||
|
"converged={}, iterations={}, log_evidence={:.4}",
|
||||||
|
report.converged, report.iterations, report.log_evidence
|
||||||
|
);
|
||||||
|
|
||||||
|
for who in &["alice", "bob", "carol"] {
|
||||||
|
let s = h.current_skill(who).unwrap();
|
||||||
|
println!("{:>6}: mu={:>7.3} sigma={:.3}", who, s.mu(), s.sigma());
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-2
@@ -1,2 +1,14 @@
|
|||||||
publish = false
|
# Publish to the registry named in Cargo.toml's `publish` list (kellnr).
|
||||||
pre-release-hook = ["sh", "-c", "git cliff -o ../CHANGELOG.md --tag {{version}} && git add CHANGELOG.md"]
|
publish = true
|
||||||
|
# Hold off pushing until tags and publish have both succeeded; `just release`
|
||||||
|
# pushes last.
|
||||||
|
push = false
|
||||||
|
# Regenerate the changelog and stage it so it lands in the release commit.
|
||||||
|
#
|
||||||
|
# Guarded on DRY_RUN because cargo-release runs pre-release hooks during a dry
|
||||||
|
# run too (verified against cargo-release 1.1.5, which exports DRY_RUN=true,
|
||||||
|
# CRATE_NAME, PREV_VERSION and NEW_VERSION to the hook). Without the guard,
|
||||||
|
# `just release-plan` — documented as a preview that writes nothing — writes and
|
||||||
|
# `git add`s CHANGELOG.md, and the clean-tree check in `just release` then
|
||||||
|
# refuses to run. That check is load-bearing: publishing is irreversible.
|
||||||
|
pre-release-hook = ["sh", "-c", '[ "$DRY_RUN" = "true" ] || (git cliff -o CHANGELOG.md --tag {{version}} && git add CHANGELOG.md)']
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
//! Active learning: which comparison teaches you the most.
|
||||||
|
//!
|
||||||
|
//! [`quality`](crate::quality) answers "is this matchup *fair*". That is a
|
||||||
|
//! different question from "is this matchup *informative*", and the two
|
||||||
|
//! coincide only for two evenly matched competitors. When each observation
|
||||||
|
//! costs something — a human click, a scheduled fixture — the question worth
|
||||||
|
//! asking is the second one.
|
||||||
|
//!
|
||||||
|
//! The quantity here is expected information gain: the outcome-weighted
|
||||||
|
//! divergence between what you believe now and what you would believe after
|
||||||
|
//! seeing the result.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! EIG(matchup) = SUM P(outcome) * KL( posterior_after(outcome) || prior )
|
||||||
|
//! outcome
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! It is the mutual information between the observed outcome and the skills,
|
||||||
|
//! which is worth remembering because it pins the scale: information gain
|
||||||
|
//! cannot exceed the entropy of the thing you are about to observe. A contest
|
||||||
|
//! with `k` distinguishable outcomes can teach you at most `ln k` nats,
|
||||||
|
//! whatever the ratings. That ceiling is the sharpest available test of an
|
||||||
|
//! implementation — see [`expected_information_gain`].
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
GameOptions, Gaussian, InferenceError, Outcome, Rating, drift::Drift, predict, time::Time,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Outcomes below this probability contribute nothing measurable and are not
|
||||||
|
/// worth an inference pass.
|
||||||
|
///
|
||||||
|
/// The contribution of an outcome is `P * KL`, and `KL` is bounded in practice
|
||||||
|
/// by tens of nats, so a probability this small moves the total by less than
|
||||||
|
/// the quadrature error already present in `P` itself.
|
||||||
|
const NEGLIGIBLE: f64 = 1e-12;
|
||||||
|
|
||||||
|
/// `KL(q || p)` for two univariate Gaussians, in nats.
|
||||||
|
///
|
||||||
|
/// Both arguments are proper posteriors from inference, so the degenerate
|
||||||
|
/// cases guarded here (zero or infinite variance) indicate that inference has
|
||||||
|
/// broken down rather than anything a caller did.
|
||||||
|
fn kl_divergence(q: Gaussian, p: Gaussian) -> f64 {
|
||||||
|
let (var_q, var_p) = (q.sigma().powi(2), p.sigma().powi(2));
|
||||||
|
|
||||||
|
if !(var_q.is_finite() && var_p.is_finite()) || var_q <= 0.0 || var_p <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mean_gap = q.mu() - p.mu();
|
||||||
|
|
||||||
|
// Algebraically `0.5 * (ln(var_p/var_q) + (var_q + gap^2)/var_p - 1)`, but
|
||||||
|
// written so that neither term can go negative.
|
||||||
|
//
|
||||||
|
// The direct form cancels against its `- 1.0` for two near-identical
|
||||||
|
// distributions and returns a *negative* divergence — measured, 762 082 of
|
||||||
|
// 3 000 000 near-identical pairs, worst `-5.55e-17`, which is exactly one
|
||||||
|
// ULP of the 1.0. It also loses the answer entirely where it is small:
|
||||||
|
// at `var_q/var_p - 1 = 1e-9` the direct form gives `0.0` where the true
|
||||||
|
// value is `2.5e-19`.
|
||||||
|
//
|
||||||
|
// With `u = var_q/var_p - 1` the variance part is `0.5 * (u - ln(1+u))`,
|
||||||
|
// which is non-negative for every `u > -1`, and the mean part is a square
|
||||||
|
// over a positive variance. Non-negativity is then structural rather than
|
||||||
|
// incidental.
|
||||||
|
let u = var_q / var_p - 1.0;
|
||||||
|
0.5 * u_minus_ln1p(u) + mean_gap * mean_gap / (2.0 * var_p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `u - ln(1 + u)`, without the cancellation that spelling invites.
|
||||||
|
///
|
||||||
|
/// Both terms are approximately `u` for small `u`, so the subtraction loses
|
||||||
|
/// everything just where the result matters. The Taylor series
|
||||||
|
/// `u^2/2 - u^3/3 + u^4/4 - ...` is exact in that regime and manifestly
|
||||||
|
/// non-negative, since `u^2/2` dominates.
|
||||||
|
fn u_minus_ln1p(u: f64) -> f64 {
|
||||||
|
if u.abs() < 1e-4 {
|
||||||
|
let u2 = u * u;
|
||||||
|
u2 * (0.5 - u / 3.0 + u2 / 4.0)
|
||||||
|
} else {
|
||||||
|
u - libm::log1p(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expected information gain of a hypothetical matchup, in nats.
|
||||||
|
///
|
||||||
|
/// Enumerates the outcomes this matchup could have, runs inference for each to
|
||||||
|
/// get the belief it would produce, and weights the resulting divergence by
|
||||||
|
/// that outcome's probability. A higher value means the result would teach you
|
||||||
|
/// more.
|
||||||
|
///
|
||||||
|
/// # Interpreting the value
|
||||||
|
///
|
||||||
|
/// Nats. The upper bound is the entropy of the outcome variable: at most
|
||||||
|
/// `ln 2 ≈ 0.693` for a two-way result, `ln 3 ≈ 1.099` once draws are
|
||||||
|
/// possible, `ln k` for `k` outcomes. A value near the ceiling means the
|
||||||
|
/// result is close to a coin flip *and* would move the posteriors a long way;
|
||||||
|
/// a value near zero means you already know what will happen, or that the
|
||||||
|
/// result would barely change your beliefs if you saw it.
|
||||||
|
///
|
||||||
|
/// This is not a monotone transform of [`quality`](crate::quality). A lopsided
|
||||||
|
/// matchup between two uncertain competitors scores well on quality-times-
|
||||||
|
/// variance heuristics and poorly here, because the near-certain outcome
|
||||||
|
/// carries almost no information.
|
||||||
|
///
|
||||||
|
/// # Cost
|
||||||
|
///
|
||||||
|
/// One full inference pass per possible outcome, so this is far more expensive
|
||||||
|
/// than `quality()` — which is one closed-form evaluation. The outcome count
|
||||||
|
/// grows quickly with team count (3 outcomes for two teams that can draw, 13
|
||||||
|
/// for three, 75 for four), and scoring every candidate pairing among `n`
|
||||||
|
/// competitors is `O(n² × outcomes)` inference passes.
|
||||||
|
///
|
||||||
|
/// For a selector over many candidates, shortlist with the cheap
|
||||||
|
/// [`quality`](crate::quality) or
|
||||||
|
/// [`predict_win_probabilities`](crate::History::predict_win_probabilities)
|
||||||
|
/// first and score only the shortlist here. The expected-variance-reduction
|
||||||
|
/// proxy sometimes suggested as a cheaper alternative is *not* cheaper: it
|
||||||
|
/// needs the same hypothetical posteriors, so it shares the dominant cost.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - `NotEnoughTeams` if fewer than two teams are supplied.
|
||||||
|
/// - `EmptyTeam` if any team has no members.
|
||||||
|
/// - `TooManyTeams` if the outcome space is too large to enumerate; see
|
||||||
|
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
|
||||||
|
/// - `InvalidParameter` for a `p_draw` outside `[0.0, 1.0)`.
|
||||||
|
/// - `GridTooCoarse` when the performance sigmas are too far apart to
|
||||||
|
/// integrate on one grid. This comes from `outcome_distribution`, which runs
|
||||||
|
/// before any inference — so it is not covered by "anything `Game::ranked`
|
||||||
|
/// returns" below.
|
||||||
|
/// - Anything [`Game::ranked`](crate::Game::ranked) returns for a hypothetical
|
||||||
|
/// outcome.
|
||||||
|
pub fn expected_information_gain<T: Time, D: Drift<T>>(
|
||||||
|
teams: &[&[Rating<T, D>]],
|
||||||
|
options: &GameOptions,
|
||||||
|
) -> Result<f64, InferenceError> {
|
||||||
|
if teams.len() < 2 {
|
||||||
|
return Err(InferenceError::NotEnoughTeams { got: teams.len() });
|
||||||
|
}
|
||||||
|
if teams.len() > crate::MAX_PREDICTED_TEAMS {
|
||||||
|
return Err(InferenceError::TooManyTeams {
|
||||||
|
got: teams.len(),
|
||||||
|
max: crate::MAX_PREDICTED_TEAMS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !(0.0..1.0).contains(&options.p_draw) {
|
||||||
|
return Err(InferenceError::InvalidParameter {
|
||||||
|
parameter: crate::Parameter::PDraw,
|
||||||
|
value: options.p_draw,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (idx, team) in teams.iter().enumerate() {
|
||||||
|
if team.is_empty() {
|
||||||
|
return Err(InferenceError::EmptyTeam { team: idx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prediction runs on performances: skill inflated by each member's beta.
|
||||||
|
let performances: Vec<Gaussian> = teams
|
||||||
|
.iter()
|
||||||
|
.map(|team| {
|
||||||
|
team.iter()
|
||||||
|
.fold(crate::N00, |acc, rating| acc.convolve(rating.performance()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Draw margins per pair, derived from the teams' betas exactly as
|
||||||
|
// inference derives them, so the outcomes weighted here are the outcomes
|
||||||
|
// that would actually be fitted.
|
||||||
|
let beta_sq: Vec<f64> = teams
|
||||||
|
.iter()
|
||||||
|
.map(|team| team.iter().map(|r| r.beta().powi(2)).sum())
|
||||||
|
.collect();
|
||||||
|
let p_draw = options.p_draw;
|
||||||
|
let margins = predict::Margins::new(teams.len(), |i, j| {
|
||||||
|
if p_draw == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
crate::compute_margin(p_draw, (beta_sq[i] + beta_sq[j]).sqrt())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut gain = 0.0;
|
||||||
|
|
||||||
|
for (ranks, probability) in predict::outcome_distribution(&performances, &margins)? {
|
||||||
|
if probability <= NEGLIGIBLE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let game = crate::Game::ranked(teams, Outcome::ranking(ranks), options)?;
|
||||||
|
let posteriors = game.posteriors();
|
||||||
|
|
||||||
|
// Beliefs factorise across competitors, so the joint divergence is the
|
||||||
|
// sum of the per-competitor ones.
|
||||||
|
let divergence: f64 = teams
|
||||||
|
.iter()
|
||||||
|
.zip(&posteriors)
|
||||||
|
.flat_map(|(team, posterior)| team.iter().zip(posterior))
|
||||||
|
.map(|(rating, &after)| kl_divergence(after, rating.prior()))
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
gain += probability * divergence;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(gain)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{BETA, ConstantDrift, GAMMA};
|
||||||
|
|
||||||
|
type R = Rating<i64, ConstantDrift>;
|
||||||
|
|
||||||
|
fn rating(mu: f64, sigma: f64) -> R {
|
||||||
|
R::new(
|
||||||
|
Gaussian::from_ms(mu, sigma),
|
||||||
|
BETA,
|
||||||
|
ConstantDrift::new(GAMMA),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options(p_draw: f64) -> GameOptions {
|
||||||
|
GameOptions {
|
||||||
|
p_draw,
|
||||||
|
..GameOptions::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eig(teams: &[&[R]], p_draw: f64) -> f64 {
|
||||||
|
expected_information_gain(teams, &options(p_draw)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The analytic ceiling. Information gain is the mutual information between
|
||||||
|
/// the outcome and the skills, so it cannot exceed the entropy of the
|
||||||
|
/// outcome variable — whatever the ratings. This is the check a subtly
|
||||||
|
/// wrong implementation fails while still returning plausible numbers: an
|
||||||
|
/// early prototype of this returned 4.77 nats from a sign error and passed
|
||||||
|
/// every monotonicity test.
|
||||||
|
#[test]
|
||||||
|
fn never_exceeds_the_entropy_of_the_outcome() {
|
||||||
|
let ceiling_two = std::f64::consts::LN_2;
|
||||||
|
|
||||||
|
for (a, b) in [
|
||||||
|
(rating(0.0, 6.0), rating(0.0, 6.0)),
|
||||||
|
(rating(0.0, 0.5), rating(0.0, 0.5)),
|
||||||
|
(rating(12.0, 6.0), rating(-12.0, 6.0)),
|
||||||
|
(rating(40.0, 1.0), rating(-40.0, 1.0)),
|
||||||
|
(rating(3.0, 6.0), rating(-2.0, 0.1)),
|
||||||
|
(rating(0.0, 25.0), rating(0.0, 25.0)),
|
||||||
|
] {
|
||||||
|
let g = eig(&[&[a], &[b]], 0.0);
|
||||||
|
assert!(
|
||||||
|
g >= 0.0 && g <= ceiling_two,
|
||||||
|
"EIG {g} outside [0, ln 2] for mu=({}, {}) sigma=({}, {})",
|
||||||
|
a.prior().mu(),
|
||||||
|
b.prior().mu(),
|
||||||
|
a.prior().sigma(),
|
||||||
|
b.prior().sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With draws enabled there are three outcomes, so the ceiling rises to
|
||||||
|
/// `ln 3` — and the two-outcome bound no longer applies.
|
||||||
|
#[test]
|
||||||
|
fn the_ceiling_follows_the_outcome_count() {
|
||||||
|
let ceiling_three = 3.0f64.ln();
|
||||||
|
for sigma in [0.5, 3.0, 6.0, 25.0] {
|
||||||
|
let g = eig(&[&[rating(0.0, sigma)], &[rating(0.0, sigma)]], 0.25);
|
||||||
|
assert!(
|
||||||
|
g >= 0.0 && g <= ceiling_three,
|
||||||
|
"EIG {g} outside [0, ln 3] at sigma {sigma}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An even matchup between uncertain competitors is the informative one.
|
||||||
|
/// A hopelessly lopsided matchup teaches you almost nothing, because you
|
||||||
|
/// already know how it ends.
|
||||||
|
#[test]
|
||||||
|
fn an_even_matchup_beats_a_lopsided_one() {
|
||||||
|
let even = eig(&[&[rating(0.0, 6.0)], &[rating(0.0, 6.0)]], 0.0);
|
||||||
|
let lopsided = eig(&[&[rating(12.0, 6.0)], &[rating(-12.0, 6.0)]], 0.0);
|
||||||
|
assert!(
|
||||||
|
even > lopsided,
|
||||||
|
"even {even} should beat lopsided {lopsided}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Certainty is the thing information gain is measuring the absence of:
|
||||||
|
/// the less you know, the more there is to learn.
|
||||||
|
#[test]
|
||||||
|
fn gain_falls_as_certainty_rises() {
|
||||||
|
let mut previous = f64::INFINITY;
|
||||||
|
for sigma in [12.0, 6.0, 3.0, 1.0, 0.5, 0.1] {
|
||||||
|
let g = eig(&[&[rating(0.0, sigma)], &[rating(0.0, sigma)]], 0.0);
|
||||||
|
assert!(
|
||||||
|
g < previous,
|
||||||
|
"sigma {sigma}: {g} did not fall below {previous}"
|
||||||
|
);
|
||||||
|
previous = g;
|
||||||
|
}
|
||||||
|
assert!(previous >= 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heuristic this replaces is `quality * sigma_a^2 * sigma_b^2`. It is
|
||||||
|
/// not a monotone transform of information gain — it ranks a lopsided
|
||||||
|
/// matchup above a confident even one, and EIG ranks them the other way.
|
||||||
|
/// Pinning the disagreement down is what stops a future "simplification"
|
||||||
|
/// from quietly reverting to the heuristic.
|
||||||
|
#[test]
|
||||||
|
fn disagrees_with_the_quality_times_variance_heuristic() {
|
||||||
|
let heuristic = |a: &R, b: &R| {
|
||||||
|
crate::quality(&[&[a.prior()], &[b.prior()]], BETA)
|
||||||
|
* a.prior().sigma().powi(2)
|
||||||
|
* b.prior().sigma().powi(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (confident_a, confident_b) = (rating(0.0, 0.5), rating(0.0, 0.5));
|
||||||
|
let (lopsided_a, lopsided_b) = (rating(12.0, 6.0), rating(-12.0, 6.0));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
heuristic(&lopsided_a, &lopsided_b) > heuristic(&confident_a, &confident_b),
|
||||||
|
"the heuristic should prefer the lopsided matchup"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
eig(&[&[confident_a], &[confident_b]], 0.0) > eig(&[&[lopsided_a], &[lopsided_b]], 0.0),
|
||||||
|
"information gain should prefer the even matchup"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supports_more_than_two_teams() {
|
||||||
|
let teams: Vec<Vec<R>> = vec![
|
||||||
|
vec![rating(0.0, 6.0)],
|
||||||
|
vec![rating(0.0, 6.0)],
|
||||||
|
vec![rating(0.0, 6.0)],
|
||||||
|
];
|
||||||
|
let refs: Vec<&[R]> = teams.iter().map(Vec::as_slice).collect();
|
||||||
|
let g = expected_information_gain(&refs, &options(0.0)).unwrap();
|
||||||
|
// Six distinguishable orderings with no draws.
|
||||||
|
assert!(
|
||||||
|
g > 0.0 && g <= 6.0f64.ln(),
|
||||||
|
"three-team EIG {g} out of range"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_member_teams_are_supported() {
|
||||||
|
let a = [rating(0.0, 6.0), rating(1.0, 4.0)];
|
||||||
|
let b = [rating(0.0, 6.0)];
|
||||||
|
let g = expected_information_gain(&[&a, &b], &options(0.0)).unwrap();
|
||||||
|
assert!(g > 0.0 && g <= std::f64::consts::LN_2, "{g}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degenerate_shapes_are_errors() {
|
||||||
|
let a = [rating(0.0, 6.0)];
|
||||||
|
assert!(matches!(
|
||||||
|
expected_information_gain(&[&a], &options(0.0)),
|
||||||
|
Err(InferenceError::NotEnoughTeams { got: 1 })
|
||||||
|
));
|
||||||
|
let empty: [R; 0] = [];
|
||||||
|
assert!(matches!(
|
||||||
|
expected_information_gain(&[&a, &empty], &options(0.0)),
|
||||||
|
Err(InferenceError::EmptyTeam { team: 1 })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
expected_information_gain(&[&a, &a], &options(1.5)),
|
||||||
|
Err(InferenceError::InvalidParameter {
|
||||||
|
parameter: crate::Parameter::PDraw,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kl_divergence_is_zero_for_identical_beliefs() {
|
||||||
|
let g = Gaussian::from_ms(3.0, 2.0);
|
||||||
|
assert!(kl_divergence(g, g).abs() < 1e-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kl_divergence_is_non_negative_and_grows_with_separation() {
|
||||||
|
let prior = Gaussian::from_ms(0.0, 3.0);
|
||||||
|
let mut previous = 0.0;
|
||||||
|
for mu in [0.0, 0.5, 1.0, 2.0, 4.0] {
|
||||||
|
let d = kl_divergence(Gaussian::from_ms(mu, 3.0), prior);
|
||||||
|
assert!(d >= 0.0, "negative divergence at mu {mu}: {d}");
|
||||||
|
assert!(d >= previous, "not increasing at mu {mu}");
|
||||||
|
previous = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
N_INF,
|
|
||||||
drift::{ConstantDrift, Drift},
|
|
||||||
gaussian::Gaussian,
|
|
||||||
player::Player,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Agent<D: Drift = ConstantDrift> {
|
|
||||||
pub player: Player<D>,
|
|
||||||
pub message: Gaussian,
|
|
||||||
pub last_time: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D: Drift> Agent<D> {
|
|
||||||
pub(crate) fn receive(&self, elapsed: i64) -> Gaussian {
|
|
||||||
if self.message != N_INF {
|
|
||||||
self.message
|
|
||||||
.forget(self.player.drift.variance_delta(elapsed))
|
|
||||||
} else {
|
|
||||||
self.player.prior
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Agent<ConstantDrift> {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
player: Player::default(),
|
|
||||||
message: N_INF,
|
|
||||||
last_time: i64::MIN,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn clean<'a, D: Drift + 'a, A: Iterator<Item = &'a mut Agent<D>>>(
|
|
||||||
agents: A,
|
|
||||||
last_time: bool,
|
|
||||||
) {
|
|
||||||
for a in agents {
|
|
||||||
a.message = N_INF;
|
|
||||||
|
|
||||||
if last_time {
|
|
||||||
a.last_time = i64::MIN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-6
@@ -10,8 +10,8 @@ impl AbsDiffEq for Gaussian {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool {
|
fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool {
|
||||||
f64::abs_diff_eq(&self.mu, &other.mu, epsilon)
|
f64::abs_diff_eq(&self.mu(), &other.mu(), epsilon)
|
||||||
&& f64::abs_diff_eq(&self.sigma, &other.sigma, epsilon)
|
&& f64::abs_diff_eq(&self.sigma(), &other.sigma(), epsilon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,8 +26,8 @@ impl RelativeEq for Gaussian {
|
|||||||
epsilon: Self::Epsilon,
|
epsilon: Self::Epsilon,
|
||||||
max_relative: Self::Epsilon,
|
max_relative: Self::Epsilon,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
f64::relative_eq(&self.mu, &other.mu, epsilon, max_relative)
|
f64::relative_eq(&self.mu(), &other.mu(), epsilon, max_relative)
|
||||||
&& f64::relative_eq(&self.sigma, &other.sigma, epsilon, max_relative)
|
&& f64::relative_eq(&self.sigma(), &other.sigma(), epsilon, max_relative)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ impl UlpsEq for Gaussian {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ulps_eq(&self, other: &Self, epsilon: Self::Epsilon, max_ulps: u32) -> bool {
|
fn ulps_eq(&self, other: &Self, epsilon: Self::Epsilon, max_ulps: u32) -> bool {
|
||||||
f64::ulps_eq(&self.mu, &other.mu, epsilon, max_ulps)
|
f64::ulps_eq(&self.mu(), &other.mu(), epsilon, max_ulps)
|
||||||
&& f64::ulps_eq(&self.sigma, &other.sigma, epsilon, max_ulps)
|
&& f64::ulps_eq(&self.sigma(), &other.sigma(), epsilon, max_ulps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
use crate::{factor::VarStore, gaussian::Gaussian};
|
||||||
|
|
||||||
|
/// Reusable scratch buffers for `Game::likelihoods`.
|
||||||
|
///
|
||||||
|
/// A `TimeSlice` owns one arena; all events in the slice share it across
|
||||||
|
/// the convergence iterations. All Vecs are cleared (not dropped) on
|
||||||
|
/// `reset()` so their heap capacity is reused across games.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ScratchArena {
|
||||||
|
pub(crate) vars: VarStore,
|
||||||
|
pub(crate) sort_buf: Vec<usize>,
|
||||||
|
pub(crate) inv_buf: Vec<usize>,
|
||||||
|
pub(crate) team_prior: Vec<Gaussian>,
|
||||||
|
pub(crate) lhood_lose: Vec<Gaussian>,
|
||||||
|
pub(crate) lhood_win: Vec<Gaussian>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScratchArena {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn reset(&mut self) {
|
||||||
|
self.vars.clear();
|
||||||
|
self.sort_buf.clear();
|
||||||
|
self.inv_buf.clear();
|
||||||
|
self.team_prior.clear();
|
||||||
|
self.lhood_lose.clear();
|
||||||
|
self.lhood_win.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{N_INF, gaussian::Gaussian};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_keeps_capacity() {
|
||||||
|
let mut arena = ScratchArena::new();
|
||||||
|
arena.vars.alloc(N_INF);
|
||||||
|
arena.sort_buf.push(42);
|
||||||
|
arena.team_prior.push(Gaussian::from_ms(0.0, 1.0));
|
||||||
|
let var_cap = arena.vars.marginals.capacity();
|
||||||
|
let sort_cap = arena.sort_buf.capacity();
|
||||||
|
let prior_cap = arena.team_prior.capacity();
|
||||||
|
arena.reset();
|
||||||
|
assert_eq!(arena.vars.len(), 0);
|
||||||
|
assert_eq!(arena.sort_buf.len(), 0);
|
||||||
|
assert_eq!(arena.team_prior.len(), 0);
|
||||||
|
assert_eq!(arena.vars.marginals.capacity(), var_cap);
|
||||||
|
assert_eq!(arena.sort_buf.capacity(), sort_cap);
|
||||||
|
assert_eq!(arena.team_prior.capacity(), prior_cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
-645
@@ -1,645 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
Index, N_INF, agent::Agent, drift::Drift, game::Game, gaussian::Gaussian, player::Player,
|
|
||||||
tuple_gt, tuple_max,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub(crate) struct Skill {
|
|
||||||
pub(crate) forward: Gaussian,
|
|
||||||
backward: Gaussian,
|
|
||||||
likelihood: Gaussian,
|
|
||||||
pub(crate) elapsed: i64,
|
|
||||||
pub(crate) online: Gaussian,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Skill {
|
|
||||||
pub(crate) fn posterior(&self) -> Gaussian {
|
|
||||||
self.likelihood * self.backward * self.forward
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Skill {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
forward: N_INF,
|
|
||||||
backward: N_INF,
|
|
||||||
likelihood: N_INF,
|
|
||||||
elapsed: 0,
|
|
||||||
online: N_INF,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Item {
|
|
||||||
agent: Index,
|
|
||||||
likelihood: Gaussian,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Item {
|
|
||||||
fn within_prior<D: Drift>(
|
|
||||||
&self,
|
|
||||||
online: bool,
|
|
||||||
forward: bool,
|
|
||||||
skills: &HashMap<Index, Skill>,
|
|
||||||
agents: &HashMap<Index, Agent<D>>,
|
|
||||||
) -> Player<D> {
|
|
||||||
let r = &agents[&self.agent].player;
|
|
||||||
let skill = &skills[&self.agent];
|
|
||||||
|
|
||||||
if online {
|
|
||||||
Player::new(skill.online, r.beta, r.drift)
|
|
||||||
} else if forward {
|
|
||||||
Player::new(skill.forward, r.beta, r.drift)
|
|
||||||
} else {
|
|
||||||
Player::new(skill.posterior() / self.likelihood, r.beta, r.drift)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Team {
|
|
||||||
items: Vec<Item>,
|
|
||||||
output: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub(crate) struct Event {
|
|
||||||
teams: Vec<Team>,
|
|
||||||
evidence: f64,
|
|
||||||
weights: Vec<Vec<f64>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Event {
|
|
||||||
fn outputs(&self) -> Vec<f64> {
|
|
||||||
self.teams
|
|
||||||
.iter()
|
|
||||||
.map(|team| team.output)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn within_priors<D: Drift>(
|
|
||||||
&self,
|
|
||||||
online: bool,
|
|
||||||
forward: bool,
|
|
||||||
skills: &HashMap<Index, Skill>,
|
|
||||||
agents: &HashMap<Index, Agent<D>>,
|
|
||||||
) -> Vec<Vec<Player<D>>> {
|
|
||||||
self.teams
|
|
||||||
.iter()
|
|
||||||
.map(|team| {
|
|
||||||
team.items
|
|
||||||
.iter()
|
|
||||||
.map(|item| item.within_prior(online, forward, skills, agents))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Batch {
|
|
||||||
pub(crate) events: Vec<Event>,
|
|
||||||
pub(crate) skills: HashMap<Index, Skill>,
|
|
||||||
pub(crate) time: i64,
|
|
||||||
p_draw: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Batch {
|
|
||||||
pub fn new(time: i64, p_draw: f64) -> Self {
|
|
||||||
Self {
|
|
||||||
events: Vec::new(),
|
|
||||||
skills: HashMap::new(),
|
|
||||||
time,
|
|
||||||
p_draw,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_events<D: Drift>(
|
|
||||||
&mut self,
|
|
||||||
composition: Vec<Vec<Vec<Index>>>,
|
|
||||||
results: Vec<Vec<f64>>,
|
|
||||||
weights: Vec<Vec<Vec<f64>>>,
|
|
||||||
agents: &HashMap<Index, Agent<D>>,
|
|
||||||
) {
|
|
||||||
let mut unique = Vec::with_capacity(10);
|
|
||||||
|
|
||||||
let this_agent = composition.iter().flatten().flatten().filter(|idx| {
|
|
||||||
if !unique.contains(idx) {
|
|
||||||
unique.push(*idx);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
});
|
|
||||||
|
|
||||||
for idx in this_agent {
|
|
||||||
let elapsed = compute_elapsed(agents[&idx].last_time, self.time);
|
|
||||||
|
|
||||||
if let Some(skill) = self.skills.get_mut(idx) {
|
|
||||||
skill.elapsed = elapsed;
|
|
||||||
skill.forward = agents[&idx].receive(elapsed);
|
|
||||||
} else {
|
|
||||||
self.skills.insert(
|
|
||||||
*idx,
|
|
||||||
Skill {
|
|
||||||
forward: agents[&idx].receive(elapsed),
|
|
||||||
elapsed,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let events = composition.iter().enumerate().map(|(e, event)| {
|
|
||||||
let teams = event
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(t, team)| {
|
|
||||||
let items = team
|
|
||||||
.iter()
|
|
||||||
.map(|&agent| Item {
|
|
||||||
agent,
|
|
||||||
likelihood: N_INF,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
Team {
|
|
||||||
items,
|
|
||||||
output: if results.is_empty() {
|
|
||||||
(event.len() - (t + 1)) as f64
|
|
||||||
} else {
|
|
||||||
results[e][t]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let weights = if weights.is_empty() {
|
|
||||||
teams
|
|
||||||
.iter()
|
|
||||||
.map(|team| vec![1.0; team.items.len()])
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
} else {
|
|
||||||
weights[e].clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
Event {
|
|
||||||
teams,
|
|
||||||
evidence: 0.0,
|
|
||||||
weights,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let from = self.events.len();
|
|
||||||
|
|
||||||
self.events.extend(events);
|
|
||||||
|
|
||||||
self.iteration(from, agents);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
|
|
||||||
self.skills
|
|
||||||
.iter()
|
|
||||||
.map(|(&idx, skill)| (idx, skill.posterior()))
|
|
||||||
.collect::<HashMap<_, _>>()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn iteration<D: Drift>(&mut self, from: usize, agents: &HashMap<Index, Agent<D>>) {
|
|
||||||
for event in self.events.iter_mut().skip(from) {
|
|
||||||
let teams = event.within_priors(false, false, &self.skills, agents);
|
|
||||||
let result = event.outputs();
|
|
||||||
|
|
||||||
let g = Game::new(teams, &result, &event.weights, self.p_draw);
|
|
||||||
|
|
||||||
for (t, team) in event.teams.iter_mut().enumerate() {
|
|
||||||
for (i, item) in team.items.iter_mut().enumerate() {
|
|
||||||
self.skills.get_mut(&item.agent).unwrap().likelihood =
|
|
||||||
(self.skills[&item.agent].likelihood / item.likelihood)
|
|
||||||
* g.likelihoods[t][i];
|
|
||||||
|
|
||||||
item.likelihood = g.likelihoods[t][i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
event.evidence = g.evidence;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) fn convergence<D: Drift>(&mut self, agents: &HashMap<Index, Agent<D>>) -> usize {
|
|
||||||
let epsilon = 1e-6;
|
|
||||||
let iterations = 20;
|
|
||||||
|
|
||||||
let mut step = (f64::INFINITY, f64::INFINITY);
|
|
||||||
let mut i = 0;
|
|
||||||
|
|
||||||
while tuple_gt(step, epsilon) && i < iterations {
|
|
||||||
let old = self.posteriors();
|
|
||||||
|
|
||||||
self.iteration(0, agents);
|
|
||||||
|
|
||||||
let new = self.posteriors();
|
|
||||||
|
|
||||||
step = old.iter().fold((0.0, 0.0), |step, (a, old)| {
|
|
||||||
tuple_max(step, old.delta(new[a]))
|
|
||||||
});
|
|
||||||
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
i
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn forward_prior_out(&self, agent: &Index) -> Gaussian {
|
|
||||||
let skill = &self.skills[agent];
|
|
||||||
|
|
||||||
skill.forward * skill.likelihood
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn backward_prior_out<D: Drift>(
|
|
||||||
&self,
|
|
||||||
agent: &Index,
|
|
||||||
agents: &HashMap<Index, Agent<D>>,
|
|
||||||
) -> Gaussian {
|
|
||||||
let skill = &self.skills[agent];
|
|
||||||
let n = skill.likelihood * skill.backward;
|
|
||||||
|
|
||||||
n.forget(agents[agent].player.drift.variance_delta(skill.elapsed))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn new_backward_info<D: Drift>(&mut self, agents: &HashMap<Index, Agent<D>>) {
|
|
||||||
for (agent, skill) in self.skills.iter_mut() {
|
|
||||||
skill.backward = agents[agent].message;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.iteration(0, agents);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn new_forward_info<D: Drift>(&mut self, agents: &HashMap<Index, Agent<D>>) {
|
|
||||||
for (agent, skill) in self.skills.iter_mut() {
|
|
||||||
skill.forward = agents[agent].receive(skill.elapsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.iteration(0, agents);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn log_evidence<D: Drift>(
|
|
||||||
&self,
|
|
||||||
online: bool,
|
|
||||||
targets: &[Index],
|
|
||||||
forward: bool,
|
|
||||||
agents: &HashMap<Index, Agent<D>>,
|
|
||||||
) -> f64 {
|
|
||||||
if targets.is_empty() {
|
|
||||||
if online || forward {
|
|
||||||
self.events
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(_, event)| {
|
|
||||||
Game::new(
|
|
||||||
event.within_priors(online, forward, &self.skills, agents),
|
|
||||||
&event.outputs(),
|
|
||||||
&event.weights,
|
|
||||||
self.p_draw,
|
|
||||||
)
|
|
||||||
.evidence
|
|
||||||
.ln()
|
|
||||||
})
|
|
||||||
.sum()
|
|
||||||
} else {
|
|
||||||
self.events.iter().map(|event| event.evidence.ln()).sum()
|
|
||||||
}
|
|
||||||
} else if online || forward {
|
|
||||||
self.events
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, event)| {
|
|
||||||
event
|
|
||||||
.teams
|
|
||||||
.iter()
|
|
||||||
.flat_map(|team| &team.items)
|
|
||||||
.any(|item| targets.contains(&item.agent))
|
|
||||||
})
|
|
||||||
.map(|(_, event)| {
|
|
||||||
Game::new(
|
|
||||||
event.within_priors(online, forward, &self.skills, agents),
|
|
||||||
&event.outputs(),
|
|
||||||
&event.weights,
|
|
||||||
self.p_draw,
|
|
||||||
)
|
|
||||||
.evidence
|
|
||||||
.ln()
|
|
||||||
})
|
|
||||||
.sum()
|
|
||||||
} else {
|
|
||||||
self.events
|
|
||||||
.iter()
|
|
||||||
.filter(|event| {
|
|
||||||
event
|
|
||||||
.teams
|
|
||||||
.iter()
|
|
||||||
.flat_map(|team| &team.items)
|
|
||||||
.any(|item| targets.contains(&item.agent))
|
|
||||||
})
|
|
||||||
.map(|event| event.evidence.ln())
|
|
||||||
.sum()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_composition(&self) -> Vec<Vec<Vec<Index>>> {
|
|
||||||
self.events
|
|
||||||
.iter()
|
|
||||||
.map(|event| {
|
|
||||||
event
|
|
||||||
.teams
|
|
||||||
.iter()
|
|
||||||
.map(|team| team.items.iter().map(|item| item.agent).collect::<Vec<_>>())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_results(&self) -> Vec<Vec<f64>> {
|
|
||||||
self.events
|
|
||||||
.iter()
|
|
||||||
.map(|event| {
|
|
||||||
event
|
|
||||||
.teams
|
|
||||||
.iter()
|
|
||||||
.map(|team| team.output)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn compute_elapsed(last_time: i64, actual_time: i64) -> i64 {
|
|
||||||
if last_time == i64::MIN {
|
|
||||||
0
|
|
||||||
} else if last_time == i64::MAX {
|
|
||||||
1
|
|
||||||
} else {
|
|
||||||
actual_time - last_time
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use approx::assert_ulps_eq;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::{IndexMap, agent::Agent, drift::ConstantDrift, player::Player};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_one_event_each() {
|
|
||||||
let mut index_map = IndexMap::new();
|
|
||||||
|
|
||||||
let a = index_map.get_or_create("a");
|
|
||||||
let b = index_map.get_or_create("b");
|
|
||||||
let c = index_map.get_or_create("c");
|
|
||||||
let d = index_map.get_or_create("d");
|
|
||||||
let e = index_map.get_or_create("e");
|
|
||||||
let f = index_map.get_or_create("f");
|
|
||||||
|
|
||||||
let mut agents = HashMap::new();
|
|
||||||
|
|
||||||
for agent in [a, b, c, d, e, f] {
|
|
||||||
agents.insert(
|
|
||||||
agent,
|
|
||||||
Agent {
|
|
||||||
player: Player::new(
|
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
|
||||||
25.0 / 6.0,
|
|
||||||
ConstantDrift(25.0 / 300.0),
|
|
||||||
),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut batch = Batch::new(0, 0.0);
|
|
||||||
|
|
||||||
batch.add_events(
|
|
||||||
vec![
|
|
||||||
vec![vec![a], vec![b]],
|
|
||||||
vec![vec![c], vec![d]],
|
|
||||||
vec![vec![e], vec![f]],
|
|
||||||
],
|
|
||||||
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
|
|
||||||
vec![],
|
|
||||||
&agents,
|
|
||||||
);
|
|
||||||
|
|
||||||
let post = batch.posteriors();
|
|
||||||
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&a],
|
|
||||||
Gaussian::from_ms(29.205220, 7.194481),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&b],
|
|
||||||
Gaussian::from_ms(20.794779, 7.194481),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&c],
|
|
||||||
Gaussian::from_ms(20.794779, 7.194481),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&d],
|
|
||||||
Gaussian::from_ms(29.205220, 7.194481),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&e],
|
|
||||||
Gaussian::from_ms(29.205220, 7.194481),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&f],
|
|
||||||
Gaussian::from_ms(20.794779, 7.194481),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(batch.convergence(&agents), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_same_strength() {
|
|
||||||
let mut index_map = IndexMap::new();
|
|
||||||
|
|
||||||
let a = index_map.get_or_create("a");
|
|
||||||
let b = index_map.get_or_create("b");
|
|
||||||
let c = index_map.get_or_create("c");
|
|
||||||
let d = index_map.get_or_create("d");
|
|
||||||
let e = index_map.get_or_create("e");
|
|
||||||
let f = index_map.get_or_create("f");
|
|
||||||
|
|
||||||
let mut agents = HashMap::new();
|
|
||||||
|
|
||||||
for agent in [a, b, c, d, e, f] {
|
|
||||||
agents.insert(
|
|
||||||
agent,
|
|
||||||
Agent {
|
|
||||||
player: Player::new(
|
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
|
||||||
25.0 / 6.0,
|
|
||||||
ConstantDrift(25.0 / 300.0),
|
|
||||||
),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut batch = Batch::new(0, 0.0);
|
|
||||||
|
|
||||||
batch.add_events(
|
|
||||||
vec![
|
|
||||||
vec![vec![a], vec![b]],
|
|
||||||
vec![vec![a], vec![c]],
|
|
||||||
vec![vec![b], vec![c]],
|
|
||||||
],
|
|
||||||
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
|
|
||||||
vec![],
|
|
||||||
&agents,
|
|
||||||
);
|
|
||||||
|
|
||||||
let post = batch.posteriors();
|
|
||||||
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&a],
|
|
||||||
Gaussian::from_ms(24.960978, 6.298544),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&b],
|
|
||||||
Gaussian::from_ms(27.095590, 6.010330),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&c],
|
|
||||||
Gaussian::from_ms(24.889681, 5.866311),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(batch.convergence(&agents) > 1);
|
|
||||||
|
|
||||||
let post = batch.posteriors();
|
|
||||||
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&a],
|
|
||||||
Gaussian::from_ms(25.000000, 5.419212),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&b],
|
|
||||||
Gaussian::from_ms(25.000000, 5.419212),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&c],
|
|
||||||
Gaussian::from_ms(25.000000, 5.419212),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_add_events() {
|
|
||||||
let mut index_map = IndexMap::new();
|
|
||||||
|
|
||||||
let a = index_map.get_or_create("a");
|
|
||||||
let b = index_map.get_or_create("b");
|
|
||||||
let c = index_map.get_or_create("c");
|
|
||||||
let d = index_map.get_or_create("d");
|
|
||||||
let e = index_map.get_or_create("e");
|
|
||||||
let f = index_map.get_or_create("f");
|
|
||||||
|
|
||||||
let mut agents = HashMap::new();
|
|
||||||
|
|
||||||
for agent in [a, b, c, d, e, f] {
|
|
||||||
agents.insert(
|
|
||||||
agent,
|
|
||||||
Agent {
|
|
||||||
player: Player::new(
|
|
||||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
|
||||||
25.0 / 6.0,
|
|
||||||
ConstantDrift(25.0 / 300.0),
|
|
||||||
),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut batch = Batch::new(0, 0.0);
|
|
||||||
|
|
||||||
batch.add_events(
|
|
||||||
vec![
|
|
||||||
vec![vec![a], vec![b]],
|
|
||||||
vec![vec![a], vec![c]],
|
|
||||||
vec![vec![b], vec![c]],
|
|
||||||
],
|
|
||||||
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
|
|
||||||
vec![],
|
|
||||||
&agents,
|
|
||||||
);
|
|
||||||
|
|
||||||
batch.convergence(&agents);
|
|
||||||
|
|
||||||
let post = batch.posteriors();
|
|
||||||
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&a],
|
|
||||||
Gaussian::from_ms(25.000000, 5.419212),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&b],
|
|
||||||
Gaussian::from_ms(25.000000, 5.419212),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&c],
|
|
||||||
Gaussian::from_ms(25.000000, 5.419212),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
|
|
||||||
batch.add_events(
|
|
||||||
vec![
|
|
||||||
vec![vec![a], vec![b]],
|
|
||||||
vec![vec![a], vec![c]],
|
|
||||||
vec![vec![b], vec![c]],
|
|
||||||
],
|
|
||||||
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
|
|
||||||
vec![],
|
|
||||||
&agents,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(batch.events.len(), 6);
|
|
||||||
|
|
||||||
batch.convergence(&agents);
|
|
||||||
|
|
||||||
let post = batch.posteriors();
|
|
||||||
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&a],
|
|
||||||
Gaussian::from_ms(25.000003, 3.880150),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&b],
|
|
||||||
Gaussian::from_ms(25.000003, 3.880150),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
assert_ulps_eq!(
|
|
||||||
post[&c],
|
|
||||||
Gaussian::from_ms(25.000003, 3.880150),
|
|
||||||
epsilon = 1e-6
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
//! Greedy graph coloring for within-slice event independence.
|
||||||
|
//!
|
||||||
|
//! Events sharing no `Index` can be processed in parallel under async-EP
|
||||||
|
//! semantics. This module partitions a list of events into "colors" such
|
||||||
|
//! that events of the same color touch disjoint index sets.
|
||||||
|
//!
|
||||||
|
//! The algorithm is greedy: for each event in ingestion order, place it in
|
||||||
|
//! the lowest-numbered color whose existing members share no `Index`. If
|
||||||
|
//! no existing color accepts the event, open a new color.
|
||||||
|
//!
|
||||||
|
//! Complexity: O(n × c × m) where n is events, c is colors (small, ≤ 5 in
|
||||||
|
//! practice), and m is average team size.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::Index;
|
||||||
|
|
||||||
|
/// Partition of event indices into color groups.
|
||||||
|
///
|
||||||
|
/// Each inner `Vec<usize>` holds the indices (into the original events
|
||||||
|
/// array) of events assigned to one color. Colors are iterated in ascending
|
||||||
|
/// order by convention.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub(crate) struct ColorGroups {
|
||||||
|
pub(crate) groups: Vec<Vec<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorGroups {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_empty(&self) -> bool {
|
||||||
|
self.groups.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of distinct colors in the partition. Test-only.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn n_colors(&self) -> usize {
|
||||||
|
self.groups.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total event count across all colors. Test-only.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn total_events(&self) -> usize {
|
||||||
|
self.groups.iter().map(|g| g.len()).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contiguous index range for one color after events have been reordered
|
||||||
|
/// into color-contiguous positions by `TimeSlice::recompute_color_groups`.
|
||||||
|
pub(crate) fn color_range(&self, color_idx: usize) -> std::ops::Range<usize> {
|
||||||
|
let group = &self.groups[color_idx];
|
||||||
|
if group.is_empty() {
|
||||||
|
return 0..0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = *group.first().unwrap();
|
||||||
|
let end = *group.last().unwrap() + 1;
|
||||||
|
|
||||||
|
debug_assert_eq!(
|
||||||
|
end - start,
|
||||||
|
group.len(),
|
||||||
|
"color {color_idx} is not contiguous; its range would overlap other colors"
|
||||||
|
);
|
||||||
|
|
||||||
|
start..end
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether every color occupies a contiguous, ascending range of event
|
||||||
|
/// indices, and no two colors overlap.
|
||||||
|
///
|
||||||
|
/// The parallel sweep derives one `&mut` sub-slice per color from these
|
||||||
|
/// ranges and relies on them being disjoint. That disjointness is what
|
||||||
|
/// makes concurrent writes to distinct skills sound, so it is checked
|
||||||
|
/// rather than assumed.
|
||||||
|
pub(crate) fn groups_are_contiguous(&self) -> bool {
|
||||||
|
let mut expected_start = 0;
|
||||||
|
|
||||||
|
for group in &self.groups {
|
||||||
|
if group.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ascending_run = group
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.all(|(offset, &idx)| idx == group[0] + offset);
|
||||||
|
|
||||||
|
if !ascending_run || group[0] != expected_start {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_start += group.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute color groups greedily.
|
||||||
|
///
|
||||||
|
/// `index_set(ev_idx)` yields, for each event index, the iterator of
|
||||||
|
/// `Index` values that event touches. The returned `ColorGroups` has one
|
||||||
|
/// inner `Vec<usize>` per color, containing event indices in the order
|
||||||
|
/// they were assigned.
|
||||||
|
pub(crate) fn color_greedy<I, F>(n_events: usize, index_set: F) -> ColorGroups
|
||||||
|
where
|
||||||
|
F: Fn(usize) -> I,
|
||||||
|
I: IntoIterator<Item = Index>,
|
||||||
|
{
|
||||||
|
let mut groups: Vec<Vec<usize>> = Vec::new();
|
||||||
|
let mut members: Vec<HashSet<Index>> = Vec::new();
|
||||||
|
|
||||||
|
for ev_idx in 0..n_events {
|
||||||
|
let ev_members: HashSet<Index> = index_set(ev_idx).into_iter().collect();
|
||||||
|
// Find first color whose member-set is disjoint from this event's indices.
|
||||||
|
let chosen = members.iter().position(|m| m.is_disjoint(&ev_members));
|
||||||
|
let color_idx = match chosen {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
groups.push(Vec::new());
|
||||||
|
members.push(HashSet::new());
|
||||||
|
groups.len() - 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
groups[color_idx].push(ev_idx);
|
||||||
|
members[color_idx].extend(ev_members);
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorGroups { groups }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn idx(i: usize) -> Index {
|
||||||
|
Index::from(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_event_gets_one_color() {
|
||||||
|
let cg = color_greedy(1, |_| vec![idx(0), idx(1)]);
|
||||||
|
assert_eq!(cg.n_colors(), 1);
|
||||||
|
assert_eq!(cg.groups[0], vec![0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disjoint_events_share_a_color() {
|
||||||
|
let cg = color_greedy(2, |i| match i {
|
||||||
|
0 => vec![idx(0), idx(1)],
|
||||||
|
1 => vec![idx(2), idx(3)],
|
||||||
|
_ => unreachable!(),
|
||||||
|
});
|
||||||
|
assert_eq!(cg.n_colors(), 1);
|
||||||
|
assert_eq!(cg.groups[0], vec![0, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlapping_events_need_separate_colors() {
|
||||||
|
let cg = color_greedy(2, |i| match i {
|
||||||
|
0 => vec![idx(0), idx(1)],
|
||||||
|
1 => vec![idx(1), idx(2)],
|
||||||
|
_ => unreachable!(),
|
||||||
|
});
|
||||||
|
assert_eq!(cg.n_colors(), 2);
|
||||||
|
assert_eq!(cg.groups[0], vec![0]);
|
||||||
|
assert_eq!(cg.groups[1], vec![1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn three_events_two_colors() {
|
||||||
|
// Event 0: {0, 1}; event 1: {2, 3}; event 2: {0, 2}.
|
||||||
|
// Greedy: ev0→c0, ev1→c0 (disjoint), ev2 overlaps both→c1.
|
||||||
|
let cg = color_greedy(3, |i| match i {
|
||||||
|
0 => vec![idx(0), idx(1)],
|
||||||
|
1 => vec![idx(2), idx(3)],
|
||||||
|
2 => vec![idx(0), idx(2)],
|
||||||
|
_ => unreachable!(),
|
||||||
|
});
|
||||||
|
assert_eq!(cg.n_colors(), 2);
|
||||||
|
assert_eq!(cg.groups[0], vec![0, 1]);
|
||||||
|
assert_eq!(cg.groups[1], vec![2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn total_events_counts_correctly() {
|
||||||
|
let cg = color_greedy(4, |_| vec![idx(0)]);
|
||||||
|
// All events touch index 0 → 4 distinct colors.
|
||||||
|
assert_eq!(cg.n_colors(), 4);
|
||||||
|
assert_eq!(cg.total_events(), 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod properties {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The property the whole parallel sweep rests on: two events sharing a
|
||||||
|
/// competitor must never land in the same color, because a color group is
|
||||||
|
/// run concurrently and two events touching one competitor would race.
|
||||||
|
///
|
||||||
|
/// Hand-written cases cover the shapes someone thought of. This covers the
|
||||||
|
/// ones nobody did — the correctness of `sweep_color_groups` depends on it
|
||||||
|
/// holding for every input, not for five.
|
||||||
|
fn check(events: &[Vec<usize>]) {
|
||||||
|
let groups = color_greedy(events.len(), |ev| {
|
||||||
|
events[ev]
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(Index::from)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disjointness *between events* within a color. Deduplicated per
|
||||||
|
// event, because one event legitimately naming a competitor twice is
|
||||||
|
// not a collision — `color_greedy` collects each event's members into
|
||||||
|
// a set for exactly that reason.
|
||||||
|
for color in 0..groups.n_colors() {
|
||||||
|
let mut seen: HashSet<usize> = HashSet::new();
|
||||||
|
for &ev in &groups.groups[color] {
|
||||||
|
let members: HashSet<usize> = events[ev].iter().copied().collect();
|
||||||
|
for competitor in members {
|
||||||
|
assert!(
|
||||||
|
seen.insert(competitor),
|
||||||
|
"competitor {competitor} shared by two events in color {color}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every event is assigned exactly once. Without this, a partition that
|
||||||
|
// dropped events would satisfy disjointness trivially.
|
||||||
|
let mut assigned: Vec<usize> = groups.groups.iter().flatten().copied().collect();
|
||||||
|
assigned.sort_unstable();
|
||||||
|
assert_eq!(assigned, (0..events.len()).collect::<Vec<_>>());
|
||||||
|
assert_eq!(groups.total_events(), events.len());
|
||||||
|
|
||||||
|
// No empty colors: one would waste a sweep and make `n_colors`
|
||||||
|
// misleading.
|
||||||
|
for (color, group) in groups.groups.iter().enumerate() {
|
||||||
|
assert!(!group.is_empty(), "color {color} is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contiguity is not a property of `color_greedy` — it holds only after
|
||||||
|
// `recompute_color_groups` reorders the events so each color occupies
|
||||||
|
// one range. What must always hold is that the reorder is *possible*:
|
||||||
|
// relabelling events in group order yields contiguous groups. The
|
||||||
|
// parallel sweep slices `&mut` sub-ranges from those, so if this ever
|
||||||
|
// failed the reorder would produce overlapping ranges.
|
||||||
|
let mut next = 0usize;
|
||||||
|
let relabelled: Vec<Vec<usize>> = groups
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| {
|
||||||
|
group
|
||||||
|
.iter()
|
||||||
|
.map(|_| {
|
||||||
|
let i = next;
|
||||||
|
next += 1;
|
||||||
|
i
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(ColorGroups { groups: relabelled }.groups_are_contiguous());
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(512))]
|
||||||
|
|
||||||
|
/// Small competitor pool, so collisions are common and colors are
|
||||||
|
/// forced to multiply.
|
||||||
|
#[test]
|
||||||
|
fn colors_are_disjoint_on_a_dense_pool(
|
||||||
|
events in prop::collection::vec(
|
||||||
|
prop::collection::vec(0usize..6, 1..4),
|
||||||
|
0..20,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
check(&events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wide pool, so most events are independent and land in one color.
|
||||||
|
#[test]
|
||||||
|
fn colors_are_disjoint_on_a_sparse_pool(
|
||||||
|
events in prop::collection::vec(
|
||||||
|
prop::collection::vec(0usize..200, 1..6),
|
||||||
|
0..30,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
check(&events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repeated competitors within one event must not confuse the
|
||||||
|
/// member-set bookkeeping.
|
||||||
|
#[test]
|
||||||
|
fn colors_are_disjoint_with_repeated_members(
|
||||||
|
events in prop::collection::vec(
|
||||||
|
prop::collection::vec(0usize..3, 1..8),
|
||||||
|
0..15,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
check(&events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use crate::{
|
||||||
|
drift::{ConstantDrift, Drift},
|
||||||
|
gaussian::Gaussian,
|
||||||
|
rating::Rating,
|
||||||
|
time::Time,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Per-history, temporal state for someone competing.
|
||||||
|
///
|
||||||
|
/// The mutable half of a competitor: `Rating` holds their static
|
||||||
|
/// configuration, this holds what inference learns as it sweeps.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Competitor<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
||||||
|
pub rating: Rating<T, D>,
|
||||||
|
/// The forward message carried from this competitor's last appearance, or
|
||||||
|
/// `None` before they have appeared anywhere.
|
||||||
|
///
|
||||||
|
/// Previously an improper `N_INF` served as the unset sentinel, which made
|
||||||
|
/// "no message yet" indistinguishable from "a legitimately improper
|
||||||
|
/// message" at the type level and required every reader to know the
|
||||||
|
/// convention.
|
||||||
|
pub message: Option<Gaussian>,
|
||||||
|
pub last_time: Option<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Time, D: Drift<T>> Competitor<T, D> {
|
||||||
|
/// Compute the message received at time `now`, with drift accumulated
|
||||||
|
/// from `self.last_time` (if any) to `now`.
|
||||||
|
pub(crate) fn receive(&self, now: &T) -> Gaussian {
|
||||||
|
match self.message {
|
||||||
|
Some(message) => {
|
||||||
|
let elapsed_variance = match &self.last_time {
|
||||||
|
Some(last) => self.rating.drift_variance_delta(last, now),
|
||||||
|
None => 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
message.forget(elapsed_variance)
|
||||||
|
}
|
||||||
|
None => self.rating.prior,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the message using a pre-cached elapsed count (in `Time::elapsed_to` units).
|
||||||
|
///
|
||||||
|
/// Used in convergence sweeps where the elapsed was cached at slice-construction time
|
||||||
|
/// and should not be recomputed from `last_time` (which may have shifted).
|
||||||
|
pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian {
|
||||||
|
match self.message {
|
||||||
|
Some(message) => message.forget(self.rating.drift_variance_for_elapsed(elapsed)),
|
||||||
|
None => self.rating.prior,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Competitor<i64, ConstantDrift> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
rating: Rating::default(),
|
||||||
|
message: None,
|
||||||
|
last_time: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clean<'a, T, D, C>(competitors: C, last_time: bool)
|
||||||
|
where
|
||||||
|
T: Time + 'a,
|
||||||
|
D: Drift<T> + 'a,
|
||||||
|
C: Iterator<Item = &'a mut Competitor<T, D>>,
|
||||||
|
{
|
||||||
|
for c in competitors {
|
||||||
|
c.message = None;
|
||||||
|
if last_time {
|
||||||
|
c.last_time = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//! Convergence configuration and reporting.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
/// 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,
|
||||||
|
/// 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,
|
||||||
|
/// EP damping factor in natural-parameter space: each per-factor
|
||||||
|
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
|
||||||
|
/// undamped (default); `< 1.0` stabilises oscillating fixed-point
|
||||||
|
/// loops at the cost of more iterations. Must be in `(0.0, 1.0]`.
|
||||||
|
///
|
||||||
|
/// Applies only to the within-game EP loop (`run_chain`). The outer
|
||||||
|
/// `History::converge` cross-history sweep is undamped regardless of
|
||||||
|
/// this value — cross-slice damping is a different concept and not
|
||||||
|
/// in scope.
|
||||||
|
pub alpha: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConvergenceOptions {
|
||||||
|
/// Reject values that would make inference silently meaningless.
|
||||||
|
///
|
||||||
|
/// `HistoryBuilder::convergence` asserts these eagerly, but the fields are
|
||||||
|
/// public and `GameOptions` carries a `ConvergenceOptions` — so a caller
|
||||||
|
/// can hand `Game::ranked` a set the builder never saw. In release the
|
||||||
|
/// engine's `debug_assert!`s are gone, and an `alpha` of zero leaves every
|
||||||
|
/// EP update unapplied: inference returns the priors, with every likelihood
|
||||||
|
/// uninformative and nothing to indicate anything went wrong.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// `InvalidParameter` if `alpha` is outside `(0.0, 1.0]` or `epsilon` is
|
||||||
|
/// negative. NaN fails both comparisons and is rejected.
|
||||||
|
pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> {
|
||||||
|
if !(self.alpha > 0.0 && self.alpha <= 1.0) {
|
||||||
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
|
parameter: crate::Parameter::Alpha,
|
||||||
|
value: self.alpha,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.epsilon.is_nan() || self.epsilon < 0.0 {
|
||||||
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
|
parameter: crate::Parameter::Epsilon,
|
||||||
|
value: self.epsilon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConvergenceOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_iter: crate::ITERATIONS,
|
||||||
|
epsilon: crate::EPSILON,
|
||||||
|
alpha: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post-hoc summary of a `History::converge` call.
|
||||||
|
///
|
||||||
|
/// From [`History::converge`](crate::History::converge) this always describes a
|
||||||
|
/// converged fit — stopping at `max_iter` is
|
||||||
|
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged) there.
|
||||||
|
/// From [`History::converge_partial`](crate::History::converge_partial) it may
|
||||||
|
/// not be, and `converged` is what says so.
|
||||||
|
/// Constructed only by `converge` / `converge_partial`, never by a caller, so
|
||||||
|
/// `#[non_exhaustive]` costs nothing here and lets a future field be additive.
|
||||||
|
/// The two *options* structs deliberately do not carry it — see the note on
|
||||||
|
/// [`ConvergenceOptions`].
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct ConvergenceReport {
|
||||||
|
/// Full forward+backward sweeps actually run. `0` for a history with no
|
||||||
|
/// time slices, which is converged trivially.
|
||||||
|
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),
|
||||||
|
/// 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,
|
||||||
|
/// 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,
|
||||||
|
/// 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]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_alpha_is_one_for_undamped_behavior() {
|
||||||
|
let opts = ConvergenceOptions::default();
|
||||||
|
assert_eq!(opts.alpha, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-7
@@ -1,14 +1,86 @@
|
|||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
|
|
||||||
pub trait Drift: Copy + Debug {
|
use crate::time::Time;
|
||||||
fn variance_delta(&self, elapsed: i64) -> f64;
|
|
||||||
|
/// Governs how much a competitor's skill can drift between two time points.
|
||||||
|
///
|
||||||
|
/// Generic over `T: Time` so seasonal or calendar-aware drift is expressible
|
||||||
|
/// without going through `i64`.
|
||||||
|
pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
|
||||||
|
/// Variance added to the skill prior for elapsed time `from -> to`.
|
||||||
|
///
|
||||||
|
/// Called with `from <= to`; returning zero means no drift accumulates.
|
||||||
|
fn variance_delta(&self, from: &T, to: &T) -> f64;
|
||||||
|
|
||||||
|
/// Variance added for a pre-computed elapsed count (in the same units as
|
||||||
|
/// `T::elapsed_to`). Used where the elapsed is already cached as `i64`.
|
||||||
|
fn variance_for_elapsed(&self, elapsed: i64) -> f64;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
/// Simple constant-per-unit-time drift.
|
||||||
pub struct ConstantDrift(pub f64);
|
///
|
||||||
|
/// For `Time = i64`: variance added is `(to - from) * gamma^2`.
|
||||||
|
/// For `Time = Untimed`: elapsed is always 0, so drift is always 0.
|
||||||
|
///
|
||||||
|
/// # 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 Drift for ConstantDrift {
|
impl ConstantDrift {
|
||||||
fn variance_delta(&self, elapsed: i64) -> f64 {
|
/// Drift of `gamma` standard deviations per unit time.
|
||||||
elapsed as f64 * self.0 * self.0
|
///
|
||||||
|
/// # 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 {
|
||||||
|
fn variance_delta(&self, from: &T, to: &T) -> f64 {
|
||||||
|
let elapsed = from.elapsed_to(to).max(0) as f64;
|
||||||
|
elapsed * self.0 * self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn variance_for_elapsed(&self, elapsed: i64) -> f64 {
|
||||||
|
elapsed.max(0) as f64 * self.0 * self.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+673
@@ -0,0 +1,673 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// How a prediction should treat a key the history has never seen.
|
||||||
|
///
|
||||||
|
/// Configured once per history via
|
||||||
|
/// [`HistoryBuilder::unknown_keys`](crate::HistoryBuilder::unknown_keys).
|
||||||
|
/// Neither known consumer wants this to vary between queries — one predicts
|
||||||
|
/// thousands of candidate matchups in a loop, the other's headline feature is
|
||||||
|
/// predicting a competitor nobody has faced — so it is a property of how you
|
||||||
|
/// intend to use the model rather than an argument on five call sites.
|
||||||
|
///
|
||||||
|
/// # There is deliberately no `Skip`
|
||||||
|
///
|
||||||
|
/// Dropping an unknown member is the obvious third option and it is wrong. A
|
||||||
|
/// team's performance is the *sum* of its members, so removing one removes its
|
||||||
|
/// variance too: measured on a two-member team with one unknown, skipping gives
|
||||||
|
/// a performance sigma of 2.37 where treating the member as unknown gives 6.53.
|
||||||
|
/// An unknown competitor would make the model *more* certain, which is
|
||||||
|
/// backwards. `Prior` is also the answer the model already gives for a
|
||||||
|
/// competitor it knows about but has no evidence for, so it corresponds to a
|
||||||
|
/// state the model can actually be in; skipping does not.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum UnknownKeys {
|
||||||
|
/// Reject the prediction with [`InferenceError::UnknownKey`].
|
||||||
|
///
|
||||||
|
/// The default, and the right one when every key is expected to be known:
|
||||||
|
/// a team of strangers should not silently produce a confident-looking
|
||||||
|
/// answer.
|
||||||
|
#[default]
|
||||||
|
Reject,
|
||||||
|
/// Treat an unknown competitor as one sitting at the history's configured
|
||||||
|
/// prior.
|
||||||
|
///
|
||||||
|
/// This is the honest Bayesian reading — a competitor you have never
|
||||||
|
/// observed is exactly the prior — and it makes "predict a matchup
|
||||||
|
/// involving someone new" a first-class question rather than something a
|
||||||
|
/// caller fakes with a neutral constant.
|
||||||
|
Prior,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which scalar an [`InferenceError::InvalidParameter`] is about.
|
||||||
|
///
|
||||||
|
/// A typed discriminator rather than a `&'static str`, so a caller can branch
|
||||||
|
/// on it and `Display` can state each parameter's actual valid range. Nine
|
||||||
|
/// distinct strings used to flow through this position, and the only thing a
|
||||||
|
/// caller could do with one was print it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Parameter {
|
||||||
|
/// Prior mean skill. Must be finite.
|
||||||
|
Mu,
|
||||||
|
/// Prior standard deviation. Must be finite and strictly positive.
|
||||||
|
Sigma,
|
||||||
|
/// Performance noise. Must be finite and non-negative.
|
||||||
|
Beta,
|
||||||
|
/// Draw probability. Must be in `[0.0, 1.0)`.
|
||||||
|
PDraw,
|
||||||
|
/// Observation noise on a score margin. Must be finite and strictly
|
||||||
|
/// positive.
|
||||||
|
ScoreSigma,
|
||||||
|
/// EP damping factor. Must be in `(0.0, 1.0]`.
|
||||||
|
Alpha,
|
||||||
|
/// Convergence threshold. Must be non-negative and not NaN.
|
||||||
|
Epsilon,
|
||||||
|
/// A competitor's multiplier on the drift variance. Must be finite and
|
||||||
|
/// non-negative.
|
||||||
|
DriftScale,
|
||||||
|
/// The variance a [`Drift`](crate::Drift) implementation actually produced
|
||||||
|
/// for a span. Must be finite and non-negative — checked because a custom
|
||||||
|
/// implementation is the one thing no constructor can validate up front.
|
||||||
|
DriftVariance,
|
||||||
|
/// A per-member weight on an event. Must be finite.
|
||||||
|
Weight,
|
||||||
|
/// A team's score on a scored event. Must be finite.
|
||||||
|
Score,
|
||||||
|
/// A team's rank on a ranked event. Must be finite.
|
||||||
|
Rank,
|
||||||
|
/// The winning team's index, as given to `Outcome::winner`. Must be less
|
||||||
|
/// than the team count.
|
||||||
|
WinnerIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Parameter {
|
||||||
|
/// The range this parameter must lie in, for the `Display` message.
|
||||||
|
fn range(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Mu => "must be finite",
|
||||||
|
Self::Sigma => "must be finite and strictly positive",
|
||||||
|
Self::Beta => "must be finite and non-negative",
|
||||||
|
Self::PDraw => "must be in [0.0, 1.0)",
|
||||||
|
Self::ScoreSigma => "must be finite and strictly positive",
|
||||||
|
Self::Alpha => "must be in (0.0, 1.0]",
|
||||||
|
Self::Epsilon => "must be non-negative and not NaN",
|
||||||
|
Self::DriftScale => "must be finite and non-negative",
|
||||||
|
Self::DriftVariance => "must be finite and non-negative",
|
||||||
|
Self::Weight => "must be finite",
|
||||||
|
Self::Score => "must be finite",
|
||||||
|
Self::Rank => "must be finite",
|
||||||
|
Self::WinnerIndex => "must be less than the number of teams",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Parameter {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let name = match self {
|
||||||
|
Self::Mu => "mu",
|
||||||
|
Self::Sigma => "sigma",
|
||||||
|
Self::Beta => "beta",
|
||||||
|
Self::PDraw => "p_draw",
|
||||||
|
Self::ScoreSigma => "score_sigma",
|
||||||
|
Self::Alpha => "alpha",
|
||||||
|
Self::Epsilon => "epsilon",
|
||||||
|
Self::DriftScale => "drift_scale",
|
||||||
|
Self::DriftVariance => "drift variance",
|
||||||
|
Self::Weight => "weight",
|
||||||
|
Self::Score => "score",
|
||||||
|
Self::Rank => "rank",
|
||||||
|
Self::WinnerIndex => "winner index",
|
||||||
|
};
|
||||||
|
f.write_str(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which two lengths an [`InferenceError::MismatchedShape`] found disagreeing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Shape {
|
||||||
|
/// The outcome describes a different number of teams than the event has.
|
||||||
|
OutcomeVsTeams,
|
||||||
|
/// A per-member weight list does not match the team's membership.
|
||||||
|
Weights,
|
||||||
|
/// A call that takes a fixed number of teams got a different number.
|
||||||
|
Teams,
|
||||||
|
/// One of `add_events_with_prior`'s parallel arrays disagreed with the
|
||||||
|
/// others.
|
||||||
|
///
|
||||||
|
/// Not reachable through the public API — the arrays are built together at
|
||||||
|
/// the ingestion chokepoint. Kept as a checked error rather than a
|
||||||
|
/// `debug_assert!` so it also holds in release, which is where this
|
||||||
|
/// crate's defects have tended to hide.
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Shape {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let what = match self {
|
||||||
|
Self::OutcomeVsTeams => {
|
||||||
|
"the outcome describes a different number of teams than the event has"
|
||||||
|
}
|
||||||
|
Self::Weights => "the weight list does not match the team's membership",
|
||||||
|
Self::Teams => "this call takes a fixed number of teams",
|
||||||
|
Self::Internal => {
|
||||||
|
"an internal array disagreed with its siblings (this is a bug in trueskill-tt)"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
f.write_str(what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which [`Outcome`](crate::Outcome) variant a call found or wanted.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum OutcomeKind {
|
||||||
|
/// [`Outcome::Ranked`](crate::Outcome::Ranked): an ordinal finish.
|
||||||
|
Ranked,
|
||||||
|
/// [`Outcome::Scored`](crate::Outcome::Scored): continuous scores.
|
||||||
|
Scored,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutcomeKind {
|
||||||
|
/// The call that takes this kind, for the `Display` message.
|
||||||
|
fn constructor(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Ranked => "Game::ranked",
|
||||||
|
Self::Scored => "Game::scored",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The adjective form, for prose.
|
||||||
|
fn adjective(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Ranked => "ranked",
|
||||||
|
Self::Scored => "scored",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for OutcomeKind {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Ranked => "Outcome::Ranked",
|
||||||
|
Self::Scored => "Outcome::Scored",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which piece of per-competitor configuration was declared twice.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum CompetitorField {
|
||||||
|
/// The starting skill distribution.
|
||||||
|
Prior,
|
||||||
|
/// The multiplier on the drift variance.
|
||||||
|
DriftScale,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CompetitorField {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Prior => "prior",
|
||||||
|
Self::DriftScale => "drift_scale",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every way ingestion, inference or prediction can refuse to answer.
|
||||||
|
///
|
||||||
|
/// The crate reports rather than repairs. An input it cannot represent, a fit
|
||||||
|
/// that never reached its fixed point, a quadrature it cannot resolve — each
|
||||||
|
/// comes back here instead of as a clamped, skipped or truncated result that
|
||||||
|
/// would still look like a number. Several variants exist precisely because the
|
||||||
|
/// silent version was measured and found to return a plausible wrong answer.
|
||||||
|
///
|
||||||
|
/// The enum and most of its variants are `#[non_exhaustive]`: new cases and new
|
||||||
|
/// fields are additive, so match with a `_` arm and construct through the
|
||||||
|
/// library rather than by literal.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum InferenceError {
|
||||||
|
/// Expected and actual lengths of some array-shaped input differ.
|
||||||
|
#[non_exhaustive]
|
||||||
|
MismatchedShape {
|
||||||
|
/// 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,
|
||||||
|
/// The length actually supplied.
|
||||||
|
got: usize,
|
||||||
|
},
|
||||||
|
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
||||||
|
#[non_exhaustive]
|
||||||
|
WrongOutcomeKind {
|
||||||
|
/// The variant the call needs.
|
||||||
|
expected: OutcomeKind,
|
||||||
|
/// The variant actually supplied.
|
||||||
|
got: OutcomeKind,
|
||||||
|
},
|
||||||
|
/// A scalar parameter is outside its valid range.
|
||||||
|
#[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.
|
||||||
|
///
|
||||||
|
/// A zero draw probability asserts that draws cannot occur, so a tied
|
||||||
|
/// result has no representable likelihood. Configure a positive `p_draw`
|
||||||
|
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
|
||||||
|
#[non_exhaustive]
|
||||||
|
TieWithoutDrawProbability {
|
||||||
|
/// Positions in the event's team list of the first tied pair, lowest
|
||||||
|
/// index first. Only one pair is reported — the event is rejected
|
||||||
|
/// whole, so enumerating the rest would add nothing.
|
||||||
|
teams: (usize, usize),
|
||||||
|
},
|
||||||
|
/// The convergence sweep hit `max_iter` with the step still above
|
||||||
|
/// `epsilon`.
|
||||||
|
///
|
||||||
|
/// A fit that stops short is wrong by a little, which is the worst
|
||||||
|
/// available failure: every posterior is finite, the ordering looks sensible,
|
||||||
|
/// and nothing in the numbers says they were still moving. Reported rather
|
||||||
|
/// than returned as a flag on an `Ok`, because a flag has to be checked
|
||||||
|
/// and `let _ = h.converge()` is the natural way not to.
|
||||||
|
///
|
||||||
|
/// Either the history needs more iterations — raise `max_iter` — or it is
|
||||||
|
/// oscillating rather than converging, in which case `alpha < 1.0` damps
|
||||||
|
/// the within-game EP loop. [`History::converge_partial`](crate::History::converge_partial)
|
||||||
|
/// returns the short fit instead when that is genuinely what is wanted.
|
||||||
|
#[non_exhaustive]
|
||||||
|
NotConverged {
|
||||||
|
/// Full forward+backward sweeps run before the loop gave up.
|
||||||
|
iterations: usize,
|
||||||
|
/// How far the last sweep still moved the fit, as
|
||||||
|
/// `(largest change in a mean, largest change in a standard
|
||||||
|
/// deviation)` over every competitor posterior it touched — the same
|
||||||
|
/// quantity as
|
||||||
|
/// [`ConvergenceReport::final_step`](crate::ConvergenceReport).
|
||||||
|
final_step: (f64, f64),
|
||||||
|
/// The threshold both components of `final_step` had to reach.
|
||||||
|
epsilon: f64,
|
||||||
|
},
|
||||||
|
/// A convergence sweep produced a non-finite step.
|
||||||
|
///
|
||||||
|
/// EP has broken down; the resulting skills are meaningless and must not
|
||||||
|
/// be treated as a converged estimate. Further iterations cannot recover,
|
||||||
|
/// so the loop stops rather than reporting a NaN step as convergence.
|
||||||
|
#[non_exhaustive]
|
||||||
|
NonFiniteStep {
|
||||||
|
/// Where the breakdown was caught, e.g. `"History::converge"`.
|
||||||
|
context: &'static str,
|
||||||
|
/// The offending step as `(|d mu|, |d sigma|)`, at least one component
|
||||||
|
/// of which is NaN or infinite.
|
||||||
|
step: (f64, f64),
|
||||||
|
},
|
||||||
|
/// A prediction read a skill with no usable mean or variance.
|
||||||
|
///
|
||||||
|
/// Split from `NonFiniteStep` (#74), which used to carry both under one
|
||||||
|
/// `step: (f64, f64)` field — a sweep step from `converge` and a skill's
|
||||||
|
/// own moments from a prediction. One field name cannot be right for both.
|
||||||
|
///
|
||||||
|
/// Reaching this means a previous `converge` failed and its error was
|
||||||
|
/// ignored: predicting from a NaN fit produced `Ok(NaN)` on some paths and
|
||||||
|
/// a plausible-looking `Ok([0.0, 0.0])` on others.
|
||||||
|
#[non_exhaustive]
|
||||||
|
NonFiniteSkill {
|
||||||
|
/// The skill's mean, which may itself be finite while `sigma` is not.
|
||||||
|
mu: f64,
|
||||||
|
/// The skill's standard deviation.
|
||||||
|
sigma: f64,
|
||||||
|
},
|
||||||
|
/// Every skill in the matchup is a point mass and `beta` is zero, so
|
||||||
|
/// there is no performance distribution to predict from.
|
||||||
|
///
|
||||||
|
/// Not `InvalidParameter`: both values are individually in range, and it
|
||||||
|
/// is their combination that leaves nothing varying. Every prediction is a
|
||||||
|
/// statement about how performances vary, and in this configuration
|
||||||
|
/// nothing does — `quality` would divide by a singular contrast covariance
|
||||||
|
/// and `predict_win_probabilities` would report zeros that sum to zero.
|
||||||
|
NoPerformanceVariance,
|
||||||
|
/// One batch declared two different values for the same competitor's
|
||||||
|
/// configuration.
|
||||||
|
///
|
||||||
|
/// `prior` and `drift_scale` configure a competitor, not an event, so a
|
||||||
|
/// batch that sets one of them twice with different values has no
|
||||||
|
/// well-defined meaning: events within a batch are not ordered, so
|
||||||
|
/// "last one wins" would make the result depend on iteration order.
|
||||||
|
/// Declaring the same value repeatedly is fine and is the expected shape
|
||||||
|
/// when a competitor's configuration is a property of the domain.
|
||||||
|
#[non_exhaustive]
|
||||||
|
ConflictingCompetitorConfig {
|
||||||
|
/// The competitor's interned slot as a raw `usize`,
|
||||||
|
/// not the user key — the batch is already flattened to indices by the
|
||||||
|
/// time the conflict is detectable.
|
||||||
|
competitor: usize,
|
||||||
|
/// Which piece of configuration was declared twice.
|
||||||
|
field: CompetitorField,
|
||||||
|
},
|
||||||
|
/// A prediction referenced a key the history has no skill for.
|
||||||
|
///
|
||||||
|
/// Reported rather than skipped: dropping unknown keys turns a team of
|
||||||
|
/// strangers into a confident-looking probability about nobody.
|
||||||
|
///
|
||||||
|
/// `key` is the offending key's `Debug` rendering. It is carried because
|
||||||
|
/// the indices alone are not actionable: a caller that logs
|
||||||
|
/// `UnknownKey { team: 0, member: 0 }` learns nothing about *which* of its
|
||||||
|
/// keys the history has not seen, and the natural handling — fall back to a
|
||||||
|
/// neutral value — turns the whole thing into a plausible constant.
|
||||||
|
#[non_exhaustive]
|
||||||
|
UnknownKey {
|
||||||
|
/// Position of the offending team in the supplied matchup. `0` on the
|
||||||
|
/// queries that take a flat list of keys rather than teams, where
|
||||||
|
/// there is only one list to index into.
|
||||||
|
team: usize,
|
||||||
|
/// Position of the offending key within that team, or within the flat
|
||||||
|
/// key list.
|
||||||
|
member: usize,
|
||||||
|
/// The key's `Debug` rendering, captured because `K` is only required
|
||||||
|
/// to be `Debug` — see the variant docs for why the indices alone are
|
||||||
|
/// not enough.
|
||||||
|
key: String,
|
||||||
|
},
|
||||||
|
/// `History::register` was called for a competitor that already exists.
|
||||||
|
///
|
||||||
|
/// Registration states a competitor's configuration before anything has
|
||||||
|
/// been observed about them, so a competitor that already exists has
|
||||||
|
/// already been configured — by an earlier `register`, or by an event that
|
||||||
|
/// created them. Silently overwriting would reintroduce exactly the
|
||||||
|
/// order-dependence registration exists to remove.
|
||||||
|
///
|
||||||
|
/// To change an existing competitor's configuration, supply it on an event
|
||||||
|
/// through `Member`; that refits the whole history.
|
||||||
|
#[non_exhaustive]
|
||||||
|
AlreadyRegistered {
|
||||||
|
/// The already-known competitor's key, in its `Debug` rendering.
|
||||||
|
key: String,
|
||||||
|
},
|
||||||
|
/// A prediction was given a team with no members.
|
||||||
|
#[non_exhaustive]
|
||||||
|
EmptyTeam {
|
||||||
|
/// Position of the memberless team in the supplied list.
|
||||||
|
team: usize,
|
||||||
|
},
|
||||||
|
/// The prediction grid cannot resolve the narrowest feature in the matchup.
|
||||||
|
///
|
||||||
|
/// `predict_outcome` and `predict_ranking` integrate every team's density
|
||||||
|
/// on one shared grid, whose resolution is set by the narrowest sigma (or a
|
||||||
|
/// narrower draw margin). When the widest and narrowest are far enough
|
||||||
|
/// apart, resolving the narrow one across the wide one's support needs more
|
||||||
|
/// nodes than the grid is allowed to hold.
|
||||||
|
///
|
||||||
|
/// Reported rather than clamped. Clamping is what this replaced, and it
|
||||||
|
/// returned probabilities greater than one — measured, a `P` of 2.79 and a
|
||||||
|
/// `Prediction::total()` of 5.41 — because the trapezoid rule stops
|
||||||
|
/// resolving a density once the step exceeds roughly 1.7 of its sigma.
|
||||||
|
///
|
||||||
|
/// `predict_win_probabilities` answers the same matchup through adaptive
|
||||||
|
/// quadrature and is accurate here; use it when only the per-team win
|
||||||
|
/// probabilities are needed.
|
||||||
|
#[non_exhaustive]
|
||||||
|
GridTooCoarse {
|
||||||
|
/// Nodes required to resolve the narrowest feature.
|
||||||
|
needed: usize,
|
||||||
|
/// Nodes the grid may hold.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// A joint posterior was requested from a history with no events.
|
||||||
|
///
|
||||||
|
/// Split out of a single `JointUnavailable { reason: &str }` (#74): the
|
||||||
|
/// three reasons are conditions a caller branches on differently, and
|
||||||
|
/// distinguishing them used to mean matching on English prose. This one
|
||||||
|
/// means "add events".
|
||||||
|
EmptyHistory,
|
||||||
|
/// A joint posterior was requested from a history containing ranked
|
||||||
|
/// events.
|
||||||
|
///
|
||||||
|
/// Exact only for an all-scored history: a scored likelihood is Gaussian
|
||||||
|
/// and its factor can be rebuilt exactly, while a ranked outcome's
|
||||||
|
/// truncation is approximated by EP and reconstructing those factors needs
|
||||||
|
/// the converged messages, which inference does not retain.
|
||||||
|
///
|
||||||
|
/// [`History::predict_win_probabilities`](crate::History::predict_win_probabilities)
|
||||||
|
/// answers the comparable question on a ranked history.
|
||||||
|
JointRequiresScoredEvents,
|
||||||
|
/// The assembled precision matrix is not positive-definite.
|
||||||
|
///
|
||||||
|
/// The usual cause is a competitor with neither a proper prior nor any
|
||||||
|
/// evidence, but an extreme prior or drift can also make the assembled
|
||||||
|
/// matrix indefinite in floating point. Unlike its two siblings this one
|
||||||
|
/// is numerical rather than structural — the same history may factorise
|
||||||
|
/// under different parameters.
|
||||||
|
NotPositiveDefinite,
|
||||||
|
/// Fewer than two teams were supplied to a prediction.
|
||||||
|
#[non_exhaustive]
|
||||||
|
NotEnoughTeams {
|
||||||
|
/// How many teams the prediction was actually given. Two is the
|
||||||
|
/// minimum: there is nothing to compare against with fewer.
|
||||||
|
got: usize,
|
||||||
|
},
|
||||||
|
/// The full outcome distribution was requested for too many teams.
|
||||||
|
///
|
||||||
|
/// Each realisation sorts into exactly one (order, tie-pattern) event, so
|
||||||
|
/// the space holds `n! * 2^(n-1)` members — 1_920 at five teams, 23_040 at
|
||||||
|
/// six, 322_560 at seven. Past `max` this stops being something to
|
||||||
|
/// enumerate on a caller's behalf; ask for individual rankings with
|
||||||
|
/// `predict_ranking`, or for `predict_win_probabilities`, both of which
|
||||||
|
/// stay cheap at any team count.
|
||||||
|
#[non_exhaustive]
|
||||||
|
TooManyTeams {
|
||||||
|
/// How many teams the outcome distribution was asked for.
|
||||||
|
got: usize,
|
||||||
|
/// The largest team count that will be enumerated,
|
||||||
|
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for InferenceError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::MismatchedShape {
|
||||||
|
shape,
|
||||||
|
expected,
|
||||||
|
got,
|
||||||
|
} => {
|
||||||
|
write!(f, "{shape}: expected {expected}, got {got}")
|
||||||
|
}
|
||||||
|
Self::WrongOutcomeKind { expected, got } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"expected {expected}, got {got}; call {} for a {} outcome",
|
||||||
|
got.constructor(),
|
||||||
|
got.adjective()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::TieWithoutDrawProbability { teams } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"teams {} and {} are tied, but p_draw is 0.0; set a positive draw probability to admit ties",
|
||||||
|
teams.0, teams.1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::NotConverged {
|
||||||
|
iterations,
|
||||||
|
final_step,
|
||||||
|
epsilon,
|
||||||
|
} => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"did not converge in {iterations} iterations: final step {final_step:?} \
|
||||||
|
is still above epsilon {epsilon}; raise max_iter, or damp with \
|
||||||
|
alpha < 1.0 if it is oscillating"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::NonFiniteStep { context, step } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{context}: inference produced a non-finite step {step:?}; EP has \
|
||||||
|
broken down and further iterations cannot recover"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::NonFiniteSkill { mu, sigma } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"a prediction read a skill with no usable mean or variance \
|
||||||
|
(mu = {mu}, sigma = {sigma}); the fit did not converge, and \
|
||||||
|
`converge` reports that"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::InvalidParameter { parameter, value } => {
|
||||||
|
write!(f, "{parameter} {} (got {value})", parameter.range())
|
||||||
|
}
|
||||||
|
Self::ConflictingCompetitorConfig { competitor, field } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"competitor {competitor}: this batch sets {field} to two different values"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::UnknownKey { team, member, key } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"team {team}, member {member}: no skill recorded for key {key} \
|
||||||
|
(every key must already be known to the history; pre-filter \
|
||||||
|
with `current_skill` if that is not guaranteed)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::AlreadyRegistered { key } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"competitor {key} is already registered; registration states \
|
||||||
|
configuration before anything is observed, so re-registering \
|
||||||
|
would silently overwrite it"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::EmptyTeam { team } => {
|
||||||
|
write!(f, "team {team} has no members")
|
||||||
|
}
|
||||||
|
Self::GridTooCoarse { needed, max } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"the prediction grid needs {needed} nodes to resolve the narrowest \
|
||||||
|
team's density across the widest team's support, but may hold only \
|
||||||
|
{max}; the sigmas in this matchup are too far apart to integrate on \
|
||||||
|
one grid. Use predict_win_probabilities, which is accurate here"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::EmptyHistory => {
|
||||||
|
f.write_str("no exact joint posterior is available: the history has no events")
|
||||||
|
}
|
||||||
|
Self::JointRequiresScoredEvents => f.write_str(
|
||||||
|
"no exact joint posterior is available: the history contains ranked \
|
||||||
|
events, whose EP factors are not retained after convergence. Use \
|
||||||
|
predict_win_probabilities for a ranked history",
|
||||||
|
),
|
||||||
|
Self::NotPositiveDefinite => f.write_str(
|
||||||
|
"the joint precision matrix is not positive-definite; the usual cause \
|
||||||
|
is a competitor with neither a proper prior nor any evidence, but an \
|
||||||
|
extreme prior or drift can also make the assembled matrix indefinite \
|
||||||
|
in floating point",
|
||||||
|
),
|
||||||
|
Self::NoPerformanceVariance => f.write_str(
|
||||||
|
"beta is zero and every skill in this matchup is a point mass, so \
|
||||||
|
there is no performance distribution to predict from; give beta a \
|
||||||
|
positive value, or a competitor a prior with positive sigma",
|
||||||
|
),
|
||||||
|
Self::NotEnoughTeams { got } => {
|
||||||
|
write!(f, "prediction needs at least 2 teams, got {got}")
|
||||||
|
}
|
||||||
|
Self::TooManyTeams { got, max } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"the outcome distribution over {got} teams is too large to enumerate (limit {max}); \
|
||||||
|
use predict_ranking or predict_win_probabilities instead"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for InferenceError {}
|
||||||
|
|
||||||
|
#[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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
//! Typed event description for bulk ingestion.
|
||||||
|
//!
|
||||||
|
//! `Event<T, K>` is the public event shape taken by `History::add_events`. It
|
||||||
|
//! is a typed front end, not a replacement: `add_events` flattens it into the
|
||||||
|
//! nested `Vec<Vec<Vec<Index>>>` / `Vec<Vec<f64>>` / `Vec<Vec<Vec<f64>>>` that
|
||||||
|
//! the internal `add_events_with_prior` chokepoint still takes, and which
|
||||||
|
//! `record_winner` and `record_draw` also route through.
|
||||||
|
|
||||||
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
|
use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
|
||||||
|
|
||||||
|
/// A single match at time `time` involving some number of teams.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
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,
|
||||||
|
/// 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]>,
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A team: list of members competing together.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
#[must_use]
|
||||||
|
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]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K> Team<K> {
|
||||||
|
/// A team with no members yet, to be filled through the public `members`
|
||||||
|
/// field.
|
||||||
|
///
|
||||||
|
/// Committing it while still empty is an `EmptyTeam` error.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
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 {
|
||||||
|
Self {
|
||||||
|
members: members.into_iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K> Default for Team<K> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One member of a team, identified by user key `K`.
|
||||||
|
///
|
||||||
|
/// `weight` applies per event and defaults to 1.0.
|
||||||
|
///
|
||||||
|
/// `prior` and `drift_scale` are **competitor configuration**, not per-event
|
||||||
|
/// values. Setting either applies to the competitor for the whole history, not
|
||||||
|
/// just to this event, and applies whenever it is supplied — including on a key
|
||||||
|
/// the history already knows. Because configuration lives on the competitor and
|
||||||
|
/// `converge` refits from competitor state, configuring one late still refits
|
||||||
|
/// the whole history rather than taking effect only from that event onward.
|
||||||
|
///
|
||||||
|
/// Repeating the same value is inert, which is the expected shape when the
|
||||||
|
/// configuration is a property of the domain. Supplying two *different* values
|
||||||
|
/// for one competitor within a single batch is
|
||||||
|
/// `InferenceError::ConflictingCompetitorConfig`: events in a batch have no
|
||||||
|
/// order, so there would be no well-defined winner.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
#[must_use]
|
||||||
|
pub struct Member<K> {
|
||||||
|
/// 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,
|
||||||
|
/// 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,
|
||||||
|
/// 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>,
|
||||||
|
/// Multiplier on the drift *variance* this competitor accumulates.
|
||||||
|
/// `None` means 1.0.
|
||||||
|
pub drift_scale: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Self {
|
||||||
|
key,
|
||||||
|
weight: 1.0,
|
||||||
|
prior: None,
|
||||||
|
drift_scale: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change how much of the team's performance this member accounts for.
|
||||||
|
///
|
||||||
|
/// Unlike `prior` and `drift_scale`, this is genuinely per-event: the same
|
||||||
|
/// key can carry a different weight in every event it appears in, which is
|
||||||
|
/// what makes it usable for partial participation — a substitute who
|
||||||
|
/// played half the match, a doubles partner credited unequally.
|
||||||
|
pub fn with_weight(mut self, weight: f64) -> Self {
|
||||||
|
self.weight = weight;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set this competitor's starting skill estimate.
|
||||||
|
///
|
||||||
|
/// Competitor configuration, not a per-event value: it applies for the
|
||||||
|
/// whole history and applies whenever it is supplied, including on a key
|
||||||
|
/// the history already knows. See the type docs.
|
||||||
|
pub fn with_prior(mut self, prior: Gaussian) -> Self {
|
||||||
|
self.prior = Some(prior);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scale how fast this competitor drifts, relative to the history's drift.
|
||||||
|
///
|
||||||
|
/// The scale multiplies the drift *variance*, so it is in the same units as
|
||||||
|
/// `gamma`: `ConstantDrift::new(g)` at `scale = s` behaves exactly as
|
||||||
|
/// `ConstantDrift::new(g * s)` would for this competitor alone.
|
||||||
|
///
|
||||||
|
/// `0.0` pins the competitor still — useful for a reference point that
|
||||||
|
/// shares a scale with moving competitors but should not itself move: a bot
|
||||||
|
/// at a known strength, a rating floor, a course difficulty.
|
||||||
|
///
|
||||||
|
/// Applies for the whole history and whenever it is supplied, including on
|
||||||
|
/// a key the history already knows; see the type docs.
|
||||||
|
/// Must be finite and non-negative, or ingestion fails with
|
||||||
|
/// [`InferenceError::InvalidParameter`](crate::InferenceError::InvalidParameter).
|
||||||
|
pub fn with_drift_scale(mut self, scale: f64) -> Self {
|
||||||
|
self.drift_scale = Some(scale);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: a member is a user key with default weight 1.0 and no prior.
|
||||||
|
impl<K> From<K> for Member<K> {
|
||||||
|
fn from(key: K) -> Self {
|
||||||
|
Self::new(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::Outcome;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_new_has_unit_weight_no_prior() {
|
||||||
|
let m = Member::new("alice");
|
||||||
|
assert_eq!(m.key, "alice");
|
||||||
|
assert_eq!(m.weight, 1.0);
|
||||||
|
assert!(m.prior.is_none());
|
||||||
|
assert!(m.drift_scale.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_builder_methods_chain() {
|
||||||
|
let m = Member::new("alice")
|
||||||
|
.with_weight(0.5)
|
||||||
|
.with_prior(Gaussian::from_ms(20.0, 5.0))
|
||||||
|
.with_drift_scale(0.0);
|
||||||
|
assert_eq!(m.weight, 0.5);
|
||||||
|
assert!(m.prior.is_some());
|
||||||
|
assert_eq!(m.drift_scale, Some(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_from_key() {
|
||||||
|
let m: Member<&str> = "bob".into();
|
||||||
|
assert_eq!(m.key, "bob");
|
||||||
|
assert_eq!(m.weight, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn team_with_members_collects() {
|
||||||
|
let t: Team<&str> = Team::with_members([Member::new("a"), Member::new("b")]);
|
||||||
|
assert_eq!(t.members.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_construction() {
|
||||||
|
use smallvec::smallvec;
|
||||||
|
let e: Event<i64, &str> = Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
};
|
||||||
|
assert_eq!(e.teams.len(), 2);
|
||||||
|
assert_eq!(e.time, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
InferenceError, Outcome,
|
||||||
|
drift::Drift,
|
||||||
|
event::{Event, Member, Team},
|
||||||
|
history::History,
|
||||||
|
observer::Observer,
|
||||||
|
time::Time,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
T: Time,
|
||||||
|
D: Drift<T>,
|
||||||
|
O: Observer<T>,
|
||||||
|
K: Eq + std::hash::Hash + Clone,
|
||||||
|
R: crate::RatingRule<K>,
|
||||||
|
{
|
||||||
|
history: &'h mut History<K, T, D, O, R>,
|
||||||
|
event: Event<T, K>,
|
||||||
|
current_team_idx: Option<usize>,
|
||||||
|
/// First validation failure seen while building, surfaced by `commit`.
|
||||||
|
///
|
||||||
|
/// The setters return `Self` so the chain stays fluent; they cannot return
|
||||||
|
/// a `Result` without breaking that. Recording the failure and reporting it
|
||||||
|
/// at `commit` keeps the check enforced in release, where the previous
|
||||||
|
/// `debug_assert!` was compiled out and a mismatched event was ingested
|
||||||
|
/// silently.
|
||||||
|
error: Option<InferenceError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'h, T, D, O, K, R> EventBuilder<'h, T, D, O, K, R>
|
||||||
|
where
|
||||||
|
T: Time,
|
||||||
|
D: Drift<T>,
|
||||||
|
O: Observer<T>,
|
||||||
|
K: Eq + std::hash::Hash + Clone,
|
||||||
|
R: crate::RatingRule<K>,
|
||||||
|
{
|
||||||
|
pub(crate) fn new(history: &'h mut History<K, T, D, O, R>, time: T) -> Self {
|
||||||
|
Self {
|
||||||
|
history,
|
||||||
|
event: Event {
|
||||||
|
time,
|
||||||
|
teams: SmallVec::new(),
|
||||||
|
outcome: Outcome::Ranked(SmallVec::new()),
|
||||||
|
},
|
||||||
|
current_team_idx: None,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a team by its member keys (weight 1.0 each, no prior overrides).
|
||||||
|
///
|
||||||
|
/// Use [`EventBuilder::members`] to set `prior` or `drift_scale`.
|
||||||
|
pub fn team<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self {
|
||||||
|
let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect();
|
||||||
|
self.event.teams.push(Team { members });
|
||||||
|
self.current_team_idx = Some(self.event.teams.len() - 1);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a team from fully-specified [`Member`] values.
|
||||||
|
///
|
||||||
|
/// [`EventBuilder::team`] is the common case and builds members with
|
||||||
|
/// `Member::new`, which leaves `prior` and `drift_scale` unset. This is the
|
||||||
|
/// escape hatch for when they matter:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use trueskill_tt::{Gaussian, History, Member};
|
||||||
|
/// # let mut h = History::builder().build();
|
||||||
|
/// h.event(0)
|
||||||
|
/// .team(["player"])
|
||||||
|
/// .members([Member::new("layout_7")
|
||||||
|
/// .with_drift_scale(0.0)
|
||||||
|
/// .with_prior(Gaussian::from_ms(0.0, 1.0))])
|
||||||
|
/// .ranking([0, 1])
|
||||||
|
/// .commit()?;
|
||||||
|
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// One method rather than a `priors` and a `drift_scales` setter beside
|
||||||
|
/// `weights`: those would have to grow a parallel array — and a parallel
|
||||||
|
/// length check — every time `Member` gains a field, and each one would be
|
||||||
|
/// a new way to get the lengths wrong. `Member`'s own builder already
|
||||||
|
/// expresses all of it.
|
||||||
|
///
|
||||||
|
/// `prior` and `drift_scale` are competitor configuration rather than
|
||||||
|
/// per-event values; see [`Member`] for what that means for a key the
|
||||||
|
/// history already knows.
|
||||||
|
pub fn members<I: IntoIterator<Item = Member<K>>>(mut self, members: I) -> Self {
|
||||||
|
self.event.teams.push(Team::with_members(members));
|
||||||
|
self.current_team_idx = Some(self.event.teams.len() - 1);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set per-member weights for the most recently added team.
|
||||||
|
///
|
||||||
|
/// A length mismatch is recorded and returned by [`EventBuilder::commit`]
|
||||||
|
/// as `InferenceError::MismatchedShape`, in both debug and release. The
|
||||||
|
/// weights are not applied in that case, so a partially-weighted team
|
||||||
|
/// cannot reach the history.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if called before any `.team(...)`.
|
||||||
|
pub fn weights<I: IntoIterator<Item = f64>>(mut self, weights: I) -> Self {
|
||||||
|
let idx = self
|
||||||
|
.current_team_idx
|
||||||
|
.expect(".weights(...) called before any .team(...)");
|
||||||
|
|
||||||
|
let ws: Vec<f64> = weights.into_iter().collect();
|
||||||
|
let team = &mut self.event.teams[idx];
|
||||||
|
|
||||||
|
if ws.len() != team.members.len() {
|
||||||
|
self.error.get_or_insert(InferenceError::MismatchedShape {
|
||||||
|
shape: crate::Shape::Weights,
|
||||||
|
expected: team.members.len(),
|
||||||
|
got: ws.len(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (m, w) in team.members.iter_mut().zip(ws) {
|
||||||
|
m.weight = w;
|
||||||
|
}
|
||||||
|
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set explicit ranks per team (length must equal number of teams).
|
||||||
|
pub fn ranking<I: IntoIterator<Item = u32>>(mut self, ranks: I) -> Self {
|
||||||
|
self.event.outcome = Outcome::ranking(ranks);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set explicit per-team continuous scores; higher = better.
|
||||||
|
pub fn scores<I: IntoIterator<Item = f64>>(mut self, scores: I) -> Self {
|
||||||
|
self.event.outcome = crate::Outcome::scores(scores);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set explicit per-team continuous scores with a per-event noise override.
|
||||||
|
///
|
||||||
|
/// `score_sigma` is the observation noise on the *score margin*, not a
|
||||||
|
/// skill sigma, and it overrides `HistoryBuilder::score_sigma` for this
|
||||||
|
/// event only. A small value takes the margin near-literally; a large one
|
||||||
|
/// barely moves the ratings.
|
||||||
|
///
|
||||||
|
/// Must be `> 0.0`. Building the outcome with a non-positive or NaN value
|
||||||
|
/// is allowed; it is rejected with `InferenceError::InvalidParameter` when
|
||||||
|
/// the event is ingested, so callers get an error from `commit` rather
|
||||||
|
/// than a panic.
|
||||||
|
pub fn scores_with_noise<I: IntoIterator<Item = f64>>(
|
||||||
|
mut self,
|
||||||
|
scores: I,
|
||||||
|
score_sigma: f64,
|
||||||
|
) -> Self {
|
||||||
|
self.event.outcome = crate::Outcome::scores_with_noise(scores, score_sigma);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark team `winner_idx` as winner; others tied for last.
|
||||||
|
pub fn winner(mut self, winner_idx: u32) -> Self {
|
||||||
|
self.event.outcome = Outcome::winner(winner_idx, self.event.teams.len() as u32);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All teams tied.
|
||||||
|
pub fn draw(mut self) -> Self {
|
||||||
|
self.event.outcome = Outcome::draw(self.event.teams.len() as u32);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit the event to the history.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns the first validation failure recorded while building — see
|
||||||
|
/// [`EventBuilder::weights`] — otherwise forwards to
|
||||||
|
/// [`History::add_events`] and returns its errors.
|
||||||
|
pub fn commit(self) -> Result<(), InferenceError> {
|
||||||
|
if let Some(error) = self.error {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.history.add_events(std::iter::once(self.event))
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
//! Factor graph machinery for within-game inference.
|
||||||
|
|
||||||
|
use crate::gaussian::Gaussian;
|
||||||
|
|
||||||
|
/// Identifier for a variable in a `VarStore`.
|
||||||
|
///
|
||||||
|
/// Variables hold the current Gaussian marginal and are owned by exactly one
|
||||||
|
/// `VarStore`. `VarId` is meaningful only within its owning store.
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub struct VarId(pub u32);
|
||||||
|
|
||||||
|
/// Flat storage of variable marginals.
|
||||||
|
///
|
||||||
|
/// Variables are allocated by `alloc()` and accessed by `VarId`. The store is
|
||||||
|
/// reused across `Game::ranked_with_arena` calls (it lives in the `ScratchArena`); call
|
||||||
|
/// `clear()` before reuse.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct VarStore {
|
||||||
|
pub(crate) marginals: Vec<Gaussian>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VarStore {
|
||||||
|
/// Test-only: inference allocates its store through `ScratchArena`.
|
||||||
|
#[cfg(test)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.marginals.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only, as `new`.
|
||||||
|
#[cfg(test)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.marginals.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn alloc(&mut self, init: Gaussian) -> VarId {
|
||||||
|
let id = VarId(self.marginals.len() as u32);
|
||||||
|
self.marginals.push(init);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: VarId) -> Gaussian {
|
||||||
|
self.marginals[id.0 as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&mut self, id: VarId, g: Gaussian) {
|
||||||
|
self.marginals[id.0 as usize] = g;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod margin;
|
||||||
|
pub mod trunc;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::N_INF;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alloc_assigns_sequential_ids() {
|
||||||
|
let mut store = VarStore::new();
|
||||||
|
let a = store.alloc(N_INF);
|
||||||
|
let b = store.alloc(N_INF);
|
||||||
|
let c = store.alloc(N_INF);
|
||||||
|
assert_eq!(a, VarId(0));
|
||||||
|
assert_eq!(b, VarId(1));
|
||||||
|
assert_eq!(c, VarId(2));
|
||||||
|
assert_eq!(store.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_returns_initial_value() {
|
||||||
|
let mut store = VarStore::new();
|
||||||
|
let g = Gaussian::from_ms(2.5, 1.0);
|
||||||
|
let id = store.alloc(g);
|
||||||
|
assert_eq!(store.get(id), g);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_updates_value() {
|
||||||
|
let mut store = VarStore::new();
|
||||||
|
let id = store.alloc(N_INF);
|
||||||
|
let new = Gaussian::from_ms(3.0, 0.5);
|
||||||
|
store.set(id, new);
|
||||||
|
assert_eq!(store.get(id), new);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_resets_length_keeping_capacity() {
|
||||||
|
let mut store = VarStore::new();
|
||||||
|
store.alloc(N_INF);
|
||||||
|
store.alloc(N_INF);
|
||||||
|
let cap = store.marginals.capacity();
|
||||||
|
store.clear();
|
||||||
|
assert_eq!(store.len(), 0);
|
||||||
|
assert_eq!(store.marginals.capacity(), cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
use crate::{
|
||||||
|
N_INF,
|
||||||
|
factor::{VarId, VarStore},
|
||||||
|
gaussian::Gaussian,
|
||||||
|
ln_pdf,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Gaussian observation factor on a diff variable.
|
||||||
|
///
|
||||||
|
/// Encodes the soft evidence `m_obs ~ N(diff, sigma²)`. The outgoing message
|
||||||
|
/// to `diff` is the constant `N(m_obs, sigma²)`, so this factor converges in a
|
||||||
|
/// single propagation: subsequent calls return a zero delta.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MarginFactor {
|
||||||
|
pub diff: VarId,
|
||||||
|
pub m_obs: f64,
|
||||||
|
pub sigma: f64,
|
||||||
|
pub(crate) msg: Gaussian,
|
||||||
|
pub(crate) log_evidence_cached: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarginFactor {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(diff: VarId, m_obs: f64, sigma: f64) -> Self {
|
||||||
|
debug_assert!(sigma > 0.0, "score sigma must be positive");
|
||||||
|
Self {
|
||||||
|
diff,
|
||||||
|
m_obs,
|
||||||
|
sigma,
|
||||||
|
msg: N_INF,
|
||||||
|
log_evidence_cached: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarginFactor {
|
||||||
|
/// Propagate this factor's message, optionally damping the update in
|
||||||
|
/// natural-parameter space. `alpha = 1.0` matches `Factor::propagate`
|
||||||
|
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
|
||||||
|
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
|
||||||
|
let marginal = vars.get(self.diff);
|
||||||
|
let cavity = marginal.cavity(self.msg);
|
||||||
|
|
||||||
|
if self.log_evidence_cached.is_none() {
|
||||||
|
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.m_obs, self.sigma));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_msg = Gaussian::from_ms(self.m_obs, self.sigma);
|
||||||
|
let damped = self.msg.damp_natural(new_msg, alpha);
|
||||||
|
let old_msg = self.msg;
|
||||||
|
self.msg = damped;
|
||||||
|
vars.set(self.diff, cavity.ep_product(damped));
|
||||||
|
|
||||||
|
old_msg.delta(damped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undamped wrappers, used by this module's tests. Inference drives these
|
||||||
|
/// factors through `propagate_with_alpha` and reads the cached log evidence
|
||||||
|
/// directly, so these are not on any production path.
|
||||||
|
#[cfg(test)]
|
||||||
|
impl MarginFactor {
|
||||||
|
pub(crate) fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||||
|
self.propagate_with_alpha(vars, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn log_evidence(&self) -> f64 {
|
||||||
|
self.log_evidence_cached.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ln` of the observed margin's density under the cavity.
|
||||||
|
///
|
||||||
|
/// Computed in log space rather than as `pdf(..).ln()`. The density underflows
|
||||||
|
/// to zero past about 38 sigma of separation, and clamping that to
|
||||||
|
/// `f64::MIN_POSITIVE` reported -708 nats however far out the observation
|
||||||
|
/// actually was — 4292 nats adrift at 100 sigma, and unbounded beyond. A score
|
||||||
|
/// far from what the model expected is exactly the observation a log-evidence
|
||||||
|
/// figure exists to notice.
|
||||||
|
fn cavity_log_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
|
||||||
|
// `hypot`, not `sqrt(a^2 + b^2)`: squaring overflows to infinity above a
|
||||||
|
// sigma of ~1.3e154 and flushes to zero below ~1.5e-154, and `Gaussian`'s
|
||||||
|
// constructors are public so a caller can reach both.
|
||||||
|
let combined_sigma = libm::hypot(cavity.sigma(), sigma);
|
||||||
|
let value = ln_pdf(m_obs, cavity.mu(), combined_sigma);
|
||||||
|
|
||||||
|
// A degenerate cavity (infinite sigma) is the only way to reach a
|
||||||
|
// non-finite result; fall back to the old floor rather than emit -inf.
|
||||||
|
if value.is_finite() {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
libm::log(f64::MIN_POSITIVE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_propagate_writes_tilted_marginal() {
|
||||||
|
let mut vars = VarStore::new();
|
||||||
|
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||||
|
let mut f = MarginFactor::new(diff, 5.0, 1.0);
|
||||||
|
|
||||||
|
f.propagate(&mut vars);
|
||||||
|
|
||||||
|
let result = vars.get(diff);
|
||||||
|
// pi = 1/36 + 1 ≈ 1.027778; tau = 0 + 5 = 5
|
||||||
|
// mu = 5 / 1.027778 ≈ 4.864865; sigma = 1/sqrt(1.027778) ≈ 0.986394
|
||||||
|
assert!((result.mu() - 4.864864864864865).abs() < 1e-12);
|
||||||
|
assert!((result.sigma() - 0.986393923832144).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converges_in_one_step() {
|
||||||
|
let mut vars = VarStore::new();
|
||||||
|
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||||
|
let mut f = MarginFactor::new(diff, 5.0, 1.0);
|
||||||
|
|
||||||
|
f.propagate(&mut vars);
|
||||||
|
let (dmu, dsig) = f.propagate(&mut vars);
|
||||||
|
assert!(
|
||||||
|
dmu < 1e-12,
|
||||||
|
"expected ~0 delta on second propagate, got {dmu}"
|
||||||
|
);
|
||||||
|
assert!(dsig < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evidence_cached_on_first_propagate() {
|
||||||
|
let mut vars = VarStore::new();
|
||||||
|
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||||
|
let mut f = MarginFactor::new(diff, 5.0, 1.0);
|
||||||
|
assert!(f.log_evidence_cached.is_none());
|
||||||
|
|
||||||
|
f.propagate(&mut vars);
|
||||||
|
let z = f.log_evidence_cached.unwrap();
|
||||||
|
// ln pdf(5, 0, sqrt(37)) = ln(0.046783...)
|
||||||
|
assert!((z.exp() - 0.04678300292616668).abs() < 1e-10);
|
||||||
|
|
||||||
|
// Subsequent propagations don't change it.
|
||||||
|
f.propagate(&mut vars);
|
||||||
|
assert_eq!(f.log_evidence_cached.unwrap(), z);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_evidence_matches_cached_ln() {
|
||||||
|
let mut vars = VarStore::new();
|
||||||
|
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||||
|
let mut f = MarginFactor::new(diff, 5.0, 1.0);
|
||||||
|
f.propagate(&mut vars);
|
||||||
|
let logz = f.log_evidence();
|
||||||
|
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn propagate_with_alpha_one_matches_undamped_propagate() {
|
||||||
|
let mut vars_a = VarStore::new();
|
||||||
|
let diff_a = vars_a.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||||
|
let mut f_a = MarginFactor::new(diff_a, 5.0, 1.0);
|
||||||
|
let delta_a = f_a.propagate(&mut vars_a);
|
||||||
|
let result_a = vars_a.get(diff_a);
|
||||||
|
|
||||||
|
let mut vars_b = VarStore::new();
|
||||||
|
let diff_b = vars_b.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||||
|
let mut f_b = MarginFactor::new(diff_b, 5.0, 1.0);
|
||||||
|
let delta_b = f_b.propagate_with_alpha(&mut vars_b, 1.0);
|
||||||
|
let result_b = vars_b.get(diff_b);
|
||||||
|
|
||||||
|
assert_eq!(result_a.pi(), result_b.pi());
|
||||||
|
assert_eq!(result_a.tau(), result_b.tau());
|
||||||
|
assert_eq!(delta_a, delta_b);
|
||||||
|
assert_eq!(f_a.msg.pi(), f_b.msg.pi());
|
||||||
|
assert_eq!(f_a.msg.tau(), f_b.msg.tau());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn propagate_with_alpha_half_blends_msg_in_natural_params() {
|
||||||
|
// Run undamped to capture (initial_msg, undamped_new_msg).
|
||||||
|
let mut vars_full = VarStore::new();
|
||||||
|
let diff_full = vars_full.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||||
|
let mut f_full = MarginFactor::new(diff_full, 5.0, 1.0);
|
||||||
|
let initial_msg_pi = f_full.msg.pi();
|
||||||
|
let initial_msg_tau = f_full.msg.tau();
|
||||||
|
f_full.propagate(&mut vars_full);
|
||||||
|
let undamped_msg_pi = f_full.msg.pi();
|
||||||
|
let undamped_msg_tau = f_full.msg.tau();
|
||||||
|
|
||||||
|
// Run damped at α = 0.5 from the same initial state.
|
||||||
|
let mut vars_half = VarStore::new();
|
||||||
|
let diff_half = vars_half.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||||
|
let mut f_half = MarginFactor::new(diff_half, 5.0, 1.0);
|
||||||
|
f_half.propagate_with_alpha(&mut vars_half, 0.5);
|
||||||
|
|
||||||
|
let expected_pi = 0.5 * undamped_msg_pi + 0.5 * initial_msg_pi;
|
||||||
|
let expected_tau = 0.5 * undamped_msg_tau + 0.5 * initial_msg_tau;
|
||||||
|
assert!((f_half.msg.pi() - expected_pi).abs() < 1e-12);
|
||||||
|
assert!((f_half.msg.tau() - expected_tau).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
use crate::{
|
||||||
|
N_INF, approx,
|
||||||
|
factor::{VarId, VarStore},
|
||||||
|
gaussian::Gaussian,
|
||||||
|
ln_interval, ln_sf,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// EP truncation factor on a diff variable.
|
||||||
|
///
|
||||||
|
/// Implements the rectified-Gaussian approximation that turns a diff
|
||||||
|
/// distribution into a "this team rank-beats that team" or "tied" likelihood.
|
||||||
|
/// Stores its outgoing message to the diff variable so the cavity computation
|
||||||
|
/// produces the correct EP message on each propagation.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TruncFactor {
|
||||||
|
pub diff: VarId,
|
||||||
|
pub margin: f64,
|
||||||
|
pub tie: bool,
|
||||||
|
/// Outgoing message to the diff variable (initial: `N_INF`, the EP identity).
|
||||||
|
pub(crate) msg: Gaussian,
|
||||||
|
/// Cached evidence (linear, not log) computed from the cavity on first propagation.
|
||||||
|
pub(crate) log_evidence_cached: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TruncFactor {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(diff: VarId, margin: f64, tie: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
diff,
|
||||||
|
margin,
|
||||||
|
tie,
|
||||||
|
msg: N_INF,
|
||||||
|
log_evidence_cached: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TruncFactor {
|
||||||
|
/// Propagate this factor's message, optionally damping the update in
|
||||||
|
/// natural-parameter space. `alpha = 1.0` matches `Factor::propagate`
|
||||||
|
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
|
||||||
|
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
|
||||||
|
let marginal = vars.get(self.diff);
|
||||||
|
let cavity = marginal.cavity(self.msg);
|
||||||
|
|
||||||
|
if self.log_evidence_cached.is_none() {
|
||||||
|
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.margin, self.tie));
|
||||||
|
}
|
||||||
|
|
||||||
|
let trunc = approx(cavity, self.margin, self.tie);
|
||||||
|
let new_msg = trunc.cavity(cavity);
|
||||||
|
|
||||||
|
let damped = self.msg.damp_natural(new_msg, alpha);
|
||||||
|
let old_msg = self.msg;
|
||||||
|
self.msg = damped;
|
||||||
|
|
||||||
|
// marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
|
||||||
|
// `trunc` (since cavity * new_msg = trunc by construction); with
|
||||||
|
// alpha < 1.0 it reflects the partially-applied update.
|
||||||
|
vars.set(self.diff, cavity.ep_product(damped));
|
||||||
|
|
||||||
|
old_msg.delta(damped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undamped wrappers, used by this module's tests. Inference drives these
|
||||||
|
/// factors through `propagate_with_alpha` and reads the cached log evidence
|
||||||
|
/// directly, so these are not on any production path.
|
||||||
|
#[cfg(test)]
|
||||||
|
impl TruncFactor {
|
||||||
|
pub(crate) fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||||
|
self.propagate_with_alpha(vars, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ln P(diff > margin)` for a win, `ln P(|diff| < margin)` for a tie.
|
||||||
|
///
|
||||||
|
/// Computed in log space throughout. Two earlier shapes both lost the tail:
|
||||||
|
/// `1 - cdf(..)` cancelled away every digit of an unlikely outcome, and even
|
||||||
|
/// once that was fixed the linear probability underflows to zero past about 38
|
||||||
|
/// sigma, where clamping reported -708 nats regardless of the truth. An upset
|
||||||
|
/// is the observation a log-evidence figure exists to notice, so it has to stay
|
||||||
|
/// exact precisely where it is smallest.
|
||||||
|
fn cavity_log_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
|
||||||
|
let (mu, sigma) = (diff.mu(), diff.sigma());
|
||||||
|
|
||||||
|
let value = if tie {
|
||||||
|
ln_interval(-margin, margin, mu, sigma)
|
||||||
|
} else {
|
||||||
|
ln_sf(margin, mu, sigma)
|
||||||
|
};
|
||||||
|
|
||||||
|
// A degenerate cavity is the only route to a non-finite result; keep the
|
||||||
|
// old floor for it rather than letting -inf poison the whole history's sum.
|
||||||
|
if value.is_finite() {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
libm::log(f64::MIN_POSITIVE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::factor::VarStore;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idempotent_after_convergence() {
|
||||||
|
// After enough iterations, propagate should return ~0 delta.
|
||||||
|
let mut vars = VarStore::new();
|
||||||
|
let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||||
|
|
||||||
|
let mut f = TruncFactor::new(diff, 0.0, false);
|
||||||
|
|
||||||
|
// Propagate many times; delta should drop toward 0.
|
||||||
|
let mut last = (f64::INFINITY, f64::INFINITY);
|
||||||
|
for _ in 0..20 {
|
||||||
|
last = f.propagate(&mut vars);
|
||||||
|
}
|
||||||
|
assert!(last.0 < 1e-10, "expected converged delta, got {}", last.0);
|
||||||
|
assert!(last.1 < 1e-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evidence_cached_on_first_propagate() {
|
||||||
|
let mut vars = VarStore::new();
|
||||||
|
let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||||
|
|
||||||
|
let mut f = TruncFactor::new(diff, 0.0, false);
|
||||||
|
assert!(f.log_evidence_cached.is_none());
|
||||||
|
|
||||||
|
f.propagate(&mut vars);
|
||||||
|
assert!(f.log_evidence_cached.is_some());
|
||||||
|
let first = f.log_evidence_cached.unwrap();
|
||||||
|
|
||||||
|
// Evidence should be P(diff > 0) for diff ~ N(2, 9) ≈ 0.748
|
||||||
|
assert!(first.exp() > 0.7);
|
||||||
|
assert!(first.exp() < 0.8);
|
||||||
|
|
||||||
|
// Subsequent propagations don't change it.
|
||||||
|
f.propagate(&mut vars);
|
||||||
|
assert_eq!(f.log_evidence_cached.unwrap(), first);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The defect this guards: `1 - cdf` collapsed to zero for a surprising
|
||||||
|
/// result, the clamp turned that into `f64::MIN_POSITIVE`, and
|
||||||
|
/// `log_evidence` reported ln of *that* — about -708 whatever the truth
|
||||||
|
/// was. An upset is the observation a model-comparison score exists to
|
||||||
|
/// notice, so it was wrong exactly where it mattered.
|
||||||
|
#[test]
|
||||||
|
fn evidence_of_an_upset_is_not_flattened_to_the_clamp_floor() {
|
||||||
|
// diff ~ N(-9, 1) with margin 0: the favoured side lost by nine sigma.
|
||||||
|
let evidence = cavity_log_evidence(Gaussian::from_ms(-9.0, 1.0), 0.0, false).exp();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
evidence > f64::MIN_POSITIVE,
|
||||||
|
"evidence collapsed onto the clamp floor: {evidence}"
|
||||||
|
);
|
||||||
|
// P(X > 0) for X ~ N(-9, 1) is the standard normal tail at 9 sigma.
|
||||||
|
assert!(
|
||||||
|
(evidence - 1.128_588e-19).abs() / 1.128_588e-19 < 1e-6,
|
||||||
|
"expected ~1.13e-19, got {evidence}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(evidence.ln() + 43.628).abs() < 1e-2,
|
||||||
|
"log evidence {} should be about -43.6, not -708",
|
||||||
|
evidence.ln()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evidence must stay finite and positive however extreme the mismatch,
|
||||||
|
/// since `log_evidence` sums across the whole history and one `-inf` or
|
||||||
|
/// `NaN` poisons all of it.
|
||||||
|
///
|
||||||
|
/// Finiteness alone is too weak a bar — the clamped version was finite too,
|
||||||
|
/// and wrong by hundreds of nats. `log_evidence_tracks_the_analytic_tail`
|
||||||
|
/// below is the assertion that actually holds this up.
|
||||||
|
#[test]
|
||||||
|
fn evidence_stays_positive_and_finite_at_any_separation() {
|
||||||
|
for mu in [-300.0f64, -50.0, -9.0, 0.0, 9.0, 50.0, 300.0] {
|
||||||
|
for tie in [false, true] {
|
||||||
|
let ln_e = cavity_log_evidence(Gaussian::from_ms(mu, 1.0), 1.0, tie);
|
||||||
|
assert!(
|
||||||
|
ln_e.is_finite() && ln_e <= 0.0,
|
||||||
|
"mu={mu} tie={tie}: log evidence {ln_e} is not a log-probability"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clamp used to floor everything past ~38 sigma at `ln(MIN_POSITIVE)`
|
||||||
|
/// = -708, however far out the real observation was. In log space the
|
||||||
|
/// answer is a polynomial and stays exact: at 1000 sigma the truth is about
|
||||||
|
/// -500_000 nats, and -708 is not a rounding error.
|
||||||
|
#[test]
|
||||||
|
fn log_evidence_tracks_the_analytic_tail() {
|
||||||
|
for mu in [-40.0f64, -60.0, -100.0, -1000.0] {
|
||||||
|
// P(diff > 0) for diff ~ N(mu, 1), mu far below zero.
|
||||||
|
let got = cavity_log_evidence(Gaussian::from_ms(mu, 1.0), 0.0, false);
|
||||||
|
|
||||||
|
// ln Phi(mu) ~ -mu^2/2 - ln(-mu) - ln(sqrt(2 pi)) for mu << 0.
|
||||||
|
let z = -mu;
|
||||||
|
let approx = -0.5 * z * z - z.ln() - (2.0 * std::f64::consts::PI).sqrt().ln();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
got < libm::log(f64::MIN_POSITIVE),
|
||||||
|
"mu={mu}: {got} is still stuck on the old clamp floor"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(got - approx).abs() / approx.abs() < 1e-3,
|
||||||
|
"mu={mu}: got {got}, asymptotic expectation {approx}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tie_evidence_uses_two_sided() {
|
||||||
|
let mut vars = VarStore::new();
|
||||||
|
let diff = vars.alloc(Gaussian::from_ms(0.0, 2.0));
|
||||||
|
|
||||||
|
let mut f = TruncFactor::new(diff, 1.0, true);
|
||||||
|
f.propagate(&mut vars);
|
||||||
|
|
||||||
|
// For diff ~ N(0, 4), tie=true with margin=1: P(-1 < diff < 1) ≈ 0.383
|
||||||
|
let ev = f.log_evidence_cached.unwrap().exp();
|
||||||
|
assert!(ev > 0.35 && ev < 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn propagate_with_alpha_one_matches_undamped_propagate() {
|
||||||
|
let mut vars_a = VarStore::new();
|
||||||
|
let diff_a = vars_a.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||||
|
let mut f_a = TruncFactor::new(diff_a, 0.0, false);
|
||||||
|
let delta_a = f_a.propagate(&mut vars_a);
|
||||||
|
let result_a = vars_a.get(diff_a);
|
||||||
|
|
||||||
|
let mut vars_b = VarStore::new();
|
||||||
|
let diff_b = vars_b.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||||
|
let mut f_b = TruncFactor::new(diff_b, 0.0, false);
|
||||||
|
let delta_b = f_b.propagate_with_alpha(&mut vars_b, 1.0);
|
||||||
|
let result_b = vars_b.get(diff_b);
|
||||||
|
|
||||||
|
assert_eq!(result_a.pi(), result_b.pi());
|
||||||
|
assert_eq!(result_a.tau(), result_b.tau());
|
||||||
|
assert_eq!(delta_a, delta_b);
|
||||||
|
assert_eq!(f_a.msg.pi(), f_b.msg.pi());
|
||||||
|
assert_eq!(f_a.msg.tau(), f_b.msg.tau());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn propagate_with_alpha_half_blends_msg_in_natural_params() {
|
||||||
|
// Run undamped to capture (initial_msg, undamped_new_msg).
|
||||||
|
let mut vars_full = VarStore::new();
|
||||||
|
let diff_full = vars_full.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||||
|
let mut f_full = TruncFactor::new(diff_full, 0.0, false);
|
||||||
|
let initial_msg_pi = f_full.msg.pi();
|
||||||
|
let initial_msg_tau = f_full.msg.tau();
|
||||||
|
f_full.propagate(&mut vars_full);
|
||||||
|
let undamped_msg_pi = f_full.msg.pi();
|
||||||
|
let undamped_msg_tau = f_full.msg.tau();
|
||||||
|
|
||||||
|
// Run damped at α = 0.5 from the same initial state.
|
||||||
|
let mut vars_half = VarStore::new();
|
||||||
|
let diff_half = vars_half.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||||
|
let mut f_half = TruncFactor::new(diff_half, 0.0, false);
|
||||||
|
f_half.propagate_with_alpha(&mut vars_half, 0.5);
|
||||||
|
|
||||||
|
let expected_pi = 0.5 * undamped_msg_pi + 0.5 * initial_msg_pi;
|
||||||
|
let expected_tau = 0.5 * undamped_msg_tau + 0.5 * initial_msg_tau;
|
||||||
|
assert!((f_half.msg.pi() - expected_pi).abs() < 1e-12);
|
||||||
|
assert!((f_half.msg.tau() - expected_tau).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1180
-269
File diff suppressed because it is too large
Load Diff
+525
-178
@@ -1,232 +1,579 @@
|
|||||||
use std::ops;
|
|
||||||
|
|
||||||
use crate::{MU, N_INF, SIGMA};
|
use crate::{MU, N_INF, SIGMA};
|
||||||
|
|
||||||
|
/// A Gaussian distribution stored in natural parameters.
|
||||||
|
///
|
||||||
|
/// `pi = 1 / sigma^2` (precision)
|
||||||
|
/// `tau = mu * pi` (precision-adjusted mean)
|
||||||
|
///
|
||||||
|
/// Multiplication and division in message passing become pure adds/subs of
|
||||||
|
/// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and
|
||||||
|
/// `sigma()` are accessors computed on demand.
|
||||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||||
|
#[must_use]
|
||||||
pub struct Gaussian {
|
pub struct Gaussian {
|
||||||
pub mu: f64,
|
pi: f64,
|
||||||
pub sigma: f64,
|
tau: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Gaussian {
|
impl Gaussian {
|
||||||
|
/// Construct from mean and standard deviation.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a
|
||||||
|
/// broken fit produces one, and `converge` reports that as
|
||||||
|
/// `NonFiniteStep` rather than panicking mid-inference.
|
||||||
|
///
|
||||||
|
/// A negative sigma used to be accepted and returned results **bit
|
||||||
|
/// identical** to its absolute value, because sigma only ever enters as
|
||||||
|
/// `sigma * sigma`. The sign was not rejected and not honoured; it simply
|
||||||
|
/// vanished. That is the same defect `HistoryBuilder::sigma`,
|
||||||
|
/// `HistoryBuilder::beta` and `Member::with_drift_scale` already reject.
|
||||||
|
///
|
||||||
|
/// # Very small sigma
|
||||||
|
///
|
||||||
|
/// `pi = 1 / sigma^2` leaves `f64`'s range below about `1.5e-154`, and
|
||||||
|
/// `tau = mu * pi` overflows sooner still — at a threshold that depends on
|
||||||
|
/// `mu`, so there is a band where `pi` is finite and only `tau` is not.
|
||||||
|
/// Both land on the same point-mass representation the `sigma == 0.0`
|
||||||
|
/// branch produces, and a point mass with a non-zero mean has `mu() = NaN`,
|
||||||
|
/// because `tau / pi` is `inf / inf`.
|
||||||
|
///
|
||||||
|
/// This is not rejected, because `approx` legitimately produces a very
|
||||||
|
/// small truncated sigma and inference must not panic. It is worth knowing
|
||||||
|
/// that such a `Gaussian` is not equal to itself, so two identical
|
||||||
|
/// declarations of one can be reported as conflicting.
|
||||||
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
||||||
Gaussian { mu, sigma }
|
// 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
|
||||||
fn pi(&self) -> f64 {
|
// panic inside inference. Rejecting it here turned that reporting path
|
||||||
if self.sigma > 0.0 {
|
// into a crash, which two tests caught immediately.
|
||||||
self.sigma.powi(-2)
|
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 {
|
||||||
|
Self { pi: 0.0, tau: 0.0 }
|
||||||
|
} else if sigma == 0.0 {
|
||||||
|
// Point mass at mu. tau = mu * pi = mu * inf.
|
||||||
|
// For mu == 0 this is 0; for mu != 0 it is inf * mu = inf (IEEE).
|
||||||
|
// Only N00 (mu=0, sigma=0) is used in practice.
|
||||||
|
Self {
|
||||||
|
pi: f64::INFINITY,
|
||||||
|
tau: if mu == 0.0 { 0.0 } else { f64::INFINITY },
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
f64::INFINITY
|
let pi = 1.0 / (sigma * sigma);
|
||||||
|
Self { pi, tau: mu * pi }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tau(&self) -> f64 {
|
/// Construct from mean and *variance*, skipping the square-root round trip.
|
||||||
if self.sigma > 0.0 {
|
///
|
||||||
self.mu * self.pi()
|
/// `from_ms(mu, var.sqrt())` immediately squares the root away again to
|
||||||
|
/// recover `pi = 1/var`. Variance-combining operations work in variance
|
||||||
|
/// space throughout, so they go through here instead and never take a
|
||||||
|
/// root. Use it whenever you already hold a variance —
|
||||||
|
/// [`variance`](Gaussian::variance) is its inverse.
|
||||||
|
#[inline]
|
||||||
|
pub fn from_mv(mu: f64, var: f64) -> Self {
|
||||||
|
if var == f64::INFINITY {
|
||||||
|
Self { pi: 0.0, tau: 0.0 }
|
||||||
|
} else if var == 0.0 {
|
||||||
|
// Point mass at mu; see `from_ms` for the tau convention.
|
||||||
|
Self {
|
||||||
|
pi: f64::INFINITY,
|
||||||
|
tau: if mu == 0.0 { 0.0 } else { f64::INFINITY },
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
let pi = 1.0 / var;
|
||||||
|
Self { pi, tau: mu * pi }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct directly from natural parameters.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) const fn from_natural(pi: f64, tau: f64) -> Self {
|
||||||
|
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]
|
||||||
|
pub(crate) fn pi(&self) -> f64 {
|
||||||
|
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]
|
||||||
|
pub(crate) fn tau(&self) -> f64 {
|
||||||
|
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]
|
||||||
|
#[must_use]
|
||||||
|
pub fn mu(&self) -> f64 {
|
||||||
|
// A non-positive precision is an improper (uninformative) Gaussian — its mean is
|
||||||
|
// undefined. Treat it like `pi == 0` and return 0. EP message cancellation can land
|
||||||
|
// `pi` on a tiny negative value (round-off of exactly zero); without this guard
|
||||||
|
// `tau / pi` would yield a spurious finite mean.
|
||||||
|
if self.pi <= 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.tau / self.pi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variance, without the root-and-square of `sigma().powi(2)`.
|
||||||
|
///
|
||||||
|
/// Mirrors [`sigma`](Gaussian::sigma)'s treatment of the improper
|
||||||
|
/// (infinite) and point-mass (zero) cases, and is the inverse of
|
||||||
|
/// [`from_mv`](Gaussian::from_mv).
|
||||||
|
#[inline]
|
||||||
|
#[must_use]
|
||||||
|
pub fn variance(&self) -> f64 {
|
||||||
|
if self.pi <= 0.0 {
|
||||||
f64::INFINITY
|
f64::INFINITY
|
||||||
|
} else if self.pi.is_infinite() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
1.0 / self.pi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn delta(&self, m: Gaussian) -> (f64, f64) {
|
/// Standard deviation: how unsure this estimate is.
|
||||||
((self.mu - m.mu).abs(), (self.sigma - m.sigma).abs())
|
///
|
||||||
|
/// Derived as `1 / sqrt(pi)`. An improper message (`pi <= 0`) reports
|
||||||
|
/// `inf`, and a point mass (`pi == inf`) reports `0.0` — both are real
|
||||||
|
/// states rather than error codes, and both are legitimate for a converged
|
||||||
|
/// fit with degenerate parameters.
|
||||||
|
#[inline]
|
||||||
|
#[must_use]
|
||||||
|
pub fn sigma(&self) -> f64 {
|
||||||
|
// A non-positive precision is improper → infinite standard deviation. Guarding
|
||||||
|
// `pi <= 0.0` (not just `== 0.0`) keeps `1.0 / pi.sqrt()` from returning NaN when EP
|
||||||
|
// cancellation produces a tiny negative precision (round-off of exactly zero).
|
||||||
|
if self.pi <= 0.0 {
|
||||||
|
f64::INFINITY
|
||||||
|
} else if self.pi.is_infinite() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
1.0 / self.pi.sqrt()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn exclude(&self, m: Gaussian) -> Self {
|
/// How far this Gaussian moved from `other`, as `(|d mu|, |d sigma|)`.
|
||||||
Self {
|
///
|
||||||
mu: self.mu - m.mu,
|
/// Identical messages have not moved, whatever their parameters, and that
|
||||||
sigma: (self.sigma.powi(2) - m.sigma.powi(2)).sqrt(),
|
/// 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) {
|
||||||
|
if self.pi == other.pi && self.tau == other.tau {
|
||||||
|
return (0.0, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
(self.mu() - other.mu()).abs(),
|
||||||
|
(self.sigma() - other.sigma()).abs(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn exclude(&self, other: Gaussian) -> Self {
|
||||||
|
let var = self.variance() - other.variance();
|
||||||
|
if var <= 0.0 {
|
||||||
|
// When sigma_self ≈ sigma_other (including ULP-level rounding differences
|
||||||
|
// from the pi→sigma accessor round-trip), the excluded contribution is N00.
|
||||||
|
// Computing from_ms(tiny_mu, 0.0) would give {pi:inf, tau:inf}, whose
|
||||||
|
// mu() = inf/inf = NaN. Returning N00 is correct: when both Gaussians
|
||||||
|
// carry the same variance, the residual is a point mass at 0.
|
||||||
|
return Gaussian::from_mv(0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::from_mv(self.mu() - other.mu(), var)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn forget(&self, variance_delta: f64) -> Self {
|
pub(crate) fn forget(&self, variance_delta: f64) -> Self {
|
||||||
Self {
|
Self::from_mv(self.mu(), self.variance() + variance_delta)
|
||||||
mu: self.mu,
|
}
|
||||||
sigma: (self.sigma.powi(2) + variance_delta).sqrt(),
|
|
||||||
|
/// `P(X < x)` under this Gaussian.
|
||||||
|
///
|
||||||
|
/// The question a stopping rule asks: *how sure am I that this competitor's
|
||||||
|
/// true skill is below the cutoff?* Expressing that as a probability keeps
|
||||||
|
/// its meaning as sigma changes, where a `mu + z * sigma` band silently
|
||||||
|
/// means different confidence at different uncertainties — which is exactly
|
||||||
|
/// the regime a stopping rule operates in.
|
||||||
|
///
|
||||||
|
/// Accurate in the *lower* tail. For the upper tail use
|
||||||
|
/// [`Gaussian::probability_above`] rather than `1.0 - probability_below(x)`,
|
||||||
|
/// which cancels away every significant digit once the result is small.
|
||||||
|
///
|
||||||
|
/// An improper Gaussian (non-positive precision) has no defined mean, so
|
||||||
|
/// this returns `0.5` — the same convention `mu()` and `sigma()` follow.
|
||||||
|
#[must_use]
|
||||||
|
pub fn probability_below(&self, x: f64) -> f64 {
|
||||||
|
if self.pi <= 0.0 {
|
||||||
|
return 0.5;
|
||||||
}
|
}
|
||||||
|
crate::cdf(x, self.mu(), self.sigma())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `P(X > x)` under this Gaussian.
|
||||||
|
///
|
||||||
|
/// Computed as a survival function rather than `1 - cdf`, so it keeps full
|
||||||
|
/// relative precision in the upper tail: `1 - cdf` returns exactly zero
|
||||||
|
/// past about 8.3 sigma, where the true value is still 1e-19 and perfectly
|
||||||
|
/// representable. A stopping rule is evaluated precisely there — the
|
||||||
|
/// interesting cases are the ones near certainty.
|
||||||
|
///
|
||||||
|
/// An improper Gaussian returns `0.5`, as [`Gaussian::probability_below`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn probability_above(&self, x: f64) -> f64 {
|
||||||
|
if self.pi <= 0.0 {
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
crate::sf(x, self.mu(), self.sigma())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EP damping in natural-parameter space: `α·new + (1−α)·self`.
|
||||||
|
///
|
||||||
|
/// Used by within-game inference to stabilise oscillating fixed-point
|
||||||
|
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
|
||||||
|
/// `alpha < 1.0` shrinks each per-step update.
|
||||||
|
pub(crate) fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
|
||||||
|
Gaussian::from_natural(
|
||||||
|
alpha * new.pi() + (1.0 - alpha) * self.pi(),
|
||||||
|
alpha * new.tau() + (1.0 - alpha) * self.tau(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Gaussian {
|
impl Default for Gaussian {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self::from_ms(MU, SIGMA)
|
||||||
mu: MU,
|
}
|
||||||
sigma: SIGMA,
|
}
|
||||||
|
|
||||||
|
impl Gaussian {
|
||||||
|
/// The EP factor **product**: multiply two messages about the same
|
||||||
|
/// variable.
|
||||||
|
///
|
||||||
|
/// Two natural-parameter additions and no square root, which is why the
|
||||||
|
/// type stores `pi` and `tau` rather than `mu` and `sigma`. This is the
|
||||||
|
/// hot path.
|
||||||
|
///
|
||||||
|
/// Not arithmetic — `N(10, 2).ep_product(N(4, 3))` is `N(8.15, 1.66)`,
|
||||||
|
/// nowhere near 40. It used to be spelled `a * b`, on a public `Mul` impl,
|
||||||
|
/// where that was a trap rather than a shorthand.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn ep_product(self, rhs: Gaussian) -> Gaussian {
|
||||||
|
Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The EP **cavity**: divide out a message this belief already absorbed.
|
||||||
|
///
|
||||||
|
/// The inverse of [`ep_product`](Gaussian::ep_product), and two
|
||||||
|
/// subtractions rather than two additions.
|
||||||
|
///
|
||||||
|
/// **May return an improper result.** Cancelling a message that carried
|
||||||
|
/// most of the precision leaves `pi <= 0`, which is not a distribution.
|
||||||
|
/// `mu()` reports `0.0` and `sigma()` reports `inf` for such a value —
|
||||||
|
/// both are the accessors' policy for "undefined", not answers. Measured:
|
||||||
|
/// `N(10, 2).cavity(N(1, 1))` has `pi = -0.75`, and its `mu()` prints a
|
||||||
|
/// confident `0`. That is why this is not a public operator.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn cavity(self, rhs: Gaussian) -> Gaussian {
|
||||||
|
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convolve two independent Gaussians: `N(mu1 + mu2, sqrt(v1 + v2))`.
|
||||||
|
///
|
||||||
|
/// The distribution of a *sum* of independent variables, so the variances
|
||||||
|
/// add — the result is always wider than either input. Used to combine a
|
||||||
|
/// skill with performance noise. Goes through `from_mv` and takes no root.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn convolve(self, rhs: Gaussian) -> Gaussian {
|
||||||
|
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convolve a *difference*: `N(mu1 - mu2, sqrt(v1 + v2))`.
|
||||||
|
///
|
||||||
|
/// The means subtract and the variances still **add**, because a
|
||||||
|
/// difference of independent variables is no more certain than a sum. That
|
||||||
|
/// is the half that made the old `Sub` impl misleading: `a - b` grew the
|
||||||
|
/// sigma from 2 to `sqrt(4 + 9)`.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn convolve_diff(self, rhs: Gaussian) -> Gaussian {
|
||||||
|
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scale by a constant: `mu` by `scalar`, `sigma` by `|scalar|`.
|
||||||
|
///
|
||||||
|
/// The one operation that *is* ordinary arithmetic — it is the
|
||||||
|
/// distribution of `scalar * X`. Used for per-member weights.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn scale(self, scalar: f64) -> Gaussian {
|
||||||
|
if !scalar.is_finite() {
|
||||||
|
return N_INF;
|
||||||
}
|
}
|
||||||
}
|
if scalar == 0.0 {
|
||||||
}
|
// Scaling by 0 collapses to a point mass at 0 (sigma' = 0, mu' = 0).
|
||||||
|
// This is N00, the additive identity, NOT N_INF.
|
||||||
impl ops::Add<Gaussian> for Gaussian {
|
return Gaussian::from_mv(0.0, 0.0);
|
||||||
type Output = Gaussian;
|
|
||||||
|
|
||||||
fn add(self, rhs: Gaussian) -> Self::Output {
|
|
||||||
Gaussian {
|
|
||||||
mu: self.mu + rhs.mu,
|
|
||||||
sigma: (self.sigma.powi(2) + rhs.sigma.powi(2)).sqrt(),
|
|
||||||
}
|
}
|
||||||
}
|
// sigma' = sigma * |scalar| => pi' = pi / scalar²
|
||||||
}
|
// mu' = mu * scalar => tau' = tau / scalar
|
||||||
|
Self::from_natural(self.pi / (scalar * scalar), self.tau / scalar)
|
||||||
impl ops::Sub<Gaussian> for Gaussian {
|
|
||||||
type Output = Gaussian;
|
|
||||||
|
|
||||||
fn sub(self, rhs: Gaussian) -> Self::Output {
|
|
||||||
Gaussian {
|
|
||||||
mu: self.mu - rhs.mu,
|
|
||||||
sigma: (self.sigma.powi(2) + rhs.sigma.powi(2)).sqrt(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ops::Mul<Gaussian> for Gaussian {
|
|
||||||
type Output = Gaussian;
|
|
||||||
|
|
||||||
fn mul(self, rhs: Gaussian) -> Self::Output {
|
|
||||||
let (mu, sigma) = if self.sigma == 0.0 || rhs.sigma == 0.0 {
|
|
||||||
let mu = self.mu / (self.sigma.powi(2) / rhs.sigma.powi(2) + 1.0)
|
|
||||||
+ rhs.mu / (rhs.sigma.powi(2) / self.sigma.powi(2) + 1.0);
|
|
||||||
|
|
||||||
let sigma = (1.0 / ((1.0 / self.sigma.powi(2)) + (1.0 / rhs.sigma.powi(2)))).sqrt();
|
|
||||||
|
|
||||||
(mu, sigma)
|
|
||||||
} else {
|
|
||||||
mu_sigma(self.tau() + rhs.tau(), self.pi() + rhs.pi())
|
|
||||||
};
|
|
||||||
|
|
||||||
Gaussian { mu, sigma }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ops::Mul<f64> for Gaussian {
|
|
||||||
type Output = Gaussian;
|
|
||||||
|
|
||||||
fn mul(self, rhs: f64) -> Self::Output {
|
|
||||||
if rhs.is_finite() {
|
|
||||||
Self {
|
|
||||||
mu: self.mu * rhs,
|
|
||||||
sigma: self.sigma * rhs,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
N_INF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ops::Div<Gaussian> for Gaussian {
|
|
||||||
type Output = Gaussian;
|
|
||||||
|
|
||||||
fn div(self, rhs: Gaussian) -> Self::Output {
|
|
||||||
let (mu, sigma) = if self.sigma == 0.0 || rhs.sigma == 0.0 {
|
|
||||||
let mu = self.mu / (1.0 - self.sigma.powi(2) / rhs.sigma.powi(2))
|
|
||||||
+ rhs.mu / (rhs.sigma.powi(2) / self.sigma.powi(2) - 1.0);
|
|
||||||
|
|
||||||
let sigma = (1.0 / ((1.0 / self.sigma.powi(2)) - (1.0 / rhs.sigma.powi(2)))).sqrt();
|
|
||||||
|
|
||||||
(mu, sigma)
|
|
||||||
} else {
|
|
||||||
mu_sigma(self.tau() - rhs.tau(), self.pi() - rhs.pi())
|
|
||||||
};
|
|
||||||
|
|
||||||
Gaussian { mu, sigma }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mu_sigma(tau: f64, pi: f64) -> (f64, f64) {
|
|
||||||
if pi > 0.0 {
|
|
||||||
(tau / pi, (1.0 / pi).sqrt())
|
|
||||||
} else if (pi + 1e-5) < 0.0 {
|
|
||||||
panic!("precision should be greater than 0");
|
|
||||||
} else {
|
|
||||||
(0.0, f64::INFINITY)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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]
|
||||||
fn test_add() {
|
fn non_positive_precision_is_improper_not_nan() {
|
||||||
let n = Gaussian {
|
// EP message cancellation can leave `pi` a tiny negative (round-off of exactly zero).
|
||||||
mu: 25.0,
|
// Such a Gaussian is improper/uninformative: mu() must be 0 and sigma() infinite, not
|
||||||
sigma: 25.0 / 3.0,
|
// NaN. A NaN here propagates through the moment-space `Sub` in the game chain and
|
||||||
};
|
// poisons every skill in the slice.
|
||||||
|
let tiny_neg = Gaussian::from_natural(-5.55e-17, -8.88e-16);
|
||||||
|
assert_eq!(tiny_neg.mu(), 0.0);
|
||||||
|
assert!(tiny_neg.sigma().is_infinite());
|
||||||
|
|
||||||
let m = Gaussian {
|
// A frankly-negative precision is treated the same way.
|
||||||
mu: 0.0,
|
let neg = Gaussian::from_natural(-1.0, 2.0);
|
||||||
sigma: 1.0,
|
assert_eq!(neg.mu(), 0.0);
|
||||||
};
|
assert!(neg.sigma().is_infinite());
|
||||||
|
|
||||||
assert_eq!(
|
// Subtracting such a message must not produce NaN (the original failure path).
|
||||||
n + m,
|
let proper = Gaussian::from_ms(9.75, 1.256);
|
||||||
Gaussian {
|
let diff = proper.convolve_diff(tiny_neg);
|
||||||
mu: 25.0,
|
assert!(diff.pi().is_finite() && !diff.pi().is_nan());
|
||||||
sigma: 8.393118874676116
|
assert!(diff.tau().is_finite() && !diff.tau().is_nan());
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sub() {
|
fn convolve_adds_variances() {
|
||||||
let n = Gaussian {
|
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||||
mu: 25.0,
|
let m = Gaussian::from_ms(0.0, 1.0);
|
||||||
sigma: 25.0 / 3.0,
|
let r = n.convolve(m);
|
||||||
};
|
assert!((r.mu() - 25.0).abs() < 1e-12);
|
||||||
|
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
|
||||||
let m = Gaussian {
|
|
||||||
mu: 1.0,
|
|
||||||
sigma: 1.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
n - m,
|
|
||||||
Gaussian {
|
|
||||||
mu: 24.0,
|
|
||||||
sigma: 8.393118874676116
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_mul() {
|
fn convolve_diff_subtracts_means_and_adds_variances() {
|
||||||
let n = Gaussian {
|
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||||
mu: 25.0,
|
let m = Gaussian::from_ms(1.0, 1.0);
|
||||||
sigma: 25.0 / 3.0,
|
let r = n.convolve_diff(m);
|
||||||
};
|
assert!((r.mu() - 24.0).abs() < 1e-12);
|
||||||
|
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
|
||||||
let m = Gaussian {
|
|
||||||
mu: 0.0,
|
|
||||||
sigma: 1.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
n * m,
|
|
||||||
Gaussian {
|
|
||||||
mu: 0.35488958990536273,
|
|
||||||
sigma: 0.992876838486922
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_div() {
|
fn ep_product_is_not_arithmetic() {
|
||||||
let n = Gaussian {
|
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||||
mu: 25.0,
|
let m = Gaussian::from_ms(0.0, 1.0);
|
||||||
sigma: 25.0 / 3.0,
|
let r = n.ep_product(m);
|
||||||
};
|
assert!((r.mu() - 0.35488958990536273).abs() < 1e-10);
|
||||||
|
assert!((r.sigma() - 0.992876838486922).abs() < 1e-10);
|
||||||
|
}
|
||||||
|
|
||||||
let m = Gaussian {
|
#[test]
|
||||||
mu: 0.0,
|
fn cavity_undoes_a_product() {
|
||||||
sigma: 1.0,
|
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||||
};
|
let m = Gaussian::from_ms(0.0, 1.0);
|
||||||
|
let r = m.cavity(n);
|
||||||
|
assert!((r.mu() - (-0.3652597402597402)).abs() < 1e-10);
|
||||||
|
assert!((r.sigma() - 1.0072787050317253).abs() < 1e-10);
|
||||||
|
}
|
||||||
|
|
||||||
assert_eq!(
|
#[test]
|
||||||
m / n,
|
fn test_n00_is_add_identity() {
|
||||||
Gaussian {
|
// N00 (sigma=0) is the identity for `convolve`.
|
||||||
mu: -0.3652597402597402,
|
// N_INF (sigma=inf) is the identity for `ep_product`.
|
||||||
sigma: 1.0072787050317253
|
let g = Gaussian::from_ms(3.0, 2.0);
|
||||||
}
|
let n00 = Gaussian::from_ms(0.0, 0.0);
|
||||||
);
|
let r = n00.convolve(g);
|
||||||
|
assert!((r.mu() - g.mu()).abs() < 1e-12);
|
||||||
|
assert!((r.sigma() - g.sigma()).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ep_product_adds_natural_parameters() {
|
||||||
|
// `ep_product` in nat-params should be pi_n + pi_m, tau_n + tau_m
|
||||||
|
let n = Gaussian::from_ms(2.0, 3.0);
|
||||||
|
let m = Gaussian::from_ms(1.0, 2.0);
|
||||||
|
let r = n.ep_product(m);
|
||||||
|
let expected_pi = n.pi() + m.pi();
|
||||||
|
let expected_tau = n.tau() + m.tau();
|
||||||
|
assert!((r.pi() - expected_pi).abs() < 1e-15);
|
||||||
|
assert!((r.tau() - expected_tau).abs() < 1e-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cavity_subtracts_natural_parameters() {
|
||||||
|
let n = Gaussian::from_ms(2.0, 1.0);
|
||||||
|
let m = Gaussian::from_ms(1.0, 2.0);
|
||||||
|
let r = n.cavity(m);
|
||||||
|
let expected_pi = n.pi() - m.pi();
|
||||||
|
let expected_tau = n.tau() - m.tau();
|
||||||
|
assert!((r.pi() - expected_pi).abs() < 1e-15);
|
||||||
|
assert!((r.tau() - expected_tau).abs() < 1e-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn damp_natural_alpha_one_returns_new() {
|
||||||
|
let old = Gaussian::from_ms(1.0, 2.0);
|
||||||
|
let new = Gaussian::from_ms(5.0, 0.5);
|
||||||
|
let damped = old.damp_natural(new, 1.0);
|
||||||
|
assert_eq!(damped.pi(), new.pi());
|
||||||
|
assert_eq!(damped.tau(), new.tau());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn damp_natural_alpha_zero_returns_self() {
|
||||||
|
let old = Gaussian::from_ms(1.0, 2.0);
|
||||||
|
let new = Gaussian::from_ms(5.0, 0.5);
|
||||||
|
let damped = old.damp_natural(new, 0.0);
|
||||||
|
assert_eq!(damped.pi(), old.pi());
|
||||||
|
assert_eq!(damped.tau(), old.tau());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn damp_natural_alpha_half_is_midpoint_in_natural_params() {
|
||||||
|
let old = Gaussian::from_ms(1.0, 2.0);
|
||||||
|
let new = Gaussian::from_ms(5.0, 0.5);
|
||||||
|
let damped = old.damp_natural(new, 0.5);
|
||||||
|
let expected_pi = 0.5 * new.pi() + 0.5 * old.pi();
|
||||||
|
let expected_tau = 0.5 * new.tau() + 0.5 * old.tau();
|
||||||
|
assert!((damped.pi() - expected_pi).abs() < 1e-12);
|
||||||
|
assert!((damped.tau() - expected_tau).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tail_probability_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probability_below_matches_published_quantiles() {
|
||||||
|
let g = Gaussian::from_ms(0.0, 1.0);
|
||||||
|
for (x, expected) in [
|
||||||
|
(-1.959_963_984_540_054, 0.025),
|
||||||
|
(0.0, 0.5),
|
||||||
|
(1.281_551_565_544_6, 0.9),
|
||||||
|
(1.959_963_984_540_054, 0.975),
|
||||||
|
] {
|
||||||
|
let got = g.probability_below(x);
|
||||||
|
assert!(
|
||||||
|
(got - expected).abs() < 1e-12,
|
||||||
|
"P(X < {x}) = {got}, expected {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_two_tails_partition_the_mass() {
|
||||||
|
let g = Gaussian::from_ms(3.0, 2.0);
|
||||||
|
for x in [-4.0f64, 0.0, 3.0, 7.5] {
|
||||||
|
let total = g.probability_below(x) + g.probability_above(x);
|
||||||
|
assert!((total - 1.0).abs() < 1e-15, "at {x}: {total}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reason `probability_above` exists rather than `1 - probability_below`.
|
||||||
|
#[test]
|
||||||
|
fn probability_above_keeps_precision_where_the_complement_collapses() {
|
||||||
|
let g = Gaussian::from_ms(0.0, 1.0);
|
||||||
|
for (x, expected) in [(9.0f64, 1.128_588e-19), (20.0, 2.753_624e-89)] {
|
||||||
|
let got = g.probability_above(x);
|
||||||
|
assert!(
|
||||||
|
(got - expected).abs() / expected < 1e-6,
|
||||||
|
"P(X > {x}) = {got}, expected ~{expected}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
1.0 - g.probability_below(x),
|
||||||
|
0.0,
|
||||||
|
"the complement should still collapse at {x}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_scaled_gaussian_shifts_and_stretches() {
|
||||||
|
let g = Gaussian::from_ms(25.0, 6.0);
|
||||||
|
assert!((g.probability_below(25.0) - 0.5).abs() < 1e-15);
|
||||||
|
// One sigma either side of the mean.
|
||||||
|
assert!((g.probability_below(31.0) - 0.841_344_746_068_543).abs() < 1e-12);
|
||||||
|
assert!((g.probability_above(19.0) - 0.841_344_746_068_543).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_improper_gaussian_is_uninformative_rather_than_nan() {
|
||||||
|
let improper = Gaussian::from_ms(0.0, f64::INFINITY);
|
||||||
|
assert_eq!(improper.probability_below(5.0), 0.5);
|
||||||
|
assert_eq!(improper.probability_above(5.0), 0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3630
-602
File diff suppressed because it is too large
Load Diff
+510
@@ -0,0 +1,510 @@
|
|||||||
|
//! Sparse Cholesky factorisation of a joint precision matrix.
|
||||||
|
//!
|
||||||
|
//! Every question the joint answers is a *bilinear form* in the precision
|
||||||
|
//! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the
|
||||||
|
//! covariance of two contrasts is `c^T L^-1 a`. None of them wants `L^-1 c`
|
||||||
|
//! itself, which is what makes the shape here worth stating explicitly.
|
||||||
|
//!
|
||||||
|
//! Writing the precision as `A = L L^T`,
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! c^T A^-1 a = c^T L^-T L^-1 a = (L^-1 c) . (L^-1 a)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! so a single forward substitution per contrast answers everything, and the
|
||||||
|
//! back substitution a general solve would do is wasted work. That halves the
|
||||||
|
//! cost of a query, and it removes a failure mode: a variance computed as
|
||||||
|
//! `c . (A^-1 c)` is a difference of products that can round to a small
|
||||||
|
//! negative number, where the same quantity as `|L^-1 c|^2` is a sum of
|
||||||
|
//! squares and cannot.
|
||||||
|
//!
|
||||||
|
//! # Why this is sparse (#52)
|
||||||
|
//!
|
||||||
|
//! A time-expanded joint is *extremely* sparse and gets sparser as the history
|
||||||
|
//! grows: a row couples only to its own previous and next appearance through
|
||||||
|
//! the drift link, and to whoever co-appeared in its slice. Measured on a
|
||||||
|
//! 76-slice, 988-duel, 200-competitor history: `n = 1976`, `nnz = 7504`,
|
||||||
|
//! **0.19% dense**.
|
||||||
|
//!
|
||||||
|
//! This used to store all `n^2` entries and run a dense `O(n^3)` factorisation
|
||||||
|
//! over them. Two measurements decided the replacement:
|
||||||
|
//!
|
||||||
|
//! - **Ordering alone does nothing to a dense factorisation.** Its inner loops
|
||||||
|
//! run over every `k` whether or not the entry is zero. A 700x700 banded
|
||||||
|
//! matrix at 0.43% density factorised in 30.196 ms in band order and
|
||||||
|
//! 29.544 ms under a scramble that destroyed the band — identical, as the
|
||||||
|
//! flop count says it must be. Fill-reducing order is worth nothing until
|
||||||
|
//! the factorisation skips zeros.
|
||||||
|
//! - **Together they are worth four orders of magnitude.** On that `n = 1976`
|
||||||
|
//! fixture, against `n^3/3 = 2.572e9` flops dense: sparse in the natural
|
||||||
|
//! order needs `5.597e7` (46x better), and sparse under an AMD fill-reducing
|
||||||
|
//! order needs `8.656e4` — **29,710x**. AMD is worth 646x *on top of*
|
||||||
|
//! sparsity and nothing without it.
|
||||||
|
//!
|
||||||
|
//! Natural ordering fills in badly here for the reason #52 predicted: a
|
||||||
|
//! competitor who appears in slice 0 and not again until slice 75 creates a
|
||||||
|
//! drift link spanning nearly the whole matrix. `nnz(L)` is 292,437 under the
|
||||||
|
//! natural order against 11,583 under AMD, from an `A` with 7,504.
|
||||||
|
//!
|
||||||
|
//! The ordering comes from `feral-amd`. The factorisation is the up-looking
|
||||||
|
//! sparse Cholesky of Davis's *Direct Methods for Sparse Linear Systems*,
|
||||||
|
//! written here rather than taken from a crate: the sparse solvers on
|
||||||
|
//! crates.io either pull SIMD dispatch (`faer`, and `feral` itself, both
|
||||||
|
//! through `pulp`), which would make results differ between an AVX-512 host
|
||||||
|
//! and an AVX2 one — the same class of drift the `libm`-over-`std` decision
|
||||||
|
//! was made to avoid — or are LGPL, or disclaim fill-reduction in their own
|
||||||
|
//! docs.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// A symmetric matrix accumulated entry by entry, before factorisation.
|
||||||
|
///
|
||||||
|
/// A `BTreeMap` rather than a hash map because the iteration order becomes the
|
||||||
|
/// factorisation's summation order, and a hash map's order varies per process.
|
||||||
|
/// `tests/cross_process_determinism.rs` exists because that has bitten before.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct SymmetricBuilder {
|
||||||
|
entries: BTreeMap<(usize, usize), f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SymmetricBuilder {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add `value` to entry `(row, col)`. Both triangles must be supplied.
|
||||||
|
pub(crate) fn add(&mut self, row: usize, col: usize, value: f64) {
|
||||||
|
*self.entries.entry((row, col)).or_insert(0.0) += value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `(row, col)` positions that hold a nonzero. For the #52 measurement.
|
||||||
|
#[cfg(feature = "measure-sparsity")]
|
||||||
|
pub(crate) fn pattern(&self) -> impl Iterator<Item = (usize, usize)> + '_ {
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, v)| **v != 0.0)
|
||||||
|
.map(|(&rc, _)| rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A factorised symmetric positive-definite matrix, reusable across queries.
|
||||||
|
pub(crate) struct Cholesky {
|
||||||
|
n: usize,
|
||||||
|
/// `inv[old] = new`: where each original row sits after the AMD reorder.
|
||||||
|
inv: Vec<usize>,
|
||||||
|
/// `L` in compressed-column form, permuted. Within a column the diagonal
|
||||||
|
/// is first and the rest ascend by row.
|
||||||
|
col_ptr: Vec<usize>,
|
||||||
|
row_idx: Vec<usize>,
|
||||||
|
val: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cholesky {
|
||||||
|
/// Factorise the accumulated matrix into `L L^T`, under a fill-reducing
|
||||||
|
/// permutation.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the matrix is not positive-definite, which for a
|
||||||
|
/// precision matrix means the model is improper — a competitor with
|
||||||
|
/// neither a proper prior nor any evidence — or if the ordering fails.
|
||||||
|
pub(crate) fn factor(built: SymmetricBuilder, n: usize) -> Option<Self> {
|
||||||
|
if n == 0 {
|
||||||
|
return Some(Self {
|
||||||
|
n: 0,
|
||||||
|
inv: Vec::new(),
|
||||||
|
col_ptr: vec![0],
|
||||||
|
row_idx: Vec::new(),
|
||||||
|
val: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let inv = Self::amd_permutation(n, &built)?;
|
||||||
|
|
||||||
|
// Upper triangle of the permuted matrix, column-major: column `c`
|
||||||
|
// holds the rows `r <= c`. Exactly one of a symmetric pair survives
|
||||||
|
// the `r <= c` filter, so nothing is double-counted.
|
||||||
|
let mut cols: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
|
||||||
|
for (&(old_r, old_c), &v) in &built.entries {
|
||||||
|
if v == 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (r, c) = (inv[old_r], inv[old_c]);
|
||||||
|
if r <= c {
|
||||||
|
cols[c].push((r, v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut a_ptr = Vec::with_capacity(n + 1);
|
||||||
|
let mut a_row = Vec::new();
|
||||||
|
let mut a_val = Vec::new();
|
||||||
|
a_ptr.push(0usize);
|
||||||
|
for col in &mut cols {
|
||||||
|
col.sort_unstable_by_key(|&(r, _)| r);
|
||||||
|
for &(r, v) in col.iter() {
|
||||||
|
a_row.push(r);
|
||||||
|
a_val.push(v);
|
||||||
|
}
|
||||||
|
a_ptr.push(a_row.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = Self::etree(n, &a_ptr, &a_row);
|
||||||
|
|
||||||
|
// Symbolic pass: how many entries each column of L will hold. Running
|
||||||
|
// `ereach` per column costs O(nnz(L)) in total, which is the same order
|
||||||
|
// as the numeric pass it sizes.
|
||||||
|
let mut counts = vec![0usize; n];
|
||||||
|
let mut stack = vec![0usize; n];
|
||||||
|
let mut mark = vec![false; n];
|
||||||
|
for k in 0..n {
|
||||||
|
let top = Self::ereach(k, &a_ptr, &a_row, &parent, &mut stack, &mut mark);
|
||||||
|
for &i in &stack[top..] {
|
||||||
|
counts[i] += 1;
|
||||||
|
}
|
||||||
|
counts[k] += 1; // the diagonal
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut col_ptr = Vec::with_capacity(n + 1);
|
||||||
|
col_ptr.push(0usize);
|
||||||
|
for &c in &counts {
|
||||||
|
col_ptr.push(col_ptr[col_ptr.len() - 1] + c);
|
||||||
|
}
|
||||||
|
let nnz = col_ptr[n];
|
||||||
|
let mut row_idx = vec![0usize; nnz];
|
||||||
|
let mut val = vec![0.0f64; nnz];
|
||||||
|
|
||||||
|
// `next[i]` is the slot column `i` will fill next. Column `i`'s
|
||||||
|
// diagonal lands first, at `col_ptr[i]`, because nothing is written to
|
||||||
|
// a column before its own iteration.
|
||||||
|
let mut next: Vec<usize> = col_ptr[..n].to_vec();
|
||||||
|
let mut x = vec![0.0f64; n];
|
||||||
|
|
||||||
|
for k in 0..n {
|
||||||
|
let top = Self::ereach(k, &a_ptr, &a_row, &parent, &mut stack, &mut mark);
|
||||||
|
|
||||||
|
for p in a_ptr[k]..a_ptr[k + 1] {
|
||||||
|
if a_row[p] <= k {
|
||||||
|
x[a_row[p]] = a_val[p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut d = x[k];
|
||||||
|
x[k] = 0.0;
|
||||||
|
|
||||||
|
for &i in &stack[top..] {
|
||||||
|
let lki = x[i] / val[col_ptr[i]];
|
||||||
|
x[i] = 0.0;
|
||||||
|
for p in col_ptr[i] + 1..next[i] {
|
||||||
|
x[row_idx[p]] -= val[p] * lki;
|
||||||
|
}
|
||||||
|
d -= lki * lki;
|
||||||
|
let p = next[i];
|
||||||
|
next[i] += 1;
|
||||||
|
row_idx[p] = k;
|
||||||
|
val[p] = lki;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here
|
||||||
|
// too, and a negated comparison would let it through as "not
|
||||||
|
// positive".
|
||||||
|
if d.is_nan() || d <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let p = next[k];
|
||||||
|
next[k] += 1;
|
||||||
|
row_idx[p] = k;
|
||||||
|
val[p] = d.sqrt();
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
n,
|
||||||
|
inv,
|
||||||
|
col_ptr,
|
||||||
|
row_idx,
|
||||||
|
val,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AMD fill-reducing order, as `inv[old] = new`.
|
||||||
|
fn amd_permutation(n: usize, built: &SymmetricBuilder) -> Option<Vec<usize>> {
|
||||||
|
let mut cols: Vec<Vec<i32>> = vec![Vec::new(); n];
|
||||||
|
for (&(r, c), &v) in &built.entries {
|
||||||
|
if v != 0.0 {
|
||||||
|
cols[c].push(i32::try_from(r).ok()?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut col_ptr = Vec::with_capacity(n + 1);
|
||||||
|
let mut row_idx = Vec::new();
|
||||||
|
col_ptr.push(0i32);
|
||||||
|
for (j, col) in cols.iter_mut().enumerate() {
|
||||||
|
col.push(i32::try_from(j).ok()?);
|
||||||
|
col.sort_unstable();
|
||||||
|
col.dedup();
|
||||||
|
row_idx.extend_from_slice(col);
|
||||||
|
col_ptr.push(i32::try_from(row_idx.len()).ok()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pattern = feral_amd::CscPattern::new(n, &col_ptr, &row_idx)?;
|
||||||
|
// `perm[new] = old`; we want the inverse.
|
||||||
|
let perm = feral_amd::amd_order(&pattern).ok()?;
|
||||||
|
let mut inv = vec![0usize; n];
|
||||||
|
for (new, &old) in perm.iter().enumerate() {
|
||||||
|
inv[usize::try_from(old).ok()?] = new;
|
||||||
|
}
|
||||||
|
Some(inv)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elimination tree of the upper-triangular pattern. `usize::MAX` is "no
|
||||||
|
/// parent", i.e. a root.
|
||||||
|
fn etree(n: usize, col_ptr: &[usize], row_idx: &[usize]) -> Vec<usize> {
|
||||||
|
let mut parent = vec![usize::MAX; n];
|
||||||
|
let mut ancestor = vec![usize::MAX; n];
|
||||||
|
for k in 0..n {
|
||||||
|
for &row in &row_idx[col_ptr[k]..col_ptr[k + 1]] {
|
||||||
|
let mut i = row;
|
||||||
|
while i != usize::MAX && i < k {
|
||||||
|
let next = ancestor[i];
|
||||||
|
ancestor[i] = k;
|
||||||
|
if next == usize::MAX {
|
||||||
|
parent[i] = k;
|
||||||
|
}
|
||||||
|
i = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nonzero pattern of row `k` of `L`, written into `stack[top..n]` in
|
||||||
|
/// topological order. Returns `top`.
|
||||||
|
///
|
||||||
|
/// `stack` is used from both ends — a scratch region from `0` while walking
|
||||||
|
/// each path up the tree, and the result from `n` downwards. They cannot
|
||||||
|
/// collide because every node is pushed at most once across the whole call.
|
||||||
|
fn ereach(
|
||||||
|
k: usize,
|
||||||
|
col_ptr: &[usize],
|
||||||
|
row_idx: &[usize],
|
||||||
|
parent: &[usize],
|
||||||
|
stack: &mut [usize],
|
||||||
|
mark: &mut [bool],
|
||||||
|
) -> usize {
|
||||||
|
let n = mark.len();
|
||||||
|
let mut top = n;
|
||||||
|
mark[k] = true;
|
||||||
|
for &row in &row_idx[col_ptr[k]..col_ptr[k + 1]] {
|
||||||
|
let mut i = row;
|
||||||
|
if i > k {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut len = 0usize;
|
||||||
|
while i != usize::MAX && !mark[i] {
|
||||||
|
stack[len] = i;
|
||||||
|
len += 1;
|
||||||
|
mark[i] = true;
|
||||||
|
i = parent[i];
|
||||||
|
}
|
||||||
|
// Reverse the path onto the output end, so the result stays in
|
||||||
|
// topological order overall.
|
||||||
|
while len > 0 {
|
||||||
|
len -= 1;
|
||||||
|
top -= 1;
|
||||||
|
stack[top] = stack[len];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for &i in &stack[top..] {
|
||||||
|
mark[i] = false;
|
||||||
|
}
|
||||||
|
mark[k] = false;
|
||||||
|
top
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whiten a contrast: `y = L^-1 P b`.
|
||||||
|
///
|
||||||
|
/// The point of the result is the dot product, not the vector: for two
|
||||||
|
/// contrasts `b` and `b'`, `y . y'` is `b^T A^-1 b'`. See the module docs.
|
||||||
|
///
|
||||||
|
/// The result is in the permuted order, and stays there — a dot product
|
||||||
|
/// does not care, as long as both operands were permuted the same way.
|
||||||
|
pub(crate) fn whiten(&self, b: &[f64]) -> Vec<f64> {
|
||||||
|
debug_assert_eq!(b.len(), self.n);
|
||||||
|
let n = self.n;
|
||||||
|
let mut y = vec![0.0f64; n];
|
||||||
|
for (old, &v) in b.iter().enumerate() {
|
||||||
|
y[self.inv[old]] = v;
|
||||||
|
}
|
||||||
|
for j in 0..n {
|
||||||
|
y[j] /= self.val[self.col_ptr[j]];
|
||||||
|
let yj = y[j];
|
||||||
|
for p in self.col_ptr[j] + 1..self.col_ptr[j + 1] {
|
||||||
|
y[self.row_idx[p]] -= self.val[p] * yj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `b^T A^-1 b'`, given the two whitened contrasts.
|
||||||
|
pub(crate) fn bilinear(y: &[f64], y_prime: &[f64]) -> f64 {
|
||||||
|
y.iter().zip(y_prime).map(|(a, b)| a * b).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Factorise a dense row-major matrix, for the goldens below.
|
||||||
|
fn dense(a: &[f64], n: usize) -> Option<Cholesky> {
|
||||||
|
let mut b = SymmetricBuilder::new();
|
||||||
|
for i in 0..n {
|
||||||
|
for j in 0..n {
|
||||||
|
if a[i * n + j] != 0.0 {
|
||||||
|
b.add(i, j, a[i * n + j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cholesky::factor(b, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[[4, 1], [1, 3]] z = [1, 2]` has `z = [1/11, 7/11]`, so the quadratic
|
||||||
|
/// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`.
|
||||||
|
#[test]
|
||||||
|
fn reproduces_a_known_quadratic_form() {
|
||||||
|
let c = dense(&[4.0, 1.0, 1.0, 3.0], 2).unwrap();
|
||||||
|
let y = c.whiten(&[1.0, 2.0]);
|
||||||
|
assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whitening `e_i` recovers the inverse's diagonal, which is the variance
|
||||||
|
/// of a single variable.
|
||||||
|
#[test]
|
||||||
|
fn recovers_the_inverse_diagonal() {
|
||||||
|
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
|
||||||
|
// [0.75, 1.0, 0.75].
|
||||||
|
let a = [2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
||||||
|
let c = dense(&a, 3).unwrap();
|
||||||
|
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
|
||||||
|
let mut e = vec![0.0; 3];
|
||||||
|
e[i] = 1.0;
|
||||||
|
let y = c.whiten(&e);
|
||||||
|
assert!((bilinear(&y, &y) - expected).abs() < 1e-12, "row {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The off-diagonal bilinear form is symmetric and matches the inverse.
|
||||||
|
#[test]
|
||||||
|
fn recovers_an_off_diagonal_covariance() {
|
||||||
|
// Same A; (A^-1)_{0,1} = 0.5.
|
||||||
|
let a = [2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
|
||||||
|
let c = dense(&a, 3).unwrap();
|
||||||
|
let y0 = c.whiten(&[1.0, 0.0, 0.0]);
|
||||||
|
let y1 = c.whiten(&[0.0, 1.0, 0.0]);
|
||||||
|
assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12);
|
||||||
|
assert!((bilinear(&y1, &y0) - 0.5).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A variance can never come out negative, because it is a sum of squares.
|
||||||
|
#[test]
|
||||||
|
fn a_quadratic_form_is_never_negative() {
|
||||||
|
let a = [1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
|
||||||
|
let c = dense(&a, 2).unwrap();
|
||||||
|
let y = c.whiten(&[1.0, -1.0]);
|
||||||
|
assert!(bilinear(&y, &y) >= 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Against an independent dense reference, on random sparse SPD matrices.
|
||||||
|
///
|
||||||
|
/// The goldens above are 2x2 and 3x3 — small enough that AMD does nothing
|
||||||
|
/// and no fill-in occurs, so they cannot catch a symbolic-pass bug. This
|
||||||
|
/// builds matrices big enough to permute and fill in, and checks every
|
||||||
|
/// bilinear form against a textbook dense factorisation of the *same*
|
||||||
|
/// matrix in its original order.
|
||||||
|
#[test]
|
||||||
|
fn agrees_with_a_dense_reference_on_random_sparse_systems() {
|
||||||
|
/// Dense Cholesky and quadratic form, deliberately naive: this is the
|
||||||
|
/// reference, so it must not share code with what it is checking.
|
||||||
|
fn dense_quadratic_form(a: &[f64], n: usize, b: &[f64], c: &[f64]) -> f64 {
|
||||||
|
let mut l = a.to_vec();
|
||||||
|
for j in 0..n {
|
||||||
|
let mut d = l[j * n + j];
|
||||||
|
for k in 0..j {
|
||||||
|
d -= l[j * n + k] * l[j * n + k];
|
||||||
|
}
|
||||||
|
let d = d.sqrt();
|
||||||
|
l[j * n + j] = d;
|
||||||
|
for i in j + 1..n {
|
||||||
|
let mut sum = l[i * n + j];
|
||||||
|
for k in 0..j {
|
||||||
|
sum -= l[i * n + k] * l[j * n + k];
|
||||||
|
}
|
||||||
|
l[i * n + j] = sum / d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let solve = |rhs: &[f64]| -> Vec<f64> {
|
||||||
|
let mut y = rhs.to_vec();
|
||||||
|
for i in 0..n {
|
||||||
|
for k in 0..i {
|
||||||
|
y[i] -= l[i * n + k] * y[k];
|
||||||
|
}
|
||||||
|
y[i] /= l[i * n + i];
|
||||||
|
}
|
||||||
|
y
|
||||||
|
};
|
||||||
|
let (yb, yc) = (solve(b), solve(c));
|
||||||
|
yb.iter().zip(&yc).map(|(x, y)| x * y).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cheap deterministic generator; no dependency, and reproducible.
|
||||||
|
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||||
|
let mut rand = move || {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 7;
|
||||||
|
seed ^= seed << 17;
|
||||||
|
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
for n in [7usize, 23, 60] {
|
||||||
|
let mut a = vec![0.0f64; n * n];
|
||||||
|
// A chain plus scattered long-range couplings: the shape of a
|
||||||
|
// time-expanded joint, where a competitor's drift link can span
|
||||||
|
// the whole matrix.
|
||||||
|
for i in 0..n {
|
||||||
|
a[i * n + i] = 4.0 + rand();
|
||||||
|
if i + 1 < n {
|
||||||
|
let v = -(0.5 + rand() * 0.5);
|
||||||
|
a[i * n + i + 1] = v;
|
||||||
|
a[(i + 1) * n + i] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for step in 0..n / 3 {
|
||||||
|
let i = (step * 7) % n;
|
||||||
|
let j = (step * 29 + 3) % n;
|
||||||
|
if i != j {
|
||||||
|
let v = -(0.1 + rand() * 0.2);
|
||||||
|
a[i * n + j] = v;
|
||||||
|
a[j * n + i] = v;
|
||||||
|
// Keep it diagonally dominant, hence positive-definite.
|
||||||
|
a[i * n + i] += 0.6;
|
||||||
|
a[j * n + j] += 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sparse = dense(&a, n).expect("spd");
|
||||||
|
|
||||||
|
for trial in 0..8 {
|
||||||
|
let b: Vec<f64> = (0..n).map(|_| rand() * 2.0 - 1.0).collect();
|
||||||
|
let c: Vec<f64> = (0..n).map(|_| rand() * 2.0 - 1.0).collect();
|
||||||
|
let got = bilinear(&sparse.whiten(&b), &sparse.whiten(&c));
|
||||||
|
let want = dense_quadratic_form(&a, n, &b, &c);
|
||||||
|
assert!(
|
||||||
|
(got - want).abs() <= 1e-10 * want.abs().max(1.0),
|
||||||
|
"n={n} trial={trial}: sparse {got} vs dense {want}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A permutation must not change which matrices are rejected.
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_non_positive_definite_matrix() {
|
||||||
|
// Singular: the second row is a multiple of the first.
|
||||||
|
assert!(dense(&[1.0, 2.0, 2.0, 4.0], 2).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
use std::{
|
||||||
|
borrow::{Borrow, ToOwned},
|
||||||
|
collections::HashMap,
|
||||||
|
hash::Hash,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::Index;
|
||||||
|
|
||||||
|
/// Maps user keys to internal `Index` handles.
|
||||||
|
///
|
||||||
|
/// Renamed from the former `IndexMap` to avoid colliding with the `indexmap`
|
||||||
|
/// crate. Power users can promote `&K` to `Index` via `get_or_create` and
|
||||||
|
/// skip the lookup on subsequent hot-path calls.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct KeyTable<K> {
|
||||||
|
forward: HashMap<K, Index>,
|
||||||
|
/// Reverse mapping, indexed by `Index.0`.
|
||||||
|
///
|
||||||
|
/// Indices are handed out densely and sequentially, so position *is* the
|
||||||
|
/// index and `key()` is a lookup rather than a scan over every entry.
|
||||||
|
reverse: Vec<K>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K> KeyTable<K>
|
||||||
|
where
|
||||||
|
K: Eq + Hash + Clone,
|
||||||
|
{
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
forward: HashMap::new(),
|
||||||
|
reverse: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index>
|
||||||
|
where
|
||||||
|
K: Borrow<Q>,
|
||||||
|
{
|
||||||
|
self.forward.get(k).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_or_create<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(
|
||||||
|
&mut self,
|
||||||
|
k: &Q,
|
||||||
|
) -> Index
|
||||||
|
where
|
||||||
|
K: Borrow<Q>,
|
||||||
|
{
|
||||||
|
if let Some(idx) = self.forward.get(k) {
|
||||||
|
*idx
|
||||||
|
} else {
|
||||||
|
let idx = Index::from(self.reverse.len());
|
||||||
|
let owned = k.to_owned();
|
||||||
|
self.reverse.push(owned.clone());
|
||||||
|
self.forward.insert(owned, idx);
|
||||||
|
idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn key(&self, idx: Index) -> Option<&K> {
|
||||||
|
self.reverse.get(idx.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every key, in the order they were first interned.
|
||||||
|
///
|
||||||
|
/// 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]
|
||||||
|
pub(crate) fn len(&self) -> usize {
|
||||||
|
self.reverse.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K> Default for KeyTable<K>
|
||||||
|
where
|
||||||
|
K: Eq + Hash + Clone,
|
||||||
|
{
|
||||||
|
fn default() -> Self {
|
||||||
|
KeyTable::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
+1169
-144
File diff suppressed because it is too large
Load Diff
+355
-117
@@ -1,29 +1,13 @@
|
|||||||
|
//! Minimal dense matrix used by `quality()`.
|
||||||
|
//!
|
||||||
|
//! `determinant` and `inverse` go through one LU decomposition with partial
|
||||||
|
//! pivoting — O(n³) and numerically stable. The previous implementation
|
||||||
|
//! expanded cofactors recursively (O(n!), allocating a `Vec` per minor) and
|
||||||
|
//! only implemented `inverse` for the 1×1 case, which limited `quality()` to
|
||||||
|
//! exactly two rating groups.
|
||||||
|
|
||||||
use std::ops;
|
use std::ops;
|
||||||
|
|
||||||
fn det(m: &[f64], x: usize) -> f64 {
|
|
||||||
if x == 1 {
|
|
||||||
m[0]
|
|
||||||
} else if x == 2 {
|
|
||||||
m[0] * m[3] - m[1] * m[2]
|
|
||||||
} else {
|
|
||||||
let mut d = 0.0;
|
|
||||||
|
|
||||||
for n in 0..x {
|
|
||||||
let ms = m
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.skip(x)
|
|
||||||
.filter(|(i, _)| (i % x) != n)
|
|
||||||
.map(|(_, v)| *v)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
d += (-1.0f64).powi(n as i32) * m[n] * det(&ms, x - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Matrix {
|
pub struct Matrix {
|
||||||
data: Box<[f64]>,
|
data: Box<[f64]>,
|
||||||
@@ -31,8 +15,132 @@ pub struct Matrix {
|
|||||||
width: usize,
|
width: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LU decomposition with partial pivoting: `PA = LU`, stored compactly.
|
||||||
|
///
|
||||||
|
/// `lu` holds `L` below the diagonal (unit diagonal implied) and `U` on and
|
||||||
|
/// above it. `sign` is the determinant sign contributed by row swaps, or 0.0
|
||||||
|
/// when the matrix is singular.
|
||||||
|
struct Lu {
|
||||||
|
lu: Vec<f64>,
|
||||||
|
perm: Vec<usize>,
|
||||||
|
n: usize,
|
||||||
|
sign: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lu {
|
||||||
|
fn decompose(m: &Matrix) -> Self {
|
||||||
|
debug_assert_eq!(m.width, m.height, "LU requires a square matrix");
|
||||||
|
|
||||||
|
let n = m.width;
|
||||||
|
let mut lu = m.data.to_vec();
|
||||||
|
let mut perm: Vec<usize> = (0..n).collect();
|
||||||
|
let mut sign = 1.0;
|
||||||
|
|
||||||
|
for col in 0..n {
|
||||||
|
// Partial pivot: take the largest-magnitude candidate to limit
|
||||||
|
// growth of round-off in the elimination below.
|
||||||
|
let mut pivot_row = col;
|
||||||
|
let mut pivot_max = lu[col * n + col].abs();
|
||||||
|
|
||||||
|
for row in (col + 1)..n {
|
||||||
|
let candidate = lu[row * n + col].abs();
|
||||||
|
if candidate > pivot_max {
|
||||||
|
pivot_max = candidate;
|
||||||
|
pivot_row = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pivot_max == 0.0 {
|
||||||
|
sign = 0.0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if pivot_row != col {
|
||||||
|
for k in 0..n {
|
||||||
|
lu.swap(col * n + k, pivot_row * n + k);
|
||||||
|
}
|
||||||
|
perm.swap(col, pivot_row);
|
||||||
|
sign = -sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pivot = lu[col * n + col];
|
||||||
|
|
||||||
|
for row in (col + 1)..n {
|
||||||
|
let factor = lu[row * n + col] / pivot;
|
||||||
|
lu[row * n + col] = factor;
|
||||||
|
|
||||||
|
for k in (col + 1)..n {
|
||||||
|
lu[row * n + k] -= factor * lu[col * n + k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { lu, perm, n, sign }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn determinant(&self) -> f64 {
|
||||||
|
if self.sign == 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut det = self.sign;
|
||||||
|
for i in 0..self.n {
|
||||||
|
det *= self.lu[i * self.n + i];
|
||||||
|
}
|
||||||
|
|
||||||
|
det
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ln |det|`, accumulated term by term rather than multiplied out.
|
||||||
|
///
|
||||||
|
/// The determinant of an `n x n` Gram matrix is a product of `n` diagonal
|
||||||
|
/// entries, so it leaves `f64`'s range long before the quantities built
|
||||||
|
/// from it do. `quality()` only ever wants a *ratio* of two determinants,
|
||||||
|
/// and that ratio is perfectly representable while the determinants
|
||||||
|
/// themselves are not — measured, at 250 rating groups both overflow and
|
||||||
|
/// the ratio came back `NaN` where the true answer is `9.51e-88`.
|
||||||
|
///
|
||||||
|
/// Returns `-inf` for a singular matrix, so `exp` of it is zero.
|
||||||
|
fn ln_abs_determinant(&self) -> f64 {
|
||||||
|
if self.sign == 0.0 {
|
||||||
|
return f64::NEG_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut acc = 0.0;
|
||||||
|
for i in 0..self.n {
|
||||||
|
acc += libm::log(self.lu[i * self.n + i].abs());
|
||||||
|
}
|
||||||
|
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Solve `Ax = b` for a single column of the identity, giving one column
|
||||||
|
/// of the inverse.
|
||||||
|
fn solve_column(&self, col: usize, out: &mut [f64]) {
|
||||||
|
let n = self.n;
|
||||||
|
|
||||||
|
// Forward substitution through L, applying the row permutation.
|
||||||
|
for i in 0..n {
|
||||||
|
let mut sum = if self.perm[i] == col { 1.0 } else { 0.0 };
|
||||||
|
for (k, &solved) in out.iter().enumerate().take(i) {
|
||||||
|
sum -= self.lu[i * n + k] * solved;
|
||||||
|
}
|
||||||
|
out[i] = sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back substitution through U.
|
||||||
|
for i in (0..n).rev() {
|
||||||
|
let mut sum = out[i];
|
||||||
|
for (k, &solved) in out.iter().enumerate().skip(i + 1) {
|
||||||
|
sum -= self.lu[i * n + k] * solved;
|
||||||
|
}
|
||||||
|
out[i] = sum / self.lu[i * n + i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Matrix {
|
impl Matrix {
|
||||||
pub fn new(height: usize, width: usize) -> Matrix {
|
pub(crate) fn new(height: usize, width: usize) -> Matrix {
|
||||||
Matrix {
|
Matrix {
|
||||||
data: vec![0.0; height * width].into_boxed_slice(),
|
data: vec![0.0; height * width].into_boxed_slice(),
|
||||||
height,
|
height,
|
||||||
@@ -40,7 +148,7 @@ impl Matrix {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn transpose(&self) -> Matrix {
|
pub(crate) fn transpose(&self) -> Matrix {
|
||||||
let mut matrix = Matrix::new(self.width, self.height);
|
let mut matrix = Matrix::new(self.width, self.height);
|
||||||
|
|
||||||
for c in 0..self.width {
|
for c in 0..self.width {
|
||||||
@@ -52,73 +160,77 @@ impl Matrix {
|
|||||||
matrix
|
matrix
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn minor(&self, row_n: usize, col_n: usize) -> Matrix {
|
/// Determinant of a square matrix. The 0×0 determinant is 1 by convention
|
||||||
let mut matrix = Matrix::new(self.height - 1, self.width - 1);
|
/// (the empty product).
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the matrix is not square.
|
||||||
|
pub(crate) fn determinant(&self) -> f64 {
|
||||||
|
assert_eq!(
|
||||||
|
self.width, self.height,
|
||||||
|
"determinant requires a square matrix, got {}x{}",
|
||||||
|
self.height, self.width
|
||||||
|
);
|
||||||
|
|
||||||
let mut nr = 0;
|
if self.width == 0 {
|
||||||
|
return 1.0;
|
||||||
for r in 0..self.height {
|
|
||||||
if r == row_n {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut nc = 0;
|
|
||||||
|
|
||||||
for c in 0..self.width {
|
|
||||||
if c == col_n {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
matrix[(nr, nc)] = self[(r, c)];
|
|
||||||
|
|
||||||
nc += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
nr += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
matrix
|
Lu::decompose(self).determinant()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn determinant(&self) -> f64 {
|
/// `ln |det|` of a square matrix; `-inf` when singular.
|
||||||
debug_assert!(self.width == self.height);
|
///
|
||||||
|
/// See [`Lu::ln_abs_determinant`] for why a ratio of determinants must be
|
||||||
|
/// taken this way.
|
||||||
|
pub(crate) fn ln_abs_determinant(&self) -> f64 {
|
||||||
|
assert_eq!(
|
||||||
|
self.width, self.height,
|
||||||
|
"determinant requires a square matrix, got {}x{}",
|
||||||
|
self.height, self.width
|
||||||
|
);
|
||||||
|
|
||||||
det(&self.data, self.width)
|
if self.width == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Lu::decompose(self).ln_abs_determinant()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn adjugate(&self) -> Matrix {
|
/// Matrix inverse via LU decomposition.
|
||||||
debug_assert!(self.width == self.height);
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the matrix is not square or is singular.
|
||||||
|
pub(crate) fn inverse(&self) -> Matrix {
|
||||||
|
assert_eq!(
|
||||||
|
self.width, self.height,
|
||||||
|
"inverse requires a square matrix, got {}x{}",
|
||||||
|
self.height, self.width
|
||||||
|
);
|
||||||
|
|
||||||
let mut matrix = Matrix::new(self.height, self.width);
|
let n = self.width;
|
||||||
|
let mut inverse = Matrix::new(n, n);
|
||||||
|
|
||||||
if matrix.height == 2 {
|
if n == 0 {
|
||||||
matrix[(0, 0)] = self[(1, 1)];
|
return inverse;
|
||||||
matrix[(0, 1)] = -self[(0, 1)];
|
}
|
||||||
matrix[(1, 0)] = -self[(1, 0)];
|
|
||||||
matrix[(1, 1)] = self[(0, 0)];
|
|
||||||
} else {
|
|
||||||
for r in 0..matrix.height {
|
|
||||||
for c in 0..matrix.width {
|
|
||||||
let sign = if (r + c) % 2 == 0 { 1.0 } else { -1.0 };
|
|
||||||
|
|
||||||
matrix[(r, c)] = self.minor(r, c).determinant() * sign;
|
let lu = Lu::decompose(self);
|
||||||
}
|
assert!(lu.sign != 0.0, "cannot invert a singular matrix");
|
||||||
|
|
||||||
|
let mut column = vec![0.0; n];
|
||||||
|
|
||||||
|
for c in 0..n {
|
||||||
|
lu.solve_column(c, &mut column);
|
||||||
|
|
||||||
|
for (r, &value) in column.iter().enumerate() {
|
||||||
|
inverse[(r, c)] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
matrix
|
inverse
|
||||||
}
|
|
||||||
|
|
||||||
pub fn inverse(&self) -> Matrix {
|
|
||||||
let mut matrix = Matrix::new(self.width, self.height);
|
|
||||||
|
|
||||||
if self.height == self.width && self.height == 1 {
|
|
||||||
matrix[(0, 0)] = 1.0 / self[(0, 0)];
|
|
||||||
} else {
|
|
||||||
panic!("eh, okey")
|
|
||||||
}
|
|
||||||
|
|
||||||
matrix
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,20 +238,62 @@ impl ops::Index<(usize, usize)> for Matrix {
|
|||||||
type Output = f64;
|
type Output = f64;
|
||||||
|
|
||||||
fn index(&self, pos: (usize, usize)) -> &Self::Output {
|
fn index(&self, pos: (usize, usize)) -> &Self::Output {
|
||||||
|
debug_assert!(
|
||||||
|
pos.0 < self.height && pos.1 < self.width,
|
||||||
|
"index ({}, {}) out of bounds for {}x{} matrix",
|
||||||
|
pos.0,
|
||||||
|
pos.1,
|
||||||
|
self.height,
|
||||||
|
self.width
|
||||||
|
);
|
||||||
|
|
||||||
&self.data[(self.width * pos.0) + pos.1]
|
&self.data[(self.width * pos.0) + pos.1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ops::IndexMut<(usize, usize)> for Matrix {
|
impl ops::IndexMut<(usize, usize)> for Matrix {
|
||||||
fn index_mut(&mut self, pos: (usize, usize)) -> &mut Self::Output {
|
fn index_mut(&mut self, pos: (usize, usize)) -> &mut Self::Output {
|
||||||
|
debug_assert!(
|
||||||
|
pos.0 < self.height && pos.1 < self.width,
|
||||||
|
"index ({}, {}) out of bounds for {}x{} matrix",
|
||||||
|
pos.0,
|
||||||
|
pos.1,
|
||||||
|
self.height,
|
||||||
|
self.width
|
||||||
|
);
|
||||||
|
|
||||||
&mut self.data[(self.width * pos.0) + pos.1]
|
&mut self.data[(self.width * pos.0) + pos.1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ops::Mul<&'a Matrix> for f64 {
|
fn multiply(lhs: &Matrix, rhs: &Matrix) -> Matrix {
|
||||||
|
assert_eq!(
|
||||||
|
lhs.width, rhs.height,
|
||||||
|
"cannot multiply {}x{} by {}x{}",
|
||||||
|
lhs.height, lhs.width, rhs.height, rhs.width
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut matrix = Matrix::new(lhs.height, rhs.width);
|
||||||
|
|
||||||
|
for r in 0..matrix.height {
|
||||||
|
for c in 0..matrix.width {
|
||||||
|
let mut value = 0.0;
|
||||||
|
|
||||||
|
for x in 0..lhs.width {
|
||||||
|
value += lhs[(r, x)] * rhs[(x, c)];
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix[(r, c)] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ops::Mul<&Matrix> for f64 {
|
||||||
type Output = Matrix;
|
type Output = Matrix;
|
||||||
|
|
||||||
fn mul(self, rhs: &'a Matrix) -> Matrix {
|
fn mul(self, rhs: &Matrix) -> Matrix {
|
||||||
let mut matrix = Matrix::new(rhs.height, rhs.width);
|
let mut matrix = Matrix::new(rhs.height, rhs.width);
|
||||||
|
|
||||||
for r in 0..rhs.height {
|
for r in 0..rhs.height {
|
||||||
@@ -152,54 +306,35 @@ impl<'a> ops::Mul<&'a Matrix> for f64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ops::Mul<&'a Matrix> for Matrix {
|
impl ops::Mul<&Matrix> for Matrix {
|
||||||
type Output = Matrix;
|
type Output = Matrix;
|
||||||
|
|
||||||
fn mul(self, rhs: &'a Matrix) -> Matrix {
|
fn mul(self, rhs: &Matrix) -> Matrix {
|
||||||
let mut matrix = Matrix::new(self.height, rhs.width);
|
multiply(&self, rhs)
|
||||||
|
|
||||||
for r in 0..matrix.height {
|
|
||||||
for c in 0..matrix.width {
|
|
||||||
let mut value = 0.0;
|
|
||||||
|
|
||||||
for x in 0..self.width {
|
|
||||||
value += self[(r, x)] * rhs[(x, c)];
|
|
||||||
}
|
|
||||||
|
|
||||||
matrix[(r, c)] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
matrix
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ops::Mul<&'a Matrix> for &'a Matrix {
|
impl ops::Mul<&Matrix> for &Matrix {
|
||||||
type Output = Matrix;
|
type Output = Matrix;
|
||||||
|
|
||||||
fn mul(self, rhs: &'a Matrix) -> Matrix {
|
fn mul(self, rhs: &Matrix) -> Matrix {
|
||||||
let mut matrix = Matrix::new(self.height, rhs.width);
|
multiply(self, rhs)
|
||||||
|
|
||||||
for r in 0..matrix.height {
|
|
||||||
for c in 0..matrix.width {
|
|
||||||
let mut value = 0.0;
|
|
||||||
|
|
||||||
for x in 0..self.width {
|
|
||||||
value += self[(r, x)] * rhs[(x, c)];
|
|
||||||
}
|
|
||||||
|
|
||||||
matrix[(r, c)] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
matrix
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ops::Add<&'a Matrix> for &'a Matrix {
|
impl ops::Add<&Matrix> for &Matrix {
|
||||||
type Output = Matrix;
|
type Output = Matrix;
|
||||||
|
|
||||||
fn add(self, rhs: &'a Matrix) -> Matrix {
|
fn add(self, rhs: &Matrix) -> Matrix {
|
||||||
|
assert!(
|
||||||
|
self.height == rhs.height && self.width == rhs.width,
|
||||||
|
"cannot add {}x{} to {}x{}",
|
||||||
|
self.height,
|
||||||
|
self.width,
|
||||||
|
rhs.height,
|
||||||
|
rhs.width
|
||||||
|
);
|
||||||
|
|
||||||
let mut matrix = Matrix::new(self.height, self.width);
|
let mut matrix = Matrix::new(self.height, self.width);
|
||||||
|
|
||||||
for r in 0..matrix.height {
|
for r in 0..matrix.height {
|
||||||
@@ -211,3 +346,106 @@ impl<'a> ops::Add<&'a Matrix> for &'a Matrix {
|
|||||||
matrix
|
matrix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn from_rows(rows: &[&[f64]]) -> Matrix {
|
||||||
|
let mut m = Matrix::new(rows.len(), rows[0].len());
|
||||||
|
for (r, row) in rows.iter().enumerate() {
|
||||||
|
for (c, &v) in row.iter().enumerate() {
|
||||||
|
m[(r, c)] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn determinant_1x1() {
|
||||||
|
assert!((from_rows(&[&[3.0]]).determinant() - 3.0).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn determinant_2x2() {
|
||||||
|
let m = from_rows(&[&[1.0, 2.0], &[3.0, 4.0]]);
|
||||||
|
assert!((m.determinant() - (-2.0)).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn determinant_3x3() {
|
||||||
|
let m = from_rows(&[&[6.0, 1.0, 1.0], &[4.0, -2.0, 5.0], &[2.0, 8.0, 7.0]]);
|
||||||
|
assert!((m.determinant() - (-306.0)).abs() < 1e-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn determinant_requires_no_pivot_at_origin() {
|
||||||
|
// A zero in the top-left forces a row swap; the sign must follow.
|
||||||
|
let m = from_rows(&[&[0.0, 1.0], &[1.0, 0.0]]);
|
||||||
|
assert!((m.determinant() - (-1.0)).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn determinant_of_singular_is_zero() {
|
||||||
|
let m = from_rows(&[&[1.0, 2.0], &[2.0, 4.0]]);
|
||||||
|
assert!(m.determinant().abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inverse_1x1() {
|
||||||
|
let inv = from_rows(&[&[4.0]]).inverse();
|
||||||
|
assert!((inv[(0, 0)] - 0.25).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inverse_times_original_is_identity() {
|
||||||
|
for rows in [
|
||||||
|
vec![vec![1.0, 2.0], vec![3.0, 4.0]],
|
||||||
|
vec![
|
||||||
|
vec![6.0, 1.0, 1.0],
|
||||||
|
vec![4.0, -2.0, 5.0],
|
||||||
|
vec![2.0, 8.0, 7.0],
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
vec![2.0, 0.0, 1.0, 3.0],
|
||||||
|
vec![1.0, 5.0, 2.0, 0.0],
|
||||||
|
vec![0.0, 1.0, 4.0, 1.0],
|
||||||
|
vec![3.0, 2.0, 0.0, 6.0],
|
||||||
|
],
|
||||||
|
] {
|
||||||
|
let refs: Vec<&[f64]> = rows.iter().map(|r| r.as_slice()).collect();
|
||||||
|
let m = from_rows(&refs);
|
||||||
|
let product = &m * &m.inverse();
|
||||||
|
|
||||||
|
for r in 0..product.height {
|
||||||
|
for c in 0..product.width {
|
||||||
|
let expected = if r == c { 1.0 } else { 0.0 };
|
||||||
|
assert!(
|
||||||
|
(product[(r, c)] - expected).abs() < 1e-9,
|
||||||
|
"({r},{c}) = {} expected {expected}",
|
||||||
|
product[(r, c)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "singular")]
|
||||||
|
fn inverse_of_singular_panics() {
|
||||||
|
let _ = from_rows(&[&[1.0, 2.0], &[2.0, 4.0]]).inverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_determinant_is_one() {
|
||||||
|
assert!((Matrix::new(0, 0).determinant() - 1.0).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transpose_round_trips() {
|
||||||
|
let m = from_rows(&[&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]]);
|
||||||
|
let t = m.transpose();
|
||||||
|
assert_eq!((t.height, t.width), (3, 2));
|
||||||
|
assert_eq!(t.transpose()[(1, 2)], m[(1, 2)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
use crate::{N_INF, gaussian::Gaussian};
|
|
||||||
|
|
||||||
pub(crate) struct TeamMessage {
|
|
||||||
pub(crate) prior: Gaussian,
|
|
||||||
pub(crate) likelihood_lose: Gaussian,
|
|
||||||
pub(crate) likelihood_win: Gaussian,
|
|
||||||
pub(crate) likelihood_draw: Gaussian,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TeamMessage {
|
|
||||||
/*
|
|
||||||
pub(crate) fn p(&self) -> Gaussian {
|
|
||||||
self.prior * self.likelihood_lose * self.likelihood_win * self.likelihood_draw
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn posterior_win(&self) -> Gaussian {
|
|
||||||
self.prior * self.likelihood_lose * self.likelihood_draw
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn posterior_lose(&self) -> Gaussian {
|
|
||||||
self.prior * self.likelihood_win * self.likelihood_draw
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub(crate) fn likelihood(&self) -> Gaussian {
|
|
||||||
self.likelihood_win * self.likelihood_lose * self.likelihood_draw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TeamMessage {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
prior: N_INF,
|
|
||||||
likelihood_lose: N_INF,
|
|
||||||
likelihood_win: N_INF,
|
|
||||||
likelihood_draw: N_INF,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
pub(crate) struct DrawMessage {
|
|
||||||
pub(crate) prior: Gaussian,
|
|
||||||
pub(crate) prior_team: Gaussian,
|
|
||||||
pub(crate) likelihood_lose: Gaussian,
|
|
||||||
pub(crate) likelihood_win: Gaussian,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DrawMessage {
|
|
||||||
pub(crate) fn p(&self) -> Gaussian {
|
|
||||||
self.prior_team * self.likelihood_lose * self.likelihood_win
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn posterior_win(&self) -> Gaussian {
|
|
||||||
self.prior_team * self.likelihood_lose
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn posterior_lose(&self) -> Gaussian {
|
|
||||||
self.prior_team * self.likelihood_win
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn likelihood(&self) -> Gaussian {
|
|
||||||
self.likelihood_win * self.likelihood_lose
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
pub(crate) struct DiffMessage {
|
|
||||||
pub(crate) prior: Gaussian,
|
|
||||||
pub(crate) likelihood: Gaussian,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DiffMessage {
|
|
||||||
/*
|
|
||||||
pub(crate) fn p(&self) -> Gaussian {
|
|
||||||
self.prior * self.likelihood
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
//! Observer trait for progress reporting during convergence.
|
||||||
|
//!
|
||||||
|
//! Replaces the old `verbose: bool` + `println!` path. Callers wire in any
|
||||||
|
//! observer that implements the trait; default methods are no-ops so users
|
||||||
|
//! override only what they need.
|
||||||
|
|
||||||
|
use crate::time::Time;
|
||||||
|
|
||||||
|
/// Receives progress callbacks during `History::converge`.
|
||||||
|
///
|
||||||
|
/// All methods have default no-op implementations; implement only what's
|
||||||
|
/// interesting.
|
||||||
|
pub trait Observer<T: Time>: Send + Sync {
|
||||||
|
/// Called after each convergence iteration across the whole history.
|
||||||
|
fn on_iteration_end(&self, _iter: usize, _max_step: (f64, f64)) {}
|
||||||
|
|
||||||
|
/// Called after each time slice is swept within an iteration.
|
||||||
|
///
|
||||||
|
/// A convergence iteration sweeps every slice twice — once travelling
|
||||||
|
/// backward through the history and once forward — so a multi-slice
|
||||||
|
/// history fires this twice per slice per iteration. A single-slice
|
||||||
|
/// history is swept once and fires once.
|
||||||
|
fn on_slice_processed(&self, _time: &T, _slice_idx: usize, _n_events: usize) {}
|
||||||
|
|
||||||
|
/// Called once when convergence completes (or max iters is reached).
|
||||||
|
fn on_converged(&self, _iters: usize, _final_step: (f64, f64), _converged: bool) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared and boxed observers forward to what they point at.
|
||||||
|
///
|
||||||
|
/// `History` takes its observer by value, so a caller who wants to *read* what
|
||||||
|
/// an observer recorded has to keep a handle to it. Without these impls the
|
||||||
|
/// natural spelling does not compile:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use std::sync::{Arc, Mutex};
|
||||||
|
/// # use trueskill_tt::{History, Observer};
|
||||||
|
/// #[derive(Default)]
|
||||||
|
/// struct Recorder {
|
||||||
|
/// iterations: Mutex<Vec<usize>>,
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// impl Observer<i64> for Recorder {
|
||||||
|
/// fn on_iteration_end(&self, iter: usize, _step: (f64, f64)) {
|
||||||
|
/// self.iterations.lock().unwrap().push(iter);
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// let recorder = Arc::new(Recorder::default());
|
||||||
|
/// let mut h = History::builder().observer(Arc::clone(&recorder)).build();
|
||||||
|
/// h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
/// h.converge().unwrap();
|
||||||
|
///
|
||||||
|
/// // The caller's handle sees what the history's copy recorded.
|
||||||
|
/// assert!(!recorder.iterations.lock().unwrap().is_empty());
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The alternative was for every observer to wrap each of its own fields in an
|
||||||
|
/// `Arc` and derive `Clone` — one allocation and one lock per field, and a
|
||||||
|
/// pattern each implementor had to rediscover.
|
||||||
|
///
|
||||||
|
/// `?Sized` is deliberate: it makes `Arc<dyn Observer<T>>` and
|
||||||
|
/// `Box<dyn Observer<T>>` work, so observers can be chosen at runtime.
|
||||||
|
impl<T: Time, O: Observer<T> + ?Sized> Observer<T> for std::sync::Arc<O> {
|
||||||
|
fn on_iteration_end(&self, iter: usize, max_step: (f64, f64)) {
|
||||||
|
(**self).on_iteration_end(iter, max_step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_slice_processed(&self, time: &T, slice_idx: usize, n_events: usize) {
|
||||||
|
(**self).on_slice_processed(time, slice_idx, n_events);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_converged(&self, iters: usize, final_step: (f64, f64), converged: bool) {
|
||||||
|
(**self).on_converged(iters, final_step, converged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Time, O: Observer<T> + ?Sized> Observer<T> for Box<O> {
|
||||||
|
fn on_iteration_end(&self, iter: usize, max_step: (f64, f64)) {
|
||||||
|
(**self).on_iteration_end(iter, max_step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_slice_processed(&self, time: &T, slice_idx: usize, n_events: usize) {
|
||||||
|
(**self).on_slice_processed(time, slice_idx, n_events);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_converged(&self, iters: usize, final_step: (f64, f64), converged: bool) {
|
||||||
|
(**self).on_converged(iters, final_step, converged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Time, O: Observer<T> + ?Sized> Observer<T> for &O {
|
||||||
|
fn on_iteration_end(&self, iter: usize, max_step: (f64, f64)) {
|
||||||
|
(**self).on_iteration_end(iter, max_step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_slice_processed(&self, time: &T, slice_idx: usize, n_events: usize) {
|
||||||
|
(**self).on_slice_processed(time, slice_idx, n_events);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_converged(&self, iters: usize, final_step: (f64, f64), converged: bool) {
|
||||||
|
(**self).on_converged(iters, final_step, converged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ZST no-op observer; the default when none is configured.
|
||||||
|
#[derive(Copy, Clone, Debug, Default)]
|
||||||
|
pub struct NullObserver;
|
||||||
|
|
||||||
|
impl<T: Time> Observer<T> for NullObserver {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn null_observer_compiles_for_i64() {
|
||||||
|
let o = NullObserver;
|
||||||
|
<NullObserver as Observer<i64>>::on_iteration_end(&o, 1, (0.0, 0.0));
|
||||||
|
<NullObserver as Observer<i64>>::on_slice_processed(&o, &7, 0, 3);
|
||||||
|
<NullObserver as Observer<i64>>::on_converged(&o, 5, (1e-6, 1e-6), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn null_observer_compiles_for_untimed() {
|
||||||
|
use crate::Untimed;
|
||||||
|
let o = NullObserver;
|
||||||
|
<NullObserver as Observer<Untimed>>::on_iteration_end(&o, 1, (0.0, 0.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
//! Outcome of a match.
|
||||||
|
//!
|
||||||
|
//! `Ranked(ranks)` for ordinal results; `Scored { scores, score_sigma }` for
|
||||||
|
//! continuous per-team scores (engages `MarginFactor` in the engine).
|
||||||
|
|
||||||
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
|
/// Final outcome of a match.
|
||||||
|
///
|
||||||
|
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
|
||||||
|
/// teams. `ranks.len()` must equal the number of teams in the event.
|
||||||
|
///
|
||||||
|
/// `Scored { scores, score_sigma }`: higher score = better. Adjacent (sorted) pairs
|
||||||
|
/// feed observed margins to `MarginFactor`. `scores.len()` must equal the
|
||||||
|
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
|
||||||
|
/// when `Some`; `None` inherits the history default.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[must_use]
|
||||||
|
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]>),
|
||||||
|
/// 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 {
|
||||||
|
/// 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]>,
|
||||||
|
/// Per-event noise override. `None` means inherit
|
||||||
|
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
|
||||||
|
score_sigma: Option<f64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
/// `n`-team outcome where team `winner` won and everyone else tied for last.
|
||||||
|
///
|
||||||
|
/// Note this ties every loser, so for `n >= 3` it needs a positive
|
||||||
|
/// `p_draw` — see `InferenceError::TieWithoutDrawProbability`.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `winner >= n`. Use [`Outcome::try_winner`] when the index
|
||||||
|
/// comes from data rather than a literal.
|
||||||
|
///
|
||||||
|
/// This is the one constructor here that validates, and deliberately so.
|
||||||
|
/// Its siblings build freely and let ingestion reject what it cannot use,
|
||||||
|
/// which works because a malformed rank vector stays recognisable. An
|
||||||
|
/// out-of-range winner does not: `winner(5, 2)` would produce ranks
|
||||||
|
/// `[1, 1]`, an all-tied draw that ingestion accepts without complaint when
|
||||||
|
/// `p_draw > 0`. Asking "team 5 won" and silently getting "everyone drew"
|
||||||
|
/// is exactly the class of quiet wrong answer this crate keeps removing, so
|
||||||
|
/// the check happens here where the mistake is.
|
||||||
|
pub fn winner(winner: u32, n: u32) -> Self {
|
||||||
|
Self::try_winner(winner, n)
|
||||||
|
.unwrap_or_else(|_| panic!("winner index {winner} out of range 0..{n}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `n`-team outcome where team `winner` won, or an error if `winner` is not
|
||||||
|
/// a valid team index.
|
||||||
|
///
|
||||||
|
/// The fallible form of [`Outcome::winner`], for when the index is computed
|
||||||
|
/// or parsed rather than written literally.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// `InvalidParameter` if `winner >= n`.
|
||||||
|
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
|
||||||
|
if winner >= n {
|
||||||
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
|
parameter: crate::Parameter::WinnerIndex,
|
||||||
|
value: f64::from(winner),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let ranks: SmallVec<[u32; 4]> = (0..n).map(|i| if i == winner { 0 } else { 1 }).collect();
|
||||||
|
Ok(Self::Ranked(ranks))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All `n` teams tied.
|
||||||
|
pub fn draw(n: u32) -> Self {
|
||||||
|
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit per-team ranking.
|
||||||
|
pub fn ranking<I: IntoIterator<Item = u32>>(ranks: I) -> Self {
|
||||||
|
Self::Ranked(ranks.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit per-team continuous scores; higher = better.
|
||||||
|
/// Inherits `HistoryBuilder::score_sigma` for the noise model.
|
||||||
|
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
|
||||||
|
Self::Scored {
|
||||||
|
scores: scores.into_iter().collect(),
|
||||||
|
score_sigma: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit per-team continuous scores with a per-event noise override.
|
||||||
|
///
|
||||||
|
/// The noise is on the *observed score margin*, in the units of the scores
|
||||||
|
/// themselves — it is not a skill sigma, which is what the old name
|
||||||
|
/// `scores_with_sigma` read as. It overrides `HistoryBuilder::score_sigma`
|
||||||
|
/// for this event only.
|
||||||
|
///
|
||||||
|
/// `score_sigma` must be `> 0.0`. Constructing an `Outcome` with a
|
||||||
|
/// non-positive or NaN value is allowed; the value is rejected with
|
||||||
|
/// `InferenceError::InvalidParameter` when the event is ingested, so
|
||||||
|
/// callers get an error rather than a panic.
|
||||||
|
pub fn scores_with_noise<I: IntoIterator<Item = f64>>(scores: I, score_sigma: f64) -> Self {
|
||||||
|
Self::Scored {
|
||||||
|
scores: scores.into_iter().collect(),
|
||||||
|
score_sigma: Some(score_sigma),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many teams this outcome describes — the number of ranks, or of
|
||||||
|
/// scores.
|
||||||
|
///
|
||||||
|
/// Ingestion checks it against the event's own team list and rejects a
|
||||||
|
/// disagreement with `MismatchedShape`, so this is the cheap way to check
|
||||||
|
/// an outcome built elsewhere before committing the event.
|
||||||
|
#[must_use]
|
||||||
|
pub fn team_count(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Ranked(r) => r.len(),
|
||||||
|
Self::Scored { scores, .. } => scores.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn as_ranks(&self) -> Option<&[u32]> {
|
||||||
|
match self {
|
||||||
|
Self::Ranked(r) => Some(r),
|
||||||
|
Self::Scored { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn as_scores(&self) -> Option<&[f64]> {
|
||||||
|
match self {
|
||||||
|
Self::Scored { scores, .. } => Some(scores),
|
||||||
|
Self::Ranked(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winner_two_teams() {
|
||||||
|
let o = Outcome::winner(0, 2);
|
||||||
|
assert_eq!(o.as_ranks(), Some(&[0u32, 1][..]));
|
||||||
|
assert_eq!(o.team_count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winner_three_teams_second_wins() {
|
||||||
|
let o = Outcome::winner(1, 3);
|
||||||
|
assert_eq!(o.as_ranks(), Some(&[1u32, 0, 1][..]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_three_teams() {
|
||||||
|
let o = Outcome::draw(3);
|
||||||
|
assert_eq!(o.as_ranks(), Some(&[0u32, 0, 0][..]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ranking_from_iter() {
|
||||||
|
let o = Outcome::ranking([2, 0, 1]);
|
||||||
|
assert_eq!(o.as_ranks(), Some(&[2u32, 0, 1][..]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "winner index 2 out of range")]
|
||||||
|
fn winner_out_of_range_panics() {
|
||||||
|
let _ = Outcome::winner(2, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scored_two_teams() {
|
||||||
|
let o = Outcome::scores([10.0, 4.0]);
|
||||||
|
assert_eq!(o.team_count(), 2);
|
||||||
|
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
|
||||||
|
assert_eq!(o.as_ranks(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scored_team_count_matches_input() {
|
||||||
|
let o = Outcome::scores([3.0, 1.0, 2.0, 0.0]);
|
||||||
|
assert_eq!(o.team_count(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ranked_as_scores_returns_none() {
|
||||||
|
let o = Outcome::winner(0, 2);
|
||||||
|
assert!(o.as_scores().is_none());
|
||||||
|
assert!(o.as_ranks().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scores_with_sigma_round_trips() {
|
||||||
|
let o = Outcome::scores_with_noise([10.0, 4.0], 0.5);
|
||||||
|
assert_eq!(o.team_count(), 2);
|
||||||
|
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scores_constructor_leaves_sigma_unset() {
|
||||||
|
let o = Outcome::scores([3.0, 1.0]);
|
||||||
|
match o {
|
||||||
|
Outcome::Scored { score_sigma, .. } => assert!(score_sigma.is_none()),
|
||||||
|
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scores_with_sigma_sets_sigma_some() {
|
||||||
|
let o = Outcome::scores_with_noise([3.0, 1.0], 2.0);
|
||||||
|
match o {
|
||||||
|
Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(2.0)),
|
||||||
|
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construction accepts any sigma; the value is validated at ingestion so
|
||||||
|
/// callers receive an `InferenceError` rather than a panic. See
|
||||||
|
/// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`.
|
||||||
|
#[test]
|
||||||
|
fn scores_with_sigma_defers_validation_to_ingestion() {
|
||||||
|
let o = Outcome::scores_with_noise([3.0, 1.0], 0.0);
|
||||||
|
match o {
|
||||||
|
Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(0.0)),
|
||||||
|
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
BETA, GAMMA,
|
|
||||||
drift::{ConstantDrift, Drift},
|
|
||||||
gaussian::Gaussian,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub struct Player<D: Drift = ConstantDrift> {
|
|
||||||
pub(crate) prior: Gaussian,
|
|
||||||
pub(crate) beta: f64,
|
|
||||||
pub(crate) drift: D,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D: Drift> Player<D> {
|
|
||||||
pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self {
|
|
||||||
Self { prior, beta, drift }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn performance(&self) -> Gaussian {
|
|
||||||
self.prior.forget(self.beta.powi(2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Player<ConstantDrift> {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
prior: Gaussian::default(),
|
|
||||||
beta: BETA,
|
|
||||||
drift: ConstantDrift(GAMMA),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+760
@@ -0,0 +1,760 @@
|
|||||||
|
//! Outcome prediction: who wins, and how likely is a given finishing order.
|
||||||
|
//!
|
||||||
|
//! Prediction runs on *performances*, not skills. A competitor's skill is
|
||||||
|
//! inflated by their performance noise `beta` before any comparison, which is
|
||||||
|
//! what separates "how good are they" from "how will they do today".
|
||||||
|
//!
|
||||||
|
//! Two questions, two algorithms:
|
||||||
|
//!
|
||||||
|
//! - **Who finishes first.** Because performances are independent Gaussians,
|
||||||
|
//! the probability that team `i` beats every other team separates into a
|
||||||
|
//! *one-dimensional* integral — no multivariate orthant integral is
|
||||||
|
//! involved. [`quadrature::integrate`] evaluates it to near machine
|
||||||
|
//! precision for a few hundred `cdf` calls.
|
||||||
|
//! - **A specific finishing order.** The factor graph only ever constrains
|
||||||
|
//! rank-*adjacent* teams (see `Game::run_chain`), so the joint probability
|
||||||
|
//! of a full order is a chain of local constraints rather than a general
|
||||||
|
//! orthant probability. That chain collapses into a sequential recursion:
|
||||||
|
//! one cumulative integral per adjacent pair, `O(teams * grid)` overall.
|
||||||
|
//!
|
||||||
|
//! Both are deterministic. A sampler would have been easier to write and
|
||||||
|
//! would have made every `predict_*` call return a slightly different number,
|
||||||
|
//! which is not a property a rating library should have.
|
||||||
|
|
||||||
|
use crate::{Gaussian, InferenceError, quadrature};
|
||||||
|
|
||||||
|
/// Teams beyond this count make the outcome enumeration impractical.
|
||||||
|
///
|
||||||
|
/// Each realisation sorts into exactly one (permutation, tie-pattern) event,
|
||||||
|
/// so the space has `n! * 2^(n-1)` members: 24 at 3 teams, 192 at 4, 1_920 at
|
||||||
|
/// 5, 23_040 at 6. The jump to 322_560 at 7 is where enumerating stops being
|
||||||
|
/// a reasonable thing to do on a caller's behalf.
|
||||||
|
pub(crate) const MAX_TEAMS_FOR_DISTRIBUTION: usize = 6;
|
||||||
|
|
||||||
|
/// Relative tolerance for the first-place integrals.
|
||||||
|
///
|
||||||
|
/// The adaptive integrator reaches the exact two-team closed form to ~1e-15 at
|
||||||
|
/// this tolerance, which is round-off for a probability. `cdf` is no longer the
|
||||||
|
/// limit — it went to ~1 ULP when `erfc` moved to `libm` — so this is the
|
||||||
|
/// integrator's own floor.
|
||||||
|
const WIN_TOLERANCE: f64 = 1e-8;
|
||||||
|
|
||||||
|
/// Nodes for the ranking grid, and the floor below which a grid is pointless.
|
||||||
|
///
|
||||||
|
/// The recursion converges as O(h^2), so this trades nodes against accuracy
|
||||||
|
/// directly. Measured against the exact two-team closed form, 2_048 nodes leave
|
||||||
|
/// ~1.2e-6 of discretisation error and 8_192 reach ~1e-7.
|
||||||
|
///
|
||||||
|
/// Unlike the adaptive path there is no approximation floor underneath this any
|
||||||
|
/// more — `cdf` is accurate to ~1 ULP since `erfc` moved to `libm` — so the
|
||||||
|
/// error here is purely the grid, and a caller who needs more can only get it
|
||||||
|
/// by paying for more nodes. 8_192 is the accuracy/cost point chosen, not a
|
||||||
|
/// point where refining stops helping.
|
||||||
|
const MIN_GRID_POINTS: usize = 8_192;
|
||||||
|
const MAX_GRID_POINTS: usize = 262_144;
|
||||||
|
/// Nodes requested across the narrowest feature the recursion must resolve.
|
||||||
|
const NODES_PER_FEATURE: f64 = 12.0;
|
||||||
|
/// Nodes below which the trapezoid rule stops resolving that feature at all.
|
||||||
|
const MIN_NODES_PER_FEATURE: f64 = 4.0;
|
||||||
|
|
||||||
|
/// How many standard deviations of support the grid and integrals cover.
|
||||||
|
///
|
||||||
|
/// The normal density is below 1e-18 of its peak past nine sigma, far under
|
||||||
|
/// the precision of everything else here.
|
||||||
|
const SUPPORT_SIGMAS: f64 = 9.0;
|
||||||
|
|
||||||
|
/// Standard normal CDF at `z`.
|
||||||
|
fn phi(z: f64) -> f64 {
|
||||||
|
crate::cdf(z, 0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normal density of `x` under `g`.
|
||||||
|
fn density(g: Gaussian, x: f64) -> f64 {
|
||||||
|
let sigma = g.sigma();
|
||||||
|
let z = (x - g.mu()) / sigma;
|
||||||
|
libm::exp(-0.5 * z * z) / (sigma * (2.0 * std::f64::consts::PI).sqrt())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-pair draw margins.
|
||||||
|
///
|
||||||
|
/// The margin is *not* a single number for the whole game: inference derives
|
||||||
|
/// it per rank-adjacent pair from those two teams' betas (`Game::likelihoods`).
|
||||||
|
/// Prediction has to use the same per-pair values or it answers a question
|
||||||
|
/// about a different model than the one that will actually be fitted.
|
||||||
|
pub(crate) struct Margins {
|
||||||
|
n: usize,
|
||||||
|
values: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Margins {
|
||||||
|
/// Build from a per-pair margin function.
|
||||||
|
pub(crate) fn new<F: Fn(usize, usize) -> f64>(n: usize, f: F) -> Self {
|
||||||
|
let mut values = vec![0.0; n * n];
|
||||||
|
for i in 0..n {
|
||||||
|
for j in 0..n {
|
||||||
|
if i != j {
|
||||||
|
values[i * n + j] = f(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self { n, values }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, i: usize, j: usize) -> f64 {
|
||||||
|
self.values[i * self.n + j]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when no pair can draw, so every tie has probability zero.
|
||||||
|
fn all_zero(&self) -> bool {
|
||||||
|
self.values.iter().all(|&v| v == 0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `P(team i finishes strictly first)` for every team.
|
||||||
|
///
|
||||||
|
/// Strictly means beating each rival by more than that pair's draw margin, so
|
||||||
|
/// with a non-zero margin these sum to less than one; the shortfall is the
|
||||||
|
/// probability that the top place is shared.
|
||||||
|
pub(crate) fn win_probabilities(perf: &[Gaussian], margins: &Margins) -> Vec<f64> {
|
||||||
|
(0..perf.len())
|
||||||
|
.map(|i| {
|
||||||
|
let (mu, sigma) = (perf[i].mu(), perf[i].sigma());
|
||||||
|
let (lo, hi) = (mu - SUPPORT_SIGMAS * sigma, mu + SUPPORT_SIGMAS * sigma);
|
||||||
|
|
||||||
|
// Each rival's CDF turns over near its own mean plus the margin.
|
||||||
|
// Seeding there is what keeps a rival with a tiny sigma — a step
|
||||||
|
// function in disguise — from being stepped over.
|
||||||
|
let mut seeds = Vec::with_capacity(3 * perf.len());
|
||||||
|
for (j, rival) in perf.iter().enumerate().filter(|&(j, _)| j != i) {
|
||||||
|
let centre = rival.mu() + margins.get(i, j);
|
||||||
|
seeds.extend_from_slice(&[centre - rival.sigma(), centre, centre + rival.sigma()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
quadrature::integrate(
|
||||||
|
|x| {
|
||||||
|
let d = density(perf[i], x);
|
||||||
|
if d == 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let beaten: f64 = (0..perf.len())
|
||||||
|
.filter(|&j| j != i)
|
||||||
|
.map(|j| phi((x - margins.get(i, j) - perf[j].mu()) / perf[j].sigma()))
|
||||||
|
.product();
|
||||||
|
d * beaten
|
||||||
|
},
|
||||||
|
lo,
|
||||||
|
hi,
|
||||||
|
&seeds,
|
||||||
|
WIN_TOLERANCE,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grid bounds and resolution covering every team's support.
|
||||||
|
///
|
||||||
|
/// Resolution is set by the *smallest* feature in play — the narrowest sigma,
|
||||||
|
/// or a draw margin narrower still — because that is what the recursion has to
|
||||||
|
/// resolve. A grid sized off the widest team would step over the narrow one.
|
||||||
|
fn grid_shape(perf: &[Gaussian], margins: &Margins) -> Result<(f64, f64, usize), InferenceError> {
|
||||||
|
let lo = perf
|
||||||
|
.iter()
|
||||||
|
.map(|g| g.mu() - SUPPORT_SIGMAS * g.sigma())
|
||||||
|
.fold(f64::INFINITY, f64::min);
|
||||||
|
let hi = perf
|
||||||
|
.iter()
|
||||||
|
.map(|g| g.mu() + SUPPORT_SIGMAS * g.sigma())
|
||||||
|
.fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
|
||||||
|
let narrowest = perf
|
||||||
|
.iter()
|
||||||
|
.map(Gaussian::sigma)
|
||||||
|
.fold(f64::INFINITY, f64::min);
|
||||||
|
let smallest_margin = margins
|
||||||
|
.values
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|&m| m > 0.0)
|
||||||
|
.fold(f64::INFINITY, f64::min);
|
||||||
|
|
||||||
|
let feature = narrowest.min(smallest_margin);
|
||||||
|
let wanted = if feature.is_finite() && feature > 0.0 {
|
||||||
|
((hi - lo) / (feature / NODES_PER_FEATURE)).ceil()
|
||||||
|
} else {
|
||||||
|
MIN_GRID_POINTS as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
if !wanted.is_finite() {
|
||||||
|
return Ok((lo, hi, MIN_GRID_POINTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report rather than clamp. Clamping is what this replaced: it silently
|
||||||
|
// handed the recursion a grid too coarse for the narrowest density, and the
|
||||||
|
// trapezoid rule then returned probabilities greater than one — measured, a
|
||||||
|
// `P` of 2.79 and a total of 5.41. Trapezoid error on a Gaussian is
|
||||||
|
// `~exp(-2 pi^2 (sigma/h)^2)`, which is 1e-12 at `h/sigma = 0.86` and O(1)
|
||||||
|
// by `h/sigma = 17`, so the cliff is sharp and there is no useful answer on
|
||||||
|
// the far side of it.
|
||||||
|
//
|
||||||
|
// The floor is `MIN_NODES_PER_FEATURE` rather than the `NODES_PER_FEATURE`
|
||||||
|
// asked for, because the request carries a large margin: measured accurate
|
||||||
|
// to 2.2e-12 at 1.4 nodes per sigma, and wrong by 1.2e-3 at 0.7.
|
||||||
|
let needed = wanted as usize;
|
||||||
|
let floor = ((hi - lo) / (feature / MIN_NODES_PER_FEATURE)).ceil();
|
||||||
|
if floor.is_finite() && floor as usize > MAX_GRID_POINTS {
|
||||||
|
return Err(InferenceError::GridTooCoarse {
|
||||||
|
needed,
|
||||||
|
max: MAX_GRID_POINTS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((lo, hi, needed.clamp(MIN_GRID_POINTS, MAX_GRID_POINTS)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Densities of each team sampled on the shared grid.
|
||||||
|
struct Sampled {
|
||||||
|
lo: f64,
|
||||||
|
step: f64,
|
||||||
|
points: usize,
|
||||||
|
density: Vec<Vec<f64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sampled {
|
||||||
|
fn new(perf: &[Gaussian], margins: &Margins) -> Result<Self, InferenceError> {
|
||||||
|
let (lo, hi, points) = grid_shape(perf, margins)?;
|
||||||
|
let step = (hi - lo) / (points - 1) as f64;
|
||||||
|
let density = perf
|
||||||
|
.iter()
|
||||||
|
.map(|&g| {
|
||||||
|
(0..points)
|
||||||
|
.map(|i| density(g, lo + i as f64 * step))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Self {
|
||||||
|
lo,
|
||||||
|
step,
|
||||||
|
points,
|
||||||
|
density,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node(&self, i: usize) -> f64 {
|
||||||
|
self.lo + i as f64 * self.step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `P(order[0] >= order[1] >= ... )` with the given adjacency pattern.
|
||||||
|
///
|
||||||
|
/// `tied[k]` says whether `order[k]` and `order[k + 1]` finish within that
|
||||||
|
/// pair's draw margin. The recursion runs bottom-up: `carry` holds, for each
|
||||||
|
/// grid node, the probability that everything *below* the current team holds
|
||||||
|
/// given that team landed on that node. A strict gap reads a cumulative
|
||||||
|
/// integral; a tie reads a window. Both are O(1) against one prefix array,
|
||||||
|
/// so each level costs O(grid) and the whole order costs O(teams * grid).
|
||||||
|
fn order_probability(margins: &Margins, sampled: &Sampled, order: &[usize], tied: &[bool]) -> f64 {
|
||||||
|
let mut carry = vec![1.0; sampled.points];
|
||||||
|
|
||||||
|
for k in (0..order.len() - 1).rev() {
|
||||||
|
let below = order[k + 1];
|
||||||
|
let above = order[k];
|
||||||
|
let margin = margins.get(above, below);
|
||||||
|
|
||||||
|
let integrand: Vec<f64> = (0..sampled.points)
|
||||||
|
.map(|i| sampled.density[below][i] * carry[i])
|
||||||
|
.collect();
|
||||||
|
let cumulative = quadrature::Grid::from_values(sampled.lo, sampled.step, integrand);
|
||||||
|
|
||||||
|
carry = (0..sampled.points)
|
||||||
|
.map(|i| {
|
||||||
|
let x = sampled.node(i);
|
||||||
|
if tied[k] {
|
||||||
|
// Sorted order already implies `below <= above`, so the
|
||||||
|
// tie window is one-sided: [x - margin, x].
|
||||||
|
cumulative.integral_between(x - margin, x)
|
||||||
|
} else {
|
||||||
|
cumulative.integral_to(x - margin)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let top = order[0];
|
||||||
|
let integrand: Vec<f64> = (0..sampled.points)
|
||||||
|
.map(|i| sampled.density[top][i] * carry[i])
|
||||||
|
.collect();
|
||||||
|
quadrature::Grid::from_values(sampled.lo, sampled.step, integrand).total()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dense ranks implied by a sorted order and its tie pattern.
|
||||||
|
fn ranks_of(order: &[usize], tied: &[bool], n: usize) -> Vec<u32> {
|
||||||
|
let mut ranks = vec![0u32; n];
|
||||||
|
let mut rank = 0u32;
|
||||||
|
ranks[order[0]] = 0;
|
||||||
|
for k in 0..order.len() - 1 {
|
||||||
|
if !tied[k] {
|
||||||
|
rank += 1;
|
||||||
|
}
|
||||||
|
ranks[order[k + 1]] = rank;
|
||||||
|
}
|
||||||
|
ranks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every (order, tie-pattern) event, or only the strict ones when no pair can
|
||||||
|
/// draw — a tie then has probability exactly zero and is not worth integrating.
|
||||||
|
fn events(n: usize, strict_only: bool) -> Vec<(Vec<usize>, Vec<bool>)> {
|
||||||
|
fn permute(current: &mut Vec<usize>, k: usize, out: &mut Vec<Vec<usize>>) {
|
||||||
|
if k == current.len() {
|
||||||
|
out.push(current.clone());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for i in k..current.len() {
|
||||||
|
current.swap(k, i);
|
||||||
|
permute(current, k + 1, out);
|
||||||
|
current.swap(k, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut orders = Vec::new();
|
||||||
|
permute(&mut (0..n).collect(), 0, &mut orders);
|
||||||
|
|
||||||
|
let patterns: Vec<Vec<bool>> = if strict_only {
|
||||||
|
vec![vec![false; n - 1]]
|
||||||
|
} else {
|
||||||
|
(0..(1u32 << (n - 1)))
|
||||||
|
.map(|mask| (0..n - 1).map(|i| mask >> i & 1 == 1).collect())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(orders.len() * patterns.len());
|
||||||
|
for order in orders {
|
||||||
|
for pattern in &patterns {
|
||||||
|
out.push((order.clone(), pattern.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full distribution over finishing orders, aggregated by rank vector.
|
||||||
|
///
|
||||||
|
/// Orders that differ only *within* a tied group describe the same finishing
|
||||||
|
/// order, so their probabilities are summed into one entry.
|
||||||
|
pub(crate) fn outcome_distribution(
|
||||||
|
perf: &[Gaussian],
|
||||||
|
margins: &Margins,
|
||||||
|
) -> Result<Vec<(Vec<u32>, f64)>, InferenceError> {
|
||||||
|
let n = perf.len();
|
||||||
|
let sampled = Sampled::new(perf, margins)?;
|
||||||
|
|
||||||
|
let mut aggregated: Vec<(Vec<u32>, f64)> = Vec::new();
|
||||||
|
for (order, tied) in events(n, margins.all_zero()) {
|
||||||
|
let p = order_probability(margins, &sampled, &order, &tied);
|
||||||
|
let ranks = ranks_of(&order, &tied, n);
|
||||||
|
match aggregated.iter_mut().find(|(r, _)| *r == ranks) {
|
||||||
|
Some((_, acc)) => *acc += p,
|
||||||
|
None => aggregated.push((ranks, p)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregated.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
Ok(aggregated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All permutations of `items`.
|
||||||
|
fn permutations(items: &[usize]) -> Vec<Vec<usize>> {
|
||||||
|
fn go(current: &mut Vec<usize>, k: usize, out: &mut Vec<Vec<usize>>) {
|
||||||
|
if k == current.len() {
|
||||||
|
out.push(current.clone());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for i in k..current.len() {
|
||||||
|
current.swap(k, i);
|
||||||
|
go(current, k + 1, out);
|
||||||
|
current.swap(k, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
go(&mut items.to_vec(), 0, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every (order, tie-pattern) event consistent with a grouping by rank.
|
||||||
|
///
|
||||||
|
/// Teams sharing a rank may finish in any internal order, so this is the
|
||||||
|
/// product of each group's permutations. Adjacencies inside a group are ties;
|
||||||
|
/// the adjacency joining one group to the next is not.
|
||||||
|
fn orders_for_groups(groups: &[Vec<usize>]) -> Vec<(Vec<usize>, Vec<bool>)> {
|
||||||
|
let per_group: Vec<Vec<Vec<usize>>> = groups.iter().map(|g| permutations(g)).collect();
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut choice = vec![0usize; groups.len()];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut order = Vec::new();
|
||||||
|
let mut tied = Vec::new();
|
||||||
|
for (gi, group) in per_group.iter().enumerate() {
|
||||||
|
for (offset, &member) in group[choice[gi]].iter().enumerate() {
|
||||||
|
if !order.is_empty() {
|
||||||
|
tied.push(offset != 0);
|
||||||
|
}
|
||||||
|
order.push(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push((order, tied));
|
||||||
|
|
||||||
|
let mut k = 0;
|
||||||
|
loop {
|
||||||
|
if k == choice.len() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
choice[k] += 1;
|
||||||
|
if choice[k] < per_group[k].len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
choice[k] = 0;
|
||||||
|
k += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probability of one specific rank vector.
|
||||||
|
///
|
||||||
|
/// Ties in `ranks` mean the tied teams may finish in any internal order, so
|
||||||
|
/// this sums the orders consistent with the requested ranking rather than
|
||||||
|
/// picking one.
|
||||||
|
pub(crate) fn ranking_probability(
|
||||||
|
perf: &[Gaussian],
|
||||||
|
margins: &Margins,
|
||||||
|
ranks: &[u32],
|
||||||
|
) -> Result<f64, InferenceError> {
|
||||||
|
let n = perf.len();
|
||||||
|
let sampled = Sampled::new(perf, margins)?;
|
||||||
|
|
||||||
|
let mut distinct: Vec<u32> = ranks.to_vec();
|
||||||
|
distinct.sort_unstable();
|
||||||
|
distinct.dedup();
|
||||||
|
|
||||||
|
let groups: Vec<Vec<usize>> = distinct
|
||||||
|
.iter()
|
||||||
|
.map(|&r| (0..n).filter(|&i| ranks[i] == r).collect())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(orders_for_groups(&groups)
|
||||||
|
.iter()
|
||||||
|
.map(|(order, tied)| order_probability(margins, &sampled, order, tied))
|
||||||
|
.sum())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A distribution over the ways a contest could finish.
|
||||||
|
///
|
||||||
|
/// Each entry pairs a rank vector — the same shape [`crate::Outcome::ranking`]
|
||||||
|
/// takes, with equal ranks meaning a tie — against its probability. Entries
|
||||||
|
/// are ordered most likely first, and cover the whole outcome space, so the
|
||||||
|
/// probabilities sum to one.
|
||||||
|
///
|
||||||
|
/// The rank vectors compose directly with inference: feeding one to
|
||||||
|
/// `Game::ranked` asks "what would we believe if *this* happened", which is
|
||||||
|
/// what an expected-information-gain calculation needs alongside the weight.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
#[must_use]
|
||||||
|
pub struct Prediction {
|
||||||
|
outcomes: Vec<(Vec<u32>, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Prediction {
|
||||||
|
pub(crate) fn new(outcomes: Vec<(Vec<u32>, f64)>) -> Self {
|
||||||
|
Self { outcomes }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every possible finishing order and its probability, most likely first.
|
||||||
|
#[must_use]
|
||||||
|
pub fn outcomes(&self) -> impl ExactSizeIterator<Item = (&[u32], f64)> {
|
||||||
|
self.outcomes.iter().map(|(r, p)| (r.as_slice(), *p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single most likely finishing order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn most_likely(&self) -> Option<(&[u32], f64)> {
|
||||||
|
self.outcomes.first().map(|(r, p)| (r.as_slice(), *p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probability of one specific finishing order, or zero if it cannot occur.
|
||||||
|
#[must_use]
|
||||||
|
pub fn probability_of(&self, ranks: &[u32]) -> f64 {
|
||||||
|
self.outcomes
|
||||||
|
.iter()
|
||||||
|
.find(|(r, _)| r.as_slice() == ranks)
|
||||||
|
.map_or(0.0, |(_, p)| *p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `P(team i finishes strictly first)`, for each team.
|
||||||
|
///
|
||||||
|
/// Sums to less than one exactly when the top place can be shared; the
|
||||||
|
/// shortfall is [`Prediction::shared_first_place`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn win_probabilities(&self) -> Vec<f64> {
|
||||||
|
let n = self.outcomes.first().map_or(0, |(r, _)| r.len());
|
||||||
|
let mut wins = vec![0.0; n];
|
||||||
|
for (ranks, p) in &self.outcomes {
|
||||||
|
let leaders = ranks.iter().filter(|&&r| r == 0).count();
|
||||||
|
if leaders == 1 {
|
||||||
|
let winner = ranks.iter().position(|&r| r == 0).expect("a rank-0 team");
|
||||||
|
wins[winner] += p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wins
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probability that two or more teams share first place.
|
||||||
|
#[must_use]
|
||||||
|
pub fn shared_first_place(&self) -> f64 {
|
||||||
|
self.outcomes
|
||||||
|
.iter()
|
||||||
|
.filter(|(r, _)| r.iter().filter(|&&x| x == 0).count() > 1)
|
||||||
|
.map(|(_, p)| p)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total probability mass, which should be one.
|
||||||
|
///
|
||||||
|
/// Exposed because it is a genuine check on the numerics rather than a
|
||||||
|
/// formality: the outcome space is exhaustive and disjoint by construction,
|
||||||
|
/// so any drift from one is integration error and nothing else.
|
||||||
|
#[must_use]
|
||||||
|
pub fn total(&self) -> f64 {
|
||||||
|
self.outcomes.iter().map(|(_, p)| p).sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn g(mu: f64, sigma: f64) -> Gaussian {
|
||||||
|
Gaussian::from_ms(mu, sigma)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flat(n: usize, eps: f64) -> Margins {
|
||||||
|
Margins::new(n, |_, _| eps)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact two-team result: `P(a first) = Phi((mu_a - mu_b - eps) / sd)`.
|
||||||
|
fn closed_form_two(a: Gaussian, b: Gaussian, eps: f64) -> (f64, f64) {
|
||||||
|
let sd = a.sigma().hypot(b.sigma());
|
||||||
|
(
|
||||||
|
phi((a.mu() - b.mu() - eps) / sd),
|
||||||
|
phi((b.mu() - a.mu() - eps) / sd),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_team_win_probabilities_match_the_closed_form() {
|
||||||
|
for (ma, sa, mb, sb, eps) in [
|
||||||
|
(0.0, 6.0, 0.0, 6.0, 0.0),
|
||||||
|
(3.0, 6.0, -2.0, 1.0, 0.0),
|
||||||
|
(0.0, 6.0, 0.0, 6.0, 2.0),
|
||||||
|
(3.0, 6.0, -2.0, 1.0, 1.5),
|
||||||
|
(40.0, 1.0, 0.0, 1.0, 0.0),
|
||||||
|
] {
|
||||||
|
let perf = [g(ma, sa), g(mb, sb)];
|
||||||
|
let got = win_probabilities(&perf, &flat(2, eps));
|
||||||
|
let (wa, wb) = closed_form_two(perf[0], perf[1], eps);
|
||||||
|
assert!(
|
||||||
|
(got[0] - wa).abs() < 1e-12 && (got[1] - wb).abs() < 1e-12,
|
||||||
|
"mu=({ma},{mb}) sigma=({sa},{sb}) eps={eps}: got {got:?}, want [{wa}, {wb}]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity that a wrong-but-plausible implementation cannot fake:
|
||||||
|
/// with no draw margin, exactly one team finishes first.
|
||||||
|
#[test]
|
||||||
|
fn win_probabilities_sum_to_one_without_a_draw_margin() {
|
||||||
|
for perf in [
|
||||||
|
vec![g(0.0, 6.0), g(0.0, 6.0)],
|
||||||
|
vec![g(5.0, 6.0), g(0.0, 3.0), g(-5.0, 1.0)],
|
||||||
|
vec![
|
||||||
|
g(8.0, 2.0),
|
||||||
|
g(3.0, 6.0),
|
||||||
|
g(0.0, 1.0),
|
||||||
|
g(-3.0, 4.0),
|
||||||
|
g(-8.0, 6.0),
|
||||||
|
],
|
||||||
|
] {
|
||||||
|
let sum: f64 = win_probabilities(&perf, &flat(perf.len(), 0.0))
|
||||||
|
.iter()
|
||||||
|
.sum();
|
||||||
|
assert!(
|
||||||
|
(sum - 1.0).abs() < 1e-7,
|
||||||
|
"{} teams: sum = {sum}",
|
||||||
|
perf.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rival with a tiny sigma is a step function in disguise. Fixed-node
|
||||||
|
/// quadrature steps over it and lands ~1e-2 out while still looking like a
|
||||||
|
/// probability; this is the case that rules that approach out.
|
||||||
|
#[test]
|
||||||
|
fn win_probabilities_survive_a_rival_with_a_tiny_sigma() {
|
||||||
|
let perf = [g(0.0, 0.001), g(0.5, 6.0), g(-0.5, 6.0)];
|
||||||
|
let got = win_probabilities(&perf, &flat(3, 0.0));
|
||||||
|
let sum: f64 = got.iter().sum();
|
||||||
|
assert!((sum - 1.0).abs() < 1e-6, "sum = {sum}, probs = {got:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stronger_team_is_more_likely_to_win() {
|
||||||
|
let perf = [g(10.0, 3.0), g(0.0, 3.0), g(-10.0, 3.0)];
|
||||||
|
let p = win_probabilities(&perf, &flat(3, 0.0));
|
||||||
|
assert!(p[0] > p[1] && p[1] > p[2], "not monotone: {p:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_teams_are_equally_likely_to_win() {
|
||||||
|
let perf = [g(1.0, 4.0), g(1.0, 4.0), g(1.0, 4.0)];
|
||||||
|
let p = win_probabilities(&perf, &flat(3, 0.0));
|
||||||
|
for probs in p.windows(2) {
|
||||||
|
assert!((probs[0] - probs[1]).abs() < 1e-9, "asymmetric: {p:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every realisation sorts into exactly one finishing order, so the whole
|
||||||
|
/// distribution must sum to one — with or without a draw margin.
|
||||||
|
#[test]
|
||||||
|
fn outcome_distribution_sums_to_one() {
|
||||||
|
for (perf, eps) in [
|
||||||
|
(vec![g(0.0, 6.0), g(0.0, 6.0)], 0.0),
|
||||||
|
(vec![g(0.0, 6.0), g(0.0, 6.0)], 2.0),
|
||||||
|
(vec![g(0.0, 6.0), g(0.0, 6.0), g(0.0, 6.0)], 0.0),
|
||||||
|
(vec![g(5.0, 6.0), g(0.0, 3.0), g(-5.0, 1.0)], 1.5),
|
||||||
|
(vec![g(0.0, 0.05), g(0.5, 6.0), g(-0.5, 6.0)], 1.0),
|
||||||
|
(
|
||||||
|
vec![g(6.0, 2.0), g(2.0, 6.0), g(-2.0, 1.0), g(-6.0, 4.0)],
|
||||||
|
1.0,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let n = perf.len();
|
||||||
|
let dist = outcome_distribution(&perf, &flat(n, eps)).unwrap();
|
||||||
|
let sum: f64 = dist.iter().map(|(_, p)| p).sum();
|
||||||
|
assert!(
|
||||||
|
(sum - 1.0).abs() < 1e-6,
|
||||||
|
"{n} teams, eps={eps}: sum = {sum} over {} outcomes",
|
||||||
|
dist.len()
|
||||||
|
);
|
||||||
|
assert!(dist.iter().all(|(_, p)| *p >= 0.0), "negative probability");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With two teams the distribution is the exact win/draw/loss triple.
|
||||||
|
#[test]
|
||||||
|
fn two_team_distribution_matches_the_closed_form() {
|
||||||
|
let perf = [g(3.0, 6.0), g(-2.0, 1.0)];
|
||||||
|
let eps = 1.5;
|
||||||
|
let dist = outcome_distribution(&perf, &flat(2, eps)).unwrap();
|
||||||
|
let (wa, wb) = closed_form_two(perf[0], perf[1], eps);
|
||||||
|
|
||||||
|
let find = |ranks: &[u32]| {
|
||||||
|
dist.iter()
|
||||||
|
.find(|(r, _)| r == ranks)
|
||||||
|
.map_or(0.0, |(_, p)| *p)
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(find(&[0, 1]) - wa).abs() < 1e-6,
|
||||||
|
"a wins: {}",
|
||||||
|
find(&[0, 1])
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(find(&[1, 0]) - wb).abs() < 1e-6,
|
||||||
|
"b wins: {}",
|
||||||
|
find(&[1, 0])
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(find(&[0, 0]) - (1.0 - wa - wb)).abs() < 1e-6,
|
||||||
|
"draw: {}",
|
||||||
|
find(&[0, 0])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asking for one ranking must agree with that ranking's entry in the
|
||||||
|
/// full distribution — the two use different code paths to the same value.
|
||||||
|
#[test]
|
||||||
|
fn ranking_probability_agrees_with_the_distribution() {
|
||||||
|
let perf = [g(5.0, 6.0), g(0.0, 3.0), g(-5.0, 1.0)];
|
||||||
|
let eps = 1.5;
|
||||||
|
let margins = flat(3, eps);
|
||||||
|
let dist = outcome_distribution(&perf, &margins).unwrap();
|
||||||
|
|
||||||
|
for (ranks, expected) in &dist {
|
||||||
|
let direct = ranking_probability(&perf, &margins, ranks).unwrap();
|
||||||
|
assert!(
|
||||||
|
(direct - expected).abs() < 1e-9,
|
||||||
|
"ranks {ranks:?}: direct {direct} vs distribution {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tie mass is controlled by the draw margin. Only the *all-tied* outcome
|
||||||
|
/// is monotone in it: every one of its constraints is a window that widens
|
||||||
|
/// with the margin. A partially-tied outcome like `[0, 0, 1]` is not, and
|
||||||
|
/// must not be asserted to be — widening the margin makes its tie easier
|
||||||
|
/// but its "and the last team is strictly behind by more than the margin"
|
||||||
|
/// clause harder, so it peaks and then falls.
|
||||||
|
#[test]
|
||||||
|
fn all_tied_probability_grows_with_the_draw_margin() {
|
||||||
|
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
|
||||||
|
let mut previous = 0.0;
|
||||||
|
for eps in [0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 24.0] {
|
||||||
|
let p = ranking_probability(&perf, &flat(3, eps), &[0, 0, 0]).unwrap();
|
||||||
|
assert!(p >= previous, "eps={eps}: {p} < {previous}");
|
||||||
|
if eps == 0.0 {
|
||||||
|
assert!(p < 1e-12, "a tie needs a margin, got {p}");
|
||||||
|
}
|
||||||
|
previous = p;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
previous > 0.9,
|
||||||
|
"a very wide margin ties everyone: {previous}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The converse, stated as the non-property it is: a partially-tied
|
||||||
|
/// outcome is non-monotone in the margin. Pinning this down stops a future
|
||||||
|
/// change from "fixing" it into monotonicity and quietly breaking the model.
|
||||||
|
#[test]
|
||||||
|
fn a_partially_tied_outcome_peaks_in_the_middle() {
|
||||||
|
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(-8.0, 2.0)];
|
||||||
|
let sweep: Vec<f64> = [0.5, 2.0, 4.0, 8.0, 16.0]
|
||||||
|
.iter()
|
||||||
|
.map(|&eps| ranking_probability(&perf, &flat(3, eps), &[0, 0, 1]).unwrap())
|
||||||
|
.collect();
|
||||||
|
let peak = sweep
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.fold(
|
||||||
|
(0, 0.0),
|
||||||
|
|(bi, bv), (i, &v)| if v > bv { (i, v) } else { (bi, bv) },
|
||||||
|
)
|
||||||
|
.0;
|
||||||
|
assert!(
|
||||||
|
peak > 0 && peak < sweep.len() - 1,
|
||||||
|
"expected an interior peak: {sweep:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With no draw margin a tie has probability exactly zero, and the
|
||||||
|
/// enumeration must not waste work pretending otherwise.
|
||||||
|
#[test]
|
||||||
|
fn ties_are_impossible_without_a_draw_margin() {
|
||||||
|
let perf = [g(0.0, 4.0), g(0.0, 4.0), g(0.0, 4.0)];
|
||||||
|
let dist = outcome_distribution(&perf, &flat(3, 0.0)).unwrap();
|
||||||
|
assert_eq!(dist.len(), 6, "expected only the 6 strict orders: {dist:?}");
|
||||||
|
assert!(dist.iter().all(|(r, _)| {
|
||||||
|
let mut seen = r.clone();
|
||||||
|
seen.sort_unstable();
|
||||||
|
seen.dedup();
|
||||||
|
seen.len() == r.len()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
//! Deterministic numerical integration for the prediction paths.
|
||||||
|
//!
|
||||||
|
//! Prediction asks two questions that have no closed form beyond two teams:
|
||||||
|
//! "who finishes first" and "how likely is this exact finishing order". Both
|
||||||
|
//! reduce to integrals over a single performance variable, so neither needs a
|
||||||
|
//! sampler — and that matters, because a Monte Carlo predictor would make
|
||||||
|
//! `predict_*` non-reproducible and would answer a slightly different question
|
||||||
|
//! on every call.
|
||||||
|
//!
|
||||||
|
//! Two routines live here:
|
||||||
|
//!
|
||||||
|
//! - [`integrate`], adaptive Gauss-Kronrod G7-K15, for the first-place
|
||||||
|
//! marginals. It carries its own error estimate, so it can refine where the
|
||||||
|
//! integrand actually bends instead of guessing a node count up front.
|
||||||
|
//! - [`Grid`], a uniform grid with trapezoid prefix sums, for the ranking
|
||||||
|
//! chain recursion, where each level needs the *running* integral of the
|
||||||
|
//! level below at arbitrary points rather than one definite integral.
|
||||||
|
//!
|
||||||
|
//! Fixed-node Gauss-Hermite is the obvious tool for the first of these and is
|
||||||
|
//! a trap: the integrand is a product of normal CDFs, and when one team's
|
||||||
|
//! sigma is much smaller than the integrating team's, that product turns into
|
||||||
|
//! a near-step function narrower than the node spacing. The nodes step over
|
||||||
|
//! it and the result is wrong by ~1e-2 while still looking like a probability.
|
||||||
|
//! Adaptive refinement is what makes the small-sigma case safe.
|
||||||
|
|
||||||
|
/// Kronrod 15-point abscissae, non-negative half, descending.
|
||||||
|
const XGK: [f64; 8] = [
|
||||||
|
0.991_455_371_120_813,
|
||||||
|
0.949_107_912_342_759,
|
||||||
|
0.864_864_423_359_769,
|
||||||
|
0.741_531_185_599_394,
|
||||||
|
0.586_087_235_467_691,
|
||||||
|
0.405_845_151_377_397,
|
||||||
|
0.207_784_955_007_898,
|
||||||
|
0.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Kronrod 15-point weights, matching [`XGK`].
|
||||||
|
const WGK: [f64; 8] = [
|
||||||
|
0.022_935_322_010_529,
|
||||||
|
0.063_092_092_629_979,
|
||||||
|
0.104_790_010_322_250,
|
||||||
|
0.140_653_259_715_525,
|
||||||
|
0.169_004_726_639_267,
|
||||||
|
0.190_350_578_064_785,
|
||||||
|
0.204_432_940_075_298,
|
||||||
|
0.209_482_141_084_728,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Gauss 7-point weights, applying to the odd-indexed [`XGK`] entries.
|
||||||
|
const WG: [f64; 4] = [
|
||||||
|
0.129_484_966_168_870,
|
||||||
|
0.279_705_391_489_277,
|
||||||
|
0.381_830_050_505_119,
|
||||||
|
0.417_959_183_673_469,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Panels are bisected worst-first; this bounds the work on a pathological
|
||||||
|
/// integrand rather than letting it spin.
|
||||||
|
const MAX_SUBDIVISIONS: usize = 200;
|
||||||
|
|
||||||
|
/// One G7-K15 panel over `[a, b]`: `(integral, absolute error estimate)`.
|
||||||
|
///
|
||||||
|
/// The error estimate is the gap between the embedded 7-point Gauss rule and
|
||||||
|
/// the 15-point Kronrod extension. It is the only reason this is preferable
|
||||||
|
/// to a fixed rule: it tells the caller *where* the integrand is hard.
|
||||||
|
fn gk15<F: Fn(f64) -> f64>(f: &F, a: f64, b: f64) -> (f64, f64) {
|
||||||
|
let centre = 0.5 * (a + b);
|
||||||
|
let half = 0.5 * (b - a);
|
||||||
|
|
||||||
|
let mut kronrod = 0.0;
|
||||||
|
let mut gauss = 0.0;
|
||||||
|
|
||||||
|
for i in 0..8 {
|
||||||
|
let offset = XGK[i] * half;
|
||||||
|
// XGK[7] is the centre node and must not be counted twice.
|
||||||
|
let sum = if i == 7 {
|
||||||
|
f(centre)
|
||||||
|
} else {
|
||||||
|
f(centre - offset) + f(centre + offset)
|
||||||
|
};
|
||||||
|
kronrod += WGK[i] * sum;
|
||||||
|
if i % 2 == 1 {
|
||||||
|
gauss += WG[i / 2] * sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(kronrod * half, ((kronrod - gauss) * half).abs())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adaptively integrate `f` over `[a, b]` to relative tolerance `tol`.
|
||||||
|
///
|
||||||
|
/// `seeds` are interior points where the integrand is known to bend sharply —
|
||||||
|
/// for a product of normal CDFs, each rival's transition centre. Splitting
|
||||||
|
/// there up front costs nothing and saves the adaptive loop from having to
|
||||||
|
/// discover a step by bisection.
|
||||||
|
///
|
||||||
|
/// Returns the integral. The error estimate is consumed internally rather
|
||||||
|
/// than returned: callers here integrate probability densities, where the
|
||||||
|
/// meaningful check is the sum-to-one identity over a whole outcome space,
|
||||||
|
/// not a per-integral residual.
|
||||||
|
pub(crate) fn integrate<F: Fn(f64) -> f64>(f: F, a: f64, b: f64, seeds: &[f64], tol: f64) -> f64 {
|
||||||
|
// Explicit rather than `!(b > a)`: a NaN bound must fall through to zero
|
||||||
|
// rather than being read as a valid ordering.
|
||||||
|
if a.partial_cmp(&b) != Some(std::cmp::Ordering::Less) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut edges: Vec<f64> = Vec::with_capacity(seeds.len() + 2);
|
||||||
|
edges.push(a);
|
||||||
|
edges.push(b);
|
||||||
|
for &s in seeds {
|
||||||
|
if s > a && s < b {
|
||||||
|
edges.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edges.sort_by(|p, q| p.partial_cmp(q).expect("integration bounds are finite"));
|
||||||
|
edges.dedup();
|
||||||
|
|
||||||
|
// (lo, hi, integral, error)
|
||||||
|
let mut panels: Vec<(f64, f64, f64, f64)> = edges
|
||||||
|
.windows(2)
|
||||||
|
.map(|w| {
|
||||||
|
let (v, e) = gk15(&f, w[0], w[1]);
|
||||||
|
(w[0], w[1], v, e)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for _ in 0..MAX_SUBDIVISIONS {
|
||||||
|
let total: f64 = panels.iter().map(|p| p.2).sum();
|
||||||
|
let error: f64 = panels.iter().map(|p| p.3).sum();
|
||||||
|
|
||||||
|
// Absolute floor as well as relative: these integrands are
|
||||||
|
// probabilities, so an absolute 1e-15 is already past the useful
|
||||||
|
// precision of the underlying `cdf`.
|
||||||
|
if error <= tol * total.abs().max(1e-12) || error < 1e-15 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let worst = panels
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.fold((0usize, f64::NEG_INFINITY), |(bi, be), (i, p)| {
|
||||||
|
if p.3 > be { (i, p.3) } else { (bi, be) }
|
||||||
|
})
|
||||||
|
.0;
|
||||||
|
|
||||||
|
let (lo, hi, _, _) = panels[worst];
|
||||||
|
let mid = 0.5 * (lo + hi);
|
||||||
|
// Bisection has hit the floating-point floor; refining further would
|
||||||
|
// loop without reducing the error.
|
||||||
|
if !(mid > lo && mid < hi) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (v1, e1) = gk15(&f, lo, mid);
|
||||||
|
let (v2, e2) = gk15(&f, mid, hi);
|
||||||
|
panels[worst] = (lo, mid, v1, e1);
|
||||||
|
panels.push((mid, hi, v2, e2));
|
||||||
|
}
|
||||||
|
|
||||||
|
panels.iter().map(|p| p.2).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A uniform grid carrying trapezoid prefix sums of one integrand.
|
||||||
|
///
|
||||||
|
/// The ranking recursion needs, at every level, the running integral of the
|
||||||
|
/// level below evaluated at arbitrary points — a cumulative integral, not a
|
||||||
|
/// definite one. Prefix sums give that in O(1) per query after an O(G) build,
|
||||||
|
/// which is what keeps a full ranking probability linear in the team count.
|
||||||
|
pub(crate) struct Grid {
|
||||||
|
lo: f64,
|
||||||
|
step: f64,
|
||||||
|
/// Integrand sampled at each node.
|
||||||
|
values: Vec<f64>,
|
||||||
|
/// `prefix[i]` is the integral from `lo` to node `i`.
|
||||||
|
prefix: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Grid {
|
||||||
|
/// Build directly from already-sampled values.
|
||||||
|
///
|
||||||
|
/// The ranking recursion evaluates every level on the same nodes, so the
|
||||||
|
/// per-team densities are sampled once and reused; re-evaluating `exp`
|
||||||
|
/// per level would dominate the cost.
|
||||||
|
pub(crate) fn from_values(lo: f64, step: f64, values: Vec<f64>) -> Self {
|
||||||
|
let mut prefix = vec![0.0; values.len()];
|
||||||
|
for i in 1..values.len() {
|
||||||
|
prefix[i] = prefix[i - 1] + 0.5 * step * (values[i - 1] + values[i]);
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
lo,
|
||||||
|
step,
|
||||||
|
values,
|
||||||
|
prefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integral from the grid's lower bound up to `x`.
|
||||||
|
///
|
||||||
|
/// Clamped at both ends: the caller sizes the grid to cover the whole
|
||||||
|
/// support, so a query outside it is asking for a tail that is zero (below)
|
||||||
|
/// or the whole mass (above).
|
||||||
|
pub(crate) fn integral_to(&self, x: f64) -> f64 {
|
||||||
|
let last = self.values.len() - 1;
|
||||||
|
if x <= self.lo {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if x >= self.lo + last as f64 * self.step {
|
||||||
|
return self.prefix[last];
|
||||||
|
}
|
||||||
|
|
||||||
|
let scaled = (x - self.lo) / self.step;
|
||||||
|
let i = scaled.floor() as usize;
|
||||||
|
let frac = scaled - i as f64;
|
||||||
|
|
||||||
|
// Whole cells, plus the trapezoid over the partial cell. The integrand
|
||||||
|
// is linear within a cell under the trapezoid rule, so the partial
|
||||||
|
// piece is exact with respect to that same approximation.
|
||||||
|
self.prefix[i]
|
||||||
|
+ frac
|
||||||
|
* self.step
|
||||||
|
* (self.values[i] + 0.5 * frac * (self.values[i + 1] - self.values[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integral over `[from, to]`.
|
||||||
|
pub(crate) fn integral_between(&self, from: f64, to: f64) -> f64 {
|
||||||
|
(self.integral_to(to) - self.integral_to(from)).max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total integral over the whole grid.
|
||||||
|
pub(crate) fn total(&self) -> f64 {
|
||||||
|
self.prefix[self.values.len() - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const TOL: f64 = 1e-10;
|
||||||
|
|
||||||
|
/// Sample `f` over `[lo, hi]` at `points` nodes.
|
||||||
|
fn sample<F: FnMut(f64) -> f64>(lo: f64, hi: f64, points: usize, mut f: F) -> Grid {
|
||||||
|
let step = (hi - lo) / (points - 1) as f64;
|
||||||
|
Grid::from_values(
|
||||||
|
lo,
|
||||||
|
step,
|
||||||
|
(0..points).map(|i| f(lo + i as f64 * step)).collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn integrates_a_polynomial_exactly() {
|
||||||
|
// G7-K15 is exact for polynomials well past cubic, so a single panel
|
||||||
|
// should already be at round-off.
|
||||||
|
let v = integrate(|x| 3.0 * x * x + 2.0 * x + 1.0, 0.0, 2.0, &[], TOL);
|
||||||
|
assert!((v - 14.0).abs() < 1e-12, "got {v}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn integrates_a_gaussian_density_to_one() {
|
||||||
|
let f = |x: f64| (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt();
|
||||||
|
let v = integrate(f, -10.0, 10.0, &[], TOL);
|
||||||
|
assert!((v - 1.0).abs() < 1e-12, "got {v}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_a_step_far_narrower_than_the_initial_panel() {
|
||||||
|
// The failure mode that rules out fixed-node quadrature: a transition
|
||||||
|
// 1e-4 wide inside a range of 20. A fixed rule steps over it.
|
||||||
|
let f = |x: f64| if x < 0.5 { 0.0 } else { 1.0 };
|
||||||
|
let v = integrate(f, -10.0, 10.0, &[0.5], TOL);
|
||||||
|
assert!((v - 9.5).abs() < 1e-6, "got {v}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seeds_do_not_change_the_value_of_a_smooth_integrand() {
|
||||||
|
let f = |x: f64| (-0.5 * x * x).exp();
|
||||||
|
let plain = integrate(f, -8.0, 8.0, &[], TOL);
|
||||||
|
let seeded = integrate(f, -8.0, 8.0, &[-3.0, 0.25, 5.5], TOL);
|
||||||
|
assert!((plain - seeded).abs() < 1e-12, "{plain} vs {seeded}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_or_inverted_range_integrates_to_zero() {
|
||||||
|
assert_eq!(integrate(|_| 1.0, 1.0, 1.0, &[], TOL), 0.0);
|
||||||
|
assert_eq!(integrate(|_| 1.0, 2.0, 1.0, &[], TOL), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grid_prefix_matches_a_known_cumulative_integral() {
|
||||||
|
// f(x) = x over [0, 4]; integral to x is x^2/2.
|
||||||
|
let g = sample(0.0, 4.0, 4001, |x| x);
|
||||||
|
for probe in [0.0, 0.5, 1.0, 2.5, 3.75, 4.0] {
|
||||||
|
let want = probe * probe / 2.0;
|
||||||
|
let got = g.integral_to(probe);
|
||||||
|
assert!(
|
||||||
|
(got - want).abs() < 1e-9,
|
||||||
|
"at {probe}: got {got}, want {want}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!((g.total() - 8.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grid_between_is_the_difference_of_two_prefixes() {
|
||||||
|
let g = sample(-5.0, 5.0, 8001, |x| (-0.5 * x * x).exp());
|
||||||
|
let whole = g.integral_between(-5.0, 5.0);
|
||||||
|
let split = g.integral_between(-5.0, 0.3) + g.integral_between(0.3, 5.0);
|
||||||
|
assert!((whole - split).abs() < 1e-12, "{whole} vs {split}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grid_clamps_queries_outside_its_support() {
|
||||||
|
let g = sample(0.0, 1.0, 101, |_| 1.0);
|
||||||
|
assert_eq!(g.integral_to(-3.0), 0.0);
|
||||||
|
assert!((g.integral_to(9.0) - 1.0).abs() < 1e-12);
|
||||||
|
// Reversed bounds must not produce negative probability mass.
|
||||||
|
assert_eq!(g.integral_between(0.8, 0.2), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
use std::marker::PhantomData;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
BETA, GAMMA,
|
||||||
|
drift::{ConstantDrift, Drift},
|
||||||
|
gaussian::Gaussian,
|
||||||
|
time::Time,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Static rating configuration: prior skill, performance noise `beta`, drift.
|
||||||
|
///
|
||||||
|
/// A configuration rather than a person: the per-history temporal state
|
||||||
|
/// (messages, last appearance) lives on `Competitor`.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
||||||
|
pub(crate) prior: Gaussian,
|
||||||
|
pub(crate) beta: f64,
|
||||||
|
pub(crate) drift: D,
|
||||||
|
/// Multiplier on the drift *variance* this competitor accumulates; 1.0 is
|
||||||
|
/// the neutral default. Set per competitor via `Member::with_drift_scale`.
|
||||||
|
pub(crate) drift_scale: f64,
|
||||||
|
pub(crate) _time: PhantomData<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
prior,
|
||||||
|
beta,
|
||||||
|
drift,
|
||||||
|
drift_scale: 1.0,
|
||||||
|
_time: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scale how fast this competitor drifts, relative to `drift`.
|
||||||
|
///
|
||||||
|
/// Multiplies the drift *variance*, so the scale is in the same units as
|
||||||
|
/// `gamma`. `0.0` pins the competitor still.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_drift_scale(mut self, drift_scale: f64) -> Self {
|
||||||
|
self.drift_scale = drift_scale;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configured prior skill estimate.
|
||||||
|
pub fn prior(&self) -> Gaussian {
|
||||||
|
self.prior
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performance noise: how much a single showing varies around the skill.
|
||||||
|
#[must_use]
|
||||||
|
pub fn beta(&self) -> f64 {
|
||||||
|
self.beta
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The drift model governing how skill may move between events.
|
||||||
|
#[must_use]
|
||||||
|
pub fn drift(&self) -> D {
|
||||||
|
self.drift
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This competitor's multiplier on the drift variance; 1.0 is neutral.
|
||||||
|
#[must_use]
|
||||||
|
pub fn drift_scale(&self) -> f64 {
|
||||||
|
self.drift_scale
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drift variance accumulated over `from -> to`, scaled for this competitor.
|
||||||
|
///
|
||||||
|
/// The single place the scale is applied for a `Time`-typed span. Callers
|
||||||
|
/// must go through this rather than `self.drift` directly, so a competitor's
|
||||||
|
/// scale cannot be silently skipped.
|
||||||
|
pub(crate) fn drift_variance_delta(&self, from: &T, to: &T) -> f64 {
|
||||||
|
self.drift.variance_delta(from, to) * self.drift_scale * self.drift_scale
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drift variance for a cached elapsed count, scaled for this competitor.
|
||||||
|
///
|
||||||
|
/// The counterpart of `drift_variance_delta` for the cached-elapsed paths.
|
||||||
|
pub(crate) fn drift_variance_for_elapsed(&self, elapsed: i64) -> f64 {
|
||||||
|
self.drift.variance_for_elapsed(elapsed) * self.drift_scale * self.drift_scale
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn performance(&self) -> Gaussian {
|
||||||
|
self.prior.forget(self.beta.powi(2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Rating<i64, ConstantDrift> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
prior: Gaussian::default(),
|
||||||
|
beta: BETA,
|
||||||
|
drift: ConstantDrift::new(GAMMA),
|
||||||
|
drift_scale: 1.0,
|
||||||
|
_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mod competitor_store;
|
||||||
|
mod skill_store;
|
||||||
|
|
||||||
|
pub use competitor_store::CompetitorStore;
|
||||||
|
pub(crate) use skill_store::SkillStore;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
use crate::{Index, competitor::Competitor, drift::Drift, time::Time};
|
||||||
|
|
||||||
|
/// Dense Vec-backed store for competitor state in History.
|
||||||
|
///
|
||||||
|
/// Indexed directly by Index.0, eliminating `HashMap` hashing in the
|
||||||
|
/// forward/backward sweep. Uses `Vec<Option<Competitor<T, D>>>` so slots can be
|
||||||
|
/// absent without an explicit present mask.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CompetitorStore<T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
||||||
|
competitors: Vec<Option<Competitor<T, D>>>,
|
||||||
|
n_present: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Time, D: Drift<T>> Default for CompetitorStore<T, D> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
competitors: Vec::new(),
|
||||||
|
n_present: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_capacity(&mut self, idx: usize) {
|
||||||
|
if idx >= self.competitors.len() {
|
||||||
|
self.competitors.resize_with(idx + 1, || None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, idx: Index, competitor: Competitor<T, D>) {
|
||||||
|
self.ensure_capacity(idx.0);
|
||||||
|
if self.competitors[idx.0].is_none() {
|
||||||
|
self.n_present += 1;
|
||||||
|
}
|
||||||
|
self.competitors[idx.0] = Some(competitor);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn get(&self, idx: Index) -> Option<&Competitor<T, D>> {
|
||||||
|
self.competitors.get(idx.0).and_then(|slot| slot.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mut(&mut self, idx: Index) -> Option<&mut Competitor<T, D>> {
|
||||||
|
self.competitors
|
||||||
|
.get_mut(idx.0)
|
||||||
|
.and_then(|slot| slot.as_mut())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn contains(&self, idx: Index) -> bool {
|
||||||
|
self.get(idx).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only: no code path in the crate needs a count.
|
||||||
|
#[cfg(test)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.n_present
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only: iterating every competitor is an assertion helper, not part
|
||||||
|
/// of inference, which walks slices rather than the store.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> {
|
||||||
|
self.competitors
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Competitor<T, D>> {
|
||||||
|
self.competitors.iter_mut().filter_map(|s| s.as_mut())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Time, D: Drift<T>> std::ops::Index<Index> for CompetitorStore<T, D> {
|
||||||
|
type Output = Competitor<T, D>;
|
||||||
|
fn index(&self, idx: Index) -> &Competitor<T, D> {
|
||||||
|
self.get(idx).expect("competitor not found at index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Time, D: Drift<T>> std::ops::IndexMut<Index> for CompetitorStore<T, D> {
|
||||||
|
fn index_mut(&mut self, idx: Index) -> &mut Competitor<T, D> {
|
||||||
|
self.get_mut(idx).expect("competitor not found at index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{competitor::Competitor, drift::ConstantDrift};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_then_get() {
|
||||||
|
let mut store: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||||
|
let idx = Index(7);
|
||||||
|
store.insert(idx, Competitor::default());
|
||||||
|
assert!(store.contains(idx));
|
||||||
|
assert_eq!(store.len(), 1);
|
||||||
|
assert!(store.get(idx).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iter_in_index_order() {
|
||||||
|
let mut store: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||||
|
store.insert(Index(2), Competitor::default());
|
||||||
|
store.insert(Index(0), Competitor::default());
|
||||||
|
store.insert(Index(5), Competitor::default());
|
||||||
|
let keys: Vec<Index> = store.iter().map(|(i, _)| i).collect();
|
||||||
|
assert_eq!(keys, vec![Index(0), Index(2), Index(5)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn index_operator_works() {
|
||||||
|
let mut store: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||||
|
store.insert(Index(3), Competitor::default());
|
||||||
|
let _ = &store[Index(3)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::{Index, time_slice::Skill};
|
||||||
|
|
||||||
|
/// Compact per-slice store for skill state, addressed by a slice-local slot.
|
||||||
|
///
|
||||||
|
/// `skills` holds one entry per competitor **in this slice**, so memory is
|
||||||
|
/// O(competitors in the slice). It used to be a dense `Vec<Skill>` indexed by
|
||||||
|
/// the global `Index.0`, which made a slice's footprint O(largest index it
|
||||||
|
/// touches): a single 1v1 game between competitors 19998 and 19999 reserved
|
||||||
|
/// 20,000 slots.
|
||||||
|
///
|
||||||
|
/// The dense layout existed to keep `HashMap` hashing out of the inner
|
||||||
|
/// convergence loop, and that property is preserved. `slots` is consulted only
|
||||||
|
/// while building a slice; every hot-path access goes through
|
||||||
|
/// [`SkillStore::at`] / [`SkillStore::at_mut`] with a slot resolved once at
|
||||||
|
/// ingestion and cached on the event's `Item`.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct SkillStore {
|
||||||
|
skills: Vec<Skill>,
|
||||||
|
/// Slot -> global index, parallel to `skills`, so iteration can report the
|
||||||
|
/// global index without a reverse lookup.
|
||||||
|
indices: Vec<Index>,
|
||||||
|
slots: HashMap<Index, u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a global index to this slice's slot, if the competitor is here.
|
||||||
|
///
|
||||||
|
/// This hashes. Call it at ingestion and cache the result; do not call it
|
||||||
|
/// from the convergence loop.
|
||||||
|
pub fn slot_of(&self, idx: Index) -> Option<u32> {
|
||||||
|
self.slots.get(&idx).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skill at a slot resolved earlier by [`SkillStore::slot_of`].
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `slot` is out of range, which means it came from a different
|
||||||
|
/// slice's store.
|
||||||
|
pub fn at(&self, slot: u32) -> &Skill {
|
||||||
|
&self.skills[slot as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable counterpart to [`SkillStore::at`].
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `slot` is out of range.
|
||||||
|
pub fn at_mut(&mut self, slot: u32) -> &mut Skill {
|
||||||
|
&mut self.skills[slot as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or overwrite a competitor's skill, returning its slot.
|
||||||
|
pub fn insert(&mut self, idx: Index, skill: Skill) -> u32 {
|
||||||
|
match self.slots.get(&idx) {
|
||||||
|
Some(&slot) => {
|
||||||
|
self.skills[slot as usize] = skill;
|
||||||
|
slot
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let slot = u32::try_from(self.skills.len())
|
||||||
|
.expect("a time slice cannot hold more than u32::MAX competitors");
|
||||||
|
|
||||||
|
self.skills.push(skill);
|
||||||
|
self.indices.push(idx);
|
||||||
|
self.slots.insert(idx, slot);
|
||||||
|
|
||||||
|
slot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, idx: Index) -> Option<&Skill> {
|
||||||
|
self.slot_of(idx).map(|slot| self.at(slot))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mut(&mut self, idx: Index) -> Option<&mut Skill> {
|
||||||
|
self.slot_of(idx)
|
||||||
|
.map(|slot| &mut self.skills[slot as usize])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a competitor is present in this slice. Test-only.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn contains(&self, idx: Index) -> bool {
|
||||||
|
self.slots.contains_key(&idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of competitors in this slice. Test-only.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.skills.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slots actually allocated — the quantity #17 is about, and NOT the same
|
||||||
|
/// as `len` for every possible implementation.
|
||||||
|
///
|
||||||
|
/// A store indexed by the global `Index` must report `max_index + 1` here
|
||||||
|
/// while reporting the true competitor count from `len`, which is exactly
|
||||||
|
/// how the original defect hid. Tests that mean to pin the footprint must
|
||||||
|
/// assert on this.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn allocated_slots(&self) -> usize {
|
||||||
|
self.skills.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate in slot order — the order competitors were first seen in this
|
||||||
|
/// slice. Deterministic for a given event order, which is what the
|
||||||
|
/// cross-thread determinism test relies on.
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> {
|
||||||
|
self.indices.iter().copied().zip(self.skills.iter())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Skill)> {
|
||||||
|
self.indices.iter().copied().zip(self.skills.iter_mut())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn keys(&self) -> impl Iterator<Item = Index> + '_ {
|
||||||
|
self.indices.iter().copied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_then_get() {
|
||||||
|
let mut store = SkillStore::new();
|
||||||
|
let idx = Index(3);
|
||||||
|
store.insert(idx, Skill::default());
|
||||||
|
assert!(store.contains(idx));
|
||||||
|
assert_eq!(store.len(), 1);
|
||||||
|
assert!(store.get(idx).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_returns_none() {
|
||||||
|
let store = SkillStore::new();
|
||||||
|
assert!(store.get(Index(0)).is_none());
|
||||||
|
assert!(!store.contains(Index(42)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iter_reports_global_indices() {
|
||||||
|
let mut store = SkillStore::new();
|
||||||
|
store.insert(Index(0), Skill::default());
|
||||||
|
store.insert(Index(5), Skill::default());
|
||||||
|
let keys: Vec<Index> = store.keys().collect();
|
||||||
|
assert_eq!(keys, vec![Index(0), Index(5)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_insert_does_not_double_count() {
|
||||||
|
let mut store = SkillStore::new();
|
||||||
|
store.insert(Index(2), Skill::default());
|
||||||
|
store.insert(Index(2), Skill::default());
|
||||||
|
assert_eq!(store.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The defect in #17: a slice holding two competitors must cost the same
|
||||||
|
/// whether their indices are small or large.
|
||||||
|
#[test]
|
||||||
|
fn footprint_is_independent_of_index_magnitude() {
|
||||||
|
let mut low = SkillStore::new();
|
||||||
|
low.insert(Index(0), Skill::default());
|
||||||
|
low.insert(Index(1), Skill::default());
|
||||||
|
|
||||||
|
let mut high = SkillStore::new();
|
||||||
|
high.insert(Index(19_998), Skill::default());
|
||||||
|
high.insert(Index(19_999), Skill::default());
|
||||||
|
|
||||||
|
assert_eq!(low.len(), high.len());
|
||||||
|
assert_eq!(low.skills.capacity(), high.skills.capacity());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slot_survives_reinsert() {
|
||||||
|
let mut store = SkillStore::new();
|
||||||
|
let first = store.insert(Index(7), Skill::default());
|
||||||
|
let again = store.insert(Index(7), Skill::default());
|
||||||
|
assert_eq!(first, again);
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
//! Generic time axis for `History`.
|
||||||
|
//!
|
||||||
|
//! Users pick the `Time` type based on their domain: `Untimed` when no
|
||||||
|
//! time axis is meaningful, `i64` for integer day/second timestamps.
|
||||||
|
//! Additional impls can be added behind feature flags.
|
||||||
|
|
||||||
|
/// A timestamp on the global ordering axis.
|
||||||
|
///
|
||||||
|
/// Must be `Ord + Copy` so slices can sort events, and `'static` so
|
||||||
|
/// `History` can store it by value without lifetimes.
|
||||||
|
pub trait Time: Copy + Ord + Send + Sync + 'static {
|
||||||
|
/// How much time elapsed between `self` and `later`.
|
||||||
|
///
|
||||||
|
/// Used by `Drift<T>::variance_delta` to compute skill drift. Returning
|
||||||
|
/// zero means no drift accumulates between the two points. Return value
|
||||||
|
/// must be non-negative for `self <= later`.
|
||||||
|
fn elapsed_to(&self, later: &Self) -> i64;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero-sized type representing "no time axis."
|
||||||
|
///
|
||||||
|
/// Used as the default `Time` when events are unordered. Elapsed is always 0,
|
||||||
|
/// so no drift accumulates across slices.
|
||||||
|
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct Untimed;
|
||||||
|
|
||||||
|
impl Time for Untimed {
|
||||||
|
fn elapsed_to(&self, _later: &Self) -> i64 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Time for i64 {
|
||||||
|
fn elapsed_to(&self, later: &Self) -> i64 {
|
||||||
|
later - self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn untimed_elapsed_is_zero() {
|
||||||
|
assert_eq!(Untimed.elapsed_to(&Untimed), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn i64_elapsed_is_difference() {
|
||||||
|
assert_eq!(5i64.elapsed_to(&10), 5);
|
||||||
|
assert_eq!(10i64.elapsed_to(&5), -5);
|
||||||
|
assert_eq!(0i64.elapsed_to(&0), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1264
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
|||||||
|
//! What an additive model does to uncertainty, and why "add the marginals" is
|
||||||
|
//! unsafe in one direction and merely wasteful in the other.
|
||||||
|
//!
|
||||||
|
//! Structurally this is the shape a joint player/layout model takes: every
|
||||||
|
//! observation measures a *sum* of nodes against a reference, so the data pins
|
||||||
|
//! differences and leaves the overall level to the prior. That is the classic
|
||||||
|
//! rating-scale indeterminacy, not a defect.
|
||||||
|
//!
|
||||||
|
//! The consequence for a consumer is that combining marginals is wrong in
|
||||||
|
//! opposite directions depending on the combination, which is worth pinning
|
||||||
|
//! because the unsafe direction is not the one you would guess:
|
||||||
|
//!
|
||||||
|
//! - **Differences** (`a - b`): the shared level cancels, so the exact width is
|
||||||
|
//! small — and adding marginals lands within a couple of percent of it here,
|
||||||
|
//! because the loopy underestimate offsets the ignored correlation.
|
||||||
|
//! - **Sums** (`a + b`): the shared level does *not* cancel, so the exact width
|
||||||
|
//! is large, and adding marginals is roughly five times too narrow. That is
|
||||||
|
//! overconfident, and it is the direction that publishes a claim the data
|
||||||
|
//! does not support.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn additive_structure_makes_sums_wide_and_differences_tight() {
|
||||||
|
// Structurally like ustat: every round is (player + hole) measured against
|
||||||
|
// a fixed reference. Only SUMS are pinned by the data; the split between
|
||||||
|
// player and hole is pinned only by the prior.
|
||||||
|
let players = ["p0", "p1", "p2"];
|
||||||
|
let holes = ["h0", "h1"];
|
||||||
|
|
||||||
|
let mut h: History = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-12,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut seed = 3u64;
|
||||||
|
let mut rnd = move || {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 7;
|
||||||
|
seed ^= seed << 17;
|
||||||
|
seed
|
||||||
|
};
|
||||||
|
// true skills, so we know what the data encodes
|
||||||
|
let truth_p = [2.0, 0.0, -2.0];
|
||||||
|
let truth_h = [1.0, -1.0];
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for _ in 0..60 {
|
||||||
|
let p = (rnd() as usize) % 3;
|
||||||
|
let q = (rnd() as usize) % 2;
|
||||||
|
let noise = ((rnd() % 1000) as f64 / 1000.0 - 0.5) * 2.0;
|
||||||
|
let score = truth_p[p] + truth_h[q] + noise;
|
||||||
|
events.push(Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(players[p]), Member::new(holes[q])]),
|
||||||
|
Team::with_members([Member::new("reference")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([score, 0.0]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let r = h.converge().unwrap();
|
||||||
|
assert!(r.converged, "{:?}", r.final_step);
|
||||||
|
|
||||||
|
println!("\n== marginals (what current_skill reports) ==");
|
||||||
|
for k in players.iter().chain(holes.iter()) {
|
||||||
|
let g = h.current_skill(k).unwrap();
|
||||||
|
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n== the same nodes via posterior_of (exact marginal) ==");
|
||||||
|
for k in players.iter().chain(holes.iter()) {
|
||||||
|
let g = h.joint().unwrap().posterior_of(&[(k, 1.0)]).unwrap();
|
||||||
|
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n== combinations the data actually pins ==");
|
||||||
|
for (label, terms) in [
|
||||||
|
("p0 + h0 (a round)", vec![(&"p0", 1.0), (&"h0", 1.0)]),
|
||||||
|
(
|
||||||
|
"p0 - p1 (rank two players)",
|
||||||
|
vec![(&"p0", 1.0), (&"p1", -1.0)],
|
||||||
|
),
|
||||||
|
("p0 - p2", vec![(&"p0", 1.0), (&"p2", -1.0)]),
|
||||||
|
(
|
||||||
|
"h0 - h1 (rank two holes)",
|
||||||
|
vec![(&"h0", 1.0), (&"h1", -1.0)],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let joint = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||||
|
// what a consumer gets today by adding marginals
|
||||||
|
let naive: f64 = terms
|
||||||
|
.iter()
|
||||||
|
.map(|(k, c)| c * c * h.current_skill(*k).unwrap().sigma().powi(2))
|
||||||
|
.sum::<f64>()
|
||||||
|
.sqrt();
|
||||||
|
println!(
|
||||||
|
" {label:<28} exact sigma {:>7.4} adding marginals {:>7.4} {:>5.2}x over",
|
||||||
|
joint.sigma(),
|
||||||
|
naive,
|
||||||
|
naive / joint.sigma()
|
||||||
|
);
|
||||||
|
|
||||||
|
let ratio = naive / joint.sigma();
|
||||||
|
if label.contains('+') {
|
||||||
|
assert!(
|
||||||
|
ratio < 0.5,
|
||||||
|
"{label}: adding marginals should be badly OVERconfident for a \
|
||||||
|
sum, got {ratio:.3}x"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
(0.8..1.25).contains(&ratio),
|
||||||
|
"{label}: adding marginals happens to be close for a difference, \
|
||||||
|
got {ratio:.3}x"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single node in an additive model is weakly identified: its exact
|
||||||
|
// posterior is far wider than message passing reports, because the level it
|
||||||
|
// shares with its partners is pinned only by the prior.
|
||||||
|
for k in players.iter().chain(holes.iter()) {
|
||||||
|
let bp = h.current_skill(k).unwrap().sigma();
|
||||||
|
let exact = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(k, 1.0)])
|
||||||
|
.unwrap()
|
||||||
|
.sigma();
|
||||||
|
assert!(
|
||||||
|
exact > 3.0 * bp,
|
||||||
|
"{k}: exact marginal {exact} should be much wider than the reported \
|
||||||
|
{bp} in an additive model"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
//! Tests for the new T2 public API surface: typed add_events(iter) and the
|
||||||
|
//! fluent event builder (added in Task 16).
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_events_bulk_via_iter() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(2.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 30,
|
||||||
|
epsilon: 1e-6,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let events: Vec<Event<i64, &'static str>> = vec![
|
||||||
|
Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
Event {
|
||||||
|
time: 2,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
Team::with_members([Member::new("c")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(report.converged);
|
||||||
|
assert!(h.current_skill("a").is_some());
|
||||||
|
assert!(h.current_skill("b").is_some());
|
||||||
|
assert!(h.current_skill("c").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_events_draw() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.25)
|
||||||
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("alice")]),
|
||||||
|
Team::with_members([Member::new("bob")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::draw(2),
|
||||||
|
}];
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_events_rejects_mismatched_outcome_ranks() {
|
||||||
|
use trueskill_tt::InferenceError;
|
||||||
|
let mut h: History = History::builder().build();
|
||||||
|
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::ranking([0, 1, 2]), // 3 ranks but 2 teams
|
||||||
|
}];
|
||||||
|
let err = h.add_events(events).unwrap_err();
|
||||||
|
assert!(matches!(err, InferenceError::MismatchedShape { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fluent_event_builder_basic() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
h.event(1)
|
||||||
|
.team(["alice", "bob"])
|
||||||
|
.weights([1.0, 0.7])
|
||||||
|
.team(["carol"])
|
||||||
|
.ranking([1, 0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(report.converged);
|
||||||
|
assert!(h.current_skill("alice").is_some());
|
||||||
|
assert!(h.current_skill("bob").is_some());
|
||||||
|
assert!(h.current_skill("carol").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fluent_event_builder_winner_convenience() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
h.event(1)
|
||||||
|
.team(["alice"])
|
||||||
|
.team(["bob"])
|
||||||
|
.winner(0)
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fluent_event_builder_draw() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.25)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
h.event(1)
|
||||||
|
.team(["alice"])
|
||||||
|
.team(["bob"])
|
||||||
|
.draw()
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_skill_and_learning_curve() {
|
||||||
|
use trueskill_tt::History;
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.build();
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
h.record_winner(&"a", &"b", 2).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let a = h.current_skill(&"a").unwrap();
|
||||||
|
assert!(a.mu() > 25.0);
|
||||||
|
let b = h.current_skill(&"b").unwrap();
|
||||||
|
assert!(b.mu() < 25.0);
|
||||||
|
|
||||||
|
let a_curve = h.learning_curve(&"a").unwrap();
|
||||||
|
assert_eq!(a_curve.len(), 2);
|
||||||
|
assert_eq!(a_curve[0].0, 1);
|
||||||
|
assert_eq!(a_curve[1].0, 2);
|
||||||
|
|
||||||
|
let all = h.learning_curves();
|
||||||
|
assert_eq!(all.len(), 2);
|
||||||
|
assert!(all.contains_key("a"));
|
||||||
|
assert!(all.contains_key("b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_evidence_total_vs_subset() {
|
||||||
|
use trueskill_tt::{ConstantDrift, History};
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.build();
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
h.record_winner(&"b", &"a", 2).unwrap();
|
||||||
|
let total = h.log_evidence();
|
||||||
|
let a_only = h.log_evidence_for(&[&"a"]).unwrap();
|
||||||
|
assert!(total.is_finite());
|
||||||
|
assert!(a_only.is_finite());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn predict_quality_two_teams() {
|
||||||
|
use trueskill_tt::History;
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.build();
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let q = h.quality(&[&[&"a"], &[&"b"]]).unwrap();
|
||||||
|
assert!(q > 0.0 && q <= 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn predict_outcome_two_teams_sums_to_one() {
|
||||||
|
use trueskill_tt::History;
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.build();
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let p = h.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
|
||||||
|
let wins = p.win_probabilities();
|
||||||
|
assert_eq!(wins.len(), 2);
|
||||||
|
// With p_draw == 0 there is no draw outcome, so the two win
|
||||||
|
// probabilities are the whole space.
|
||||||
|
assert!((p.total() - 1.0).abs() < 1e-9, "total = {}", p.total());
|
||||||
|
assert!((wins[0] + wins[1] - 1.0).abs() < 1e-9);
|
||||||
|
assert!(wins[0] > wins[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fluent_event_builder_scores() {
|
||||||
|
use trueskill_tt::ConstantDrift;
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
h.event(1)
|
||||||
|
.team(["alice"])
|
||||||
|
.team(["bob"])
|
||||||
|
.scores([12.0, 4.0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let a = h.current_skill(&"alice").unwrap();
|
||||||
|
let b = h.current_skill(&"bob").unwrap();
|
||||||
|
assert!(a.mu() > b.mu());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every field of `ConvergenceReport` must carry real information.
|
||||||
|
///
|
||||||
|
/// `slices_skipped` was public, hardcoded to `0`, and reported a plausible
|
||||||
|
/// value for a feature that never existed — the same shape as the inert
|
||||||
|
/// `online` flag in #19. It was removed in #33. This pins the remaining fields
|
||||||
|
/// so the next always-constant member has to survive an assertion rather than
|
||||||
|
/// just a reviewer's attention.
|
||||||
|
#[test]
|
||||||
|
fn every_convergence_report_field_is_populated() {
|
||||||
|
let mut h = History::builder().build();
|
||||||
|
|
||||||
|
for time in 1..=6i64 {
|
||||||
|
h.record_winner(&"a", &"b", time).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
report.iterations > 0,
|
||||||
|
"iterations is zero on a real converge"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(report.converged, "fixture must converge");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
report.final_step.0.is_finite() && report.final_step.1.is_finite(),
|
||||||
|
"final_step is not finite: {:?}",
|
||||||
|
report.final_step
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
report.log_evidence.is_finite() && report.log_evidence < 0.0,
|
||||||
|
"log_evidence is not a finite negative log probability: {}",
|
||||||
|
report.log_evidence
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
report.per_iteration_time.len(),
|
||||||
|
report.iterations,
|
||||||
|
"per_iteration_time must carry one duration per iteration"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//! Helpers shared across the integration suites.
|
||||||
|
//!
|
||||||
|
//! Each integration file is its own binary, so `mod common;` compiles a copy
|
||||||
|
//! per suite. Anything unused in a given suite would warn, hence the
|
||||||
|
//! `#![allow(dead_code)]`.
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use trueskill_tt::Gaussian;
|
||||||
|
|
||||||
|
/// A posterior must be finite with a strictly positive sigma.
|
||||||
|
///
|
||||||
|
/// A non-finite posterior is the failure mode this crate is most prone to —
|
||||||
|
/// EP breaking down produces NaN rather than an error — and a zero or negative
|
||||||
|
/// sigma means the precision went non-positive, which `Gaussian::sigma` reports
|
||||||
|
/// as improper rather than trapping.
|
||||||
|
pub fn assert_finite(g: Gaussian, what: &str) {
|
||||||
|
assert!(
|
||||||
|
g.mu().is_finite(),
|
||||||
|
"{what}: mu is not finite (mu={}, sigma={})",
|
||||||
|
g.mu(),
|
||||||
|
g.sigma()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
g.sigma().is_finite() && g.sigma() > 0.0,
|
||||||
|
"{what}: sigma must be finite and positive (mu={}, sigma={})",
|
||||||
|
g.mu(),
|
||||||
|
g.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every point on every learning curve must be finite.
|
||||||
|
pub fn assert_curve_finite(curve: &[(i64, Gaussian)], who: &str) {
|
||||||
|
for (time, g) in curve {
|
||||||
|
assert_finite(*g, &format!("{who} at t={time}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
//! `Member::with_prior` / `with_drift_scale` — competitor configuration.
|
||||||
|
//!
|
||||||
|
//! Both were previously consumed only on the branch that *creates* a
|
||||||
|
//! competitor, so configuration supplied for a key the history already knew was
|
||||||
|
//! dropped with no error. `with_prior` had no coverage in this directory at
|
||||||
|
//! all, which is how that survived.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
|
||||||
|
max_iter: 2_000,
|
||||||
|
epsilon: 1e-12,
|
||||||
|
alpha: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn history() -> History {
|
||||||
|
History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.convergence(CONVERGENCE)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One event, optionally configuring `a`.
|
||||||
|
fn bout(
|
||||||
|
a: &'static str,
|
||||||
|
b: &'static str,
|
||||||
|
time: i64,
|
||||||
|
prior: Option<Gaussian>,
|
||||||
|
scale: Option<f64>,
|
||||||
|
) -> Event<i64, &'static str> {
|
||||||
|
let mut member = Member::new(a);
|
||||||
|
if let Some(p) = prior {
|
||||||
|
member = member.with_prior(p);
|
||||||
|
}
|
||||||
|
if let Some(s) = scale {
|
||||||
|
member = member.with_drift_scale(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
Event {
|
||||||
|
time,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([member]),
|
||||||
|
Team::with_members([Member::new(b)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skill_of(h: &History, key: &str) -> Gaussian {
|
||||||
|
h.current_skill(&key).expect("key in history")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baseline: the mechanism works at all on a competitor's first appearance.
|
||||||
|
#[test]
|
||||||
|
fn a_prior_applies_to_a_new_competitor() {
|
||||||
|
let seeded = Gaussian::from_ms(40.0, 1.0);
|
||||||
|
|
||||||
|
let mut with = history();
|
||||||
|
with.add_events(vec![bout("a", "b", 0, Some(seeded), None)])
|
||||||
|
.unwrap();
|
||||||
|
let _ = with.converge().unwrap();
|
||||||
|
|
||||||
|
let mut without = history();
|
||||||
|
without
|
||||||
|
.add_events(vec![bout("a", "b", 0, None, None)])
|
||||||
|
.unwrap();
|
||||||
|
let _ = without.converge().unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(skill_of(&with, "a").mu() - skill_of(&without, "a").mu()).abs() > 1.0,
|
||||||
|
"a seeded prior should move the fit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The defect in #10: a prior supplied for a competitor the history already
|
||||||
|
/// knows was silently discarded, and the caller got output computed from the
|
||||||
|
/// default prior with no indication anything had been dropped.
|
||||||
|
#[test]
|
||||||
|
fn a_prior_applies_to_a_competitor_the_history_already_knows() {
|
||||||
|
let seeded = Gaussian::from_ms(40.0, 1.0);
|
||||||
|
|
||||||
|
let mut late = history();
|
||||||
|
late.add_events(vec![bout("a", "b", 0, None, None)])
|
||||||
|
.unwrap();
|
||||||
|
// "a" now exists. Configuring it here used to do nothing whatsoever.
|
||||||
|
late.add_events(vec![bout("a", "b", 1, Some(seeded), None)])
|
||||||
|
.unwrap();
|
||||||
|
let _ = late.converge().unwrap();
|
||||||
|
|
||||||
|
let mut never = history();
|
||||||
|
never
|
||||||
|
.add_events(vec![
|
||||||
|
bout("a", "b", 0, None, None),
|
||||||
|
bout("a", "b", 1, None, None),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = never.converge().unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(skill_of(&late, "a").mu() - skill_of(&never, "a").mu()).abs() > 1.0,
|
||||||
|
"a late prior must not be silently dropped: {} vs {}",
|
||||||
|
skill_of(&late, "a").mu(),
|
||||||
|
skill_of(&never, "a").mu()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration is competitor-scoped, not event-scoped, and `converge` refits
|
||||||
|
/// from competitor state — so seeding late reaches the same fit as seeding from
|
||||||
|
/// the start. This is the documented scope, asserted rather than assumed.
|
||||||
|
#[test]
|
||||||
|
fn a_prior_is_whole_history_scoped_not_per_event() {
|
||||||
|
let seeded = Gaussian::from_ms(40.0, 1.0);
|
||||||
|
|
||||||
|
let mut late = history();
|
||||||
|
late.add_events(vec![bout("a", "b", 0, None, None)])
|
||||||
|
.unwrap();
|
||||||
|
late.add_events(vec![bout("a", "b", 1, Some(seeded), None)])
|
||||||
|
.unwrap();
|
||||||
|
let _ = late.converge().unwrap();
|
||||||
|
|
||||||
|
let mut early = history();
|
||||||
|
early
|
||||||
|
.add_events(vec![
|
||||||
|
bout("a", "b", 0, Some(seeded), None),
|
||||||
|
bout("a", "b", 1, Some(seeded), None),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = early.converge().unwrap();
|
||||||
|
|
||||||
|
let (l, e) = (skill_of(&late, "a"), skill_of(&early, "a"));
|
||||||
|
assert!(
|
||||||
|
(l.mu() - e.mu()).abs() < 1e-9 && (l.sigma() - e.sigma()).abs() < 1e-9,
|
||||||
|
"late seeding should refit the whole history: {l:?} vs {e:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeating_the_same_prior_is_inert() {
|
||||||
|
let seeded = Gaussian::from_ms(40.0, 1.0);
|
||||||
|
|
||||||
|
let mut once = history();
|
||||||
|
once.add_events(vec![
|
||||||
|
bout("a", "b", 0, Some(seeded), None),
|
||||||
|
bout("a", "b", 1, None, None),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = once.converge().unwrap();
|
||||||
|
|
||||||
|
let mut every_time = history();
|
||||||
|
every_time
|
||||||
|
.add_events(vec![
|
||||||
|
bout("a", "b", 0, Some(seeded), None),
|
||||||
|
bout("a", "b", 1, Some(seeded), None),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = every_time.converge().unwrap();
|
||||||
|
|
||||||
|
let (o, e) = (skill_of(&once, "a"), skill_of(&every_time, "a"));
|
||||||
|
assert!(
|
||||||
|
(o.mu() - e.mu()).abs() < 1e-12 && (o.sigma() - e.sigma()).abs() < 1e-12,
|
||||||
|
"declaring the same prior repeatedly changed the fit: {o:?} vs {e:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events within a batch have no order, so two different values for one
|
||||||
|
/// competitor have no well-defined winner. Rejecting is what keeps the answer
|
||||||
|
/// independent of iteration order.
|
||||||
|
#[test]
|
||||||
|
fn a_batch_declaring_two_different_priors_is_rejected() {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.add_events(vec![
|
||||||
|
bout("a", "b", 0, Some(Gaussian::from_ms(40.0, 1.0)), None),
|
||||||
|
bout("a", "b", 1, Some(Gaussian::from_ms(10.0, 1.0)), None),
|
||||||
|
])
|
||||||
|
.expect_err("two different priors for one competitor in one batch");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
|
field: trueskill_tt::CompetitorField::Prior,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A member setting only `drift_scale` must not also assert the default prior,
|
||||||
|
/// or it would silently undo a prior seeded earlier. This is why the collected
|
||||||
|
/// configuration tracks each field separately rather than a merged `Rating`.
|
||||||
|
#[test]
|
||||||
|
fn setting_one_field_late_leaves_the_other_alone() {
|
||||||
|
let seeded = Gaussian::from_ms(40.0, 1.0);
|
||||||
|
|
||||||
|
let mut h = history();
|
||||||
|
h.add_events(vec![bout("a", "b", 0, Some(seeded), None)])
|
||||||
|
.unwrap();
|
||||||
|
// Only the scale this time — the prior above must survive.
|
||||||
|
h.add_events(vec![bout("a", "b", 1, None, Some(0.5))])
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let mut both_upfront = history();
|
||||||
|
both_upfront
|
||||||
|
.add_events(vec![
|
||||||
|
bout("a", "b", 0, Some(seeded), Some(0.5)),
|
||||||
|
bout("a", "b", 1, None, None),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let _ = both_upfront.converge().unwrap();
|
||||||
|
|
||||||
|
let (a, b) = (skill_of(&h, "a"), skill_of(&both_upfront, "a"));
|
||||||
|
assert!(
|
||||||
|
(a.mu() - b.mu()).abs() < 1e-9 && (a.sigma() - b.sigma()).abs() < 1e-9,
|
||||||
|
"setting drift_scale late clobbered the earlier prior: {a:?} vs {b:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
//! Stopping short of convergence is an error, not a flag on a success.
|
||||||
|
//!
|
||||||
|
//! A fit that hits `max_iter` is wrong by a little: every rating is finite,
|
||||||
|
//! the ordering looks sensible, and nothing in the numbers says they were
|
||||||
|
//! still moving. When that was `Ok` with `converged: false`, detecting it was
|
||||||
|
//! opt-in and `let _ = h.converge()` was the natural way to opt out — which is
|
||||||
|
//! how a real defect once hid in this crate's own suite.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
type H = History;
|
||||||
|
|
||||||
|
fn duel(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
|
||||||
|
Event {
|
||||||
|
time: t,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a)]),
|
||||||
|
Team::with_members([Member::new(b)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([3.0, 1.0]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capped(max_iter: usize) -> H {
|
||||||
|
History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.5))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill(h: &mut H) {
|
||||||
|
h.add_events((1..=6).map(|t| duel("a", "b", t)).collect::<Vec<_>>())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitting_the_cap_is_an_error() {
|
||||||
|
let mut h = capped(1);
|
||||||
|
fill(&mut h);
|
||||||
|
let err = h.converge().unwrap_err();
|
||||||
|
match err {
|
||||||
|
InferenceError::NotConverged {
|
||||||
|
iterations,
|
||||||
|
final_step,
|
||||||
|
epsilon,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(iterations, 1);
|
||||||
|
assert!(
|
||||||
|
final_step.0 > epsilon || final_step.1 > epsilon,
|
||||||
|
"{final_step:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected NotConverged, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message has to name what to do about it, since the fit looks fine.
|
||||||
|
#[test]
|
||||||
|
fn the_error_says_how_to_fix_it() {
|
||||||
|
let mut h = capped(1);
|
||||||
|
fill(&mut h);
|
||||||
|
let text = h.converge().unwrap_err().to_string();
|
||||||
|
assert!(text.contains("did not converge in 1 iterations"), "{text}");
|
||||||
|
assert!(text.contains("max_iter"), "{text}");
|
||||||
|
assert!(text.contains("alpha"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The escape hatch: a deliberately capped fit is still reachable.
|
||||||
|
#[test]
|
||||||
|
fn converge_partial_returns_the_short_fit() {
|
||||||
|
let mut h = capped(1);
|
||||||
|
fill(&mut h);
|
||||||
|
let report = h.converge_partial().unwrap();
|
||||||
|
assert_eq!(report.iterations, 1);
|
||||||
|
assert!(!report.converged);
|
||||||
|
assert!(h.current_skill(&"a").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both agree when the fit does converge, so the strict path costs nothing.
|
||||||
|
#[test]
|
||||||
|
fn the_two_agree_on_a_converged_fit() {
|
||||||
|
let mut strict = capped(20_000);
|
||||||
|
fill(&mut strict);
|
||||||
|
let a = strict.converge().unwrap();
|
||||||
|
|
||||||
|
let mut partial = capped(20_000);
|
||||||
|
fill(&mut partial);
|
||||||
|
let b = partial.converge_partial().unwrap();
|
||||||
|
|
||||||
|
assert!(a.converged && b.converged);
|
||||||
|
assert_eq!(a.iterations, b.iterations);
|
||||||
|
assert_eq!(a.final_step, b.final_step);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default cap must be high enough that an ordinary history clears it.
|
||||||
|
/// At the old value of 30 this history stopped short and said nothing.
|
||||||
|
#[test]
|
||||||
|
fn the_default_cap_clears_an_ordinary_history() {
|
||||||
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.05))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for t in 0..20i64 {
|
||||||
|
for j in 0..8usize {
|
||||||
|
let k = (t as usize) * 8 + j;
|
||||||
|
events.push(Event {
|
||||||
|
time: t,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(format!("p{}", k % 100))]),
|
||||||
|
Team::with_members([Member::new(format!("p{}", (k + 37) % 100))]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([3.0, 1.0]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
|
||||||
|
let report = h
|
||||||
|
.converge()
|
||||||
|
.expect("an ordinary history must converge by default");
|
||||||
|
assert!(
|
||||||
|
report.iterations > 30,
|
||||||
|
"needed {} sweeps",
|
||||||
|
report.iterations
|
||||||
|
);
|
||||||
|
assert!(report.iterations < trueskill_tt::ITERATIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty history converges trivially rather than erroring.
|
||||||
|
#[test]
|
||||||
|
fn an_empty_history_converges() {
|
||||||
|
let mut h = capped(1);
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(report.converged);
|
||||||
|
assert_eq!(report.iterations, 0);
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
//! Degenerate, boundary, and error-path coverage.
|
||||||
|
//!
|
||||||
|
//! These run in both debug and release: the defects they pin were all
|
||||||
|
//! guarded only by `debug_assert!`, so a debug-only suite never saw them.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::assert_finite;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
|
||||||
|
Outcome, Rating,
|
||||||
|
};
|
||||||
|
|
||||||
|
type R = Rating<i64, ConstantDrift>;
|
||||||
|
|
||||||
|
fn rating() -> R {
|
||||||
|
R::new(
|
||||||
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
|
25.0 / 6.0,
|
||||||
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_draw_without_draw_probability_is_rejected() {
|
||||||
|
let mut h = History::default();
|
||||||
|
let err = h.record_draw(&"a", &"b", 1).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::TieWithoutDrawProbability { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builder_draw_without_draw_probability_is_rejected() {
|
||||||
|
let mut h = History::default();
|
||||||
|
let err = h
|
||||||
|
.event(1)
|
||||||
|
.team(["a"])
|
||||||
|
.team(["b"])
|
||||||
|
.draw()
|
||||||
|
.commit()
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::TieWithoutDrawProbability { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_with_positive_draw_probability_is_finite() {
|
||||||
|
let mut h = History::builder().p_draw(0.25).build();
|
||||||
|
h.record_draw(&"a", &"b", 1).unwrap();
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
|
||||||
|
assert_finite(h.current_skill("a").unwrap(), "drawn competitor skill");
|
||||||
|
assert_finite(h.current_skill("b").unwrap(), "drawn competitor skill");
|
||||||
|
assert!(report.log_evidence.is_finite());
|
||||||
|
assert!(report.converged);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_ranked_rejects_tie_without_draw_probability() {
|
||||||
|
let a = [rating()];
|
||||||
|
let b = [rating()];
|
||||||
|
let teams: Vec<&[R]> = vec![&a, &b];
|
||||||
|
let err = Game::ranked(&teams, Outcome::draw(2), &GameOptions::default()).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::TieWithoutDrawProbability { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Outcome::winner(w, n)` ties every loser, so any n >= 3 free-for-all hits
|
||||||
|
/// the tie path even though the caller never asked for a draw.
|
||||||
|
#[test]
|
||||||
|
fn winner_of_three_or_more_requires_draw_probability() {
|
||||||
|
let a = [rating()];
|
||||||
|
let b = [rating()];
|
||||||
|
let c = [rating()];
|
||||||
|
let teams: Vec<&[R]> = vec![&a, &b, &c];
|
||||||
|
|
||||||
|
let err = Game::ranked(&teams, Outcome::winner(0, 3), &GameOptions::default()).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::TieWithoutDrawProbability { .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
let opts = GameOptions {
|
||||||
|
p_draw: 0.1,
|
||||||
|
..GameOptions::default()
|
||||||
|
};
|
||||||
|
let game = Game::ranked(&teams, Outcome::winner(0, 3), &opts).unwrap();
|
||||||
|
for team in game.posteriors() {
|
||||||
|
for skill in team {
|
||||||
|
assert_finite(skill, "3-team winner posterior");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_ranking_without_ties_needs_no_draw_probability() {
|
||||||
|
let a = [rating()];
|
||||||
|
let b = [rating()];
|
||||||
|
let c = [rating()];
|
||||||
|
let teams: Vec<&[R]> = vec![&a, &b, &c];
|
||||||
|
let game = Game::ranked(&teams, Outcome::ranking([0, 1, 2]), &GameOptions::default()).unwrap();
|
||||||
|
|
||||||
|
for team in game.posteriors() {
|
||||||
|
for skill in team {
|
||||||
|
assert_finite(skill, "strict ranking posterior");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_history_converges_trivially() {
|
||||||
|
let mut h = History::default();
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert_eq!(report.iterations, 0);
|
||||||
|
assert!(report.converged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #27's exact reproduction: a non-default key type reaching `converge`
|
||||||
|
/// with no events at all. The underflow it reported trapped in debug and
|
||||||
|
/// indexed out of bounds in release, so this must run in both profiles.
|
||||||
|
#[test]
|
||||||
|
fn converge_on_an_empty_history_with_owned_keys() {
|
||||||
|
let mut history: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.score_sigma(5.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let report = history.converge().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(report.iterations, 0);
|
||||||
|
assert!(report.converged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A weights/team length mismatch used to be a `debug_assert!`, so release
|
||||||
|
/// builds ingested the event with the weights silently unapplied. This file's
|
||||||
|
/// CI job runs in release too, which is the point of pinning it here.
|
||||||
|
#[test]
|
||||||
|
fn event_builder_rejects_a_weights_length_mismatch() {
|
||||||
|
let mut h = History::default();
|
||||||
|
|
||||||
|
let err = h
|
||||||
|
.event(1)
|
||||||
|
.team(["a"])
|
||||||
|
.weights([1.0, 2.0])
|
||||||
|
.team(["b"])
|
||||||
|
.winner(0)
|
||||||
|
.commit()
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::MismatchedShape {
|
||||||
|
shape: trueskill_tt::Shape::Weights,
|
||||||
|
expected: 1,
|
||||||
|
got: 2,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"expected a weights MismatchedShape, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mismatch must not be applied even partially — a half-weighted team
|
||||||
|
/// reaching the history would be worse than the error.
|
||||||
|
#[test]
|
||||||
|
fn event_builder_weights_mismatch_leaves_the_history_untouched() {
|
||||||
|
let mut h = History::default();
|
||||||
|
|
||||||
|
// Two teams, so ingestion would otherwise succeed. A one-team event is
|
||||||
|
// rejected as `NotEnoughTeams` before the weights are ever examined, so
|
||||||
|
// building this with one team would pass vacuously.
|
||||||
|
let _ = h
|
||||||
|
.event(1)
|
||||||
|
.team(["a"])
|
||||||
|
.weights([1.0, 2.0])
|
||||||
|
.team(["b"])
|
||||||
|
.winner(0)
|
||||||
|
.commit();
|
||||||
|
|
||||||
|
// The rejected event never reached the history, so "a" was never interned.
|
||||||
|
// `None` is the honest answer, and it is distinguishable from a competitor
|
||||||
|
// that IS known but has no appearances yet.
|
||||||
|
assert!(h.learning_curve("a").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_event_stream_then_converge() {
|
||||||
|
let mut h = History::default();
|
||||||
|
h.add_events(std::iter::empty()).unwrap();
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert_eq!(report.iterations, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_history_queries_do_not_panic() {
|
||||||
|
let h = History::default();
|
||||||
|
assert!(h.learning_curves().is_empty());
|
||||||
|
assert!(h.learning_curve("nobody").is_none());
|
||||||
|
assert!(h.current_skill("nobody").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_event_history_converges() {
|
||||||
|
let mut h = History::default();
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(report.converged);
|
||||||
|
assert_finite(h.current_skill("a").unwrap(), "single-event skill");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scored_event_rejects_non_positive_sigma() {
|
||||||
|
let mut h = History::builder().score_sigma(2.0).build();
|
||||||
|
let err = h
|
||||||
|
.event(1)
|
||||||
|
.team(["a"])
|
||||||
|
.team(["b"])
|
||||||
|
.scores_with_noise([3.0, 1.0], f64::NAN)
|
||||||
|
.commit()
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::ScoreSigma,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convergence_reports_are_finite_across_many_teams() {
|
||||||
|
let opts = GameOptions {
|
||||||
|
p_draw: 0.1,
|
||||||
|
convergence: ConvergenceOptions::default(),
|
||||||
|
..GameOptions::default()
|
||||||
|
};
|
||||||
|
let holders: Vec<[R; 1]> = (0..12).map(|_| [rating()]).collect();
|
||||||
|
let teams: Vec<&[R]> = holders.iter().map(|t| t.as_slice()).collect();
|
||||||
|
let game = Game::ranked(&teams, Outcome::ranking(0..12), &opts).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
game.log_evidence().is_finite(),
|
||||||
|
"12-team log-evidence must be finite, got {}",
|
||||||
|
game.log_evidence()
|
||||||
|
);
|
||||||
|
for team in game.posteriors() {
|
||||||
|
for skill in team {
|
||||||
|
assert_finite(skill, "12-team posterior");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A long diff chain underflows a linear evidence product: each link
|
||||||
|
/// contributes a probability in (0, 1], so ~1000 links flush the product to
|
||||||
|
/// exactly 0.0 and `ln(0.0)` is `-inf`. Accumulating in log space keeps it
|
||||||
|
/// finite.
|
||||||
|
#[test]
|
||||||
|
fn log_evidence_survives_a_long_diff_chain() {
|
||||||
|
let holders: Vec<[R; 1]> = (0..1200).map(|_| [rating()]).collect();
|
||||||
|
let teams: Vec<&[R]> = holders.iter().map(|t| t.as_slice()).collect();
|
||||||
|
let game = Game::ranked(
|
||||||
|
&teams,
|
||||||
|
Outcome::ranking(0..holders.len() as u32),
|
||||||
|
&GameOptions::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let log_evidence = game.log_evidence();
|
||||||
|
assert!(
|
||||||
|
log_evidence.is_finite(),
|
||||||
|
"1200-team log-evidence must be finite, got {log_evidence}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
log_evidence < 0.0,
|
||||||
|
"log-evidence of a probability must be negative, got {log_evidence}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A near-certain outcome rounds the losing tail to exactly zero in the
|
||||||
|
/// `erfc` approximation; the evidence floor keeps `ln` finite.
|
||||||
|
#[test]
|
||||||
|
fn log_evidence_finite_for_near_certain_outcome() {
|
||||||
|
let overwhelming = R::new(
|
||||||
|
Gaussian::from_ms(5_000.0, 0.5),
|
||||||
|
1.0,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
);
|
||||||
|
let hopeless = R::new(
|
||||||
|
Gaussian::from_ms(-5_000.0, 0.5),
|
||||||
|
1.0,
|
||||||
|
ConstantDrift::new(0.0),
|
||||||
|
);
|
||||||
|
let a = [overwhelming];
|
||||||
|
let b = [hopeless];
|
||||||
|
let teams: Vec<&[R]> = vec![&a, &b];
|
||||||
|
|
||||||
|
let game = Game::ranked(&teams, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
|
||||||
|
assert!(
|
||||||
|
game.log_evidence().is_finite(),
|
||||||
|
"got {}",
|
||||||
|
game.log_evidence()
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the reverse — a colossal upset — must also stay finite.
|
||||||
|
let upset = Game::ranked(&teams, Outcome::winner(1, 2), &GameOptions::default()).unwrap();
|
||||||
|
assert!(
|
||||||
|
upset.log_evidence().is_finite(),
|
||||||
|
"upset log-evidence must be finite, got {}",
|
||||||
|
upset.log_evidence()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_history_has_no_filtered_estimates() {
|
||||||
|
let history: History = History::builder().build();
|
||||||
|
|
||||||
|
assert_eq!(history.filtered_log_evidence(), 0.0);
|
||||||
|
|
||||||
|
assert!(history.filtered_learning_curves().is_empty());
|
||||||
|
|
||||||
|
assert!(history.filtered_learning_curve("nobody").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Boundary inputs (#26) ----------------------------------------------
|
||||||
|
|
||||||
|
fn tight() -> ConvergenceOptions {
|
||||||
|
ConvergenceOptions {
|
||||||
|
max_iter: 2_000,
|
||||||
|
epsilon: 1e-12,
|
||||||
|
..ConvergenceOptions::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_curve_finite(h: &History, keys: &[&str], what: &str) {
|
||||||
|
for key in keys {
|
||||||
|
for (time, g) in h.learning_curve(*key).unwrap() {
|
||||||
|
assert!(
|
||||||
|
g.mu().is_finite() && g.sigma().is_finite(),
|
||||||
|
"{what}: non-finite posterior for {key} at t={time} (mu={} sigma={})",
|
||||||
|
g.mu(),
|
||||||
|
g.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A zero weight reaches `(m - performance.exclude(..)) * (1.0 / w)`, i.e. a
|
||||||
|
/// division by zero. The commit is accepted today, so this pins that the
|
||||||
|
/// resulting posterior is still finite rather than quietly NaN.
|
||||||
|
#[test]
|
||||||
|
fn zero_weight_does_not_produce_a_non_finite_posterior() {
|
||||||
|
let mut h = History::builder().build();
|
||||||
|
|
||||||
|
h.event(1)
|
||||||
|
.team(["a"])
|
||||||
|
.weights([0.0])
|
||||||
|
.team(["b"])
|
||||||
|
.winner(0)
|
||||||
|
.commit()
|
||||||
|
.expect("a zero weight is accepted today; update this test if that changes");
|
||||||
|
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
assert_curve_finite(&h, &["a", "b"], "zero weight");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_weight_does_not_produce_a_non_finite_posterior() {
|
||||||
|
let mut h = History::builder().build();
|
||||||
|
|
||||||
|
h.event(1)
|
||||||
|
.team(["a"])
|
||||||
|
.weights([-1.0])
|
||||||
|
.team(["b"])
|
||||||
|
.winner(0)
|
||||||
|
.commit()
|
||||||
|
.expect("a negative weight is accepted today; update this test if that changes");
|
||||||
|
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
assert_curve_finite(&h, &["a", "b"], "negative weight");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events supplied newest-first must land in the same slices as oldest-first:
|
||||||
|
/// ingestion sorts by time rather than trusting arrival order.
|
||||||
|
#[test]
|
||||||
|
fn out_of_order_timestamps_converge_to_the_same_answer() {
|
||||||
|
fn build(descending: bool) -> History {
|
||||||
|
let mut h = History::builder().convergence(tight()).build();
|
||||||
|
|
||||||
|
let mut times: Vec<i64> = (1..=6).collect();
|
||||||
|
if descending {
|
||||||
|
times.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
for time in times {
|
||||||
|
h.record_winner(&"a", &"b", time).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
let ascending = build(false);
|
||||||
|
let descending = build(true);
|
||||||
|
|
||||||
|
let one = ascending.current_skill("a").unwrap();
|
||||||
|
let other = descending.current_skill("a").unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(one.mu() - other.mu()).abs() < 1e-8 && (one.sigma() - other.sigma()).abs() < 1e-8,
|
||||||
|
"arrival order changed the answer: ascending mu={} sigma={}, descending mu={} sigma={}",
|
||||||
|
one.mu(),
|
||||||
|
one.sigma(),
|
||||||
|
other.mu(),
|
||||||
|
other.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extreme_beta_and_sigma_stay_finite() {
|
||||||
|
for (beta, sigma) in [(1e-6, 1e-6), (1e6, 1e6), (1e-6, 1e6), (1e6, 1e-6)] {
|
||||||
|
let mut h = History::builder().beta(beta).sigma(sigma).build();
|
||||||
|
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
h.record_winner(&"a", &"b", 2).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
assert_curve_finite(&h, &["a", "b"], &format!("beta={beta} sigma={sigma}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! Determinism across `RAYON_NUM_THREADS`, on a workload that actually reaches
|
||||||
|
//! 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")]
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Comfortably above the crate's internal `RAYON_THRESHOLD` of 64.
|
||||||
|
const EVENTS_PER_SLICE: usize = 96;
|
||||||
|
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)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-9,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut events: Vec<Event<i64, String>> = Vec::new();
|
||||||
|
for slice in 0..SLICES {
|
||||||
|
for e in 0..EVENTS_PER_SLICE {
|
||||||
|
// Disjoint within the slice: event `e` owns competitors 2e and
|
||||||
|
// 2e+1. Rotating by the slice index makes the pairings differ
|
||||||
|
// between slices, so competitors accumulate a real history.
|
||||||
|
let a = (2 * e + slice as usize) % COMPETITORS;
|
||||||
|
let b = (2 * e + 1 + slice as usize * 3) % COMPETITORS;
|
||||||
|
if a == b {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
events.push(Event {
|
||||||
|
time: slice + 1,
|
||||||
|
teams: smallvec![
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
|
||||||
|
let report = h.converge().expect("fixture must converge");
|
||||||
|
|
||||||
|
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]
|
||||||
|
fn posteriors_identical_across_thread_counts() {
|
||||||
|
let sizes = [1usize, 2, 4, 8];
|
||||||
|
let mut results: Vec<Fingerprint> = Vec::new();
|
||||||
|
|
||||||
|
for &n in &sizes {
|
||||||
|
let pool = rayon::ThreadPoolBuilder::new()
|
||||||
|
.num_threads(n)
|
||||||
|
.build()
|
||||||
|
.expect("rayon pool build");
|
||||||
|
results.push(pool.install(build_and_converge));
|
||||||
|
}
|
||||||
|
|
||||||
|
let reference = &results[0];
|
||||||
|
|
||||||
|
// 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!(
|
||||||
|
got.iterations, reference.iterations,
|
||||||
|
"iterations differ at {n} threads"
|
||||||
|
);
|
||||||
|
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!(
|
||||||
|
curve.len(),
|
||||||
|
ref_curve.len(),
|
||||||
|
"curve length differs for {key} at {n} threads"
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
//! Per-competitor drift scaling via `Member::with_drift_scale`.
|
||||||
|
//!
|
||||||
|
//! The scale multiplies the *variance* the history's `Drift` contributes for
|
||||||
|
//! that competitor, so `scale` is in the same units as `gamma`:
|
||||||
|
//! `ConstantDrift::new(g)` at `scale = s` behaves as `ConstantDrift::new(g * s)` would.
|
||||||
|
//! `scale = 0.0` pins a competitor still — an anchor, a rating floor, a course
|
||||||
|
//! difficulty — while everyone around them keeps drifting.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
|
||||||
|
Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Fit = History;
|
||||||
|
|
||||||
|
const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
|
||||||
|
max_iter: 64,
|
||||||
|
epsilon: 1e-9,
|
||||||
|
alpha: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Two events separated by a long gap, so drift has room to matter.
|
||||||
|
fn distant_pair(anchor_scale: Option<f64>) -> Vec<Event<i64, &'static str>> {
|
||||||
|
let anchor = |s: Option<f64>| match s {
|
||||||
|
Some(scale) => Member::new("anchor").with_drift_scale(scale),
|
||||||
|
None => Member::new("anchor"),
|
||||||
|
};
|
||||||
|
|
||||||
|
vec![
|
||||||
|
Event {
|
||||||
|
time: 0,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([anchor(anchor_scale)]),
|
||||||
|
Team::with_members([Member::new("player")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
Event {
|
||||||
|
time: 1000,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([anchor(anchor_scale)]),
|
||||||
|
Team::with_members([Member::new("player")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(1, 2),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fit(events: Vec<Event<i64, &'static str>>, gamma: f64) -> Fit {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.drift(ConstantDrift::new(gamma))
|
||||||
|
.convergence(CONVERGENCE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
fn curve(h: &Fit, key: &str) -> Vec<(i64, Gaussian)> {
|
||||||
|
let mut c = h.learning_curves().remove(key).expect("key in curves");
|
||||||
|
c.sort_by_key(|(t, _)| *t);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A competitor at `scale = 0.0` is one latent skill observed twice, so the
|
||||||
|
/// posterior is the same distribution at both times — and strictly tighter
|
||||||
|
/// than the same competitor left to drift.
|
||||||
|
#[test]
|
||||||
|
fn zero_scale_pins_a_competitor_still() {
|
||||||
|
let pinned = fit(distant_pair(Some(0.0)), 25.0 / 300.0);
|
||||||
|
let drifting = fit(distant_pair(None), 25.0 / 300.0);
|
||||||
|
|
||||||
|
let pinned_curve = curve(&pinned, "anchor");
|
||||||
|
assert_eq!(pinned_curve.len(), 2);
|
||||||
|
|
||||||
|
let (t0, first) = pinned_curve[0];
|
||||||
|
let (t1, second) = pinned_curve[1];
|
||||||
|
assert_eq!((t0, t1), (0, 1000));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(first.sigma() - second.sigma()).abs() < 1e-9,
|
||||||
|
"a pinned competitor's uncertainty must not move between t=0 and t=1000: \
|
||||||
|
{} vs {}",
|
||||||
|
first.sigma(),
|
||||||
|
second.sigma()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(first.mu() - second.mu()).abs() < 1e-9,
|
||||||
|
"a pinned competitor's mean must not move: {} vs {}",
|
||||||
|
first.mu(),
|
||||||
|
second.mu()
|
||||||
|
);
|
||||||
|
|
||||||
|
let drifting_curve = curve(&drifting, "anchor");
|
||||||
|
assert!(
|
||||||
|
drifting_curve[0].1.sigma() > first.sigma() + 1e-6,
|
||||||
|
"drift must leave the anchor less certain than pinning does: {} vs {}",
|
||||||
|
drifting_curve[0].1.sigma(),
|
||||||
|
first.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scale is composable with `gamma`: scaling every competitor by `s` is
|
||||||
|
/// exactly the same fit as scaling the history's drift by `s`.
|
||||||
|
#[test]
|
||||||
|
fn scale_is_equivalent_to_scaling_gamma() {
|
||||||
|
let scaled: Vec<Event<i64, &'static str>> = vec![
|
||||||
|
Event {
|
||||||
|
time: 0,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a").with_drift_scale(0.5)]),
|
||||||
|
Team::with_members([Member::new("b").with_drift_scale(0.5)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
Event {
|
||||||
|
time: 400,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("b").with_drift_scale(0.5)]),
|
||||||
|
Team::with_members([Member::new("a").with_drift_scale(0.5)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let plain: Vec<Event<i64, &'static str>> = vec![
|
||||||
|
Event {
|
||||||
|
time: 0,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
Event {
|
||||||
|
time: 400,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let by_scale = fit(scaled, 0.3);
|
||||||
|
let by_gamma = fit(plain, 0.15);
|
||||||
|
|
||||||
|
for key in ["a", "b"] {
|
||||||
|
let lhs = curve(&by_scale, key);
|
||||||
|
let rhs = curve(&by_gamma, key);
|
||||||
|
assert_eq!(lhs.len(), rhs.len());
|
||||||
|
|
||||||
|
for ((t_l, g_l), (t_r, g_r)) in lhs.iter().zip(rhs.iter()) {
|
||||||
|
assert_eq!(t_l, t_r);
|
||||||
|
assert!(
|
||||||
|
(g_l.mu() - g_r.mu()).abs() < 1e-9 && (g_l.sigma() - g_r.sigma()).abs() < 1e-9,
|
||||||
|
"ConstantDrift::new(0.3) at scale 0.5 must equal ConstantDrift::new(0.15) for {key} at \
|
||||||
|
t={t_l}: ({}, {}) vs ({}, {})",
|
||||||
|
g_l.mu(),
|
||||||
|
g_l.sigma(),
|
||||||
|
g_r.mu(),
|
||||||
|
g_r.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `None` means 1.0: an explicit unit scale changes nothing.
|
||||||
|
#[test]
|
||||||
|
fn unset_scale_matches_an_explicit_unit_scale() {
|
||||||
|
let implicit = fit(distant_pair(None), 25.0 / 300.0);
|
||||||
|
let explicit = fit(distant_pair(Some(1.0)), 25.0 / 300.0);
|
||||||
|
|
||||||
|
for key in ["anchor", "player"] {
|
||||||
|
let lhs = curve(&implicit, key);
|
||||||
|
let rhs = curve(&explicit, key);
|
||||||
|
assert_eq!(lhs.len(), rhs.len());
|
||||||
|
|
||||||
|
for ((t_l, g_l), (t_r, g_r)) in lhs.iter().zip(rhs.iter()) {
|
||||||
|
assert_eq!(t_l, t_r);
|
||||||
|
assert_eq!(
|
||||||
|
(g_l.mu(), g_l.sigma()),
|
||||||
|
(g_r.mu(), g_r.sigma()),
|
||||||
|
"an explicit scale of 1.0 must be bit-identical to leaving it unset, \
|
||||||
|
for {key} at t={t_l}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The use case from the issue: a static difficulty alongside drifting players,
|
||||||
|
/// in one graph. The anchor must hold still without absorbing drift through its
|
||||||
|
/// neighbours, and everything must stay finite.
|
||||||
|
#[test]
|
||||||
|
fn mixed_static_and_drifting_graph_converges() {
|
||||||
|
let mut events: Vec<Event<i64, &'static str>> = Vec::new();
|
||||||
|
let players = ["p0", "p1", "p2"];
|
||||||
|
|
||||||
|
for (i, p) in players.iter().cycle().take(9).enumerate() {
|
||||||
|
events.push(Event {
|
||||||
|
time: (i as i64) * 100,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(*p)]),
|
||||||
|
Team::with_members([Member::new("layout").with_drift_scale(0.0)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner((i % 2) as u32, 2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
|
.convergence(CONVERGENCE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(report.converged, "mixed graph must converge: {report:?}");
|
||||||
|
|
||||||
|
let curves = h.learning_curves();
|
||||||
|
for (key, points) in &curves {
|
||||||
|
for (t, g) in points {
|
||||||
|
assert!(
|
||||||
|
g.mu().is_finite() && g.sigma().is_finite() && g.sigma() > 0.0,
|
||||||
|
"{key} at t={t} is not a usable posterior: mu={}, sigma={}",
|
||||||
|
g.mu(),
|
||||||
|
g.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout = curve(&h, "layout");
|
||||||
|
assert_eq!(layout.len(), 9);
|
||||||
|
let (_, first) = layout[0];
|
||||||
|
for (t, g) in &layout {
|
||||||
|
assert!(
|
||||||
|
(g.sigma() - first.sigma()).abs() < 1e-9,
|
||||||
|
"a static layout must not accumulate uncertainty; t={t} has sigma {} vs {}",
|
||||||
|
g.sigma(),
|
||||||
|
first.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let p0 = curve(&h, "p0");
|
||||||
|
assert!(
|
||||||
|
p0.last().unwrap().1.sigma() > 0.0,
|
||||||
|
"a drifting player should still have a proper posterior"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject(scale: f64) -> InferenceError {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||||
|
time: 0,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a").with_drift_scale(scale)]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}];
|
||||||
|
|
||||||
|
h.add_events(events)
|
||||||
|
.expect_err("an out-of-range drift_scale must be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_scale_is_rejected() {
|
||||||
|
assert!(matches!(
|
||||||
|
reject(-1.0),
|
||||||
|
InferenceError::InvalidParameter { parameter: trueskill_tt::Parameter::DriftScale, value, .. }
|
||||||
|
if value == -1.0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_finite_scale_is_rejected() {
|
||||||
|
for scale in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
reject(scale),
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::DriftScale,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"a drift_scale of {scale} must be rejected as an invalid parameter"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scale must reach the filtering pass too, not just `converge()`.
|
||||||
|
/// `filtered_learning_curves` runs its own drift application, so a pinned
|
||||||
|
/// competitor has to stay pinned there as well.
|
||||||
|
#[test]
|
||||||
|
fn zero_scale_pins_a_competitor_in_the_filtered_pass() {
|
||||||
|
let pinned = fit(distant_pair(Some(0.0)), 25.0 / 300.0);
|
||||||
|
let drifting = fit(distant_pair(None), 25.0 / 300.0);
|
||||||
|
|
||||||
|
let filtered = |h: &Fit| -> Vec<(i64, Gaussian)> {
|
||||||
|
let mut c = h
|
||||||
|
.filtered_learning_curves()
|
||||||
|
.remove("anchor")
|
||||||
|
.expect("anchor in filtered curves");
|
||||||
|
c.sort_by_key(|(t, _)| *t);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
|
||||||
|
let pinned_curve = filtered(&pinned);
|
||||||
|
let drifting_curve = filtered(&drifting);
|
||||||
|
assert_eq!(pinned_curve.len(), 2);
|
||||||
|
assert_eq!(drifting_curve.len(), 2);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
pinned_curve[1].1.sigma() < pinned_curve[0].1.sigma(),
|
||||||
|
"a pinned competitor's filtered uncertainty must shrink with a second \
|
||||||
|
observation, not be re-inflated by drift: {} then {}",
|
||||||
|
pinned_curve[0].1.sigma(),
|
||||||
|
pinned_curve[1].1.sigma()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
pinned_curve[1].1.sigma() < drifting_curve[1].1.sigma() - 1e-6,
|
||||||
|
"pinning must leave the filtered estimate tighter than drifting does: \
|
||||||
|
{} vs {}",
|
||||||
|
pinned_curve[1].1.sigma(),
|
||||||
|
drifting_curve[1].1.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `drift_scale` is competitor configuration, and configuration supplied for a
|
||||||
|
/// competitor the history already knows is now *applied* rather than dropped.
|
||||||
|
///
|
||||||
|
/// This test previously asserted the opposite. It was written as a deliberate
|
||||||
|
/// change-detector — "moving the capture would be a visible break, not a silent
|
||||||
|
/// one" — and that is exactly what happened: the capture moved, and the
|
||||||
|
/// assertion inverted rather than being deleted.
|
||||||
|
///
|
||||||
|
/// Because configuration lives on the competitor and `converge` refits from
|
||||||
|
/// competitor state, a late pin applies to the *whole* history, not just to
|
||||||
|
/// events after it. So a scale set on the second batch must reach the same fit
|
||||||
|
/// as one set from the very first event.
|
||||||
|
#[test]
|
||||||
|
fn drift_scale_applies_when_set_after_first_appearance() {
|
||||||
|
let mut late = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
||||||
|
.convergence(CONVERGENCE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// First batch creates "anchor" with the default scale.
|
||||||
|
late.add_events(vec![Event {
|
||||||
|
time: 0,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("anchor")]),
|
||||||
|
Team::with_members([Member::new("player")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Second batch asks for a pin. No longer too late.
|
||||||
|
late.add_events(vec![Event {
|
||||||
|
time: 1000,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("anchor").with_drift_scale(0.0)]),
|
||||||
|
Team::with_members([Member::new("player")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(1, 2),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
let _ = late.converge().unwrap();
|
||||||
|
|
||||||
|
let applied = curve(&late, "anchor");
|
||||||
|
let pinned_from_the_start = curve(&fit(distant_pair(Some(0.0)), 25.0 / 300.0), "anchor");
|
||||||
|
let never_pinned = curve(&fit(distant_pair(None), 25.0 / 300.0), "anchor");
|
||||||
|
|
||||||
|
for ((t_l, g_l), (t_r, g_r)) in applied.iter().zip(pinned_from_the_start.iter()) {
|
||||||
|
assert_eq!(t_l, t_r);
|
||||||
|
assert!(
|
||||||
|
(g_l.sigma() - g_r.sigma()).abs() < 1e-9,
|
||||||
|
"a late pin should refit the whole history: t={t_l}, {} vs {}",
|
||||||
|
g_l.sigma(),
|
||||||
|
g_r.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it must actually have done something.
|
||||||
|
assert!(
|
||||||
|
applied
|
||||||
|
.iter()
|
||||||
|
.zip(never_pinned.iter())
|
||||||
|
.any(|((_, a), (_, b))| (a.sigma() - b.sigma()).abs() > 1e-9),
|
||||||
|
"the pin had no effect at all — the silent drop is back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-declaring the same configuration must be inert. This is the shape a
|
||||||
|
/// caller gets when the configuration is a property of the domain — "layouts
|
||||||
|
/// are static" — so every ingestion path repeats it on every event.
|
||||||
|
///
|
||||||
|
/// Both histories see exactly the same events; only how many times the scale
|
||||||
|
/// is declared differs.
|
||||||
|
#[test]
|
||||||
|
fn repeating_the_same_configuration_changes_nothing() {
|
||||||
|
let events = |declare_every_time: bool| {
|
||||||
|
let anchor = |first: bool| {
|
||||||
|
if first || declare_every_time {
|
||||||
|
Member::new("anchor").with_drift_scale(0.0)
|
||||||
|
} else {
|
||||||
|
Member::new("anchor")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
Event {
|
||||||
|
time: 0,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([anchor(true)]),
|
||||||
|
Team::with_members([Member::new("player")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
Event {
|
||||||
|
time: 1000,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([anchor(false)]),
|
||||||
|
Team::with_members([Member::new("player")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(1, 2),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
let once = curve(&fit(events(false), 25.0 / 300.0), "anchor");
|
||||||
|
let every_time = curve(&fit(events(true), 25.0 / 300.0), "anchor");
|
||||||
|
|
||||||
|
for ((t_l, a), (t_r, b)) in once.iter().zip(every_time.iter()) {
|
||||||
|
assert_eq!(t_l, t_r);
|
||||||
|
assert!(
|
||||||
|
(a.sigma() - b.sigma()).abs() < 1e-12,
|
||||||
|
"t={t_l}: declaring the same scale repeatedly changed the fit, {} vs {}",
|
||||||
|
a.sigma(),
|
||||||
|
b.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_batch_that_contradicts_itself_is_rejected() {
|
||||||
|
let mut h = History::builder().convergence(CONVERGENCE).build();
|
||||||
|
|
||||||
|
let err = h
|
||||||
|
.add_events(vec![
|
||||||
|
Event {
|
||||||
|
time: 0,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("anchor").with_drift_scale(0.0)]),
|
||||||
|
Team::with_members([Member::new("player")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("anchor").with_drift_scale(1.0)]),
|
||||||
|
Team::with_members([Member::new("player")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.expect_err("two different scales for one competitor in one batch");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
|
field: trueskill_tt::CompetitorField::DriftScale,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! Equivalence tests: every historical golden from the pre-T2 tests is
|
||||||
|
//! reproduced here at the integration level via the new public API.
|
||||||
|
//!
|
||||||
|
//! The in-crate tests in `src/history.rs::tests` and
|
||||||
|
//! `src/time_slice.rs::tests` are the primary regression net for numerical
|
||||||
|
//! behavior. This file provides Game-level goldens that stand alone and are
|
||||||
|
//! more naturally expressed as integration tests.
|
||||||
|
|
||||||
|
use approx::assert_ulps_eq;
|
||||||
|
use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
|
||||||
|
|
||||||
|
type R = Rating<i64, ConstantDrift>;
|
||||||
|
|
||||||
|
fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R {
|
||||||
|
R::new(
|
||||||
|
Gaussian::from_ms(mu, sigma),
|
||||||
|
beta,
|
||||||
|
ConstantDrift::new(gamma),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_1v1_golden_matches_historical() {
|
||||||
|
let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
|
||||||
|
let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
|
||||||
|
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default())
|
||||||
|
.unwrap()
|
||||||
|
.posteriors();
|
||||||
|
let (a_post, b_post) = (post[0][0], post[1][0]);
|
||||||
|
// Historical golden from pre-T2 test_1vs1 (team 0 wins):
|
||||||
|
assert_ulps_eq!(
|
||||||
|
a_post,
|
||||||
|
Gaussian::from_ms(29.205220, 7.194481),
|
||||||
|
epsilon = 1e-6
|
||||||
|
);
|
||||||
|
assert_ulps_eq!(
|
||||||
|
b_post,
|
||||||
|
Gaussian::from_ms(20.794779, 7.194481),
|
||||||
|
epsilon = 1e-6
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_1v1_draw_golden() {
|
||||||
|
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 g = Game::<i64, _>::ranked(
|
||||||
|
&[&[a], &[b]],
|
||||||
|
Outcome::draw(2),
|
||||||
|
&GameOptions {
|
||||||
|
p_draw: 0.25,
|
||||||
|
score_sigma: 1.0,
|
||||||
|
convergence: Default::default(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let p = g.posteriors();
|
||||||
|
// Historical golden from pre-T2 test_1vs1_draw. The mean is 25.0 exactly
|
||||||
|
// by symmetry — two identical competitors drawing cannot move apart — and
|
||||||
|
// the reference's 24.999999 is that value transcribed to six decimals.
|
||||||
|
assert_ulps_eq!(p[0][0], Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
|
||||||
|
assert_ulps_eq!(p[1][0], Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//! `EventBuilder::members` must reach exactly what the typed path reaches.
|
||||||
|
//!
|
||||||
|
//! Before this existed, `EventBuilder` could set weights and nothing else, so
|
||||||
|
//! `prior` and `drift_scale` were expressible only through `Event`/`Team`/
|
||||||
|
//! `Member` + `add_events`. Which ingestion route a competitor arrived through
|
||||||
|
//! decided whether it could be configured at all.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
|
||||||
|
Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
type H = History;
|
||||||
|
|
||||||
|
fn history() -> H {
|
||||||
|
History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.5))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIOR: Gaussian = Gaussian::from_ms(3.0, 1.5);
|
||||||
|
|
||||||
|
/// The contract that makes the escape hatch worth having: same configuration,
|
||||||
|
/// same fit, bit for bit.
|
||||||
|
#[test]
|
||||||
|
fn members_matches_the_typed_path_exactly() {
|
||||||
|
let mut typed = history();
|
||||||
|
typed
|
||||||
|
.add_events(vec![Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("player")]),
|
||||||
|
Team::with_members([Member::new("layout_7")
|
||||||
|
.with_drift_scale(0.0)
|
||||||
|
.with_prior(PRIOR)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([5.0, 2.0]),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
assert!(typed.converge().unwrap().converged);
|
||||||
|
|
||||||
|
let mut fluent = history();
|
||||||
|
fluent
|
||||||
|
.event(1)
|
||||||
|
.team(["player"])
|
||||||
|
.members([Member::new("layout_7")
|
||||||
|
.with_drift_scale(0.0)
|
||||||
|
.with_prior(PRIOR)])
|
||||||
|
.scores([5.0, 2.0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
assert!(fluent.converge().unwrap().converged);
|
||||||
|
|
||||||
|
for key in ["player", "layout_7"] {
|
||||||
|
let a = typed.current_skill(&key).unwrap();
|
||||||
|
let b = fluent.current_skill(&key).unwrap();
|
||||||
|
// Exact equality, on the public moments rather than the natural
|
||||||
|
// parameters: `mu` and `variance` are `tau/pi` and `1/pi`, so
|
||||||
|
// bit-equal natural parameters give bit-equal moments.
|
||||||
|
assert_eq!(a.mu(), b.mu(), "{key} mu");
|
||||||
|
assert_eq!(a.variance(), b.variance(), "{key} variance");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configuration has to actually take effect, not merely round-trip: a
|
||||||
|
/// competitor pinned with `drift_scale = 0.0` must not move across slices,
|
||||||
|
/// where an unpinned one does.
|
||||||
|
///
|
||||||
|
/// The comparison is against a control rather than against a fixed epsilon.
|
||||||
|
/// Pinned marginals are not bit-identical across slices — each slice combines
|
||||||
|
/// its own forward and backward messages, so the arithmetic order differs and
|
||||||
|
/// the last bit moves. What "pinned" promises is that no drift variance
|
||||||
|
/// accumulates, and the control is what makes that measurable.
|
||||||
|
#[test]
|
||||||
|
fn a_drift_scale_set_through_members_is_applied() {
|
||||||
|
fn spread(h: &H, key: &'static str) -> f64 {
|
||||||
|
let curve = h.learning_curve(&key).unwrap();
|
||||||
|
assert!(curve.len() >= 2, "{key}: expected several appearances");
|
||||||
|
let (lo, hi) = curve.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (_, g)| {
|
||||||
|
(lo.min(g.sigma()), hi.max(g.sigma()))
|
||||||
|
});
|
||||||
|
(hi - lo) / hi
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut h = history();
|
||||||
|
for t in 1..=4 {
|
||||||
|
h.event(t)
|
||||||
|
.team(["player"])
|
||||||
|
.members([Member::new("pinned").with_drift_scale(0.0)])
|
||||||
|
.scores([5.0, 2.0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
// Same shape, no pinning: the control.
|
||||||
|
h.event(t)
|
||||||
|
.team(["rival"])
|
||||||
|
.team(["drifting"])
|
||||||
|
.scores([5.0, 2.0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
|
||||||
|
let pinned = spread(&h, "pinned");
|
||||||
|
let drifting = spread(&h, "drifting");
|
||||||
|
assert!(pinned < 1e-9, "pinned competitor moved: {pinned:e}");
|
||||||
|
assert!(
|
||||||
|
drifting > 1e-3,
|
||||||
|
"control did not move, so the test proves nothing: {drifting:e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `weights` still applies to a team added through `members`, and still
|
||||||
|
/// records a mismatch rather than partially applying it.
|
||||||
|
#[test]
|
||||||
|
fn weights_still_guards_a_members_team() {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.event(1)
|
||||||
|
.team(["a"])
|
||||||
|
.members([Member::new("b"), Member::new("c")])
|
||||||
|
.weights([1.0])
|
||||||
|
.winner(0)
|
||||||
|
.commit()
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::MismatchedShape {
|
||||||
|
shape: trueskill_tt::Shape::Weights,
|
||||||
|
expected: 2,
|
||||||
|
got: 1,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
assert!(h.current_skill(&"b").is_none(), "nothing may reach history");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An invalid `drift_scale` surfaces from `commit`, not from a panic and not
|
||||||
|
/// silently.
|
||||||
|
#[test]
|
||||||
|
fn an_invalid_drift_scale_surfaces_from_commit() {
|
||||||
|
for bad in [-1.0, f64::NAN, f64::INFINITY] {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.event(1)
|
||||||
|
.team(["a"])
|
||||||
|
.members([Member::new("b").with_drift_scale(bad)])
|
||||||
|
.winner(0)
|
||||||
|
.commit()
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::DriftScale,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{bad}: {err:?}"
|
||||||
|
);
|
||||||
|
assert!(h.current_skill(&"b").is_none(), "{bad} reached the history");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `members` and `team` compose in either order.
|
||||||
|
#[test]
|
||||||
|
fn members_and_team_interleave() {
|
||||||
|
let mut h = history();
|
||||||
|
h.event(1)
|
||||||
|
.members([Member::new("a").with_prior(PRIOR)])
|
||||||
|
.team(["b"])
|
||||||
|
.scores([3.0, 1.0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
h.event(2)
|
||||||
|
.team(["b"])
|
||||||
|
.members([Member::new("c").with_prior(PRIOR)])
|
||||||
|
.scores([2.0, 4.0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
for key in ["a", "b", "c"] {
|
||||||
|
assert!(h.current_skill(&key).is_some(), "{key} missing");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! Forward-only (filtering) estimates: what the model knew at the time,
|
||||||
|
//! as opposed to the smoothed posteriors `learning_curve` reports.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
/// `games` one-on-one matches at successive times, won by "a" every time,
|
||||||
|
/// built with the given convergence options.
|
||||||
|
fn repeated_winner_with(games: i64, convergence: ConvergenceOptions) -> History {
|
||||||
|
let mut history = History::builder().convergence(convergence).build();
|
||||||
|
|
||||||
|
for time in 1..=games {
|
||||||
|
history
|
||||||
|
.add_events([Event {
|
||||||
|
time,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
history
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `games` one-on-one matches at successive times, won by "a" every time.
|
||||||
|
///
|
||||||
|
/// This is the fixture from issue #19, where `online(true)` reported
|
||||||
|
/// `games * ln(0.5)`.
|
||||||
|
fn repeated_winner(games: i64) -> History {
|
||||||
|
repeated_winner_with(games, ConvergenceOptions::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default 30-iteration cap leaves a residual around 1e-6, which would
|
||||||
|
/// swamp these comparisons. Drive both sides well past the fixed point.
|
||||||
|
fn tight() -> ConvergenceOptions {
|
||||||
|
ConvergenceOptions {
|
||||||
|
max_iter: 2_000,
|
||||||
|
epsilon: 1e-12,
|
||||||
|
..ConvergenceOptions::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_evidence_sits_between_coin_flip_and_batch() {
|
||||||
|
let mut history = repeated_winner(5);
|
||||||
|
|
||||||
|
let _ = history.converge().unwrap();
|
||||||
|
|
||||||
|
let coin_flip = 5.0 * 0.5f64.ln();
|
||||||
|
let batch = history.log_evidence();
|
||||||
|
let filtered = history.filtered_log_evidence();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
filtered > coin_flip,
|
||||||
|
"filtered evidence {filtered} is at or below {coin_flip}, the all-coin-flip \
|
||||||
|
value the inert online flag reported; game one is a coin flip but games two \
|
||||||
|
through five are not"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
filtered < batch,
|
||||||
|
"filtered evidence {filtered} is not below the smoothed {batch}; filtering \
|
||||||
|
scores each game on strictly less information than smoothing does"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_first_point_is_less_certain_than_smoothed() {
|
||||||
|
let mut history = repeated_winner(12);
|
||||||
|
|
||||||
|
let _ = history.converge().unwrap();
|
||||||
|
|
||||||
|
let smoothed = history.learning_curve("a").unwrap();
|
||||||
|
let filtered = history.filtered_learning_curve("a").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
smoothed.len(),
|
||||||
|
filtered.len(),
|
||||||
|
"both curves must cover the same time points"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (smoothed_time, first_smoothed) = smoothed[0];
|
||||||
|
let (filtered_time, first_filtered) = filtered[0];
|
||||||
|
|
||||||
|
assert_eq!(smoothed_time, filtered_time);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
first_filtered.sigma() > first_smoothed.sigma(),
|
||||||
|
"filtered sigma {} at the first point is not above smoothed {}; the smoother \
|
||||||
|
collapses uncertainty before the first round is drawn, which is the whole \
|
||||||
|
reason this method exists",
|
||||||
|
first_filtered.sigma(),
|
||||||
|
first_smoothed.sigma()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
first_filtered.sigma() < trueskill_tt::SIGMA,
|
||||||
|
"filtered sigma {} at the first point is not below the prior {}; one game was \
|
||||||
|
played, so some uncertainty must have been resolved",
|
||||||
|
first_filtered.sigma(),
|
||||||
|
trueskill_tt::SIGMA
|
||||||
|
);
|
||||||
|
|
||||||
|
for pair in filtered.windows(2) {
|
||||||
|
assert!(
|
||||||
|
pair[1].1.mu() > pair[0].1.mu(),
|
||||||
|
"filtered mu must climb at every step for a competitor who wins every \
|
||||||
|
game: t={} mu={} then t={} mu={}",
|
||||||
|
pair[0].0,
|
||||||
|
pair[0].1.mu(),
|
||||||
|
pair[1].0,
|
||||||
|
pair[1].1.mu()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_curves_plural_agrees_with_singular() {
|
||||||
|
let mut history = repeated_winner(4);
|
||||||
|
|
||||||
|
let _ = history.converge().unwrap();
|
||||||
|
|
||||||
|
let curves = history.filtered_learning_curves();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
curves["b"],
|
||||||
|
history.filtered_learning_curve("b").unwrap(),
|
||||||
|
"the plural form must agree with the singular for the same key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_evidence_is_invariant_to_convergence() {
|
||||||
|
let mut history = repeated_winner_with(6, tight());
|
||||||
|
|
||||||
|
let before = history.filtered_log_evidence();
|
||||||
|
|
||||||
|
let report = history.converge().unwrap();
|
||||||
|
assert!(
|
||||||
|
report.converged,
|
||||||
|
"fixture must converge: {:?}",
|
||||||
|
report.final_step
|
||||||
|
);
|
||||||
|
|
||||||
|
let after = history.filtered_log_evidence();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(before - after).abs() < 1e-8,
|
||||||
|
"filtered evidence moved across converge(): {before} -> {after}. The pass must \
|
||||||
|
carry its own forward messages; anything reading skill.forward shows exactly \
|
||||||
|
this drift, because converge() contaminates it with backward information."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_slice_filtered_matches_smoothed() {
|
||||||
|
let mut history = History::builder().convergence(tight()).build();
|
||||||
|
|
||||||
|
history
|
||||||
|
.add_events([
|
||||||
|
Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("c")]),
|
||||||
|
Team::with_members([Member::new("d")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let _ = history.converge().unwrap();
|
||||||
|
|
||||||
|
let smoothed = history.learning_curve("a").unwrap();
|
||||||
|
let filtered = history.filtered_learning_curve("a").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(smoothed.len(), 1);
|
||||||
|
assert_eq!(filtered.len(), 1);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(smoothed[0].1.mu() - filtered[0].1.mu()).abs() < 1e-8
|
||||||
|
&& (smoothed[0].1.sigma() - filtered[0].1.sigma()).abs() < 1e-8,
|
||||||
|
"one slice has no future to propagate back, so filtered and smoothed must \
|
||||||
|
agree: smoothed mu={} sigma={}, filtered mu={} sigma={}",
|
||||||
|
smoothed[0].1.mu(),
|
||||||
|
smoothed[0].1.sigma(),
|
||||||
|
filtered[0].1.mu(),
|
||||||
|
filtered[0].1.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_curves_do_not_depend_on_ingestion_order() {
|
||||||
|
let events = |time: i64, winner: &'static str, loser: &'static str| Event {
|
||||||
|
time,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(winner)]),
|
||||||
|
Team::with_members([Member::new(loser)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
};
|
||||||
|
|
||||||
|
let all = vec![
|
||||||
|
events(1, "a", "b"),
|
||||||
|
events(1, "c", "d"),
|
||||||
|
events(1, "a", "c"),
|
||||||
|
events(1, "b", "d"),
|
||||||
|
events(2, "a", "d"),
|
||||||
|
events(2, "b", "c"),
|
||||||
|
events(2, "a", "b"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut batched = History::builder().convergence(tight()).build();
|
||||||
|
batched.add_events(all.clone()).unwrap();
|
||||||
|
let _ = batched.converge().unwrap();
|
||||||
|
|
||||||
|
let mut incremental = History::builder().convergence(tight()).build();
|
||||||
|
for event in all {
|
||||||
|
incremental.add_events([event]).unwrap();
|
||||||
|
}
|
||||||
|
let _ = incremental.converge().unwrap();
|
||||||
|
|
||||||
|
let from_batched = batched.filtered_learning_curve("a").unwrap();
|
||||||
|
let from_incremental = incremental.filtered_learning_curve("a").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(from_batched.len(), from_incremental.len());
|
||||||
|
|
||||||
|
for ((time_b, gaussian_b), (time_i, gaussian_i)) in
|
||||||
|
from_batched.iter().zip(from_incremental.iter())
|
||||||
|
{
|
||||||
|
assert_eq!(time_b, time_i);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(gaussian_b.mu() - gaussian_i.mu()).abs() < 1e-8
|
||||||
|
&& (gaussian_b.sigma() - gaussian_i.sigma()).abs() < 1e-8,
|
||||||
|
"at t={time_b}: batched mu={} sigma={}, incremental mu={} sigma={}",
|
||||||
|
gaussian_b.mu(),
|
||||||
|
gaussian_b.sigma(),
|
||||||
|
gaussian_i.mu(),
|
||||||
|
gaussian_i.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, InferenceError, Outcome, Rating,
|
||||||
|
};
|
||||||
|
|
||||||
|
type R = Rating<i64, ConstantDrift>;
|
||||||
|
|
||||||
|
fn default_rating() -> R {
|
||||||
|
R::new(
|
||||||
|
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||||
|
25.0 / 6.0,
|
||||||
|
ConstantDrift::new(25.0 / 300.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_ranked_1v1_golden() {
|
||||||
|
let a = default_rating();
|
||||||
|
let b = default_rating();
|
||||||
|
let g = Game::<i64, _>::ranked(
|
||||||
|
&[&[a], &[b]],
|
||||||
|
Outcome::winner(0, 2),
|
||||||
|
&GameOptions::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let p = g.posteriors();
|
||||||
|
assert!(p[0][0].mu() > 25.0);
|
||||||
|
assert!(p[1][0].mu() < 25.0);
|
||||||
|
assert!((p[0][0].sigma() - p[1][0].sigma()).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_one_v_one_shortcut() {
|
||||||
|
let a = default_rating();
|
||||||
|
let b = default_rating();
|
||||||
|
let game =
|
||||||
|
Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
|
||||||
|
let post = game.posteriors();
|
||||||
|
let (a_post, b_post) = (post[0][0], post[1][0]);
|
||||||
|
assert!(a_post.mu() > 25.0);
|
||||||
|
assert!(b_post.mu() < 25.0);
|
||||||
|
|
||||||
|
// It returns a game like every other constructor, so evidence is askable.
|
||||||
|
// Two identical ratings make either result equally likely.
|
||||||
|
assert!((game.log_evidence() - 0.5_f64.ln()).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_ranked_rejects_bad_p_draw() {
|
||||||
|
let a = R::new(Gaussian::default(), 1.0, ConstantDrift::new(0.0));
|
||||||
|
let err = Game::<i64, _>::ranked(
|
||||||
|
&[&[a], &[a]],
|
||||||
|
Outcome::winner(0, 2),
|
||||||
|
&GameOptions {
|
||||||
|
p_draw: 1.5,
|
||||||
|
score_sigma: 1.0,
|
||||||
|
convergence: ConvergenceOptions::default(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, InferenceError::InvalidParameter { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_ranked_rejects_mismatched_ranks() {
|
||||||
|
let a = R::new(Gaussian::default(), 1.0, ConstantDrift::new(0.0));
|
||||||
|
let err = Game::<i64, _>::ranked(
|
||||||
|
&[&[a], &[a]],
|
||||||
|
Outcome::ranking([0, 1, 2]),
|
||||||
|
&GameOptions::default(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, InferenceError::MismatchedShape { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_free_for_all_three_players() {
|
||||||
|
let a = default_rating();
|
||||||
|
let b = default_rating();
|
||||||
|
let c = default_rating();
|
||||||
|
let g = Game::<i64, _>::free_for_all(
|
||||||
|
&[&a, &b, &c],
|
||||||
|
Outcome::ranking([0, 1, 2]),
|
||||||
|
&GameOptions::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let p = g.posteriors();
|
||||||
|
assert_eq!(p.len(), 3);
|
||||||
|
assert!(p[0][0].mu() > p[1][0].mu());
|
||||||
|
assert!(p[1][0].mu() > p[2][0].mu());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_log_evidence_is_finite() {
|
||||||
|
let a = default_rating();
|
||||||
|
let b = default_rating();
|
||||||
|
let g = Game::<i64, _>::ranked(
|
||||||
|
&[&[a], &[b]],
|
||||||
|
Outcome::winner(0, 2),
|
||||||
|
&GameOptions::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(g.log_evidence().is_finite());
|
||||||
|
assert!(g.log_evidence() < 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `one_v_one` used to hardcode `GameOptions::default()`, so a 1v1 could
|
||||||
|
/// never set `p_draw` and a drawn 1v1 was unreachable through it.
|
||||||
|
#[test]
|
||||||
|
fn one_v_one_honours_the_draw_probability_it_is_given() {
|
||||||
|
let a = default_rating();
|
||||||
|
let b = default_rating();
|
||||||
|
|
||||||
|
// Default options still reject a draw, because the default p_draw is zero.
|
||||||
|
let err = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &GameOptions::default())
|
||||||
|
.expect_err("a draw needs a positive p_draw");
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::TieWithoutDrawProbability { .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
// With a draw probability supplied it succeeds — which was impossible
|
||||||
|
// before the signature took options.
|
||||||
|
let options = GameOptions {
|
||||||
|
p_draw: 0.25,
|
||||||
|
..GameOptions::default()
|
||||||
|
};
|
||||||
|
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &options)
|
||||||
|
.expect("a draw is representable once p_draw is positive")
|
||||||
|
.posteriors();
|
||||||
|
let (a_post, b_post) = (post[0][0], post[1][0]);
|
||||||
|
|
||||||
|
// A symmetric draw leaves the means alone and sharpens both sides.
|
||||||
|
assert!((a_post.mu() - b_post.mu()).abs() < 1e-9);
|
||||||
|
assert!(a_post.sigma() < 25.0 / 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convergence options reach the 1v1 path too, not just `p_draw`.
|
||||||
|
#[test]
|
||||||
|
fn one_v_one_honours_convergence_options() {
|
||||||
|
let a = default_rating();
|
||||||
|
let b = default_rating();
|
||||||
|
let options = GameOptions {
|
||||||
|
convergence: ConvergenceOptions::default(),
|
||||||
|
..GameOptions::default()
|
||||||
|
};
|
||||||
|
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options)
|
||||||
|
.unwrap()
|
||||||
|
.posteriors();
|
||||||
|
assert!(post[0][0].mu() > 25.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Game` is a public entry point that does not pass through `History`'s
|
||||||
|
/// ingestion chokepoint, so it needs its own boundary — and did not have one.
|
||||||
|
///
|
||||||
|
/// A one-team game panicked at `src/game.rs:317` with "range start index 1 out
|
||||||
|
/// of range for slice of length 0", in release, from safe API. This is the
|
||||||
|
/// same defect `tests/ingestion_shape.rs` covers for `History`; fixing that
|
||||||
|
/// path left this one open, because they share no validation.
|
||||||
|
mod malformed_games {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_one_team_ranked_game_is_an_error_not_a_panic() {
|
||||||
|
let a = default_rating();
|
||||||
|
let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default())
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_one_team_scored_game_is_an_error_not_a_panic() {
|
||||||
|
let a = default_rating();
|
||||||
|
let err = Game::<i64, _>::scored(
|
||||||
|
&[&[a]],
|
||||||
|
Outcome::scores([1.0]),
|
||||||
|
&GameOptions {
|
||||||
|
score_sigma: 1.0,
|
||||||
|
..GameOptions::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_team_game_is_an_error() {
|
||||||
|
let err =
|
||||||
|
Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default())
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The quiet half: an empty team contributed no performance, so the game
|
||||||
|
/// returned a finite posterior for its opponent as though it had won one.
|
||||||
|
#[test]
|
||||||
|
fn an_empty_team_is_an_error() {
|
||||||
|
let a = default_rating();
|
||||||
|
let err =
|
||||||
|
Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default())
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_finite_score_is_an_error() {
|
||||||
|
let a = default_rating();
|
||||||
|
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||||
|
let err = Game::<i64, _>::scored(
|
||||||
|
&[&[a], &[a]],
|
||||||
|
Outcome::scores([bad, 1.0]),
|
||||||
|
&GameOptions {
|
||||||
|
score_sigma: 1.0,
|
||||||
|
..GameOptions::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Score,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{bad}: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `free_for_all` and `one_v_one` build their teams internally, so they
|
||||||
|
/// must keep working — the check must not catch well-formed games.
|
||||||
|
#[test]
|
||||||
|
fn well_formed_games_are_untouched() {
|
||||||
|
let a = default_rating();
|
||||||
|
assert!(
|
||||||
|
Game::<i64, _>::ranked(
|
||||||
|
&[&[a], &[a]],
|
||||||
|
Outcome::winner(0, 2),
|
||||||
|
&GameOptions::default()
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
Game::<i64, _>::free_for_all(
|
||||||
|
&[&a, &a, &a],
|
||||||
|
Outcome::ranking([0, 1, 2]),
|
||||||
|
&GameOptions::default()
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
Game::<i64, _>::one_v_one(&a, &a, Outcome::winner(0, 2), &GameOptions::default())
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! Ingesting the same events must give the same answer however they were
|
||||||
|
//! batched.
|
||||||
|
//!
|
||||||
|
//! The numerical goldens all ingest in a single call with one slice per
|
||||||
|
//! timestamp, so they never exercise the "append to an existing slice" path.
|
||||||
|
//! These do.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
/// Converge tightly: the default cap of 30 iterations leaves a residual around
|
||||||
|
/// 1e-6, which would swamp the comparison. Both paths must reach the same
|
||||||
|
/// fixed point, so drive both well past it.
|
||||||
|
fn tight() -> ConvergenceOptions {
|
||||||
|
ConvergenceOptions {
|
||||||
|
max_iter: 2_000,
|
||||||
|
epsilon: 1e-12,
|
||||||
|
..ConvergenceOptions::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(a: &str, b: &str, time: i64) -> Event<i64, String> {
|
||||||
|
Event {
|
||||||
|
time,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a.to_string())]),
|
||||||
|
Team::with_members([Member::new(b.to_string())]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`event`], but `a` carries competitor configuration.
|
||||||
|
///
|
||||||
|
/// `prior` and `drift_scale` configure the competitor rather than the event, so
|
||||||
|
/// they are the part of ingestion most exposed to order: they are consumed once,
|
||||||
|
/// where the competitor's state is written.
|
||||||
|
fn configured_event(a: &str, b: &str, time: i64, scale: f64) -> Event<i64, String> {
|
||||||
|
Event {
|
||||||
|
time,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a.to_string()).with_drift_scale(scale)]),
|
||||||
|
Team::with_members([Member::new(b.to_string())]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
|
||||||
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.convergence(tight())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if batched {
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
} else {
|
||||||
|
for ev in events {
|
||||||
|
h.add_events(std::iter::once(ev)).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(
|
||||||
|
report.converged,
|
||||||
|
"fixture must converge before results can be compared; final step {:?}",
|
||||||
|
report.final_step
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut skills: Vec<(String, Gaussian)> = h
|
||||||
|
.learning_curves()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, curve)| (key, curve.last().unwrap().1))
|
||||||
|
.collect();
|
||||||
|
skills.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
skills
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_same(batched: &[(String, Gaussian)], incremental: &[(String, Gaussian)], what: &str) {
|
||||||
|
assert_eq!(
|
||||||
|
batched.len(),
|
||||||
|
incremental.len(),
|
||||||
|
"{what}: competitor count differs"
|
||||||
|
);
|
||||||
|
|
||||||
|
for ((kb, gb), (ki, gi)) in batched.iter().zip(incremental.iter()) {
|
||||||
|
assert_eq!(kb, ki, "{what}: key order differs");
|
||||||
|
assert!(
|
||||||
|
(gb.mu() - gi.mu()).abs() < 1e-8 && (gb.sigma() - gi.sigma()).abs() < 1e-8,
|
||||||
|
"{what}: {kb} differs — batched mu={} sigma={}, incremental mu={} sigma={}",
|
||||||
|
gb.mu(),
|
||||||
|
gb.sigma(),
|
||||||
|
gi.mu(),
|
||||||
|
gi.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All events share one timestamp, so incremental ingestion repeatedly appends
|
||||||
|
/// to an existing slice.
|
||||||
|
#[test]
|
||||||
|
fn same_slice_incremental_matches_batched() {
|
||||||
|
let events = vec![
|
||||||
|
event("a", "b", 1),
|
||||||
|
event("c", "d", 1),
|
||||||
|
event("e", "f", 1),
|
||||||
|
event("a", "c", 1),
|
||||||
|
event("b", "e", 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
let batched = converged_skills(events.clone(), true);
|
||||||
|
let incremental = converged_skills(events, false);
|
||||||
|
assert_same(&batched, &incremental, "single shared slice");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distinct timestamps, so each append lands in a fresh slice appended after
|
||||||
|
/// the existing ones.
|
||||||
|
#[test]
|
||||||
|
fn distinct_slices_incremental_matches_batched() {
|
||||||
|
let events = vec![
|
||||||
|
event("a", "b", 1),
|
||||||
|
event("b", "c", 2),
|
||||||
|
event("c", "a", 3),
|
||||||
|
event("a", "c", 4),
|
||||||
|
];
|
||||||
|
|
||||||
|
let batched = converged_skills(events.clone(), true);
|
||||||
|
let incremental = converged_skills(events, false);
|
||||||
|
assert_same(&batched, &incremental, "distinct slices");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Several events per timestamp across several timestamps — appends to
|
||||||
|
/// existing slices interleaved with new ones.
|
||||||
|
#[test]
|
||||||
|
fn mixed_slices_incremental_matches_batched() {
|
||||||
|
let events = vec![
|
||||||
|
event("a", "b", 1),
|
||||||
|
event("c", "d", 1),
|
||||||
|
event("a", "c", 2),
|
||||||
|
event("b", "d", 2),
|
||||||
|
event("a", "d", 3),
|
||||||
|
event("b", "c", 3),
|
||||||
|
];
|
||||||
|
|
||||||
|
let batched = converged_skills(events.clone(), true);
|
||||||
|
let incremental = converged_skills(events, false);
|
||||||
|
assert_same(&batched, &incremental, "mixed slices");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appending an event to a slice that is *not* the most recent one exercises
|
||||||
|
/// the forward refresh of every later slice.
|
||||||
|
#[test]
|
||||||
|
fn back_dated_event_matches_batched() {
|
||||||
|
let events = vec![
|
||||||
|
event("a", "b", 1),
|
||||||
|
event("b", "c", 5),
|
||||||
|
event("c", "a", 9),
|
||||||
|
// arrives last, but belongs to the middle slice
|
||||||
|
event("a", "c", 5),
|
||||||
|
];
|
||||||
|
|
||||||
|
let batched = converged_skills(events.clone(), true);
|
||||||
|
let incremental = converged_skills(events, false);
|
||||||
|
assert_same(&batched, &incremental, "back-dated event");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The invariant this file protects was only ever checked for *unconfigured*
|
||||||
|
/// competitors — every helper above built members with `Member::new`.
|
||||||
|
///
|
||||||
|
/// Configuration is the part most exposed to ordering, because it is consumed
|
||||||
|
/// once at the point the competitor's state is written rather than replayed per
|
||||||
|
/// event. These cover it.
|
||||||
|
#[test]
|
||||||
|
fn configured_competitors_are_order_independent() {
|
||||||
|
let events = vec![
|
||||||
|
configured_event("a", "b", 0, 0.0),
|
||||||
|
configured_event("a", "c", 1, 0.0),
|
||||||
|
configured_event("a", "b", 2, 0.0),
|
||||||
|
event("b", "c", 3),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_same(
|
||||||
|
&converged_skills(events.clone(), true),
|
||||||
|
&converged_skills(events, false),
|
||||||
|
"configuration repeated on every appearance",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration supplied only on a *later* event is the case that used to be
|
||||||
|
/// silently dropped. It must now reach the same fit either way it is ingested.
|
||||||
|
#[test]
|
||||||
|
fn late_configuration_is_order_independent() {
|
||||||
|
let events = vec![
|
||||||
|
event("a", "b", 0),
|
||||||
|
configured_event("a", "c", 1, 0.0),
|
||||||
|
event("a", "b", 2),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_same(
|
||||||
|
&converged_skills(events.clone(), true),
|
||||||
|
&converged_skills(events, false),
|
||||||
|
"configuration supplied after first appearance",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And it must actually be doing something — an implementation that dropped
|
||||||
|
/// configuration entirely would pass both tests above.
|
||||||
|
#[test]
|
||||||
|
fn configuration_changes_the_fit_however_it_is_ingested() {
|
||||||
|
let configured = vec![
|
||||||
|
event("a", "b", 0),
|
||||||
|
configured_event("a", "c", 1, 0.0),
|
||||||
|
event("a", "b", 2),
|
||||||
|
];
|
||||||
|
let plain = vec![event("a", "b", 0), event("a", "c", 1), event("a", "b", 2)];
|
||||||
|
|
||||||
|
for batched in [true, false] {
|
||||||
|
let with = converged_skills(configured.clone(), batched);
|
||||||
|
let without = converged_skills(plain.clone(), batched);
|
||||||
|
assert!(
|
||||||
|
with.iter()
|
||||||
|
.zip(&without)
|
||||||
|
.any(|((_, x), (_, y))| (x.sigma() - y.sigma()).abs() > 1e-9),
|
||||||
|
"batched={batched}: configuration had no effect, so the order tests are vacuous"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! Malformed events must be rejected at the ingestion boundary.
|
||||||
|
//!
|
||||||
|
//! Every case here was reachable from safe public API in a release build. Two
|
||||||
|
//! of them are the two shapes this crate's defects keep taking: a panic from
|
||||||
|
//! deep inside inference, and a finite, plausible-looking posterior computed
|
||||||
|
//! from an event that should never have been accepted.
|
||||||
|
//!
|
||||||
|
//! `InferenceError::NotEnoughTeams` and `EmptyTeam` already existed when these
|
||||||
|
//! were found — they were checked on the prediction paths and nowhere else, so
|
||||||
|
//! ingestion could still manufacture the states they describe.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
|
||||||
|
|
||||||
|
type Ev = Event<i64, &'static str>;
|
||||||
|
|
||||||
|
fn history() -> History {
|
||||||
|
History::builder().score_sigma(1.0).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn teams(names: &[&[&'static str]]) -> smallvec::SmallVec<[Team<&'static str>; 4]> {
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.map(|team| Team::with_members(team.iter().map(|k| Member::new(*k))))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The regression this file exists for: `run_chain` builds one diff link per
|
||||||
|
/// adjacent pair of teams, so a one-team event left it indexing `links[1..]`
|
||||||
|
/// on an empty vector and panicked — in release, from `History::add_events`.
|
||||||
|
#[test]
|
||||||
|
fn a_one_team_event_is_an_error_not_a_panic() {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.add_events(vec![Ev {
|
||||||
|
time: 1,
|
||||||
|
teams: teams(&[&["a"]]),
|
||||||
|
outcome: Outcome::winner(0, 1),
|
||||||
|
}])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_team_event_is_an_error() {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.add_events(vec![Ev {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![],
|
||||||
|
outcome: Outcome::ranking([]),
|
||||||
|
}])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The quiet half. An empty team contributes no performance, so before this
|
||||||
|
/// was rejected the event converged and handed back a finite posterior for its
|
||||||
|
/// opponent — a plausible constant computed from nothing.
|
||||||
|
#[test]
|
||||||
|
fn an_empty_team_is_an_error_rather_than_a_free_win() {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.add_events(vec![Ev {
|
||||||
|
time: 1,
|
||||||
|
teams: teams(&[&[], &["b"]]),
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
// Nothing was recorded, so the history is still empty.
|
||||||
|
assert!(h.current_skill(&"b").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_team_is_reported_by_position() {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.add_events(vec![Ev {
|
||||||
|
time: 1,
|
||||||
|
teams: teams(&[&["a"], &[]]),
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::EmptyTeam { team: 1, .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A NaN score used to ingest cleanly. `converge` reported `NonFiniteResult`,
|
||||||
|
/// but a caller who read `current_skill` first was handed `tau: NaN` with
|
||||||
|
/// nothing to say so.
|
||||||
|
#[test]
|
||||||
|
fn a_non_finite_score_is_rejected_at_ingestion() {
|
||||||
|
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.add_events(vec![Ev {
|
||||||
|
time: 1,
|
||||||
|
teams: teams(&[&["a"], &["b"]]),
|
||||||
|
outcome: Outcome::scores([bad, 0.0]),
|
||||||
|
}])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Score,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{bad}: {err:?}"
|
||||||
|
);
|
||||||
|
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-finite weight behaved exactly as `0.0` — the member contributed
|
||||||
|
/// nothing — while `converge` reported `converged: true` after one iteration
|
||||||
|
/// with a step of `(0.0, 0.0)`. So a NaN arriving from a division or a parse
|
||||||
|
/// was indistinguishable from a deliberate zero, and looked like a clean fit.
|
||||||
|
#[test]
|
||||||
|
fn a_non_finite_weight_is_rejected_at_ingestion() {
|
||||||
|
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h
|
||||||
|
.event(1)
|
||||||
|
.team(["a"])
|
||||||
|
.weights([bad])
|
||||||
|
.team(["b"])
|
||||||
|
.winner(0)
|
||||||
|
.commit()
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Weight,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{bad}: {err:?}"
|
||||||
|
);
|
||||||
|
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero and negative weights are expressible choices about how much a member
|
||||||
|
/// contributes, not malformed input, and `tests/degenerate_inputs.rs` pins
|
||||||
|
/// their behaviour deliberately. Rejecting non-finite values must not catch
|
||||||
|
/// them too.
|
||||||
|
#[test]
|
||||||
|
fn zero_and_negative_weights_still_ingest() {
|
||||||
|
for w in [0.0, -1.0, 0.5] {
|
||||||
|
let mut h = history();
|
||||||
|
h.event(1)
|
||||||
|
.team(["a"])
|
||||||
|
.weights([w])
|
||||||
|
.team(["b"])
|
||||||
|
.winner(0)
|
||||||
|
.commit()
|
||||||
|
.unwrap_or_else(|e| panic!("weight {w} should ingest: {e:?}"));
|
||||||
|
assert!(h.current_skill(&"a").is_some(), "weight {w}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fluent builder routes through the same chokepoint, so it inherits the
|
||||||
|
/// checks rather than needing its own.
|
||||||
|
#[test]
|
||||||
|
fn the_event_builder_inherits_the_shape_checks() {
|
||||||
|
let mut h = history();
|
||||||
|
let err = h.event(1).team(["a"]).winner(0).commit().unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A well-formed event is untouched by any of this.
|
||||||
|
#[test]
|
||||||
|
fn a_well_formed_event_still_ingests() {
|
||||||
|
let mut h = history();
|
||||||
|
h.add_events(vec![Ev {
|
||||||
|
time: 1,
|
||||||
|
teams: teams(&[&["a"], &["b"]]),
|
||||||
|
outcome: Outcome::scores([3.0, 1.0]),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
assert!(h.current_skill(&"a").unwrap().mu() > h.current_skill(&"b").unwrap().mu());
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
//! `History::joint` factorises once and answers many questions.
|
||||||
|
//!
|
||||||
|
//! The contract that matters is *identity*: a `Joint` must return exactly what
|
||||||
|
//! the one-shot call returns, bit for bit. A faster path that quietly disagreed
|
||||||
|
//! with the slow one would be worse than no fast path — a caller would get
|
||||||
|
//! different numbers depending on how many questions they happened to ask.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
|
||||||
|
UnknownKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
type H = History;
|
||||||
|
|
||||||
|
fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> {
|
||||||
|
Event {
|
||||||
|
time: t,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a)]),
|
||||||
|
Team::with_members([Member::new(b)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([sa, sb]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ranked(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
|
||||||
|
Event {
|
||||||
|
time: t,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(a)]),
|
||||||
|
Team::with_members([Member::new(b)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history(unknown: UnknownKeys) -> H {
|
||||||
|
History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.5))
|
||||||
|
.unknown_keys(unknown)
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Several slices, competitors with different last appearances, so `latest`
|
||||||
|
/// and `at_slice` both have work to do.
|
||||||
|
fn fitted(unknown: UnknownKeys) -> H {
|
||||||
|
let mut h = history(unknown);
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("a", "b", 1, 5.0, 2.0),
|
||||||
|
duel("c", "d", 1, 3.0, 3.5),
|
||||||
|
duel("a", "c", 2, 6.0, 1.0),
|
||||||
|
duel("b", "d", 3, 4.0, 3.0),
|
||||||
|
duel("a", "d", 4, 7.0, 2.0),
|
||||||
|
duel("b", "c", 5, 2.0, 4.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(report.converged, "fixture must converge");
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAIRS: [(&str, &str); 6] = [
|
||||||
|
("a", "b"),
|
||||||
|
("a", "c"),
|
||||||
|
("a", "d"),
|
||||||
|
("b", "c"),
|
||||||
|
("b", "d"),
|
||||||
|
("c", "d"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A joint reused across questions answers exactly what a fresh one per
|
||||||
|
/// question does. That is the whole correctness claim behind caching the
|
||||||
|
/// factorisation (#51); it used to be checked against the `History` one-shot
|
||||||
|
/// wrappers, which were deleted in #78, so it is checked against a fresh
|
||||||
|
/// factorisation instead — the same comparison, without the wrapper.
|
||||||
|
#[test]
|
||||||
|
fn a_reused_joint_answers_exactly_what_a_fresh_one_does() {
|
||||||
|
let h = fitted(UnknownKeys::Reject);
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
|
||||||
|
for (a, b) in PAIRS {
|
||||||
|
let terms = [(&a, 1.0), (&b, -1.0)];
|
||||||
|
let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||||
|
let cached = joint.posterior_of(&terms).unwrap();
|
||||||
|
assert_eq!(one_shot.mu(), cached.mu(), "{a} - {b}");
|
||||||
|
assert_eq!(one_shot.variance(), cached.variance(), "{a} - {b}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_joint_agrees_at_a_pinned_time_too() {
|
||||||
|
let h = fitted(UnknownKeys::Reject);
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
|
||||||
|
for time in 1..=5 {
|
||||||
|
for (a, b) in PAIRS {
|
||||||
|
let terms = [(&a, 1.0), (&b, -1.0)];
|
||||||
|
let one_shot = h.joint().unwrap().posterior_of_at(time, &terms);
|
||||||
|
let cached = joint.posterior_of_at(time, &terms);
|
||||||
|
match (one_shot, cached) {
|
||||||
|
(Ok(x), Ok(y)) => {
|
||||||
|
assert_eq!(x.mu(), y.mu(), "t={time} {a} - {b}");
|
||||||
|
assert_eq!(x.variance(), y.variance(), "t={time} {a} - {b}");
|
||||||
|
}
|
||||||
|
(Err(x), Err(y)) => assert_eq!(x, y, "t={time} {a} - {b}"),
|
||||||
|
(x, y) => panic!("t={time} {a} - {b}: disagreed on success: {x:?} vs {y:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_joint_scores_candidate_matchups_identically() {
|
||||||
|
let h = fitted(UnknownKeys::Reject);
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
let (a, b) = ("a", "b");
|
||||||
|
let target = [(&a, 1.0), (&b, -1.0)];
|
||||||
|
|
||||||
|
for (x, y) in PAIRS {
|
||||||
|
let teams: [&[&&str]; 2] = [&[&x], &[&y]];
|
||||||
|
let one_shot = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.expected_variance_reduction(&teams, &target)
|
||||||
|
.unwrap();
|
||||||
|
let cached = joint.expected_variance_reduction(&teams, &target).unwrap();
|
||||||
|
assert_eq!(one_shot, cached, "{x} vs {y}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole point: a competitor appears once per slice, so the joint is over
|
||||||
|
/// appearances rather than competitors, and a caller sizing a batch needs to
|
||||||
|
/// know which.
|
||||||
|
#[test]
|
||||||
|
fn variables_counts_appearances_not_competitors() {
|
||||||
|
let h = fitted(UnknownKeys::Reject);
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
// Four competitors, twelve appearances across five slices, all with
|
||||||
|
// positive drift between them, so no two collapse.
|
||||||
|
assert_eq!(joint.variables(), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much the collapse is worth, which is the part a caller has to plan
|
||||||
|
/// around: a drift-free competitor contributes **one** variable however long
|
||||||
|
/// the history, so the same events at `gamma = 0` and `gamma > 0` differ by
|
||||||
|
/// roughly the slice count in problem size — and by its cube in solve time.
|
||||||
|
///
|
||||||
|
/// Reported by a consumer as an 8x difference in solve time on a ~2,000-node,
|
||||||
|
/// 76-slice model (787 ms career against 6,214 ms drifting). This pins the
|
||||||
|
/// mechanism behind that so a change to the collapse rule cannot quietly
|
||||||
|
/// remove it.
|
||||||
|
#[test]
|
||||||
|
fn drift_free_competitors_shrink_the_joint_by_the_slice_count() {
|
||||||
|
fn variables(gamma: f64) -> usize {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(gamma))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
h.add_events(
|
||||||
|
(1..=10)
|
||||||
|
.map(|t| duel("a", "b", t, 5.0, 2.0))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h.joint().unwrap().variables()
|
||||||
|
}
|
||||||
|
|
||||||
|
let drifting = variables(0.5);
|
||||||
|
let career = variables(0.0);
|
||||||
|
|
||||||
|
// Two competitors over ten slices: twenty appearances, or two variables.
|
||||||
|
assert_eq!(drifting, 20);
|
||||||
|
assert_eq!(career, 2);
|
||||||
|
assert_eq!(
|
||||||
|
drifting / career,
|
||||||
|
10,
|
||||||
|
"collapse should track the slice count"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With `drift = 0` consecutive appearances are the same latent variable, so
|
||||||
|
/// the joint is smaller than the appearance count.
|
||||||
|
#[test]
|
||||||
|
fn pinned_competitors_collapse_consecutive_appearances() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
h.add_events(vec![
|
||||||
|
duel("a", "b", 1, 5.0, 2.0),
|
||||||
|
duel("a", "b", 2, 4.0, 3.0),
|
||||||
|
duel("a", "b", 3, 6.0, 1.0),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
assert_eq!(h.joint().unwrap().variables(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ranked_history_has_no_exact_joint() {
|
||||||
|
let mut h = history(UnknownKeys::Reject);
|
||||||
|
h.add_events(vec![duel("a", "b", 1, 5.0, 2.0), ranked("a", "b", 2)])
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
h.joint().unwrap_err(),
|
||||||
|
InferenceError::JointRequiresScoredEvents
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distinguishable from the ranked case, which is the point of splitting
|
||||||
|
/// `JointUnavailable { reason: &str }` into three variants (#74): "add events"
|
||||||
|
/// and "use predict_win_probabilities" are different instructions, and telling
|
||||||
|
/// them apart used to mean matching on English prose.
|
||||||
|
#[test]
|
||||||
|
fn an_empty_history_has_no_joint() {
|
||||||
|
let h = history(UnknownKeys::Reject);
|
||||||
|
assert!(matches!(
|
||||||
|
h.joint().unwrap_err(),
|
||||||
|
InferenceError::EmptyHistory
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unknown keys are decided per query, not when the joint is factorised — the
|
||||||
|
/// factorisation does not depend on the question.
|
||||||
|
#[test]
|
||||||
|
fn unknown_keys_are_rejected_per_query() {
|
||||||
|
let h = fitted(UnknownKeys::Reject);
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
let (a, z) = ("a", "nobody");
|
||||||
|
assert!(matches!(
|
||||||
|
joint.posterior_of(&[(&a, 1.0), (&z, -1.0)]).unwrap_err(),
|
||||||
|
InferenceError::UnknownKey { .. }
|
||||||
|
));
|
||||||
|
// The handle is still usable afterwards.
|
||||||
|
let b = "b";
|
||||||
|
assert!(joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Under `Prior`, an unseen competitor is independent of everything in the
|
||||||
|
/// history, and a reused joint must add the same prior variance a fresh one
|
||||||
|
/// does.
|
||||||
|
#[test]
|
||||||
|
fn unseen_competitors_match_a_fresh_factorisation() {
|
||||||
|
let h = fitted(UnknownKeys::Prior);
|
||||||
|
let joint = h.joint().unwrap();
|
||||||
|
let (a, z) = ("a", "nobody");
|
||||||
|
let terms = [(&a, 1.0), (&z, -1.0)];
|
||||||
|
let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
|
||||||
|
let cached = joint.posterior_of(&terms).unwrap();
|
||||||
|
assert_eq!(one_shot.mu(), cached.mu());
|
||||||
|
assert_eq!(one_shot.variance(), cached.variance());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A drift too small to represent must collapse, not corrupt the matrix.
|
||||||
|
///
|
||||||
|
/// The collapse rule used to fire only at `drift <= 0.0` exactly. Anything
|
||||||
|
/// smaller-but-positive got an explicit `1.0 / drift` precision, and at
|
||||||
|
/// `drift = 1e-16` that entry is `1e16` — so `1e16 + 0.28` rounds back to
|
||||||
|
/// `1e16` and the prior and contrasts are annihilated in the stored `f64`.
|
||||||
|
///
|
||||||
|
/// Measured before the fix, at `drift_scale = 1e-10` this returned a variance
|
||||||
|
/// **12 000x too small** (a 111x overconfident interval) as `Ok`, with a band
|
||||||
|
/// just above it returning a misleading `JointUnavailable`.
|
||||||
|
#[test]
|
||||||
|
fn a_drift_too_small_to_represent_collapses_rather_than_corrupting() {
|
||||||
|
fn variance(scale: f64) -> f64 {
|
||||||
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.5))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for t in 0..15i64 {
|
||||||
|
for k in 0..4usize {
|
||||||
|
let x = format!("p{}", (t as usize * 4 + k) % 8);
|
||||||
|
let y = format!("p{}", (t as usize * 4 + k + 3) % 8);
|
||||||
|
events.push(Event {
|
||||||
|
time: t,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(x).with_drift_scale(scale)]),
|
||||||
|
Team::with_members([Member::new(y).with_drift_scale(scale)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([3.0, 1.0]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
assert!(h.converge().unwrap().converged);
|
||||||
|
let (a, b) = ("p0".to_string(), "p1".to_string());
|
||||||
|
let joint = h
|
||||||
|
.joint()
|
||||||
|
.expect("a tiny drift must not make the joint unavailable");
|
||||||
|
let g = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).unwrap();
|
||||||
|
g.sigma() * g.sigma()
|
||||||
|
}
|
||||||
|
|
||||||
|
let collapsed = variance(0.0);
|
||||||
|
|
||||||
|
// Below the threshold every scale must reach the collapsed answer exactly,
|
||||||
|
// and none may error.
|
||||||
|
for scale in [1e-3, 1e-4, 1e-6, 1e-8, 1e-10, 1e-12] {
|
||||||
|
let v = variance(scale);
|
||||||
|
assert_eq!(
|
||||||
|
v.to_bits(),
|
||||||
|
collapsed.to_bits(),
|
||||||
|
"drift_scale {scale:e}: {v} vs collapsed {collapsed}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Above it, real drift is still modelled — otherwise this test would pass
|
||||||
|
// by collapsing everything.
|
||||||
|
let drifting = variance(1e-2);
|
||||||
|
assert!(
|
||||||
|
(drifting - collapsed).abs() / collapsed > 1e-5,
|
||||||
|
"a drift of 1e-2 must still move the answer: {drifting} vs {collapsed}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
//! Regression: a single time slice with many distinct competitors must converge to finite
|
||||||
|
//! skills. Before the `pi <= 0` guard in `Gaussian::mu()/sigma()`, EP message cancellation
|
||||||
|
//! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the
|
||||||
|
//! moment-space `Sub` in the game chain propagated into every skill once the slice grew past
|
||||||
|
//! ~75 competitors (e.g. a real ranking dataset with hundreds of players).
|
||||||
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS};
|
||||||
|
|
||||||
|
/// Tiny deterministic LCG — avoids a dev-dependency on `rand`.
|
||||||
|
struct Lcg(u64);
|
||||||
|
impl Lcg {
|
||||||
|
fn next(&mut self) -> u64 {
|
||||||
|
self.0 = self
|
||||||
|
.0
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(1442695040888963407);
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
fn below(&mut self, n: usize) -> usize {
|
||||||
|
(self.next() >> 33) as usize % n
|
||||||
|
}
|
||||||
|
fn coin(&mut self) -> bool {
|
||||||
|
self.next() & 1 == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nan_after_fit(players: usize) -> usize {
|
||||||
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.beta(1.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.drift(ConstantDrift::new(0.1))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: ITERATIONS,
|
||||||
|
epsilon: EPSILON,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let ids: Vec<String> = (0..players).map(|i| format!("p{i:04}")).collect();
|
||||||
|
let mut rng = Lcg(1);
|
||||||
|
for _ in 0..(players * 4) {
|
||||||
|
let a = rng.below(players);
|
||||||
|
let mut b = rng.below(players - 1);
|
||||||
|
if b >= a {
|
||||||
|
b += 1;
|
||||||
|
}
|
||||||
|
let (w, l) = if rng.coin() { (a, b) } else { (b, a) };
|
||||||
|
h.record_winner(&ids[w], &ids[l], 0).unwrap();
|
||||||
|
}
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
ids.iter()
|
||||||
|
.filter(|id| {
|
||||||
|
h.current_skill(id.as_str())
|
||||||
|
.map(|g| !g.mu().is_finite() || !g.sigma().is_finite())
|
||||||
|
.unwrap_or(true)
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn many_competitors_converge_to_finite_skills() {
|
||||||
|
// The NaN regression onset was between 70 and 80 competitors; 250 is comfortably past it
|
||||||
|
// and in the range of a real ranking dataset.
|
||||||
|
for players in [12usize, 75, 150, 250] {
|
||||||
|
assert_eq!(
|
||||||
|
nan_after_fit(players),
|
||||||
|
0,
|
||||||
|
"{players}-competitor history produced NaN skills"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
//! Calibration of the crate's marginals against the EXACT posterior.
|
||||||
|
//!
|
||||||
|
//! A scored history is linear-Gaussian — `MarginFactor` encodes
|
||||||
|
//! `score_a - score_b ~ N(perf_a - perf_b, score_sigma^2)` — so the true joint
|
||||||
|
//! posterior has a closed form and the crate can be checked against ground
|
||||||
|
//! truth rather than against intuition. That is not possible for ranked
|
||||||
|
//! outcomes, whose truncation likelihood EP genuinely approximates.
|
||||||
|
//!
|
||||||
|
//! Two things are pinned here, and one is deliberately only recorded.
|
||||||
|
//!
|
||||||
|
//! **Pinned: on a tree the crate is exact**, means and variances both. Message
|
||||||
|
//! passing has no approximation to make when the factor graph has no cycles, so
|
||||||
|
//! any drift here would be a real defect.
|
||||||
|
//!
|
||||||
|
//! **Pinned: means are exact even with cycles.** This is the standard result
|
||||||
|
//! for Gaussian belief propagation (Weiss & Freeman 2001) and it is what makes
|
||||||
|
//! ratings trustworthy.
|
||||||
|
//!
|
||||||
|
//! **Recorded, not asserted: with cycles, marginal variances are too narrow.**
|
||||||
|
//! Measured on the round-robin fixture below, the crate reports sigma 1.430
|
||||||
|
//! where the exact posterior is 2.851 — a ratio of 0.502. That is the known
|
||||||
|
//! behaviour of loopy Gaussian BP, not a bug in this crate, and it is left
|
||||||
|
//! unasserted because fixing it is exactly what #46 proposes.
|
||||||
|
//!
|
||||||
|
//! Why that matters for a consumer, and why #46 cannot be implemented as "add
|
||||||
|
//! a covariance accessor": the exact correlation between two nodes here is
|
||||||
|
//! +0.857, so a consumer computing `sqrt(sa^2 + sb^2)` for a difference
|
||||||
|
//! overstates its width. But the too-narrow marginals partially cancel that,
|
||||||
|
//! leaving 1.327x rather than 2.646x. Adding true correlations to these
|
||||||
|
//! marginals without also correcting them would give 0.765 against a true
|
||||||
|
//! 1.524 — *overconfident*, which is the unsafe direction.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
const N: usize = 5;
|
||||||
|
const MU0: f64 = 0.0;
|
||||||
|
const SIGMA0: f64 = 6.0;
|
||||||
|
const BETA: f64 = 1.0;
|
||||||
|
const SCORE_SIGMA: f64 = 2.0;
|
||||||
|
|
||||||
|
/// A STAR: every event touches c0, so the node-event graph is a tree and
|
||||||
|
/// Gaussian BP is exact. Any discrepancy here is not caused by loops.
|
||||||
|
fn tree_fixture() -> Vec<(usize, usize, f64)> {
|
||||||
|
vec![(0, 1, 3.0), (0, 2, 5.0), (0, 3, 4.0), (0, 4, 6.0)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (winner, loser, score_diff)
|
||||||
|
fn fixture() -> Vec<(usize, usize, f64)> {
|
||||||
|
vec![
|
||||||
|
(0, 1, 3.0),
|
||||||
|
(0, 2, 5.0),
|
||||||
|
(1, 2, 2.0),
|
||||||
|
(3, 4, 1.0),
|
||||||
|
(0, 3, 4.0),
|
||||||
|
(1, 4, 2.5),
|
||||||
|
(2, 3, 0.5),
|
||||||
|
(0, 4, 6.0),
|
||||||
|
(1, 3, 1.5),
|
||||||
|
(2, 4, 3.0),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invert a small symmetric positive-definite matrix by Gauss-Jordan.
|
||||||
|
fn inverse(mut a: Vec<Vec<f64>>) -> Vec<Vec<f64>> {
|
||||||
|
let n = a.len();
|
||||||
|
let mut inv: Vec<Vec<f64>> = (0..n)
|
||||||
|
.map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
|
||||||
|
.collect();
|
||||||
|
for col in 0..n {
|
||||||
|
// partial pivot
|
||||||
|
let mut piv = col;
|
||||||
|
for r in col + 1..n {
|
||||||
|
if a[r][col].abs() > a[piv][col].abs() {
|
||||||
|
piv = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.swap(col, piv);
|
||||||
|
inv.swap(col, piv);
|
||||||
|
let d = a[col][col];
|
||||||
|
for j in 0..n {
|
||||||
|
a[col][j] /= d;
|
||||||
|
inv[col][j] /= d;
|
||||||
|
}
|
||||||
|
for r in 0..n {
|
||||||
|
if r == col {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let f = a[r][col];
|
||||||
|
for j in 0..n {
|
||||||
|
a[r][j] -= f * a[col][j];
|
||||||
|
inv[r][j] -= f * inv[col][j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact posterior of a linear-Gaussian model:
|
||||||
|
/// precision = prior precision + sum of a_k a_k^T / v_k.
|
||||||
|
fn exact_for(obs: &[(usize, usize, f64)]) -> (Vec<f64>, Vec<Vec<f64>>) {
|
||||||
|
let mut lambda = vec![vec![0.0; N]; N];
|
||||||
|
let mut eta = [0.0; N];
|
||||||
|
for (i, row) in lambda.iter_mut().enumerate() {
|
||||||
|
row[i] = 1.0 / (SIGMA0 * SIGMA0);
|
||||||
|
eta[i] = MU0 / (SIGMA0 * SIGMA0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each 1v1 observation: d ~ N(x_a - x_b, score_sigma^2 + 2 beta^2)
|
||||||
|
let v = SCORE_SIGMA * SCORE_SIGMA + 2.0 * BETA * BETA;
|
||||||
|
for &(a, b, d) in obs {
|
||||||
|
let mut vec_a = [0.0; N];
|
||||||
|
vec_a[a] = 1.0;
|
||||||
|
vec_a[b] = -1.0;
|
||||||
|
for i in 0..N {
|
||||||
|
for j in 0..N {
|
||||||
|
lambda[i][j] += vec_a[i] * vec_a[j] / v;
|
||||||
|
}
|
||||||
|
eta[i] += vec_a[i] * d / v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cov = inverse(lambda);
|
||||||
|
let mean: Vec<f64> = (0..N)
|
||||||
|
.map(|i| (0..N).map(|j| cov[i][j] * eta[j]).sum())
|
||||||
|
.collect();
|
||||||
|
(mean, cov)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(i: usize) -> &'static str {
|
||||||
|
["c0", "c1", "c2", "c3", "c4"][i]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns (worst mean error, worst sd ratio).
|
||||||
|
fn fitted(obs: &[(usize, usize, f64)]) -> History {
|
||||||
|
let mut h: History = History::builder()
|
||||||
|
.mu(MU0)
|
||||||
|
.sigma(SIGMA0)
|
||||||
|
.beta(BETA)
|
||||||
|
.score_sigma(SCORE_SIGMA)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let events: Vec<Event<i64, &'static str>> = obs
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|(a, b, d)| Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(key(a))]),
|
||||||
|
Team::with_members([Member::new(key(b))]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([d, 0.0]),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(
|
||||||
|
report.converged,
|
||||||
|
"fixture must converge: {:?}",
|
||||||
|
report.final_step
|
||||||
|
);
|
||||||
|
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns (worst mean error, worst sd ratio gap).
|
||||||
|
fn run(name: &str, obs: Vec<(usize, usize, f64)>) -> (f64, f64) {
|
||||||
|
println!("\n########## {name} ##########");
|
||||||
|
let h = fitted(&obs);
|
||||||
|
let (mean, cov) = exact_for(&obs);
|
||||||
|
|
||||||
|
println!("\n== marginals: crate vs the exact linear-Gaussian posterior ==");
|
||||||
|
println!(
|
||||||
|
"{:>4} {:>12} {:>12} {:>12} {:>12} {:>8}",
|
||||||
|
"node", "crate mu", "exact mu", "crate sd", "exact sd", "sd ratio"
|
||||||
|
);
|
||||||
|
for i in 0..N {
|
||||||
|
let g = h.current_skill(&key(i)).unwrap();
|
||||||
|
let exact_sd = cov[i][i].sqrt();
|
||||||
|
println!(
|
||||||
|
"{:>4} {:>12.6} {:>12.6} {:>12.6} {:>12.6} {:>8.3}",
|
||||||
|
key(i),
|
||||||
|
g.mu(),
|
||||||
|
mean[i],
|
||||||
|
g.sigma(),
|
||||||
|
exact_sd,
|
||||||
|
g.sigma() / exact_sd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut worst_mean = 0.0f64;
|
||||||
|
let mut worst_ratio_gap = 0.0f64;
|
||||||
|
for i in 0..N {
|
||||||
|
let g = h.current_skill(&key(i)).unwrap();
|
||||||
|
worst_mean = worst_mean.max((g.mu() - mean[i]).abs());
|
||||||
|
worst_ratio_gap = worst_ratio_gap.max((g.sigma() / cov[i][i].sqrt() - 1.0).abs());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n== what a consumer actually computes for a DIFFERENCE ==");
|
||||||
|
println!(
|
||||||
|
"{:>8} {:>12} {:>14} {:>14} {:>12}",
|
||||||
|
"pair", "exact", "naive(exact)", "naive(crate)", "crate err"
|
||||||
|
);
|
||||||
|
for i in 0..N {
|
||||||
|
for j in i + 1..N {
|
||||||
|
if i != 0 && j != 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let gi = h.current_skill(&key(i)).unwrap();
|
||||||
|
let gj = h.current_skill(&key(j)).unwrap();
|
||||||
|
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
|
||||||
|
let naive_exact = (cov[i][i] + cov[j][j]).sqrt();
|
||||||
|
let naive_crate = (gi.sigma().powi(2) + gj.sigma().powi(2)).sqrt();
|
||||||
|
let corr = cov[i][j] / (cov[i][i].sqrt() * cov[j][j].sqrt());
|
||||||
|
println!(
|
||||||
|
"{:>8} {:>12.6} {:>14.6} {:>14.6} {:>11.3}x (corr {corr:.4})",
|
||||||
|
format!("{}-{}", key(i), key(j)),
|
||||||
|
exact_sd,
|
||||||
|
naive_exact,
|
||||||
|
naive_crate,
|
||||||
|
naive_crate / exact_sd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(worst_mean, worst_ratio_gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With no cycles there is nothing for message passing to approximate.
|
||||||
|
#[test]
|
||||||
|
fn on_a_tree_the_marginals_are_exact() {
|
||||||
|
let (mean_err, sd_gap) = run("TREE (star: no loops, BP is exact)", tree_fixture());
|
||||||
|
assert!(
|
||||||
|
mean_err < 1e-9,
|
||||||
|
"tree means should be exact, worst error {mean_err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sd_gap < 1e-9,
|
||||||
|
"tree sigmas should be exact, worst ratio gap {sd_gap}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With cycles the means stay exact — the property ratings depend on — while
|
||||||
|
/// the variances do not. The variance gap is measured and reported rather than
|
||||||
|
/// asserted; see the module docs.
|
||||||
|
#[test]
|
||||||
|
fn with_cycles_the_means_stay_exact_but_the_variances_shrink() {
|
||||||
|
let (mean_err, sd_gap) = run("LOOPY (round robin)", fixture());
|
||||||
|
assert!(
|
||||||
|
mean_err < 1e-9,
|
||||||
|
"loopy means must still be exact, worst error {mean_err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sd_gap > 0.1,
|
||||||
|
"the loopy variance gap is the premise of #46; if it has closed, that \
|
||||||
|
issue and these docs need revisiting (worst ratio gap {sd_gap})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The point of #46: `posterior_of` must reproduce the exact joint, including
|
||||||
|
/// the correlation that marginals cannot express.
|
||||||
|
#[test]
|
||||||
|
fn posterior_of_matches_the_exact_joint() {
|
||||||
|
for (name, obs) in [("tree", tree_fixture()), ("loopy", fixture())] {
|
||||||
|
let h = fitted(&obs);
|
||||||
|
let (_, cov) = exact_for(&obs);
|
||||||
|
|
||||||
|
println!("\n== posterior_of vs exact ({name}) ==");
|
||||||
|
println!(
|
||||||
|
"{:>12} {:>14} {:>14} {:>10}",
|
||||||
|
"functional", "posterior_of", "exact", "ratio"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] {
|
||||||
|
let got = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)])
|
||||||
|
.expect("scored slice should have a joint");
|
||||||
|
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
|
||||||
|
println!(
|
||||||
|
"{:>12} {:>14.6} {:>14.6} {:>10.4}",
|
||||||
|
format!("{}-{}", key(i), key(j)),
|
||||||
|
got.sigma(),
|
||||||
|
exact_sd,
|
||||||
|
got.sigma() / exact_sd
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
|
||||||
|
"{name} {}-{}: posterior_of gave {} where the exact joint is {exact_sd}",
|
||||||
|
key(i),
|
||||||
|
key(j),
|
||||||
|
got.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single competitor: this is where the loopy marginal was 2x narrow.
|
||||||
|
for (i, row) in cov.iter().enumerate() {
|
||||||
|
let got = h.joint().unwrap().posterior_of(&[(&key(i), 1.0)]).unwrap();
|
||||||
|
let exact_sd = row[i].sqrt();
|
||||||
|
assert!(
|
||||||
|
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
|
||||||
|
"{name} {}: posterior_of gave {} where exact is {exact_sd}",
|
||||||
|
key(i),
|
||||||
|
got.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(" single-competitor marginals also exact");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cost of the dense solve as the slice grows. Recorded, not asserted.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "timing probe, run explicitly"]
|
||||||
|
fn cost_scaling() {
|
||||||
|
use std::time::Instant;
|
||||||
|
for n in [50usize, 100, 200, 400, 800] {
|
||||||
|
let names: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
|
||||||
|
let mut h: History<String> = History::builder()
|
||||||
|
.key_type::<String>()
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 200,
|
||||||
|
epsilon: 1e-8,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
let mut seed = 5u64;
|
||||||
|
let mut rnd = move || {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 7;
|
||||||
|
seed ^= seed << 17;
|
||||||
|
seed
|
||||||
|
};
|
||||||
|
let events: Vec<Event<i64, String>> = (0..n * 4)
|
||||||
|
.map(|_| {
|
||||||
|
let a = (rnd() as usize) % n;
|
||||||
|
let mut b = (rnd() as usize) % n;
|
||||||
|
if b == a {
|
||||||
|
b = (b + 1) % n;
|
||||||
|
}
|
||||||
|
Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(names[a].clone())]),
|
||||||
|
Team::with_members([Member::new(names[b].clone())]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores([1.0, 0.0]),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
h.add_events(events).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let t = Instant::now();
|
||||||
|
let g = h
|
||||||
|
.joint()
|
||||||
|
.unwrap()
|
||||||
|
.posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)])
|
||||||
|
.unwrap();
|
||||||
|
println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//! Inference must report numerical breakdown rather than call it convergence.
|
||||||
|
//!
|
||||||
|
//! The boundary rejects inputs that are *not numbers*, but finite inputs can
|
||||||
|
//! still overflow during inference — `beta.powi(2)` at 1e300 is infinite, and
|
||||||
|
//! infinity minus infinity is NaN. `NonFiniteResult` is the guard for that, and
|
||||||
|
//! it matters because the alternative is silent: NaN fails every comparison, so
|
||||||
|
//! a naive `step < epsilon` check reads a NaN step as *converged*.
|
||||||
|
//!
|
||||||
|
//! That is why the crate has `step_converged` / `step_is_finite` rather than
|
||||||
|
//! `!tuple_gt(..)`. These tests pin the guard from outside.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{
|
||||||
|
ConstantDrift, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn scored_fit(
|
||||||
|
sigma: f64,
|
||||||
|
beta: f64,
|
||||||
|
score_sigma: f64,
|
||||||
|
scores: [f64; 2],
|
||||||
|
) -> Result<bool, InferenceError> {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(sigma)
|
||||||
|
.beta(beta)
|
||||||
|
.score_sigma(score_sigma)
|
||||||
|
.build();
|
||||||
|
h.add_events(vec![Event {
|
||||||
|
time: 1i64,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::scores(scores),
|
||||||
|
}])?;
|
||||||
|
h.converge().map(|r| r.converged)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every one of these is built from finite, individually legal parameters. The
|
||||||
|
/// overflow happens inside inference, which is exactly the case the boundary
|
||||||
|
/// checks cannot catch.
|
||||||
|
///
|
||||||
|
/// Matched rather than merely `is_err()`: an assertion that only checks "some
|
||||||
|
/// error" would keep passing if these started failing at the boundary for an
|
||||||
|
/// unrelated reason, and would then be testing nothing.
|
||||||
|
#[test]
|
||||||
|
fn overflow_during_inference_is_reported_not_hidden() {
|
||||||
|
let cases: [(&str, f64, f64, f64, [f64; 2]); 5] = [
|
||||||
|
("huge sigma", 1e300, 1.0, 1.0, [3.0, 1.0]),
|
||||||
|
("huge beta", 6.0, 1e300, 1.0, [3.0, 1.0]),
|
||||||
|
("tiny sigma", 1e-300, 1.0, 1.0, [3.0, 1.0]),
|
||||||
|
("tiny score_sigma", 6.0, 1.0, 1e-300, [3.0, 1.0]),
|
||||||
|
("huge scores", 6.0, 1.0, 1.0, [1e308, -1e308]),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, sigma, beta, score_sigma, scores) in cases {
|
||||||
|
match scored_fit(sigma, beta, score_sigma, scores) {
|
||||||
|
Err(InferenceError::NonFiniteStep { context, step, .. }) => {
|
||||||
|
assert_eq!(context, "History::converge", "{name}");
|
||||||
|
assert!(
|
||||||
|
!step.0.is_finite() || !step.1.is_finite(),
|
||||||
|
"{name}: reported NonFiniteResult with a finite step {step:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("{name}: expected NonFiniteResult, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trap the invariant exists for: NaN fails every comparison, so a naive
|
||||||
|
/// `step < epsilon` test reads a NaN step as converged. A breakdown must never
|
||||||
|
/// come back as a successful fit.
|
||||||
|
#[test]
|
||||||
|
fn a_broken_fit_is_never_reported_as_converged() {
|
||||||
|
let mut h = History::builder().build();
|
||||||
|
h.add_events(vec![Event {
|
||||||
|
time: 1i64,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(1e300, 1e-300))]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = h.converge().unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||||
|
"a breakdown must not be reported as convergence: {err:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// `converge_partial` must not launder it into an `Ok` either — the
|
||||||
|
// permissive path is permissive about *stopping short*, not about NaN.
|
||||||
|
let mut h2 = History::builder().build();
|
||||||
|
h2.add_events(vec![Event {
|
||||||
|
time: 1i64,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(1e300, 1e-300))]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
h2.converge_partial().unwrap_err(),
|
||||||
|
InferenceError::NonFiniteStep { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The neighbouring case, so the tests above cannot pass by the fit simply
|
||||||
|
/// always failing: ordinary extreme-but-workable parameters still converge.
|
||||||
|
#[test]
|
||||||
|
fn merely_extreme_parameters_still_converge() {
|
||||||
|
assert!(scored_fit(1e6, 1.0, 1.0, [3.0, 1.0]).unwrap());
|
||||||
|
assert!(scored_fit(1e-6, 1.0, 1.0, [3.0, 1.0]).unwrap());
|
||||||
|
assert!(scored_fit(6.0, 1.0, 1e6, [3.0, 1.0]).unwrap());
|
||||||
|
assert!(scored_fit(6.0, 1.0, 1.0, [1e150, -1e150]).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A NaN in one competitor must not be masked by a healthy competitor reduced
|
||||||
|
/// after it.
|
||||||
|
///
|
||||||
|
/// The convergence step is a fold over a `HashMap`, so which competitor is
|
||||||
|
/// reduced last is per-process hash order. Before the fix, `tuple_max` dropped
|
||||||
|
/// a NaN accumulator in favour of the next finite delta and this returned
|
||||||
|
/// `Ok(converged: true)` with a NaN posterior in **16 of 30 runs** on identical
|
||||||
|
/// input. Deterministic now, but note this test can only ever sample one hash
|
||||||
|
/// order per run — the ordering guarantee itself is pinned by
|
||||||
|
/// `tuple_max_propagates_a_nan_from_any_position` in the crate's unit tests.
|
||||||
|
#[test]
|
||||||
|
fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.p_draw(0.1)
|
||||||
|
.build();
|
||||||
|
h.add_events(vec![
|
||||||
|
Event {
|
||||||
|
time: 1i64,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, 1e-200))]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
// A healthy pair in the same slice, to be reduced alongside the NaN.
|
||||||
|
Event {
|
||||||
|
time: 1i64,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("c")]),
|
||||||
|
Team::with_members([Member::new("d")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = h
|
||||||
|
.converge()
|
||||||
|
.expect_err("a NaN fit must never be reported as converged");
|
||||||
|
assert!(
|
||||||
|
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tie observed with a narrow draw margin between far-apart competitors must
|
||||||
|
/// produce a fit, not NaN skills.
|
||||||
|
///
|
||||||
|
/// The tie branch forms the truncated variance from `v^2 - u`, and both grow as
|
||||||
|
/// `alpha^2` while their difference stays `O(1)`. Deep enough into the tail
|
||||||
|
/// that subtraction had four digits left: measured, it returned `1 - w`
|
||||||
|
/// negative and `sqrt` of it was NaN. The half-line escape hatch did not cover
|
||||||
|
/// it, because that keys on how many window-widths from the mean the window
|
||||||
|
/// sits and a narrow window fails that however deep it is.
|
||||||
|
///
|
||||||
|
/// These parameters are ordinary for a precise-scoring domain, and the
|
||||||
|
/// neighbouring wider-margin case always worked — so this was a cliff, not
|
||||||
|
/// "extreme inputs break".
|
||||||
|
#[test]
|
||||||
|
fn a_narrow_draw_margin_far_into_the_tail_still_fits() {
|
||||||
|
for (beta, p_draw, sd, gap) in [
|
||||||
|
(1e-2, 1e-8, 1e-2, 10.0),
|
||||||
|
(1e-3, 1e-9, 1e-3, 1.0),
|
||||||
|
(1e-4, 1e-12, 1e-4, 1.0),
|
||||||
|
] {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(sd)
|
||||||
|
.beta(beta)
|
||||||
|
.p_draw(p_draw)
|
||||||
|
.drift(ConstantDrift::new(0.0))
|
||||||
|
.build();
|
||||||
|
h.add_events(vec![Event {
|
||||||
|
time: 1i64,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, sd))]),
|
||||||
|
Team::with_members([Member::new("b").with_prior(Gaussian::from_ms(gap, sd))]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::draw(2),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let report = h
|
||||||
|
.converge()
|
||||||
|
.unwrap_or_else(|e| panic!("beta {beta:e}, p_draw {p_draw:e}: {e:?}"));
|
||||||
|
assert!(report.converged);
|
||||||
|
|
||||||
|
let skill = h.current_skill(&"a").unwrap();
|
||||||
|
assert!(
|
||||||
|
skill.mu().is_finite() && skill.sigma().is_finite() && skill.sigma() > 0.0,
|
||||||
|
"beta {beta:e}, p_draw {p_draw:e}: {skill:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
//! `Observer` callbacks must actually fire.
|
||||||
|
//!
|
||||||
|
//! `on_slice_processed` (formerly `on_batch_processed`) was declared on the
|
||||||
|
//! trait and never called from anywhere, so implementors wired up a callback
|
||||||
|
//! that could not run. These tests exist so that cannot silently recur.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use trueskill_tt::{History, Observer};
|
||||||
|
|
||||||
|
/// Plain fields. `Arc<O>` implements `Observer`, so the caller shares the
|
||||||
|
/// observer itself rather than wrapping each field in its own `Arc`.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Recorder {
|
||||||
|
iterations: Mutex<Vec<usize>>,
|
||||||
|
slices: Mutex<Vec<(i64, usize, usize)>>,
|
||||||
|
converged: Mutex<Vec<(usize, bool)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Observer<i64> for Recorder {
|
||||||
|
fn on_iteration_end(&self, iter: usize, _max_step: (f64, f64)) {
|
||||||
|
self.iterations.lock().unwrap().push(iter);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_slice_processed(&self, time: &i64, slice_idx: usize, n_events: usize) {
|
||||||
|
self.slices
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((*time, slice_idx, n_events));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_converged(&self, iters: usize, _final_step: (f64, f64), converged: bool) {
|
||||||
|
self.converged.lock().unwrap().push((iters, converged));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_observer_callback_fires() {
|
||||||
|
let recorder = Arc::new(Recorder::default());
|
||||||
|
let mut h = History::builder().observer(Arc::clone(&recorder)).build();
|
||||||
|
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
h.record_winner(&"b", &"c", 2).unwrap();
|
||||||
|
h.record_winner(&"c", &"a", 3).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!recorder.iterations.lock().unwrap().is_empty(),
|
||||||
|
"on_iteration_end never fired"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!recorder.converged.lock().unwrap().is_empty(),
|
||||||
|
"on_converged never fired"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!recorder.slices.lock().unwrap().is_empty(),
|
||||||
|
"on_slice_processed never fired — the defect this test exists for"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slice_callbacks_report_the_slice_they_swept() {
|
||||||
|
let recorder = Arc::new(Recorder::default());
|
||||||
|
let mut h = History::builder().observer(Arc::clone(&recorder)).build();
|
||||||
|
|
||||||
|
h.record_winner(&"a", &"b", 10).unwrap();
|
||||||
|
h.record_winner(&"a", &"b", 20).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let slices = recorder.slices.lock().unwrap();
|
||||||
|
|
||||||
|
// Only the times actually in the history, and each with its own events.
|
||||||
|
for &(time, idx, events) in slices.iter() {
|
||||||
|
assert!(time == 10 || time == 20, "unexpected slice time {time}");
|
||||||
|
assert!(idx < 2, "slice index {idx} out of range");
|
||||||
|
assert_eq!(events, 1, "each slice holds exactly one event");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both slices must be reported, not just one end of the sweep.
|
||||||
|
assert!(
|
||||||
|
slices.iter().any(|&(t, ..)| t == 10),
|
||||||
|
"slice 10 never reported"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
slices.iter().any(|&(t, ..)| t == 20),
|
||||||
|
"slice 20 never reported"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_slice_history_still_reports_its_sweep() {
|
||||||
|
let recorder = Arc::new(Recorder::default());
|
||||||
|
let mut h = History::builder().observer(Arc::clone(&recorder)).build();
|
||||||
|
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let slices = recorder.slices.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
!slices.is_empty(),
|
||||||
|
"the single-slice path must report its sweep too"
|
||||||
|
);
|
||||||
|
assert!(slices.iter().all(|&(t, idx, _)| t == 1 && idx == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gap #40 closed: without `impl Observer for Arc<O>`, an observer that
|
||||||
|
/// accumulates anything had to wrap every field in its own `Arc` and derive
|
||||||
|
/// `Clone`, because `History` consumes the observer and never hands it back.
|
||||||
|
#[test]
|
||||||
|
fn a_shared_observer_reaches_the_callers_handle() {
|
||||||
|
let recorder = Arc::new(Recorder::default());
|
||||||
|
let mut h = History::builder().observer(Arc::clone(&recorder)).build();
|
||||||
|
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
assert!(!recorder.iterations.lock().unwrap().is_empty());
|
||||||
|
assert!(!recorder.slices.lock().unwrap().is_empty());
|
||||||
|
assert!(!recorder.converged.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `?Sized` on the blanket impls means the observer can be chosen at runtime.
|
||||||
|
#[test]
|
||||||
|
fn a_trait_object_observer_works() {
|
||||||
|
let boxed: Box<dyn Observer<i64>> = Box::new(Recorder::default());
|
||||||
|
let mut h = History::builder().observer(boxed).build();
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
let shared: Arc<dyn Observer<i64>> = Arc::new(Recorder::default());
|
||||||
|
let mut h = History::builder().observer(Arc::clone(&shared)).build();
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-shared observer can be reclaimed after convergence instead.
|
||||||
|
#[test]
|
||||||
|
fn into_observer_returns_the_accumulated_state() {
|
||||||
|
let mut h = History::builder().observer(Recorder::default()).build();
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
|
||||||
|
// Readable in place...
|
||||||
|
assert!(!h.observer().iterations.lock().unwrap().is_empty());
|
||||||
|
|
||||||
|
// ...and reclaimable by value.
|
||||||
|
let recorder = h.into_observer();
|
||||||
|
assert!(!recorder.slices.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrowing works too, for an observer that outlives the history.
|
||||||
|
#[test]
|
||||||
|
fn a_borrowed_observer_works() {
|
||||||
|
let recorder = Recorder::default();
|
||||||
|
{
|
||||||
|
let mut h = History::builder().observer(&recorder).build();
|
||||||
|
h.record_winner(&"a", &"b", 1).unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
}
|
||||||
|
assert!(!recorder.iterations.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user