docs: document the whole public surface and deny(missing_docs)
80 undocumented public items, including three that are first contact:
`History::current_skill` — the method the crate's own first example calls
— `EventBuilder`, the type `h.event(t)` hands you, and `Gaussian::mu()`.
Now zero, and `#![deny(missing_docs)]` keeps it that way.
Several docs are measurements rather than readings of the code:
- `Outcome::Ranked` says ranks are used ordinally, so `[0, 1, 2]` and
`[0, 5, 90]` are the same observation. Measured: bit-identical
posteriors for both.
- `OwnedGame::log_evidence` says two identically-rated competitors give
exactly `ln(0.5)`. Written as a doctest, so it runs.
- `Member::weight` says zero and negative are accepted. Measured.
- `ConvergenceReport::final_step` is `(|Δmu|, |Δsigma|)` in skill units,
NOT natural parameters. That one had to be traced through
`Gaussian::delta` rather than assumed from the neighbouring vocabulary.
- `GameOptions::score_sigma` rejects non-positive and NaN but accepts
`+inf`, which is what the guard actually says.
README: it is the front door for a crate on a private registry, and it
opened with a link dump followed by 130 lines on drift. The first
`record_winner → converge → current_skill` block was at line 226 of 307.
It now leads with what the crate is, an install line, a quickstart, a
"which entry point?" table, and the `converge`-is-strict rationale that
was the crate's most opinionated recent decision and went unmentioned.
The two canonical examples disagreed on spelling (`History::default()`
vs `History::builder().build()`, `current_skill("a")` vs
`current_skill(&"a")`); they now agree. Five new README blocks are
doctested, taking the suite from 19 to 25.
`pub use smallvec;`. Four public items name `SmallVec` in their
signatures, and the only `Joint` example failed to compile from a
consumer crate with `unresolved import smallvec` — the dependency was in
the API but not reachable. Both worked examples now use the re-export,
so they teach the path that works downstream.
Vocabulary, from #75: "agent" was a fourth word for competitor, 200
occurrences, and it had reached public signatures before #73 un-exported
`TimeSlice`. Now zero.
Closes #77. Refs #75.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
@@ -1,15 +1,142 @@
|
|||||||
# TrueSkill - Through Time
|
# TrueSkill - Through Time
|
||||||
|
|
||||||
Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
|
Bayesian skill rating over a time axis.
|
||||||
|
|
||||||
## Other implementations
|
Where plain TrueSkill gives each competitor one running estimate, TrueSkill
|
||||||
|
Through Time treats a whole history as a single model and infers skill *at every
|
||||||
|
point in time*. Evidence flows both directions: a result today sharpens the
|
||||||
|
estimate of who someone was last year, so early estimates stop being frozen
|
||||||
|
guesses and comparisons across eras become meaningful.
|
||||||
|
|
||||||
- [ttt-scala](https://github.com/ankurdave/ttt-scala)
|
A Rust port of
|
||||||
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
|
[TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
|
||||||
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
|
|
||||||
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
|
## Install
|
||||||
- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf)
|
|
||||||
- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/)
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
trueskill-tt = "0.8"
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional features, both off by default:
|
||||||
|
|
||||||
|
- `approx` — `approx`'s equality traits for `Gaussian`. Useful in tests.
|
||||||
|
- `rayon` — parallelises the within-slice sweep and the per-slice passes of
|
||||||
|
`learning_curves` / `log_evidence`. Results stay bit-identical regardless of
|
||||||
|
worker count; `just determinism` asserts it at 1, 2, 4 and 8 threads.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
Record results, converge, then read off skills.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::History;
|
||||||
|
|
||||||
|
let mut history = History::default();
|
||||||
|
|
||||||
|
history.record_winner(&"alice", &"bob", 1)?;
|
||||||
|
history.record_winner(&"bob", &"carol", 2)?;
|
||||||
|
history.record_winner(&"alice", &"carol", 3)?;
|
||||||
|
|
||||||
|
history.converge()?;
|
||||||
|
|
||||||
|
let alice = history.current_skill("alice").unwrap();
|
||||||
|
assert!(alice.mu() > 0.0, "alice won every game she played");
|
||||||
|
# Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
The third argument is the time. It is what makes this Through Time rather than
|
||||||
|
plain TrueSkill: skill is inferred at each of those moments, not once at the
|
||||||
|
end. `learning_curve` reads the whole trajectory back.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use trueskill_tt::History;
|
||||||
|
# let mut history = History::default();
|
||||||
|
# history.record_winner(&"alice", &"bob", 1)?;
|
||||||
|
# history.record_winner(&"bob", &"carol", 2)?;
|
||||||
|
# history.record_winner(&"alice", &"carol", 3)?;
|
||||||
|
# history.converge()?;
|
||||||
|
// `None` means the key is unknown; `Some(vec![])` means known but unplayed.
|
||||||
|
let curve = history.learning_curve("alice").unwrap();
|
||||||
|
for (time, skill) in &curve {
|
||||||
|
println!("t={time}: {:.2} ± {:.2}", skill.mu(), skill.sigma());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everyone's latest posterior in one pass — the leaderboard query.
|
||||||
|
let latest = history.current_skills();
|
||||||
|
assert_eq!(latest.len(), 3);
|
||||||
|
# Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Teams, rankings and draws
|
||||||
|
|
||||||
|
Anything beyond one-versus-one goes through the fluent event builder. An event
|
||||||
|
is only recorded by the terminal `.commit()`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::History;
|
||||||
|
|
||||||
|
let mut history = History::builder().p_draw(0.1).build();
|
||||||
|
|
||||||
|
history
|
||||||
|
.event(1)
|
||||||
|
.team(["alice", "bob"])
|
||||||
|
.team(["carol", "dave"])
|
||||||
|
.ranking([0, 1]) // lower is better; equal values are a tie
|
||||||
|
.commit()?;
|
||||||
|
|
||||||
|
history.converge()?;
|
||||||
|
# Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
**A tie needs a positive `p_draw`.** A `p_draw` of zero asserts draws cannot
|
||||||
|
happen, so a tied result has no representable likelihood and is rejected rather
|
||||||
|
than fitted to something else:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use trueskill_tt::{History, InferenceError};
|
||||||
|
|
||||||
|
let mut history = History::default(); // p_draw defaults to 0.0
|
||||||
|
let err = history.record_draw(&"alice", &"bob", 1).unwrap_err();
|
||||||
|
assert!(matches!(err, InferenceError::TieWithoutDrawProbability { .. }));
|
||||||
|
```
|
||||||
|
|
||||||
|
This also catches `Outcome::winner(w, n)` for three or more teams, which ties
|
||||||
|
every loser.
|
||||||
|
|
||||||
|
## Which entry point?
|
||||||
|
|
||||||
|
| You want to | Use |
|
||||||
|
|---|---|
|
||||||
|
| One match, two competitors | `record_winner` / `record_draw` |
|
||||||
|
| Teams, explicit ranks, scores, per-member weights | `history.event(t)…commit()` |
|
||||||
|
| A batch you already have as values | `add_events(iter)` |
|
||||||
|
| Score a hypothetical with no history at all | `Game` |
|
||||||
|
|
||||||
|
`Game` is the odd one out and worth being explicit about: it is a single match's
|
||||||
|
factor graph, it does not participate in a `History`, and nothing it computes is
|
||||||
|
remembered. Reach for it to evaluate a matchup in isolation; reach for `History`
|
||||||
|
for everything that accumulates.
|
||||||
|
|
||||||
|
## `converge` is strict
|
||||||
|
|
||||||
|
`converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the
|
||||||
|
step still above `epsilon`, and `Err(NonFiniteResult)` if a sweep produces NaN.
|
||||||
|
|
||||||
|
It used to return `Ok` with `converged: false`, which was the worst available
|
||||||
|
shape. A fit that stops short is *wrong by a little*: every posterior is finite,
|
||||||
|
the ordering looks sensible, and nothing about the output says the numbers were
|
||||||
|
still moving. Detection was opt-in, and `let _ = h.converge()` silently opted
|
||||||
|
out — which is how a real defect hid in this crate's own test suite.
|
||||||
|
|
||||||
|
The default `max_iter` is high enough that reaching it means something is
|
||||||
|
genuinely wrong rather than that the history is large; the loop exits at
|
||||||
|
`epsilon` long before, so raising the cap costs nothing when it is not needed.
|
||||||
|
Use `converge_partial` when a deliberately capped, unconverged fit is the point.
|
||||||
|
|
||||||
|
Predictions are strict for the same reason: every `predict_*` method reads
|
||||||
|
skills through one gate that refuses a NaN-poisoned fit, rather than returning a
|
||||||
|
plausible number computed from it.
|
||||||
|
|
||||||
## Drift
|
## Drift
|
||||||
|
|
||||||
@@ -228,11 +355,11 @@ certain because it knows less.
|
|||||||
```rust
|
```rust
|
||||||
use trueskill_tt::History;
|
use trueskill_tt::History;
|
||||||
|
|
||||||
let mut h = History::builder().build();
|
let mut h = History::default();
|
||||||
h.record_winner(&"alice", &"bob", 1).unwrap();
|
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||||||
let _ = h.converge().unwrap();
|
h.converge().unwrap();
|
||||||
|
|
||||||
let skill = h.current_skill(&"alice").unwrap();
|
let skill = h.current_skill("alice").unwrap();
|
||||||
|
|
||||||
// "How sure am I that this is below the cutoff?" — a probability, not a
|
// "How sure am I that this is below the cutoff?" — a probability, not a
|
||||||
// `mu + z * sigma` band whose confidence drifts as sigma changes.
|
// `mu + z * sigma` band whose confidence drifts as sigma changes.
|
||||||
@@ -254,7 +381,7 @@ what you believe now and what you would believe afterwards.
|
|||||||
```rust
|
```rust
|
||||||
use trueskill_tt::History;
|
use trueskill_tt::History;
|
||||||
|
|
||||||
let mut h = History::builder().build();
|
let mut h = History::default();
|
||||||
for t in 1..=10 {
|
for t in 1..=10 {
|
||||||
h.record_winner(&"veteran", &"regular", t).unwrap();
|
h.record_winner(&"veteran", &"regular", t).unwrap();
|
||||||
h.record_winner(&"regular", &"veteran", t + 100).unwrap();
|
h.record_winner(&"regular", &"veteran", t + 100).unwrap();
|
||||||
@@ -278,16 +405,21 @@ expensive than `quality()`. Scoring every pairing among `n` competitors is
|
|||||||
`O(n² × outcomes)` passes — shortlist with `quality()` or
|
`O(n² × outcomes)` passes — shortlist with `quality()` or
|
||||||
`predict_win_probabilities` first, then score only the shortlist.
|
`predict_win_probabilities` first, then score only the shortlist.
|
||||||
|
|
||||||
## Todo
|
## Other implementations
|
||||||
|
|
||||||
- [x] Implement approx for Gaussian
|
- [ttt-scala](https://github.com/ankurdave/ttt-scala)
|
||||||
- [x] Add more tests from `TrueSkillThroughTime.jl`
|
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
|
||||||
- [x] Generalise a time axis — `Time` is now a trait (`Untimed`, `i64`), not an enum
|
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
|
||||||
- [x] Add examples (`examples/atp.rs`, `examples/scored.rs`)
|
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
|
||||||
- [x] Add Observer (`Observer` / `NullObserver`)
|
- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf)
|
||||||
- [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`)
|
- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/)
|
||||||
- [x] N-team `predict_outcome` with draw mass, and `expected_information_gain`
|
|
||||||
- [x] Cross-check `quality()` against [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) — N identical teams follow the closed form `(1/5)^((n-1)/2)` for the conventional parameters, asserted for n = 2..10, and the n=3/n=5 values (0.200, 0.040) match the reference package
|
## Status
|
||||||
|
|
||||||
|
Every box on the old todo list is ticked, so it has been retired; open work
|
||||||
|
lives in the issue tracker instead. The crate is in use and the API is still
|
||||||
|
moving — breaking changes are batched into minor releases rather than dribbled
|
||||||
|
out, and `CHANGELOG.md` records them.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,8 @@
|
|||||||
use plotters::prelude::*;
|
use plotters::prelude::*;
|
||||||
use smallvec::smallvec;
|
|
||||||
use time::{Date, Month};
|
use time::{Date, Month};
|
||||||
use trueskill_tt::{Event, History, Member, Outcome, Team, drift::ConstantDrift};
|
use trueskill_tt::{
|
||||||
|
Event, History, Member, Outcome, Team, drift::ConstantDrift, smallvec::smallvec,
|
||||||
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
|
let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
|
||||||
|
|||||||
+1
-2
@@ -6,8 +6,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Run with: `cargo run --example scored --release`
|
//! Run with: `cargo run --example scored --release`
|
||||||
|
|
||||||
use smallvec::smallvec;
|
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team, smallvec::smallvec};
|
||||||
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut h = History::builder()
|
let mut h = History::builder()
|
||||||
|
|||||||
@@ -4,9 +4,30 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use smallvec::SmallVec;
|
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.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct ConvergenceOptions {
|
pub struct ConvergenceOptions {
|
||||||
|
/// Hard cap on full forward+backward sweeps.
|
||||||
|
///
|
||||||
|
/// A runaway guard, not a budget: the loop exits as soon as the step falls
|
||||||
|
/// to `epsilon`, so raising this costs nothing on a history that converges.
|
||||||
|
/// Reaching it is
|
||||||
|
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged).
|
||||||
pub max_iter: usize,
|
pub max_iter: usize,
|
||||||
|
/// Convergence threshold, in skill units.
|
||||||
|
///
|
||||||
|
/// The sweep stops once *both* components of the step — the largest change
|
||||||
|
/// a whole iteration made to any competitor's posterior mean, and to any
|
||||||
|
/// posterior standard deviation — are at or below this. Larger values stop
|
||||||
|
/// sooner and further from the fixed point. Must be non-negative; NaN is
|
||||||
|
/// rejected, since every comparison against it is false and the loop would
|
||||||
|
/// read it as converged.
|
||||||
pub epsilon: f64,
|
pub epsilon: f64,
|
||||||
/// EP damping factor in natural-parameter space: each per-factor
|
/// EP damping factor in natural-parameter space: each per-factor
|
||||||
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
|
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
|
||||||
@@ -70,10 +91,38 @@ impl Default for ConvergenceOptions {
|
|||||||
/// not be, and `converged` is what says so.
|
/// not be, and `converged` is what says so.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct ConvergenceReport {
|
pub struct ConvergenceReport {
|
||||||
|
/// Full forward+backward sweeps actually run. `0` for a history with no
|
||||||
|
/// time slices, which is converged trivially.
|
||||||
pub iterations: usize,
|
pub iterations: usize,
|
||||||
|
/// How far the last sweep still moved the fit, as `(mean, standard
|
||||||
|
/// deviation)`.
|
||||||
|
///
|
||||||
|
/// Not natural parameters: each component is a componentwise maximum of
|
||||||
|
/// `|Δmu|` and `|Δsigma|` over every competitor posterior the sweep
|
||||||
|
/// touched, so both are in skill units and both are non-negative. Each is
|
||||||
|
/// compared against `epsilon` separately — `converged` means neither
|
||||||
|
/// exceeds it. `(0.0, 0.0)` for a history with no time slices.
|
||||||
pub final_step: (f64, f64),
|
pub final_step: (f64, f64),
|
||||||
|
/// Natural log of the model evidence for the whole history at this fit,
|
||||||
|
/// summed over every time slice.
|
||||||
|
///
|
||||||
|
/// The same quantity
|
||||||
|
/// [`History::log_evidence`](crate::History::log_evidence) returns, taken
|
||||||
|
/// once the sweep has stopped. Only comparable between fits of the same
|
||||||
|
/// events; higher means the model explains them better.
|
||||||
pub log_evidence: f64,
|
pub log_evidence: f64,
|
||||||
|
/// Whether the sweep reached `epsilon` rather than stopping at `max_iter`.
|
||||||
|
///
|
||||||
|
/// Always `true` from [`History::converge`](crate::History::converge),
|
||||||
|
/// which reports the other case as `NotConverged`. From
|
||||||
|
/// [`History::converge_partial`](crate::History::converge_partial) this is
|
||||||
|
/// the only thing that distinguishes a finished fit from a capped one.
|
||||||
pub converged: bool,
|
pub converged: bool,
|
||||||
|
/// Wall-clock time each sweep took, in the order they ran.
|
||||||
|
///
|
||||||
|
/// One entry per iteration, so its length equals `iterations`; empty for a
|
||||||
|
/// history with no time slices. It times the sweeps only, so the final
|
||||||
|
/// log-evidence pass is not in any entry.
|
||||||
pub per_iteration_time: SmallVec<[Duration; 32]>,
|
pub per_iteration_time: SmallVec<[Duration; 32]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+89
-8
@@ -39,36 +39,71 @@ pub enum UnknownKeys {
|
|||||||
Prior,
|
Prior,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every way ingestion, inference or prediction can refuse to answer.
|
||||||
|
///
|
||||||
|
/// The crate reports rather than repairs. An input it cannot represent, a fit
|
||||||
|
/// that never reached its fixed point, a quadrature it cannot resolve — each
|
||||||
|
/// comes back here instead of as a clamped, skipped or truncated result that
|
||||||
|
/// would still look like a number. Several variants exist precisely because the
|
||||||
|
/// silent version was measured and found to return a plausible wrong answer.
|
||||||
|
///
|
||||||
|
/// The enum and most of its variants are `#[non_exhaustive]`: new cases and new
|
||||||
|
/// fields are additive, so match with a `_` arm and construct through the
|
||||||
|
/// library rather than by literal.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub enum InferenceError {
|
pub enum InferenceError {
|
||||||
/// Expected and actual lengths of some array-shaped input differ.
|
/// Expected and actual lengths of some array-shaped input differ.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
MismatchedShape {
|
MismatchedShape {
|
||||||
|
/// Which input disagreed, as a short label — `"ranks vs teams"`,
|
||||||
|
/// `"weights"`, `"times"`.
|
||||||
kind: &'static str,
|
kind: &'static str,
|
||||||
|
/// The length it had to have, taken from whatever it must line up with
|
||||||
|
/// (usually the event's team count).
|
||||||
expected: usize,
|
expected: usize,
|
||||||
|
/// The length actually supplied.
|
||||||
got: usize,
|
got: usize,
|
||||||
},
|
},
|
||||||
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
WrongOutcomeKind {
|
WrongOutcomeKind {
|
||||||
|
/// The call that rejected the outcome, e.g. `"Game::ranked"`.
|
||||||
context: &'static str,
|
context: &'static str,
|
||||||
|
/// The [`Outcome`](crate::Outcome) variant that call needs, by name.
|
||||||
expected: &'static str,
|
expected: &'static str,
|
||||||
|
/// The variant actually supplied, by name.
|
||||||
got: &'static str,
|
got: &'static str,
|
||||||
},
|
},
|
||||||
/// A probability value is outside `[0, 1]`.
|
/// A probability value is outside `[0, 1]`.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
InvalidProbability { value: f64 },
|
InvalidProbability {
|
||||||
|
/// The value supplied, as it fell outside `[0, 1]`. Today only
|
||||||
|
/// `p_draw` reaches here.
|
||||||
|
value: f64,
|
||||||
|
},
|
||||||
/// A scalar parameter is outside its valid range.
|
/// A scalar parameter is outside its valid range.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
InvalidParameter { name: &'static str, value: f64 },
|
InvalidParameter {
|
||||||
|
/// The parameter, spelled as the API spells it — `"alpha"`,
|
||||||
|
/// `"epsilon"`, `"score_sigma"`, `"drift_scale"`, `"drift variance"`.
|
||||||
|
name: &'static str,
|
||||||
|
/// The value supplied for it. Out of that parameter's range, or NaN,
|
||||||
|
/// which fails every range comparison and is rejected on that basis.
|
||||||
|
value: f64,
|
||||||
|
},
|
||||||
/// An event contains tied teams, but the draw probability is zero.
|
/// An event contains tied teams, but the draw probability is zero.
|
||||||
///
|
///
|
||||||
/// A zero draw probability asserts that draws cannot occur, so a tied
|
/// A zero draw probability asserts that draws cannot occur, so a tied
|
||||||
/// result has no representable likelihood. Configure a positive `p_draw`
|
/// result has no representable likelihood. Configure a positive `p_draw`
|
||||||
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
|
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
TieWithoutDrawProbability { teams: (usize, usize) },
|
TieWithoutDrawProbability {
|
||||||
|
/// Positions in the event's team list of the first tied pair, lowest
|
||||||
|
/// index first. Only one pair is reported — the event is rejected
|
||||||
|
/// whole, so enumerating the rest would add nothing.
|
||||||
|
teams: (usize, usize),
|
||||||
|
},
|
||||||
/// The convergence sweep hit `max_iter` with the step still above
|
/// The convergence sweep hit `max_iter` with the step still above
|
||||||
/// `epsilon`.
|
/// `epsilon`.
|
||||||
///
|
///
|
||||||
@@ -84,8 +119,15 @@ pub enum InferenceError {
|
|||||||
/// returns the short fit instead when that is genuinely what is wanted.
|
/// returns the short fit instead when that is genuinely what is wanted.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
NotConverged {
|
NotConverged {
|
||||||
|
/// Full forward+backward sweeps run before the loop gave up.
|
||||||
iterations: usize,
|
iterations: usize,
|
||||||
|
/// How far the last sweep still moved the fit, as
|
||||||
|
/// `(largest change in a mean, largest change in a standard
|
||||||
|
/// deviation)` over every competitor posterior it touched — the same
|
||||||
|
/// quantity as
|
||||||
|
/// [`ConvergenceReport::final_step`](crate::ConvergenceReport).
|
||||||
final_step: (f64, f64),
|
final_step: (f64, f64),
|
||||||
|
/// The threshold both components of `final_step` had to reach.
|
||||||
epsilon: f64,
|
epsilon: f64,
|
||||||
},
|
},
|
||||||
/// Inference produced a non-finite value (NaN or infinity).
|
/// Inference produced a non-finite value (NaN or infinity).
|
||||||
@@ -94,7 +136,12 @@ pub enum InferenceError {
|
|||||||
/// and must not be treated as a converged estimate.
|
/// and must not be treated as a converged estimate.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
NonFiniteResult {
|
NonFiniteResult {
|
||||||
|
/// Where the breakdown was caught — `"History::converge"` for a sweep,
|
||||||
|
/// or a phrase naming the prediction that read an unusable skill.
|
||||||
context: &'static str,
|
context: &'static str,
|
||||||
|
/// The offending pair, at least one component of which is NaN or
|
||||||
|
/// infinite. From `converge` it is the sweep's step; from a prediction
|
||||||
|
/// it is the skill's own `(mu, sigma)`.
|
||||||
step: (f64, f64),
|
step: (f64, f64),
|
||||||
},
|
},
|
||||||
/// One batch declared two different values for the same competitor's
|
/// One batch declared two different values for the same competitor's
|
||||||
@@ -108,7 +155,12 @@ pub enum InferenceError {
|
|||||||
/// when a competitor's configuration is a property of the domain.
|
/// when a competitor's configuration is a property of the domain.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
ConflictingCompetitorConfig {
|
ConflictingCompetitorConfig {
|
||||||
|
/// The competitor's interned [`Index`](crate::Index) as a raw `usize`,
|
||||||
|
/// not the user key — the batch is already flattened to indices by the
|
||||||
|
/// time the conflict is detectable.
|
||||||
competitor: usize,
|
competitor: usize,
|
||||||
|
/// Which piece of configuration was declared twice: `"prior"` or
|
||||||
|
/// `"drift_scale"`.
|
||||||
field: &'static str,
|
field: &'static str,
|
||||||
},
|
},
|
||||||
/// A prediction referenced a key the history has no skill for.
|
/// A prediction referenced a key the history has no skill for.
|
||||||
@@ -123,8 +175,16 @@ pub enum InferenceError {
|
|||||||
/// neutral value — turns the whole thing into a plausible constant.
|
/// neutral value — turns the whole thing into a plausible constant.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
UnknownKey {
|
UnknownKey {
|
||||||
|
/// Position of the offending team in the supplied matchup. `0` on the
|
||||||
|
/// queries that take a flat list of keys rather than teams, where
|
||||||
|
/// there is only one list to index into.
|
||||||
team: usize,
|
team: usize,
|
||||||
|
/// Position of the offending key within that team, or within the flat
|
||||||
|
/// key list.
|
||||||
member: usize,
|
member: usize,
|
||||||
|
/// The key's `Debug` rendering, captured because `K` is only required
|
||||||
|
/// to be `Debug` — see the variant docs for why the indices alone are
|
||||||
|
/// not enough.
|
||||||
key: String,
|
key: String,
|
||||||
},
|
},
|
||||||
/// `History::register` was called for a competitor that already exists.
|
/// `History::register` was called for a competitor that already exists.
|
||||||
@@ -138,10 +198,16 @@ pub enum InferenceError {
|
|||||||
/// To change an existing competitor's configuration, supply it on an event
|
/// To change an existing competitor's configuration, supply it on an event
|
||||||
/// through `Member`; that refits the whole history.
|
/// through `Member`; that refits the whole history.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
AlreadyRegistered { key: String },
|
AlreadyRegistered {
|
||||||
|
/// The already-known competitor's key, in its `Debug` rendering.
|
||||||
|
key: String,
|
||||||
|
},
|
||||||
/// A prediction was given a team with no members.
|
/// A prediction was given a team with no members.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
EmptyTeam { team: usize },
|
EmptyTeam {
|
||||||
|
/// Position of the memberless team in the supplied list.
|
||||||
|
team: usize,
|
||||||
|
},
|
||||||
/// The prediction grid cannot resolve the narrowest feature in the matchup.
|
/// The prediction grid cannot resolve the narrowest feature in the matchup.
|
||||||
///
|
///
|
||||||
/// `predict_outcome` and `predict_ranking` integrate every team's density
|
/// `predict_outcome` and `predict_ranking` integrate every team's density
|
||||||
@@ -167,10 +233,19 @@ pub enum InferenceError {
|
|||||||
},
|
},
|
||||||
/// A joint posterior was requested where one cannot be formed exactly.
|
/// A joint posterior was requested where one cannot be formed exactly.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
JointUnavailable { reason: &'static str },
|
JointUnavailable {
|
||||||
|
/// Why no exact joint exists here: the history has no events, it holds
|
||||||
|
/// ranked events whose EP factors are not retained past convergence, or
|
||||||
|
/// the assembled precision matrix is not positive-definite.
|
||||||
|
reason: &'static str,
|
||||||
|
},
|
||||||
/// Fewer than two teams were supplied to a prediction.
|
/// Fewer than two teams were supplied to a prediction.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
NotEnoughTeams { got: usize },
|
NotEnoughTeams {
|
||||||
|
/// How many teams the prediction was actually given. Two is the
|
||||||
|
/// minimum: there is nothing to compare against with fewer.
|
||||||
|
got: usize,
|
||||||
|
},
|
||||||
/// The full outcome distribution was requested for too many teams.
|
/// The full outcome distribution was requested for too many teams.
|
||||||
///
|
///
|
||||||
/// Each realisation sorts into exactly one (order, tie-pattern) event, so
|
/// Each realisation sorts into exactly one (order, tie-pattern) event, so
|
||||||
@@ -180,7 +255,13 @@ pub enum InferenceError {
|
|||||||
/// `predict_ranking`, or for `predict_win_probabilities`, both of which
|
/// `predict_ranking`, or for `predict_win_probabilities`, both of which
|
||||||
/// stay cheap at any team count.
|
/// stay cheap at any team count.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
TooManyTeams { got: usize, max: usize },
|
TooManyTeams {
|
||||||
|
/// How many teams the outcome distribution was asked for.
|
||||||
|
got: usize,
|
||||||
|
/// The largest team count that will be enumerated,
|
||||||
|
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for InferenceError {
|
impl fmt::Display for InferenceError {
|
||||||
|
|||||||
@@ -13,8 +13,25 @@ use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
|
|||||||
/// A single match at time `time` involving some number of teams.
|
/// A single match at time `time` involving some number of teams.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct Event<T: Time, K> {
|
pub struct Event<T: Time, K> {
|
||||||
|
/// When the match happened, on the history's time axis.
|
||||||
|
///
|
||||||
|
/// Events sharing a `time` land in the same time slice and are fitted
|
||||||
|
/// together, so nothing distinguishes their order. Drift is driven by the
|
||||||
|
/// gap between a competitor's *consecutive appearances*, not by the gap
|
||||||
|
/// between slices, so a competitor idle across several slices accumulates
|
||||||
|
/// the whole span at once when it next plays.
|
||||||
pub time: T,
|
pub time: T,
|
||||||
|
/// The teams that took part, positionally aligned with `outcome`: team `i`
|
||||||
|
/// here is the team `outcome` ranks or scores at index `i`.
|
||||||
|
///
|
||||||
|
/// Ingestion rejects fewer than two teams (`NotEnoughTeams`) and any team
|
||||||
|
/// with no members (`EmptyTeam`).
|
||||||
pub teams: SmallVec<[Team<K>; 4]>,
|
pub teams: SmallVec<[Team<K>; 4]>,
|
||||||
|
/// How the match ended: ranks (lower is better) or per-team scores (higher
|
||||||
|
/// is better), one entry per entry of `teams`.
|
||||||
|
///
|
||||||
|
/// A tie — two equal ranks — needs a positive `p_draw`, otherwise
|
||||||
|
/// ingestion fails with `TieWithoutDrawProbability`.
|
||||||
pub outcome: Outcome,
|
pub outcome: Outcome,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,16 +39,31 @@ pub struct Event<T: Time, K> {
|
|||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub struct Team<K> {
|
pub struct Team<K> {
|
||||||
|
/// The competitors playing together, in no significant order: the team's
|
||||||
|
/// performance is the weight-scaled sum over its members, which does not
|
||||||
|
/// depend on how they are listed.
|
||||||
|
///
|
||||||
|
/// Must be non-empty — an empty team contributes no performance at all, so
|
||||||
|
/// ingestion rejects it with `EmptyTeam` rather than returning a plausible
|
||||||
|
/// posterior for whoever it was matched against.
|
||||||
pub members: SmallVec<[Member<K>; 4]>,
|
pub members: SmallVec<[Member<K>; 4]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<K> Team<K> {
|
impl<K> Team<K> {
|
||||||
|
/// A team with no members yet, to be filled through the public `members`
|
||||||
|
/// field.
|
||||||
|
///
|
||||||
|
/// Committing it while still empty is an `EmptyTeam` error.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
members: SmallVec::new(),
|
members: SmallVec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A team of exactly these competitors.
|
||||||
|
///
|
||||||
|
/// Members must be built already — `Member::from(key)` covers the common
|
||||||
|
/// case of a plain key at default weight with no overrides.
|
||||||
pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self {
|
pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self {
|
||||||
Self {
|
Self {
|
||||||
members: members.into_iter().collect(),
|
members: members.into_iter().collect(),
|
||||||
@@ -64,8 +96,26 @@ impl<K> Default for Team<K> {
|
|||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub struct Member<K> {
|
pub struct Member<K> {
|
||||||
|
/// The competitor's identity. Equal keys across events are the same
|
||||||
|
/// competitor: `History` interns each distinct key to an internal `Index`
|
||||||
|
/// the first time it sees it, and every later appearance resolves to that
|
||||||
|
/// same competitor's temporal state.
|
||||||
pub key: K,
|
pub key: K,
|
||||||
|
/// This member's share of the team's performance, for this event only.
|
||||||
|
///
|
||||||
|
/// The team's performance is the sum of `weight × member performance`, so
|
||||||
|
/// `1.0` is a full share and `0.5` counts the member half; the message
|
||||||
|
/// coming back to the member is divided by the same weight. Defaults to
|
||||||
|
/// `1.0`.
|
||||||
|
///
|
||||||
|
/// Must be finite — a NaN or infinite weight is `InvalidParameter` at
|
||||||
|
/// ingestion. Zero and negative are accepted, both being expressible in
|
||||||
|
/// the same arithmetic.
|
||||||
pub weight: f64,
|
pub weight: f64,
|
||||||
|
/// Starting skill for this competitor, replacing the history's `mu`/`sigma`
|
||||||
|
/// default. `None` keeps the history default.
|
||||||
|
///
|
||||||
|
/// Competitor configuration, not a per-event value; see the type docs.
|
||||||
pub prior: Option<Gaussian>,
|
pub prior: Option<Gaussian>,
|
||||||
/// Multiplier on the drift *variance* this competitor accumulates.
|
/// Multiplier on the drift *variance* this competitor accumulates.
|
||||||
/// `None` means 1.0.
|
/// `None` means 1.0.
|
||||||
@@ -73,6 +123,8 @@ pub struct Member<K> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<K> Member<K> {
|
impl<K> Member<K> {
|
||||||
|
/// A competitor taking a full share of its team's performance, with no
|
||||||
|
/// configuration overrides: the history's prior and drift apply.
|
||||||
pub fn new(key: K) -> Self {
|
pub fn new(key: K) -> Self {
|
||||||
Self {
|
Self {
|
||||||
key,
|
key,
|
||||||
@@ -82,6 +134,12 @@ impl<K> Member<K> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Change how much of the team's performance this member accounts for.
|
||||||
|
///
|
||||||
|
/// Unlike `prior` and `drift_scale`, this is genuinely per-event: the same
|
||||||
|
/// key can carry a different weight in every event it appears in, which is
|
||||||
|
/// what makes it usable for partial participation — a substitute who
|
||||||
|
/// played half the match, a doubles partner credited unequally.
|
||||||
pub fn with_weight(mut self, weight: f64) -> Self {
|
pub fn with_weight(mut self, weight: f64) -> Self {
|
||||||
self.weight = weight;
|
self.weight = weight;
|
||||||
self
|
self
|
||||||
|
|||||||
@@ -9,6 +9,33 @@ use crate::{
|
|||||||
time::Time,
|
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 \
|
#[must_use = "an event is only recorded by `.commit()`; a dropped builder \
|
||||||
silently ingests nothing"]
|
silently ingests nothing"]
|
||||||
pub struct EventBuilder<'h, T, D, O, K>
|
pub struct EventBuilder<'h, T, D, O, K>
|
||||||
|
|||||||
+68
@@ -70,8 +70,25 @@ impl DiffFactor {
|
|||||||
/// how much the engine trusts the observed score margin (smaller σ = more trust).
|
/// how much the engine trusts the observed score margin (smaller σ = more trust).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct GameOptions {
|
pub struct GameOptions {
|
||||||
|
/// Probability the model assigns to two teams drawing, which sets the width
|
||||||
|
/// of the truncation band around a tie. Must be in `[0.0, 1.0)`; defaults
|
||||||
|
/// to [`P_DRAW`](crate::P_DRAW).
|
||||||
|
///
|
||||||
|
/// At `0.0` the band has zero width, so a ranked outcome that ties two
|
||||||
|
/// teams has no representable likelihood and [`Game::ranked`] rejects it
|
||||||
|
/// with `TieWithoutDrawProbability`.
|
||||||
pub p_draw: f64,
|
pub p_draw: f64,
|
||||||
|
/// Standard deviation of the observation noise on an observed score margin,
|
||||||
|
/// used only by [`Game::scored`], which rejects a non-positive or NaN value
|
||||||
|
/// with `InvalidParameter`. Defaults to `1.0`.
|
||||||
|
///
|
||||||
|
/// It is in the units of the scores themselves, and says how much of a
|
||||||
|
/// margin the model reads as skill rather than noise: a small sigma takes
|
||||||
|
/// the margin near-literally, a large one barely moves the ratings.
|
||||||
pub score_sigma: f64,
|
pub score_sigma: f64,
|
||||||
|
/// Stopping rule and damping for the within-game message-passing loop:
|
||||||
|
/// iterate until the largest message change falls below `epsilon`, or
|
||||||
|
/// `max_iter` passes, with each update damped by `alpha`.
|
||||||
pub convergence: crate::ConvergenceOptions,
|
pub convergence: crate::ConvergenceOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +108,9 @@ impl Default for GameOptions {
|
|||||||
/// History's internal state), `OwnedGame<T, D>` owns the team ratings, so it
|
/// History's internal state), `OwnedGame<T, D>` owns the team ratings, so it
|
||||||
/// can be returned freely from public constructors. The inference inputs
|
/// can be returned freely from public constructors. The inference inputs
|
||||||
/// themselves are not retained — nothing reads them back.
|
/// themselves are not retained — nothing reads them back.
|
||||||
|
///
|
||||||
|
/// A fitted single match, and nothing more: see [`Game`] for why that is not
|
||||||
|
/// the same as a step of a [`History`](crate::History).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub struct OwnedGame<T: Time, D: Drift<T>> {
|
pub struct OwnedGame<T: Time, D: Drift<T>> {
|
||||||
@@ -145,6 +165,13 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updated skill belief for every competitor, as `[team][member]` in the
|
||||||
|
/// order the teams and members were passed in.
|
||||||
|
///
|
||||||
|
/// Each is the competitor's own prior multiplied by the likelihood this one
|
||||||
|
/// match produced for it — so it reflects this match and the rating handed
|
||||||
|
/// in, and nothing else. Feeding it back as the next match's prior is the
|
||||||
|
/// caller's job; that is what a [`History`](crate::History) automates.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
||||||
self.likelihoods
|
self.likelihoods
|
||||||
@@ -154,12 +181,48 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Natural log of how probable this outcome was under the priors, summed
|
||||||
|
/// over the diff chain's links.
|
||||||
|
///
|
||||||
|
/// Higher means the result was less surprising, so it doubles as a
|
||||||
|
/// closeness measure — two identically-rated competitors give exactly
|
||||||
|
/// `ln(0.5)`, either of them being equally likely to win:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
|
||||||
|
/// let r = Rating::new(Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift::new(0.0));
|
||||||
|
/// let g = Game::<i64, _>::ranked(&[&[r], &[r]], Outcome::winner(0, 2), &GameOptions::default())?;
|
||||||
|
/// assert!((g.log_evidence() - 0.5_f64.ln()).abs() < 1e-12);
|
||||||
|
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Accumulated in log space because the linear product over a long chain
|
||||||
|
/// underflows to zero, and `ln(0.0)` is `-inf`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn log_evidence(&self) -> f64 {
|
pub fn log_evidence(&self) -> f64 {
|
||||||
self.log_evidence
|
self.log_evidence
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One match's factor graph, fitted on its own.
|
||||||
|
///
|
||||||
|
/// Rate a single match against ratings you already hold and get the updated
|
||||||
|
/// beliefs straight back. There is no history behind it: nothing is stored,
|
||||||
|
/// nothing propagates backward, and the priors you hand in are the only
|
||||||
|
/// evidence used. That makes it the wrong tool for the thing this crate exists
|
||||||
|
/// for — [`History`](crate::History) is what infers skill *through time*,
|
||||||
|
/// revising past estimates as later matches arrive, and a sequence of `Game`s
|
||||||
|
/// chained by hand is a forward-only filter, not the same answer.
|
||||||
|
///
|
||||||
|
/// Reach for `Game` when a history would be overkill or unavailable: a
|
||||||
|
/// one-off matchup, replaying a rating step from stored numbers, checking the
|
||||||
|
/// engine against a reference, or a caller that keeps its own persistence and
|
||||||
|
/// only wants the update rule.
|
||||||
|
///
|
||||||
|
/// The type is mostly a namespace. Its constructors — [`Game::ranked`],
|
||||||
|
/// [`Game::scored`], [`Game::one_v_one`], [`Game::free_for_all`] — return an
|
||||||
|
/// [`OwnedGame`], because `Game<'a, …>` borrows the result and weight slices
|
||||||
|
/// that `History` keeps internally and so cannot be handed out.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
||||||
teams: Vec<Vec<Rating<T, D>>>,
|
teams: Vec<Vec<Rating<T, D>>>,
|
||||||
@@ -413,6 +476,9 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
self.likelihoods = likelihoods;
|
self.likelihoods = likelihoods;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updated skill belief for every competitor, as `[team][member]` in the
|
||||||
|
/// order the teams and members were passed in — prior times this match's
|
||||||
|
/// likelihood, exactly as [`OwnedGame::posteriors`].
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
||||||
self.likelihoods
|
self.likelihoods
|
||||||
@@ -427,6 +493,8 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Natural log of how probable this outcome was under the priors, summed
|
||||||
|
/// over the diff chain's links — as [`OwnedGame::log_evidence`].
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn log_evidence(&self) -> f64 {
|
pub fn log_evidence(&self) -> f64 {
|
||||||
self.log_evidence
|
self.log_evidence
|
||||||
|
|||||||
@@ -100,18 +100,34 @@ impl Gaussian {
|
|||||||
Self { pi, tau }
|
Self { pi, tau }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Precision, `1 / sigma^2` — one of the two natural parameters.
|
||||||
|
///
|
||||||
|
/// This is the representation the type actually stores, which is why the EP
|
||||||
|
/// product and cavity (`Mul` / `Div`) are plain adds and subtracts. Larger
|
||||||
|
/// means more certain; `0.0` is an improper, uninformative message and
|
||||||
|
/// `inf` is a point mass.
|
||||||
#[inline]
|
#[inline]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn pi(&self) -> f64 {
|
pub fn pi(&self) -> f64 {
|
||||||
self.pi
|
self.pi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Precision-adjusted mean, `mu / sigma^2` — the other natural parameter.
|
||||||
|
///
|
||||||
|
/// Stored rather than derived, for the same reason as [`Gaussian::pi`].
|
||||||
|
/// Meaningful only alongside `pi`: on its own it is not a location.
|
||||||
#[inline]
|
#[inline]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn tau(&self) -> f64 {
|
pub fn tau(&self) -> f64 {
|
||||||
self.tau
|
self.tau
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mean skill: the point estimate.
|
||||||
|
///
|
||||||
|
/// Derived from the natural parameters as `tau / pi`. An improper message
|
||||||
|
/// (`pi <= 0`) has no defined mean and reports `0.0` — see
|
||||||
|
/// [`Gaussian::sigma`], which reports `inf` for the same state, and read
|
||||||
|
/// the two together before treating a mean as informative.
|
||||||
#[inline]
|
#[inline]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn mu(&self) -> f64 {
|
pub fn mu(&self) -> f64 {
|
||||||
@@ -141,6 +157,12 @@ impl Gaussian {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Standard deviation: how unsure this estimate is.
|
||||||
|
///
|
||||||
|
/// Derived as `1 / sqrt(pi)`. An improper message (`pi <= 0`) reports
|
||||||
|
/// `inf`, and a point mass (`pi == inf`) reports `0.0` — both are real
|
||||||
|
/// states rather than error codes, and both are legitimate for a converged
|
||||||
|
/// fit with degenerate parameters.
|
||||||
#[inline]
|
#[inline]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn sigma(&self) -> f64 {
|
pub fn sigma(&self) -> f64 {
|
||||||
|
|||||||
+80
-24
@@ -24,6 +24,19 @@ use crate::{
|
|||||||
tuple_gt, tuple_max,
|
tuple_gt, tuple_max,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Configures a [`History`] before any events are added.
|
||||||
|
///
|
||||||
|
/// Everything a history needs that is not an event lives here: the prior
|
||||||
|
/// (`mu`, `sigma`), the performance noise `beta`, the draw probability, the
|
||||||
|
/// drift model, the convergence settings, the observer, and what to do about
|
||||||
|
/// an unknown key. None of them can be changed after `build`, because they
|
||||||
|
/// define the model the fit is of.
|
||||||
|
///
|
||||||
|
/// Two of the setters change the builder's *type* rather than a field —
|
||||||
|
/// [`HistoryBuilder::drift`] and [`HistoryBuilder::observer`] — so bind the
|
||||||
|
/// result rather than calling them on a `&mut`. [`HistoryBuilder::time_type`]
|
||||||
|
/// and [`HistoryBuilder::key_type`] exist for the same reason: to name a type
|
||||||
|
/// parameter that nothing in the call chain would otherwise infer.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
#[must_use = "a builder does nothing until `.build()`"]
|
#[must_use = "a builder does nothing until `.build()`"]
|
||||||
pub struct HistoryBuilder<
|
pub struct HistoryBuilder<
|
||||||
@@ -99,6 +112,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the drift model: how far skill may move between appearances.
|
||||||
|
///
|
||||||
|
/// Changes the builder's type, since `D` is a type parameter — bind the
|
||||||
|
/// result. [`ConstantDrift`] is the default; a custom [`Drift`] impl is
|
||||||
|
/// the way to express a calendar-dependent or per-competitor rule that
|
||||||
|
/// elapsed ticks alone cannot.
|
||||||
|
///
|
||||||
|
/// Not validated here: the builder cannot inspect an arbitrary
|
||||||
|
/// implementation. `converge` checks the variance each competitor actually
|
||||||
|
/// accumulates and reports `InvalidParameter` if it is negative or
|
||||||
|
/// non-finite.
|
||||||
pub fn drift<D2: Drift<T>>(self, drift: D2) -> HistoryBuilder<T, D2, O, K> {
|
pub fn drift<D2: Drift<T>>(self, drift: D2) -> HistoryBuilder<T, D2, O, K> {
|
||||||
HistoryBuilder {
|
HistoryBuilder {
|
||||||
drift,
|
drift,
|
||||||
@@ -248,6 +272,12 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach an [`Observer`] to be called as inference progresses.
|
||||||
|
///
|
||||||
|
/// Changes the builder's type — bind the result. The history takes the
|
||||||
|
/// observer by value; to keep a handle on one that accumulates state, pass
|
||||||
|
/// an `Arc` and keep a clone, or read it back with
|
||||||
|
/// [`History::observer`] / [`History::into_observer`].
|
||||||
pub fn observer<O2: Observer<T>>(self, observer: O2) -> HistoryBuilder<T, D, O2, K> {
|
pub fn observer<O2: Observer<T>>(self, observer: O2) -> HistoryBuilder<T, D, O2, K> {
|
||||||
HistoryBuilder {
|
HistoryBuilder {
|
||||||
mu: self.mu,
|
mu: self.mu,
|
||||||
@@ -264,6 +294,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Finish configuring and produce an empty [`History`].
|
||||||
|
///
|
||||||
|
/// Every parameter was validated as it was set, so this cannot fail.
|
||||||
pub fn build(self) -> History<T, D, O, K> {
|
pub fn build(self) -> History<T, D, O, K> {
|
||||||
History {
|
History {
|
||||||
size: 0,
|
size: 0,
|
||||||
@@ -417,6 +450,12 @@ impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl History<i64, ConstantDrift, NullObserver, &'static str> {
|
impl History<i64, ConstantDrift, NullObserver, &'static str> {
|
||||||
|
/// Start configuring a history.
|
||||||
|
///
|
||||||
|
/// The defaults are `i64` time, [`ConstantDrift`], no observer and
|
||||||
|
/// `&'static str` keys. Any of the four can be changed — the two type
|
||||||
|
/// parameters that no argument would pin are named with
|
||||||
|
/// [`HistoryBuilder::time_type`] and [`HistoryBuilder::key_type`].
|
||||||
pub fn builder() -> HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str> {
|
pub fn builder() -> HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str> {
|
||||||
HistoryBuilder::default()
|
HistoryBuilder::default()
|
||||||
}
|
}
|
||||||
@@ -444,6 +483,11 @@ impl<T: Time, K: Eq + Hash + Clone> HistoryBuilder<T, ConstantDrift, NullObserve
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
|
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
|
||||||
|
/// Promote a key to its [`Index`], creating the entry if it is new.
|
||||||
|
///
|
||||||
|
/// Interning a key does not register a competitor or give them a rating —
|
||||||
|
/// it only reserves the slot. Use [`History::register`] to declare a
|
||||||
|
/// competitor's configuration up front.
|
||||||
pub fn intern<Q>(&mut self, key: &Q) -> Index
|
pub fn intern<Q>(&mut self, key: &Q) -> Index
|
||||||
where
|
where
|
||||||
K: Borrow<Q>,
|
K: Borrow<Q>,
|
||||||
@@ -452,6 +496,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
self.keys.get_or_create(key)
|
self.keys.get_or_create(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve an existing key to its [`Index`], or `None` if the history has
|
||||||
|
/// never seen it.
|
||||||
|
///
|
||||||
|
/// The read-only counterpart of [`History::intern`]: it never creates.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn lookup<Q>(&self, key: &Q) -> Option<Index>
|
pub fn lookup<Q>(&self, key: &Q) -> Option<Index>
|
||||||
where
|
where
|
||||||
@@ -731,6 +779,15 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
.then(|| self.competitors[idx].rating)
|
.then(|| self.competitors[idx].rating)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The competitor's latest posterior skill, or `None` if the history has
|
||||||
|
/// never seen the key or they have no appearances.
|
||||||
|
///
|
||||||
|
/// "Latest" is their own last appearance, which need not be the last slice
|
||||||
|
/// in the history. For everyone at once — a leaderboard — use
|
||||||
|
/// [`History::current_skills`], which is one pass rather than one per key.
|
||||||
|
///
|
||||||
|
/// This reads whatever the fit currently holds. It does not converge, and
|
||||||
|
/// it does not check that a previous `converge` succeeded.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
|
pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
|
||||||
where
|
where
|
||||||
@@ -1144,8 +1201,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
// accident.
|
// accident.
|
||||||
if self.beta == 0.0 && gathered.iter().flatten().all(|g| g.sigma() == 0.0) {
|
if self.beta == 0.0 && gathered.iter().flatten().all(|g| g.sigma() == 0.0) {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "beta is zero and every skill is a point mass, so there is \
|
name: "beta with point-mass skills",
|
||||||
no performance distribution to predict from",
|
|
||||||
value: 0.0,
|
value: 0.0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1160,7 +1216,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// As [`History::member_skills`].
|
/// As `member_skills`.
|
||||||
fn performances(&self, teams: &[&[&K]]) -> Result<(Vec<Gaussian>, Vec<usize>), InferenceError>
|
fn performances(&self, teams: &[&[&K]]) -> Result<(Vec<Gaussian>, Vec<usize>), InferenceError>
|
||||||
where
|
where
|
||||||
K: std::fmt::Debug,
|
K: std::fmt::Debug,
|
||||||
@@ -1566,7 +1622,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
/// substitution — and returns exactly what the one-shot call would.
|
/// substitution — and returns exactly what the one-shot call would.
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// # use smallvec::smallvec;
|
/// # use trueskill_tt::smallvec::smallvec;
|
||||||
/// # use trueskill_tt::{Event, History, Member, Outcome, Team};
|
/// # use trueskill_tt::{Event, History, Member, Outcome, Team};
|
||||||
/// # let mut h = History::builder().score_sigma(1.0).build();
|
/// # let mut h = History::builder().score_sigma(1.0).build();
|
||||||
/// # let round = |x, y, sx, sy, t| Event {
|
/// # let round = |x, y, sx, sy, t| Event {
|
||||||
@@ -1928,7 +1984,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
///
|
///
|
||||||
/// It used to return `Ok` with `converged: false`, which was the worst
|
/// 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
|
/// available shape. A fit that stops short is *wrong by a little*: every
|
||||||
/// rating is finite, the ordering looks sensible, and nothing about the
|
/// posterior is finite, the ordering looks sensible, and nothing about the
|
||||||
/// output says the numbers were still moving. Detection was opt-in, and
|
/// output says the numbers were still moving. Detection was opt-in, and
|
||||||
/// `let _ = h.converge()` silently opted out — which is how a real defect
|
/// `let _ = h.converge()` silently opted out — which is how a real defect
|
||||||
/// hid in this crate's own test suite.
|
/// hid in this crate's own test suite.
|
||||||
@@ -2248,14 +2304,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
|
|
||||||
competitor::clean(self.competitors.values_mut(), true);
|
competitor::clean(self.competitors.values_mut(), true);
|
||||||
|
|
||||||
let mut this_agent = Vec::with_capacity(1024);
|
let mut these_competitors = Vec::with_capacity(1024);
|
||||||
|
|
||||||
for competitor in composition.iter().flatten().flatten() {
|
for competitor in composition.iter().flatten().flatten() {
|
||||||
if this_agent.contains(competitor) {
|
if these_competitors.contains(competitor) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
this_agent.push(*competitor);
|
these_competitors.push(*competitor);
|
||||||
|
|
||||||
// From `declared` rather than `priors`: a competitor configured by
|
// From `declared` rather than `priors`: a competitor configured by
|
||||||
// `register` before any event has nothing in this batch's map.
|
// `register` before any event has nothing in this batch's map.
|
||||||
@@ -2355,17 +2411,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
time_slice.new_forward_info(&self.competitors);
|
time_slice.new_forward_info(&self.competitors);
|
||||||
}
|
}
|
||||||
|
|
||||||
for agent_idx in &this_agent {
|
for competitor_idx in &these_competitors {
|
||||||
if let Some(skill) = time_slice.skills.get_mut(*agent_idx) {
|
if let Some(skill) = time_slice.skills.get_mut(*competitor_idx) {
|
||||||
skill.elapsed = time_slice::compute_elapsed(
|
skill.elapsed = time_slice::compute_elapsed(
|
||||||
self.competitors[*agent_idx].last_time.as_ref(),
|
self.competitors[*competitor_idx].last_time.as_ref(),
|
||||||
&time_slice.time,
|
&time_slice.time,
|
||||||
);
|
);
|
||||||
|
|
||||||
let competitor = self.competitors.get_mut(*agent_idx).unwrap();
|
let competitor = self.competitors.get_mut(*competitor_idx).unwrap();
|
||||||
|
|
||||||
competitor.last_time = Some(time_slice.time);
|
competitor.last_time = Some(time_slice.time);
|
||||||
competitor.message = Some(time_slice.forward_prior_out(agent_idx));
|
competitor.message = Some(time_slice.forward_prior_out(competitor_idx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2400,11 +2456,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
&self.competitors,
|
&self.competitors,
|
||||||
);
|
);
|
||||||
|
|
||||||
for agent_idx in time_slice.skills.keys() {
|
for competitor_idx in time_slice.skills.keys() {
|
||||||
let competitor = self.competitors.get_mut(agent_idx).unwrap();
|
let competitor = self.competitors.get_mut(competitor_idx).unwrap();
|
||||||
|
|
||||||
competitor.last_time = Some(t);
|
competitor.last_time = Some(t);
|
||||||
competitor.message = Some(time_slice.forward_prior_out(&agent_idx));
|
competitor.message = Some(time_slice.forward_prior_out(&competitor_idx));
|
||||||
}
|
}
|
||||||
|
|
||||||
k += 1;
|
k += 1;
|
||||||
@@ -2422,11 +2478,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
|
|
||||||
let time_slice = &self.time_slices[k];
|
let time_slice = &self.time_slices[k];
|
||||||
|
|
||||||
for agent_idx in time_slice.skills.keys() {
|
for competitor_idx in time_slice.skills.keys() {
|
||||||
let competitor = self.competitors.get_mut(agent_idx).unwrap();
|
let competitor = self.competitors.get_mut(competitor_idx).unwrap();
|
||||||
|
|
||||||
competitor.last_time = Some(t);
|
competitor.last_time = Some(t);
|
||||||
competitor.message = Some(time_slice.forward_prior_out(&agent_idx));
|
competitor.message = Some(time_slice.forward_prior_out(&competitor_idx));
|
||||||
}
|
}
|
||||||
|
|
||||||
k += 1;
|
k += 1;
|
||||||
@@ -2440,17 +2496,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
|
|
||||||
time_slice.new_forward_info(&self.competitors);
|
time_slice.new_forward_info(&self.competitors);
|
||||||
|
|
||||||
for agent_idx in &this_agent {
|
for competitor_idx in &these_competitors {
|
||||||
if let Some(skill) = time_slice.skills.get_mut(*agent_idx) {
|
if let Some(skill) = time_slice.skills.get_mut(*competitor_idx) {
|
||||||
skill.elapsed = time_slice::compute_elapsed(
|
skill.elapsed = time_slice::compute_elapsed(
|
||||||
self.competitors[*agent_idx].last_time.as_ref(),
|
self.competitors[*competitor_idx].last_time.as_ref(),
|
||||||
&time_slice.time,
|
&time_slice.time,
|
||||||
);
|
);
|
||||||
|
|
||||||
let competitor = self.competitors.get_mut(*agent_idx).unwrap();
|
let competitor = self.competitors.get_mut(*competitor_idx).unwrap();
|
||||||
|
|
||||||
competitor.last_time = Some(time_slice.time);
|
competitor.last_time = Some(time_slice.time);
|
||||||
competitor.message = Some(time_slice.forward_prior_out(agent_idx));
|
competitor.message = Some(time_slice.forward_prior_out(competitor_idx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+71
@@ -85,6 +85,10 @@
|
|||||||
//! regardless of worker count.
|
//! regardless of worker count.
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
// Turned on once the surface was fully documented (80 items at the time), so
|
||||||
|
// the next undocumented public item is a build failure rather than a warning
|
||||||
|
// nobody reads.
|
||||||
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
/// Compiles every `rust` block in `README.md` as a doctest.
|
/// Compiles every `rust` block in `README.md` as a doctest.
|
||||||
///
|
///
|
||||||
@@ -111,12 +115,25 @@ pub(crate) mod arena;
|
|||||||
mod color_group;
|
mod color_group;
|
||||||
mod competitor;
|
mod competitor;
|
||||||
mod convergence;
|
mod convergence;
|
||||||
|
/// Skill drift: how much a competitor's skill is allowed to move between
|
||||||
|
/// appearances.
|
||||||
|
///
|
||||||
|
/// Public because [`Drift`] is a trait a caller may implement — a per-sport
|
||||||
|
/// off-season, say, or a schedule where drift is a function of the calendar
|
||||||
|
/// rather than of elapsed ticks. [`ConstantDrift`] is what
|
||||||
|
/// [`HistoryBuilder`] uses by default.
|
||||||
pub mod drift;
|
pub mod drift;
|
||||||
mod error;
|
mod error;
|
||||||
mod event;
|
mod event;
|
||||||
mod event_builder;
|
mod event_builder;
|
||||||
pub(crate) mod factor;
|
pub(crate) mod factor;
|
||||||
mod game;
|
mod game;
|
||||||
|
/// The Gaussian message type and its expectation-propagation algebra.
|
||||||
|
///
|
||||||
|
/// Public because [`Gaussian`] appears throughout the results: a posterior
|
||||||
|
/// skill, a learning-curve point, a predicted margin. The module carries the
|
||||||
|
/// operator documentation — `Mul`/`Div` are the EP product and cavity, not
|
||||||
|
/// arithmetic on random variables.
|
||||||
pub mod gaussian;
|
pub mod gaussian;
|
||||||
mod history;
|
mod history;
|
||||||
mod joint;
|
mod joint;
|
||||||
@@ -145,13 +162,58 @@ pub use observer::{NullObserver, Observer};
|
|||||||
pub use outcome::Outcome;
|
pub use outcome::Outcome;
|
||||||
pub use predict::Prediction;
|
pub use predict::Prediction;
|
||||||
pub use rating::Rating;
|
pub use rating::Rating;
|
||||||
|
/// The `smallvec` crate, re-exported.
|
||||||
|
///
|
||||||
|
/// Four public items name `SmallVec` in their signatures: [`Event::teams`],
|
||||||
|
/// [`Team::members`], [`Outcome::Ranked`]'s payload and
|
||||||
|
/// [`ConvergenceReport::per_iteration_time`]. You can *build* an `Event`
|
||||||
|
/// without ever naming the type — `vec![..].into()` and `.collect()` both work
|
||||||
|
/// — and iterate the timings through `Deref`. But writing a helper that
|
||||||
|
/// *returns* a teams list, or a `match` arm that binds ranks and passes them
|
||||||
|
/// on, requires the type by name.
|
||||||
|
///
|
||||||
|
/// Measured: the only `Joint` doc example failed to compile from a consumer
|
||||||
|
/// crate with `unresolved import \`smallvec\``, because the dependency was in
|
||||||
|
/// the signature but not reachable. Re-exported so a consumer takes this
|
||||||
|
/// crate's version rather than pinning a matching one of their own.
|
||||||
|
pub use smallvec;
|
||||||
pub use time::{Time, Untimed};
|
pub use time::{Time, Untimed};
|
||||||
|
|
||||||
|
/// Default performance noise: how much a single showing varies around skill.
|
||||||
|
///
|
||||||
|
/// Every other default is expressed as a multiple of this, so `BETA` sets the
|
||||||
|
/// scale of the whole rating system. Doubling it and doubling `SIGMA` and
|
||||||
|
/// `GAMMA` with it gives the same fit on a rescaled axis.
|
||||||
pub const BETA: f64 = 1.0;
|
pub const BETA: f64 = 1.0;
|
||||||
|
/// Default prior mean skill.
|
||||||
|
///
|
||||||
|
/// Zero rather than a conventional 25: the scale is set by `BETA`, and a
|
||||||
|
/// centred axis makes a negative rating mean "below the prior" instead of
|
||||||
|
/// looking like an error.
|
||||||
pub const MU: f64 = 0.0;
|
pub const MU: f64 = 0.0;
|
||||||
|
/// Default prior standard deviation: how unsure the model starts out.
|
||||||
|
///
|
||||||
|
/// Six betas is deliberately wide — a new competitor's first result should
|
||||||
|
/// move them a long way, and the prior should not fight the evidence.
|
||||||
pub const SIGMA: f64 = BETA * 6.0;
|
pub const SIGMA: f64 = BETA * 6.0;
|
||||||
|
/// Default drift: the standard deviation of skill movement per unit of time.
|
||||||
|
///
|
||||||
|
/// Enters inference as a *variance* (`gamma^2` per elapsed tick), which is why
|
||||||
|
/// [`ConstantDrift`] squares it and why a negative gamma would be
|
||||||
|
/// indistinguishable from its absolute value — see [`ConstantDrift::new`].
|
||||||
pub const GAMMA: f64 = BETA * 0.03;
|
pub const GAMMA: f64 = BETA * 0.03;
|
||||||
|
/// Default draw probability: zero, meaning ties are not modelled.
|
||||||
|
///
|
||||||
|
/// A history that ingests a tie needs a positive value. With `p_draw == 0.0`
|
||||||
|
/// the truncation margin is zero and the two-sided tie update evaluates
|
||||||
|
/// `0/0`, so ingestion rejects such events with
|
||||||
|
/// [`InferenceError::TieWithoutDrawProbability`].
|
||||||
pub const P_DRAW: f64 = 0.0;
|
pub const P_DRAW: f64 = 0.0;
|
||||||
|
/// Default convergence threshold, in the same units as
|
||||||
|
/// [`ConvergenceReport::final_step`](crate::ConvergenceReport).
|
||||||
|
///
|
||||||
|
/// The sweep stops once the largest change a full iteration makes to any
|
||||||
|
/// message falls below this.
|
||||||
pub const EPSILON: f64 = 1e-6;
|
pub const EPSILON: f64 = 1e-6;
|
||||||
/// Default cap on convergence sweeps.
|
/// Default cap on convergence sweeps.
|
||||||
///
|
///
|
||||||
@@ -226,6 +288,15 @@ const ASYMPTOTIC_MILLS_ALPHA: f64 = 100.0;
|
|||||||
pub(crate) const N00: Gaussian = Gaussian::from_ms(0.0, 0.0);
|
pub(crate) const N00: Gaussian = Gaussian::from_ms(0.0, 0.0);
|
||||||
pub(crate) const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
|
pub(crate) const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
|
||||||
|
|
||||||
|
/// An interned competitor handle: a dense slot number, not a user key.
|
||||||
|
///
|
||||||
|
/// [`History`] stores skills and messages by `Index` rather than by `K`, so
|
||||||
|
/// the hot path never hashes a key. [`History::intern`] promotes a key to one
|
||||||
|
/// and [`History::lookup`] resolves an existing key without creating.
|
||||||
|
///
|
||||||
|
/// Indices are assigned in interning order and are stable for the life of a
|
||||||
|
/// history. They are **not** portable between histories: the same key interns
|
||||||
|
/// to different slots depending on ingestion order.
|
||||||
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
|
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
|
||||||
pub struct Index(usize);
|
pub struct Index(usize);
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,28 @@ use smallvec::SmallVec;
|
|||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub enum Outcome {
|
pub enum Outcome {
|
||||||
|
/// An ordinal finish: one rank per team, in the order the teams were given.
|
||||||
|
///
|
||||||
|
/// Lower is better, `0` is first, and equal values are a tie between those
|
||||||
|
/// teams — which needs `p_draw > 0`, or ingestion rejects the event with
|
||||||
|
/// [`InferenceError::TieWithoutDrawProbability`](crate::InferenceError::TieWithoutDrawProbability).
|
||||||
|
///
|
||||||
|
/// Only the ordering and the equalities are used. Ranks need not be dense
|
||||||
|
/// or start at zero: inference sorts the teams and compares rank-adjacent
|
||||||
|
/// pairs against a margin set by `p_draw`, so `[0, 1, 2]` and `[0, 5, 90]`
|
||||||
|
/// are the same observation. A gap does not mean a bigger win — use
|
||||||
|
/// `Scored` when the size of the difference is evidence.
|
||||||
Ranked(SmallVec<[u32; 4]>),
|
Ranked(SmallVec<[u32; 4]>),
|
||||||
|
/// A continuous finish: one score per team, higher is better.
|
||||||
|
///
|
||||||
|
/// Unlike `Ranked`, the *sizes* of the differences are evidence. Teams are
|
||||||
|
/// sorted by score and each adjacent pair's observed gap is fed to a
|
||||||
|
/// `MarginFactor` as a measurement with standard deviation `sigma`, so
|
||||||
|
/// beating a team by ten says more than beating them by one.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
Scored {
|
Scored {
|
||||||
|
/// Per-team scores, in the order the teams were given; higher is
|
||||||
|
/// better. Must have one entry per team, and every entry finite.
|
||||||
scores: SmallVec<[f64; 4]>,
|
scores: SmallVec<[f64; 4]>,
|
||||||
/// Per-event noise override. `None` means inherit
|
/// Per-event noise override. `None` means inherit
|
||||||
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
|
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
|
||||||
@@ -104,6 +123,12 @@ impl Outcome {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many teams this outcome describes — the number of ranks, or of
|
||||||
|
/// scores.
|
||||||
|
///
|
||||||
|
/// Ingestion checks it against the event's own team list and rejects a
|
||||||
|
/// disagreement with `MismatchedShape`, so this is the cheap way to check
|
||||||
|
/// an outcome built elsewhere before committing the event.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn team_count(&self) -> usize {
|
pub fn team_count(&self) -> usize {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
+18
-10
@@ -95,7 +95,7 @@ pub(crate) struct Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Event {
|
impl Event {
|
||||||
pub(crate) fn iter_agents(&self) -> impl Iterator<Item = Index> + '_ {
|
pub(crate) fn iter_competitors(&self) -> impl Iterator<Item = Index> + '_ {
|
||||||
self.teams
|
self.teams
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|t| t.items.iter().map(|it| it.competitor))
|
.flat_map(|t| t.items.iter().map(|it| it.competitor))
|
||||||
@@ -255,7 +255,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cg = color_greedy(n, |ev_idx| {
|
let cg = color_greedy(n, |ev_idx| {
|
||||||
self.events[ev_idx].iter_agents().collect::<Vec<_>>()
|
self.events[ev_idx].iter_competitors().collect::<Vec<_>>()
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut reordered: Vec<Event> = Vec::with_capacity(n);
|
let mut reordered: Vec<Event> = Vec::with_capacity(n);
|
||||||
@@ -292,7 +292,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
) {
|
) {
|
||||||
let mut unique = Vec::with_capacity(10);
|
let mut unique = Vec::with_capacity(10);
|
||||||
|
|
||||||
let this_agent = composition.iter().flatten().flatten().filter(|idx| {
|
let these_competitors = composition.iter().flatten().flatten().filter(|idx| {
|
||||||
if !unique.contains(idx) {
|
if !unique.contains(idx) {
|
||||||
unique.push(*idx);
|
unique.push(*idx);
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
false
|
false
|
||||||
});
|
});
|
||||||
|
|
||||||
for idx in this_agent {
|
for idx in these_competitors {
|
||||||
let elapsed = compute_elapsed(competitors[*idx].last_time.as_ref(), &self.time);
|
let elapsed = compute_elapsed(competitors[*idx].last_time.as_ref(), &self.time);
|
||||||
|
|
||||||
let forward = competitors[*idx].receive(&self.time);
|
let forward = competitors[*idx].receive(&self.time);
|
||||||
@@ -1235,14 +1235,22 @@ mod tests {
|
|||||||
// Events at positions 0 and 1 (color 0) must be disjoint — verify by
|
// Events at positions 0 and 1 (color 0) must be disjoint — verify by
|
||||||
// checking that the competitor sets of self.events[0] and self.events[1] do
|
// checking that the competitor sets of self.events[0] and self.events[1] do
|
||||||
// not include the competitor at self.events[2].
|
// not include the competitor at self.events[2].
|
||||||
let agents_in_ev2: Vec<Index> = ts.events[2].iter_agents().collect();
|
let competitors_in_ev2: Vec<Index> = ts.events[2].iter_competitors().collect();
|
||||||
let agents_in_ev0: Vec<Index> = ts.events[0].iter_agents().collect();
|
let competitors_in_ev0: Vec<Index> = ts.events[0].iter_competitors().collect();
|
||||||
let agents_in_ev1: Vec<Index> = ts.events[1].iter_agents().collect();
|
let competitors_in_ev1: Vec<Index> = ts.events[1].iter_competitors().collect();
|
||||||
// ev0 and ev1 must be disjoint from each other (color-0 invariant).
|
// ev0 and ev1 must be disjoint from each other (color-0 invariant).
|
||||||
assert!(agents_in_ev0.iter().all(|ag| !agents_in_ev1.contains(ag)));
|
assert!(
|
||||||
|
competitors_in_ev0
|
||||||
|
.iter()
|
||||||
|
.all(|ag| !competitors_in_ev1.contains(ag))
|
||||||
|
);
|
||||||
// ev2 must share an competitor with ev0 or ev1 (it needed its own color).
|
// ev2 must share an competitor with ev0 or ev1 (it needed its own color).
|
||||||
let ev2_overlaps_ev0 = agents_in_ev2.iter().any(|ag| agents_in_ev0.contains(ag));
|
let ev2_overlaps_ev0 = competitors_in_ev2
|
||||||
let ev2_overlaps_ev1 = agents_in_ev2.iter().any(|ag| agents_in_ev1.contains(ag));
|
.iter()
|
||||||
|
.any(|ag| competitors_in_ev0.contains(ag));
|
||||||
|
let ev2_overlaps_ev1 = competitors_in_ev2
|
||||||
|
.iter()
|
||||||
|
.any(|ag| competitors_in_ev1.contains(ag));
|
||||||
assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1);
|
assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user