2 Commits
Author SHA1 Message Date
logaritmisk 251211f134 Merge docs/missing-docs (#77, #75) 2026-09-09 21:53:51 +02:00
logaritmiskandClaude Opus 5 31564b71a0 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
2026-09-09 21:53:50 +02:00
13 changed files with 664 additions and 67 deletions
+153 -21
View File
@@ -1,15 +1,142 @@
# 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)
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
- [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/)
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(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
@@ -228,11 +355,11 @@ certain because it knows less.
```rust
use trueskill_tt::History;
let mut h = History::builder().build();
let mut h = History::default();
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
// `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
use trueskill_tt::History;
let mut h = History::builder().build();
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();
@@ -278,16 +405,21 @@ 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.
## Todo
## Other implementations
- [x] Implement approx for Gaussian
- [x] Add more tests from `TrueSkillThroughTime.jl`
- [x] Generalise a time axis — `Time` is now a trait (`Untimed`, `i64`), not an enum
- [x] Add examples (`examples/atp.rs`, `examples/scored.rs`)
- [x] Add Observer (`Observer` / `NullObserver`)
- [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`)
- [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
- [ttt-scala](https://github.com/ankurdave/ttt-scala)
- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis)
- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl)
- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R)
- [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/)
## 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
+3 -2
View File
@@ -1,7 +1,8 @@
use plotters::prelude::*;
use smallvec::smallvec;
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() {
let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
+1 -2
View File
@@ -6,8 +6,7 @@
//!
//! Run with: `cargo run --example scored --release`
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team, smallvec::smallvec};
fn main() {
let mut h = History::builder()
+49
View File
@@ -4,9 +4,30 @@ 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.
#[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
@@ -70,10 +91,38 @@ impl Default for ConvergenceOptions {
/// not be, and `converged` is what says so.
#[derive(Clone, Debug, PartialEq)]
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]>,
}
+89 -8
View File
@@ -39,36 +39,71 @@ pub enum UnknownKeys {
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)]
#[non_exhaustive]
pub enum InferenceError {
/// Expected and actual lengths of some array-shaped input differ.
#[non_exhaustive]
MismatchedShape {
/// Which input disagreed, as a short label — `"ranks vs teams"`,
/// `"weights"`, `"times"`.
kind: &'static str,
/// 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 call that rejected the outcome, e.g. `"Game::ranked"`.
context: &'static str,
/// The [`Outcome`](crate::Outcome) variant that call needs, by name.
expected: &'static str,
/// The variant actually supplied, by name.
got: &'static str,
},
/// A probability value is outside `[0, 1]`.
#[non_exhaustive]
InvalidProbability { 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.
#[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.
///
/// 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 { 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
/// `epsilon`.
///
@@ -84,8 +119,15 @@ pub enum InferenceError {
/// 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,
},
/// 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.
#[non_exhaustive]
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,
/// 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),
},
/// 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.
#[non_exhaustive]
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,
/// Which piece of configuration was declared twice: `"prior"` or
/// `"drift_scale"`.
field: &'static str,
},
/// 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.
#[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.
@@ -138,10 +198,16 @@ pub enum InferenceError {
/// To change an existing competitor's configuration, supply it on an event
/// through `Member`; that refits the whole history.
#[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.
#[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.
///
/// `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.
#[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.
#[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.
///
/// 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
/// stay cheap at any team count.
#[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 {
+58
View File
@@ -13,8 +13,25 @@ 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,
}
@@ -22,16 +39,31 @@ pub struct Event<T: Time, K> {
#[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(),
@@ -64,8 +96,26 @@ impl<K> Default for Team<K> {
#[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.
@@ -73,6 +123,8 @@ pub struct 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 {
Self {
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 {
self.weight = weight;
self
+27
View File
@@ -9,6 +9,33 @@ use crate::{
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>
+68
View File
@@ -70,8 +70,25 @@ impl DiffFactor {
/// how much the engine trusts the observed score margin (smaller σ = more trust).
#[derive(Clone, Copy, Debug, PartialEq)]
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,
/// 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,
/// 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,
}
@@ -91,6 +108,9 @@ impl Default for GameOptions {
/// History's internal state), `OwnedGame<T, D>` owns the team ratings, so it
/// can be returned freely from public constructors. The inference inputs
/// themselves are not retained — nothing reads them back.
///
/// 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)]
#[must_use]
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]
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods
@@ -154,12 +181,48 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
.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]
pub fn log_evidence(&self) -> f64 {
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)]
pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
teams: Vec<Vec<Rating<T, D>>>,
@@ -413,6 +476,9 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
self.likelihoods = likelihoods;
}
/// Updated skill belief for every competitor, as `[team][member]` in the
/// order the teams and members were passed in — prior times this match's
/// likelihood, exactly as [`OwnedGame::posteriors`].
#[must_use]
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods
@@ -427,6 +493,8 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
.collect::<Vec<_>>()
}
/// Natural log of how probable this outcome was under the priors, summed
/// over the diff chain's links — as [`OwnedGame::log_evidence`].
#[must_use]
pub fn log_evidence(&self) -> f64 {
self.log_evidence
+22
View File
@@ -100,18 +100,34 @@ impl Gaussian {
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]
#[must_use]
pub 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]
#[must_use]
pub 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 {
@@ -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]
#[must_use]
pub fn sigma(&self) -> f64 {
+80 -24
View File
@@ -24,6 +24,19 @@ use crate::{
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)]
#[must_use = "a builder does nothing until `.build()`"]
pub struct HistoryBuilder<
@@ -99,6 +112,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
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> {
HistoryBuilder {
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> {
HistoryBuilder {
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> {
History {
size: 0,
@@ -417,6 +450,12 @@ impl Default for 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> {
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> {
/// 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
where
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)
}
/// 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]
pub fn lookup<Q>(&self, key: &Q) -> Option<Index>
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)
}
/// 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]
pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
where
@@ -1144,8 +1201,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
// accident.
if self.beta == 0.0 && gathered.iter().flatten().all(|g| g.sigma() == 0.0) {
return Err(InferenceError::InvalidParameter {
name: "beta is zero and every skill is a point mass, so there is \
no performance distribution to predict from",
name: "beta with point-mass skills",
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
///
/// As [`History::member_skills`].
/// As `member_skills`.
fn performances(&self, teams: &[&[&K]]) -> Result<(Vec<Gaussian>, Vec<usize>), InferenceError>
where
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.
///
/// ```
/// # use smallvec::smallvec;
/// # use trueskill_tt::smallvec::smallvec;
/// # use trueskill_tt::{Event, History, Member, Outcome, Team};
/// # let mut h = History::builder().score_sigma(1.0).build();
/// # 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
/// 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
/// `let _ = h.converge()` silently opted out — which is how a real defect
/// 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);
let mut this_agent = Vec::with_capacity(1024);
let mut these_competitors = Vec::with_capacity(1024);
for competitor in composition.iter().flatten().flatten() {
if this_agent.contains(competitor) {
if these_competitors.contains(competitor) {
continue;
}
this_agent.push(*competitor);
these_competitors.push(*competitor);
// From `declared` rather than `priors`: a competitor configured by
// `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);
}
for agent_idx in &this_agent {
if let Some(skill) = time_slice.skills.get_mut(*agent_idx) {
for competitor_idx in &these_competitors {
if let Some(skill) = time_slice.skills.get_mut(*competitor_idx) {
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,
);
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.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,
);
for agent_idx in time_slice.skills.keys() {
let competitor = self.competitors.get_mut(agent_idx).unwrap();
for competitor_idx in time_slice.skills.keys() {
let competitor = self.competitors.get_mut(competitor_idx).unwrap();
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;
@@ -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];
for agent_idx in time_slice.skills.keys() {
let competitor = self.competitors.get_mut(agent_idx).unwrap();
for competitor_idx in time_slice.skills.keys() {
let competitor = self.competitors.get_mut(competitor_idx).unwrap();
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;
@@ -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);
for agent_idx in &this_agent {
if let Some(skill) = time_slice.skills.get_mut(*agent_idx) {
for competitor_idx in &these_competitors {
if let Some(skill) = time_slice.skills.get_mut(*competitor_idx) {
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,
);
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.message = Some(time_slice.forward_prior_out(agent_idx));
competitor.message = Some(time_slice.forward_prior_out(competitor_idx));
}
}
+71
View File
@@ -85,6 +85,10 @@
//! regardless of worker count.
#![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.
///
@@ -111,12 +115,25 @@ pub(crate) mod arena;
mod color_group;
mod competitor;
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;
mod error;
mod event;
mod event_builder;
pub(crate) mod factor;
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;
mod history;
mod joint;
@@ -145,13 +162,58 @@ pub use observer::{NullObserver, Observer};
pub use outcome::Outcome;
pub use predict::Prediction;
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};
/// 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;
/// 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;
/// 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;
/// 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;
/// 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;
/// 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;
/// 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 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)]
pub struct Index(usize);
+25
View File
@@ -18,9 +18,28 @@ use smallvec::SmallVec;
#[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 `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`.
@@ -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]
pub fn team_count(&self) -> usize {
match self {
+18 -10
View File
@@ -95,7 +95,7 @@ pub(crate) struct 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
.iter()
.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| {
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);
@@ -292,7 +292,7 @@ impl<T: Time> TimeSlice<T> {
) {
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) {
unique.push(*idx);
@@ -302,7 +302,7 @@ impl<T: Time> TimeSlice<T> {
false
});
for idx in this_agent {
for idx in these_competitors {
let elapsed = compute_elapsed(competitors[*idx].last_time.as_ref(), &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
// checking that the competitor sets of self.events[0] and self.events[1] do
// not include the competitor at self.events[2].
let agents_in_ev2: Vec<Index> = ts.events[2].iter_agents().collect();
let agents_in_ev0: Vec<Index> = ts.events[0].iter_agents().collect();
let agents_in_ev1: Vec<Index> = ts.events[1].iter_agents().collect();
let competitors_in_ev2: Vec<Index> = ts.events[2].iter_competitors().collect();
let competitors_in_ev0: Vec<Index> = ts.events[0].iter_competitors().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).
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).
let ev2_overlaps_ev0 = agents_in_ev2.iter().any(|ag| agents_in_ev0.contains(ag));
let ev2_overlaps_ev1 = agents_in_ev2.iter().any(|ag| agents_in_ev1.contains(ag));
let ev2_overlaps_ev0 = competitors_in_ev2
.iter()
.any(|ag| competitors_in_ev0.contains(ag));
let ev2_overlaps_ev1 = competitors_in_ev2
.iter()
.any(|ag| competitors_in_ev1.contains(ag));
assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1);
}
}