5 Commits
Author SHA1 Message Date
logaritmisk 2cba3d10f6 chore: Release trueskill-tt version 0.9.0 2026-09-10 07:50:43 +02:00
logaritmisk 5d9501307e Merge chore/release-0.9.0: migration guide and changelog cleanup 2026-09-10 07:47:20 +02:00
logaritmiskandClaude Opus 5 327324c411 docs: add a migration guide for 0.9.0, and drop merge noise from the changelog
Twenty-one breaking changes in one release, with a live consumer. The
changelog lists them; `MIGRATING.md` says what to do about them, leading
with the three that change what an existing, *compiling* call returns —
unknown keys, predictions from a broken fit, and `Gaussian`'s operators
— since those are the ones the compiler will not find for you.

Every "after" snippet was compiled, not written from memory, and doing
so caught three errors in my own guide:

- `log_evidence_for(&[&"alice"])` does not compile at `K = String`. The
  right spelling is `&["alice"]`, which works at *both* key types —
  checked, because a guide that is right for half its readers is worse
  than no guide.
- the same for `filtered_log_evidence_for`
- the `Analysis<'h> { joint: Joint<'h> }` example needs a history at the
  default key type; pairing it with a `History<String>` does not compile

git-cliff skips merge commits now. Every branch lands with `--no-ff`, so
a release's merges outnumber its real commits and say nothing the merged
ones do not — 0.9.0's changelog had fourteen lines of them under "Other
(unconventional)". `ci:` commits get a group instead of falling through
to that catch-all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-10 07:47:19 +02:00
logaritmisk 61da3aca33 Merge api/typed-errors (#74) 2026-09-10 07:31:31 +02:00
logaritmiskandClaude Opus 5 061c481aad refactor!: typed discriminators for InferenceError
Six of fifteen variants carried a `&'static str` discriminator, about
thirty magic strings between them, and the only thing a caller could do
with one was print it. Four new enums replace them:

    Parameter        13 variants, replacing 9 strings in InvalidParameter
    Shape             4 variants, replacing 10 in MismatchedShape
    OutcomeKind       2 variants, replacing WrongOutcomeKind's three fields
    CompetitorField   2 variants, replacing ConflictingCompetitorConfig's

`InvalidProbability` folds into `InvalidParameter` as
`Parameter::PDraw`. It was a bespoke variant for one scalar while every
other scalar shared `InvalidParameter`, and it omitted the parameter
name — so the same parameter had two mechanisms.

`JointUnavailable { reason: &'static str }` splits into `EmptyHistory`,
`JointRequiresScoredEvents` and `NotPositiveDefinite`. The three are
conditions a caller branches on differently — add events, use
`predict_win_probabilities`, or reconsider the priors — and telling them
apart used to mean string-matching English. One test already proved the
distinction was load-bearing: the blanket conversion mapped the
empty-history case onto the ranked one and `an_empty_history_has_no_joint`
caught it immediately.

`NonFiniteResult` splits into `NonFiniteStep { context, step }` and
`NonFiniteSkill { mu, sigma }`. One `step: (f64, f64)` field was
carrying a sweep step from `converge` and a skill's own moments from a
prediction — two situations in one variant, and a field name that could
only be right for one of them.

`InvalidParameter { name: "beta with point-mass skills" }` becomes
`NoPerformanceVariance`. It was never a parameter out of range: both
values are individually valid and it is their combination that leaves
nothing varying.

Three `Display` impls did not meet the standard the others set, and the
typed data is what makes fixing them possible:

    before  drift variance is invalid: NaN
    after   drift variance must be finite and non-negative (got NaN)

    before  kinds: expected length 3, got 2
    after   the outcome describes a different number of teams than the
            event has: expected 3, got 2

    before  Game::ranked: expected Outcome::Ranked, got Outcome::Scored
    after   expected Outcome::Ranked, got Outcome::Scored; call
            Game::scored for a scored outcome

`Parameter::range()` states each parameter's actual bounds, which no
`&'static str` name could have. `error::message_tests` renders every one
and asserts each is a sentence rather than a label, and that the three
above now carry a range or a next step.

The four internal `MismatchedShape` kinds — `results`, `times`, `kinds`,
and the weights array — collapse to `Shape::Internal`, whose `Display`
says plainly that reaching it is a bug in this crate. They are checks on
`add_events_with_prior`'s own parallel arrays and are unreachable
through the public API; they stay checked rather than becoming
`debug_assert!`s, because release is where this crate's defects hide.

Closes #74.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-10 07:31:31 +02:00
26 changed files with 805 additions and 195 deletions
+61 -10
View File
@@ -2,6 +2,65 @@
All notable changes to this project will be documented in this file.
## 0.9.0 - 2026-09-10
### Breaking Changes
- fix!: propagate NaN through the convergence reduction
- fix!: collapse a drift too small to represent, on a relative threshold
- fix!: report an unresolvable prediction grid instead of clamping
- fix!: validate the constructors below HistoryBuilder
- fix!: seal ConstantDrift's field so gamma can be validated
- fix!: make the Time generic reachable
- refactor!: un-export six types that no caller could reach
- fix!: correct eight wrong `# Errors` sections and seal the error variants
- fix!: per-key queries report unknown keys instead of a plausible constant
- fix!: no prediction path answers from a fit it cannot answer from
- docs!: one name for score noise, and say which of beta/sigma to turn
- fix!: non_exhaustive on ConvergenceReport, and not on the options structs
- feat!: prediction and joint queries take borrowed keys
- feat!: Game is the type you get, and one_v_one returns one
- feat!: Gaussian's EP operations stop wearing arithmetic's clothes
- refactor!: retire Index, intern and lookup
- refactor!: scores_with_noise, and History::quality
- refactor!: the joint is reached through Joint, not mirrored on History
- refactor!: K comes first in History, HistoryBuilder and Joint
- perf!: sparse Cholesky with an AMD ordering for the joint
- refactor!: typed discriminators for InferenceError
### Bug Fixes
- fix: take quality's determinant ratio in log space
- fix: keep the truncated variance representable in the far tail
- fix: route the last three transcendentals through libm, and enforce it
- fix: make posterior_of reproducible across processes
- fix: warn on dropped builders and values; stop exporting EP internals
### CI
- ci: measure the runner's own benchmark variance, and fix the joint bench
### Documentation
- docs: document the whole public surface and deny(missing_docs)
- docs: add a migration guide for 0.9.0, and drop merge noise from the changelog
### Features
- feat: add the missing trait impls and make `#[must_use]` consistent
- feat: complete the evidence matrix and add current_skills
- feat: PartialEq on the config types, and pin the public trait impls
- feat: HistoryBuilder::default_rating_for, a rule instead of a roll call
### Refactor
- refactor: one word per concept
### Testing
- test: scale the ceiling sweep by build profile
- test: make the determinism test exercise the parallel sweep
## 0.8.0 - 2026-09-08
### Breaking Changes
@@ -25,13 +84,9 @@ All notable changes to this project will be documented in this file.
- feat: add EventBuilder::members for per-member configuration
### Other (unconventional)
### Miscellaneous Tasks
- Merge branch 'fix/ingestion-shape'
- Merge branch 'feat/convergence-strictness'
- Merge branch 'fix/non-finite-weights'
- Merge branch 'test/close-coverage-gaps'
- Merge branch 'fix/game-boundary'
- chore: Release trueskill-tt version 0.8.0
### Testing
@@ -47,10 +102,6 @@ All notable changes to this project will be documented in this file.
- chore: Release trueskill-tt version 0.7.0
### Other (unconventional)
- Merge branch 'feat/joint-handle'
## 0.6.0 - 2026-09-08
### Breaking Changes
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "trueskill-tt"
version = "0.8.0"
version = "0.9.0"
edition = "2024"
rust-version = "1.85"
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
+204
View File
@@ -0,0 +1,204 @@
# Migrating
## 0.8.0 → 0.9.0
Twenty-one breaking changes. Nearly all of them are mechanical, and the
compiler finds every one — nothing here changes behaviour silently.
Three exceptions are worth reading before you start, because they change
what an existing, compiling call *returns*: [unknown keys](#unknown-keys-are-reported-not-skipped),
[predictions from a broken fit](#predictions-refuse-a-fit-they-cannot-answer-from),
and [`Gaussian`'s operators](#gaussians-operators-are-gone).
### Type parameters: `K` comes first
`K` was last, so naming a history meant writing all four parameters to change
the one that matters.
```rust
// before
struct Ladder { history: History<i64, ConstantDrift, NullObserver, String> }
struct Analysis<'h> { joint: Joint<'h, i64, ConstantDrift, NullObserver, &'static str> }
// after
struct Ladder { history: History<String> }
struct Analysis<'h> { joint: Joint<'h> }
```
`History<K, T, D, O, R>` — key, time, drift, observer, rating rule — all
defaulted. `HistoryBuilder` matches. There is a fifth parameter now (`R`), and
you will never write it unless you use `default_rating_for`.
`HistoryBuilder::<Untimed, _, _, String>::new()` becomes
`HistoryBuilder::<String, Untimed>::new()`.
### Predictions and joint queries take borrowed keys
At `K = String` a string literal used to be impossible, and asking "who wins"
cost four allocations of temporaries that all had to outlive the call.
```rust
// before, at K = String
let ta = vec![a.to_string()];
let ra: Vec<&String> = ta.iter().collect();
let tb = vec![b.to_string()];
let rb: Vec<&String> = tb.iter().collect();
let teams: Vec<&[&String]> = vec![&ra, &rb];
h.predict_win_probabilities(&teams)?;
// after, at either key type
h.predict_win_probabilities(&[&["alice"], &["bob"]])?;
h.posterior_of(&[("alice", 1.0), ("bob", -1.0)])?;
```
At `K = &'static str` the old `&[&[&"a"]]` spelling still compiles — `Q` infers
to `&str` and the two shapes coincide — so this is only a break for owned keys,
where nothing compiled before.
One cost: `predict_outcome(&[])` can no longer infer the key type. Annotate it,
`let none: &[&[&str]] = &[];`. It bites only on that degenerate call.
### Unknown keys are reported, not skipped
**Read this one.** `log_evidence_for` used to `filter_map` unknown keys away,
and an empty target list means *no restriction* downstream — so a list of
entirely unknown keys returned the **whole-history** value. Measured:
`log_evidence_for(["typo"])` returned exactly `log_evidence()`. On the one
workload it is documented for, leave-one-out cross-validation, that is the
un-held-out score.
```rust
let e = h.log_evidence_for(&["alice"])?; // now Result
let curve = h.learning_curve("alice"); // now Option
```
Note `&["alice"]`, not `&[&"alice"]`. These take borrowed keys like the
prediction methods, so one spelling works at both key types.
`learning_curve` and `filtered_learning_curve` return `Option`: `None` is "never
heard of this key", `Some(vec![])` is "known, has not played". They used to be
the same empty `Vec`.
### Predictions refuse a fit they cannot answer from
**Read this one too.** `converge` already refused to report a NaN fit, but
nothing stopped a caller ignoring that error and predicting anyway. On a
NaN-poisoned fit, `quality` returned `Ok(NaN)`, `predict_outcome().total()` was
`NaN`, and `predict_win_probabilities` returned `Ok([0.0, 0.0])` — finite,
plausible, and summing to zero against a doc promising one.
Every `predict_*` path now returns `Err(NonFiniteSkill { .. })` there, and
`Err(NoPerformanceVariance)` when `beta` is zero and every skill is a point
mass. If you were ignoring `converge`'s error, you will start seeing these.
### `Gaussian`'s operators are gone
`Mul`, `Div`, `Add` and `Sub` were the EP product, cavity and variance-space
convolutions, not arithmetic — `N(10,2) * N(4,3)` is `N(8.15, 1.66)`, and
`a / c` could leave a negative precision whose `mu()` printed a confident `0`.
They are `pub(crate)` inherent methods now. The public surface is `from_ms`,
`from_mv`, `mu`, `sigma`, `variance`, `probability_below`, `probability_above`;
`pi()` and `tau()` are internal. If you compared fits bit-for-bit on
`(pi, tau)`, compare `(mu, variance)` — same information, still exact.
### `Game` is the type you get
`Game::ranked` returned an `OwnedGame`, so `let g: Game = Game::ranked(..)?` did
not compile. Names swapped: `Game<T, D>` is public, `OwnedGame` is gone.
`one_v_one` returns a `Game` rather than `(Gaussian, Gaussian)`, so it can be
asked for `log_evidence()` like its siblings. For the old shape:
```rust
let post = Game::one_v_one(&a, &b, outcome, &opts)?.posteriors();
let (a_post, b_post) = (post[0][0], post[1][0]);
```
### The joint is reached through `Joint`
`History::posterior_of`, `posterior_of_at` and `expected_variance_reduction`
were one-shot wrappers that re-factorised on every call. They are gone.
```rust
// before — pays for the factorisation twice
let a = h.posterior_of(&terms)?;
let b = h.posterior_of(&other)?;
// after — pays once, and the borrow says so
let joint = h.joint()?;
let a = joint.posterior_of(&terms)?;
let b = joint.posterior_of(&other)?;
```
### `InferenceError` is typed
Six variants carried `&'static str` discriminators. Four enums replace them:
`Parameter`, `Shape`, `OutcomeKind`, `CompetitorField`.
```rust
// before
InferenceError::InvalidParameter { name: "drift_scale", value }
InferenceError::MismatchedShape { kind: "ranks vs teams", .. }
InferenceError::WrongOutcomeKind { context, expected, got } // three &str
// after
InferenceError::InvalidParameter { parameter: Parameter::DriftScale, value }
InferenceError::MismatchedShape { shape: Shape::OutcomeVsTeams, .. }
InferenceError::WrongOutcomeKind { expected: OutcomeKind::Ranked, got }
```
Variants that split or merged:
| before | after |
|---|---|
| `InvalidProbability { value }` | `InvalidParameter { parameter: Parameter::PDraw, value }` |
| `JointUnavailable { reason }` | `EmptyHistory`, `JointRequiresScoredEvents`, `NotPositiveDefinite` |
| `NonFiniteResult { context, step }` | `NonFiniteStep { context, step }` (convergence), `NonFiniteSkill { mu, sigma }` (prediction) |
Every struct variant is `#[non_exhaustive]`, so `match` with a `..` and
construct through the library.
### Renames
| before | after |
|---|---|
| `History::predict_quality` | `History::quality` |
| `Outcome::scores_with_sigma` | `Outcome::scores_with_noise` |
| `EventBuilder::scores_with_sigma` | `EventBuilder::scores_with_noise` |
| `Outcome::Scored { sigma }` | `Outcome::Scored { score_sigma }` |
| `OwnedGame` | `Game` |
### Removed
`History::intern`, `History::lookup` and `Index`. Nothing public ever accepted
an `Index`, so there was nothing to do with one. `current_skill`, `rating` and
`learning_curve` answer "does this history know this key" and all take a
borrowed key.
`TimeSlice`, `EventKind`, `KeyTable`, `CompetitorStore`, `Competitor` and `N01`
are no longer exported. None was obtainable from a `History`.
### Warnings, not errors
`#[must_use]` now sits on the value types, so a dropped `EventBuilder` — an
event you forgot to `.commit()`, previously a silent no-op — warns. So do
dropped `Team`, `Member`, `Outcome` and `Joint` values. A `-D warnings` build
will need updating.
### Nothing to do, but worth knowing
The joint factorisation is sparse with an AMD fill-reducing ordering:
**745 ms → 1.11 ms** on a 1976-appearance fixture, and near-linear scaling where
it was cubic. Results are unchanged; `feral-amd` is a new dependency (two
crates, both `#![forbid(unsafe_code)]`).
`HistoryBuilder::gamma(f64)` is shorthand for
`.drift(ConstantDrift::new(gamma))`.
`History::current_skills()` is the leaderboard query — every competitor's latest
posterior in one pass, rather than a full smoothed curve each.
`History::filtered_log_evidence_for(&["alice"])` completes the evidence matrix:
forward-only *and* key-restricted, which is what per-competitor prequential
scoring needs.
+3 -2
View File
@@ -121,7 +121,7 @@ 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.
step still above `epsilon`, and `Err(NonFiniteStep)` if a sweep produces NaN.
It used to return `Ok` with `converged: false`, which was the worst available
shape. A fit that stops short is *wrong by a little*: every posterior is finite,
@@ -419,7 +419,8 @@ expensive than `quality()`. Scoring every pairing among `n` competitors is
Every box on the old todo list is ticked, so it has been retired; open work
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.
out. `CHANGELOG.md` lists them and [`MIGRATING.md`](MIGRATING.md) explains what
to do about them.
## License
+5
View File
@@ -58,6 +58,11 @@ commit_parsers = [
{ message = "^test", group = "Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore", group = "Miscellaneous Tasks" },
{ message = "^ci", group = "CI" },
# Every branch lands with `--no-ff`, so a release's merge commits outnumber
# its real ones and say nothing the merged commits do not. They were
# filling an "Other (unconventional)" section with 14 lines of noise.
{ message = "^Merge ", skip = true },
{ body = ".*security", group = "Security" },
{ body = ".*", group = "Other (unconventional)" },
]
+7 -3
View File
@@ -123,7 +123,7 @@ fn u_minus_ln1p(u: f64) -> f64 {
/// - `EmptyTeam` if any team has no members.
/// - `TooManyTeams` if the outcome space is too large to enumerate; see
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
/// - `InvalidParameter` for a `p_draw` outside `[0.0, 1.0)`.
/// - `GridTooCoarse` when the performance sigmas are too far apart to
/// integrate on one grid. This comes from `outcome_distribution`, which runs
/// before any inference — so it is not covered by "anything `Game::ranked`
@@ -144,7 +144,8 @@ pub fn expected_information_gain<T: Time, D: Drift<T>>(
});
}
if !(0.0..1.0).contains(&options.p_draw) {
return Err(InferenceError::InvalidProbability {
return Err(InferenceError::InvalidParameter {
parameter: crate::Parameter::PDraw,
value: options.p_draw,
});
}
@@ -367,7 +368,10 @@ mod tests {
));
assert!(matches!(
expected_information_gain(&[&a, &a], &options(1.5)),
Err(InferenceError::InvalidProbability { .. })
Err(InferenceError::InvalidParameter {
parameter: crate::Parameter::PDraw,
..
})
));
}
+2 -2
View File
@@ -66,13 +66,13 @@ impl ConvergenceOptions {
pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> {
if !(self.alpha > 0.0 && self.alpha <= 1.0) {
return Err(crate::InferenceError::InvalidParameter {
name: "alpha",
parameter: crate::Parameter::Alpha,
value: self.alpha,
});
}
if self.epsilon.is_nan() || self.epsilon < 0.0 {
return Err(crate::InferenceError::InvalidParameter {
name: "epsilon",
parameter: crate::Parameter::Epsilon,
value: self.epsilon,
});
}
+1 -1
View File
@@ -35,7 +35,7 @@ pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
/// `variance_for_elapsed` would have been worse: it runs inside the sweep, so a
/// construction-time mistake would panic mid-inference — and `Gaussian::from_ms`
/// is a worked example of why that is the wrong place for a guard, where
/// rejecting NaN turned the `NonFiniteResult` reporting path into a crash.
/// rejecting NaN turned the `NonFiniteStep` reporting path into a crash.
///
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
/// back with [`ConstantDrift::gamma`].
+365 -58
View File
@@ -39,6 +39,182 @@ pub enum UnknownKeys {
Prior,
}
/// Which scalar an [`InferenceError::InvalidParameter`] is about.
///
/// A typed discriminator rather than a `&'static str`, so a caller can branch
/// on it and `Display` can state each parameter's actual valid range. Nine
/// distinct strings used to flow through this position, and the only thing a
/// caller could do with one was print it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Parameter {
/// Prior mean skill. Must be finite.
Mu,
/// Prior standard deviation. Must be finite and strictly positive.
Sigma,
/// Performance noise. Must be finite and non-negative.
Beta,
/// Draw probability. Must be in `[0.0, 1.0)`.
PDraw,
/// Observation noise on a score margin. Must be finite and strictly
/// positive.
ScoreSigma,
/// EP damping factor. Must be in `(0.0, 1.0]`.
Alpha,
/// Convergence threshold. Must be non-negative and not NaN.
Epsilon,
/// A competitor's multiplier on the drift variance. Must be finite and
/// non-negative.
DriftScale,
/// The variance a [`Drift`](crate::Drift) implementation actually produced
/// for a span. Must be finite and non-negative — checked because a custom
/// implementation is the one thing no constructor can validate up front.
DriftVariance,
/// A per-member weight on an event. Must be finite.
Weight,
/// A team's score on a scored event. Must be finite.
Score,
/// A team's rank on a ranked event. Must be finite.
Rank,
/// The winning team's index, as given to `Outcome::winner`. Must be less
/// than the team count.
WinnerIndex,
}
impl Parameter {
/// The range this parameter must lie in, for the `Display` message.
fn range(self) -> &'static str {
match self {
Self::Mu => "must be finite",
Self::Sigma => "must be finite and strictly positive",
Self::Beta => "must be finite and non-negative",
Self::PDraw => "must be in [0.0, 1.0)",
Self::ScoreSigma => "must be finite and strictly positive",
Self::Alpha => "must be in (0.0, 1.0]",
Self::Epsilon => "must be non-negative and not NaN",
Self::DriftScale => "must be finite and non-negative",
Self::DriftVariance => "must be finite and non-negative",
Self::Weight => "must be finite",
Self::Score => "must be finite",
Self::Rank => "must be finite",
Self::WinnerIndex => "must be less than the number of teams",
}
}
}
impl std::fmt::Display for Parameter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
Self::Mu => "mu",
Self::Sigma => "sigma",
Self::Beta => "beta",
Self::PDraw => "p_draw",
Self::ScoreSigma => "score_sigma",
Self::Alpha => "alpha",
Self::Epsilon => "epsilon",
Self::DriftScale => "drift_scale",
Self::DriftVariance => "drift variance",
Self::Weight => "weight",
Self::Score => "score",
Self::Rank => "rank",
Self::WinnerIndex => "winner index",
};
f.write_str(name)
}
}
/// Which two lengths an [`InferenceError::MismatchedShape`] found disagreeing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Shape {
/// The outcome describes a different number of teams than the event has.
OutcomeVsTeams,
/// A per-member weight list does not match the team's membership.
Weights,
/// A call that takes a fixed number of teams got a different number.
Teams,
/// One of `add_events_with_prior`'s parallel arrays disagreed with the
/// others.
///
/// Not reachable through the public API — the arrays are built together at
/// the ingestion chokepoint. Kept as a checked error rather than a
/// `debug_assert!` so it also holds in release, which is where this
/// crate's defects have tended to hide.
Internal,
}
impl std::fmt::Display for Shape {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let what = match self {
Self::OutcomeVsTeams => {
"the outcome describes a different number of teams than the event has"
}
Self::Weights => "the weight list does not match the team's membership",
Self::Teams => "this call takes a fixed number of teams",
Self::Internal => {
"an internal array disagreed with its siblings (this is a bug in trueskill-tt)"
}
};
f.write_str(what)
}
}
/// Which [`Outcome`](crate::Outcome) variant a call found or wanted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum OutcomeKind {
/// [`Outcome::Ranked`](crate::Outcome::Ranked): an ordinal finish.
Ranked,
/// [`Outcome::Scored`](crate::Outcome::Scored): continuous scores.
Scored,
}
impl OutcomeKind {
/// The call that takes this kind, for the `Display` message.
fn constructor(self) -> &'static str {
match self {
Self::Ranked => "Game::ranked",
Self::Scored => "Game::scored",
}
}
/// The adjective form, for prose.
fn adjective(self) -> &'static str {
match self {
Self::Ranked => "ranked",
Self::Scored => "scored",
}
}
}
impl std::fmt::Display for OutcomeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Ranked => "Outcome::Ranked",
Self::Scored => "Outcome::Scored",
})
}
}
/// Which piece of per-competitor configuration was declared twice.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CompetitorField {
/// The starting skill distribution.
Prior,
/// The multiplier on the drift variance.
DriftScale,
}
impl std::fmt::Display for CompetitorField {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Prior => "prior",
Self::DriftScale => "drift_scale",
})
}
}
/// Every way ingestion, inference or prediction can refuse to answer.
///
/// The crate reports rather than repairs. An input it cannot represent, a fit
@@ -56,9 +232,8 @@ 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,
/// Which pair of lengths disagreed.
shape: Shape,
/// The length it had to have, taken from whatever it must line up with
/// (usually the event's team count).
expected: usize,
@@ -68,28 +243,18 @@ pub enum InferenceError {
/// 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 {
/// The value supplied, as it fell outside `[0, 1]`. Today only
/// `p_draw` reaches here.
value: f64,
/// The variant the call needs.
expected: OutcomeKind,
/// The variant actually supplied.
got: OutcomeKind,
},
/// A scalar parameter is outside its valid range.
#[non_exhaustive]
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.
/// Which parameter. `Display` states its valid range.
parameter: Parameter,
/// The value supplied for it: outside that range, or NaN, which fails
/// every range comparison and is rejected on that basis.
value: f64,
},
/// An event contains tied teams, but the draw probability is zero.
@@ -130,20 +295,44 @@ pub enum InferenceError {
/// The threshold both components of `final_step` had to reach.
epsilon: f64,
},
/// Inference produced a non-finite value (NaN or infinity).
/// A convergence sweep produced a non-finite step.
///
/// Indicates numerical breakdown; the resulting skills are meaningless
/// and must not be treated as a converged estimate.
/// EP has broken down; the resulting skills are meaningless and must not
/// be treated as a converged estimate. Further iterations cannot recover,
/// so the loop stops rather than reporting a NaN step as convergence.
#[non_exhaustive]
NonFiniteResult {
/// Where the breakdown was caught `"History::converge"` for a sweep,
/// or a phrase naming the prediction that read an unusable skill.
NonFiniteStep {
/// Where the breakdown was caught, e.g. `"History::converge"`.
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)`.
/// The offending step as `(|d mu|, |d sigma|)`, at least one component
/// of which is NaN or infinite.
step: (f64, f64),
},
/// A prediction read a skill with no usable mean or variance.
///
/// Split from `NonFiniteStep` (#74), which used to carry both under one
/// `step: (f64, f64)` field — a sweep step from `converge` and a skill's
/// own moments from a prediction. One field name cannot be right for both.
///
/// Reaching this means a previous `converge` failed and its error was
/// ignored: predicting from a NaN fit produced `Ok(NaN)` on some paths and
/// a plausible-looking `Ok([0.0, 0.0])` on others.
#[non_exhaustive]
NonFiniteSkill {
/// The skill's mean, which may itself be finite while `sigma` is not.
mu: f64,
/// The skill's standard deviation.
sigma: f64,
},
/// Every skill in the matchup is a point mass and `beta` is zero, so
/// there is no performance distribution to predict from.
///
/// Not `InvalidParameter`: both values are individually in range, and it
/// is their combination that leaves nothing varying. Every prediction is a
/// statement about how performances vary, and in this configuration
/// nothing does — `quality` would divide by a singular contrast covariance
/// and `predict_win_probabilities` would report zeros that sum to zero.
NoPerformanceVariance,
/// One batch declared two different values for the same competitor's
/// configuration.
///
@@ -159,9 +348,8 @@ pub enum InferenceError {
/// 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,
/// Which piece of configuration was declared twice.
field: CompetitorField,
},
/// A prediction referenced a key the history has no skill for.
///
@@ -231,14 +419,32 @@ pub enum InferenceError {
/// Nodes the grid may hold.
max: usize,
},
/// A joint posterior was requested where one cannot be formed exactly.
#[non_exhaustive]
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,
},
/// A joint posterior was requested from a history with no events.
///
/// Split out of a single `JointUnavailable { reason: &str }` (#74): the
/// three reasons are conditions a caller branches on differently, and
/// distinguishing them used to mean matching on English prose. This one
/// means "add events".
EmptyHistory,
/// A joint posterior was requested from a history containing ranked
/// events.
///
/// Exact only for an all-scored history: a scored likelihood is Gaussian
/// and its factor can be rebuilt exactly, while a ranked outcome's
/// truncation is approximated by EP and reconstructing those factors needs
/// the converged messages, which inference does not retain.
///
/// [`History::predict_win_probabilities`](crate::History::predict_win_probabilities)
/// answers the comparable question on a ranked history.
JointRequiresScoredEvents,
/// The assembled precision matrix is not positive-definite.
///
/// The usual cause is a competitor with neither a proper prior nor any
/// evidence, but an extreme prior or drift can also make the assembled
/// matrix indefinite in floating point. Unlike its two siblings this one
/// is numerical rather than structural — the same history may factorise
/// under different parameters.
NotPositiveDefinite,
/// Fewer than two teams were supplied to a prediction.
#[non_exhaustive]
NotEnoughTeams {
@@ -268,21 +474,19 @@ impl fmt::Display for InferenceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MismatchedShape {
kind,
shape,
expected,
got,
} => {
write!(f, "{kind}: expected length {expected}, got {got}")
write!(f, "{shape}: expected {expected}, got {got}")
}
Self::WrongOutcomeKind {
context,
expected,
got,
} => {
write!(f, "{context}: expected {expected}, got {got}")
}
Self::InvalidProbability { value } => {
write!(f, "probability must be in [0, 1]; got {value}")
Self::WrongOutcomeKind { expected, got } => {
write!(
f,
"expected {expected}, got {got}; call {} for a {} outcome",
got.constructor(),
got.adjective()
)
}
Self::TieWithoutDrawProbability { teams } => {
write!(
@@ -303,14 +507,23 @@ impl fmt::Display for InferenceError {
alpha < 1.0 if it is oscillating"
)
}
Self::NonFiniteResult { context, step } => {
Self::NonFiniteStep { context, step } => {
write!(
f,
"{context}: inference produced a non-finite result (step = {step:?})"
"{context}: inference produced a non-finite step {step:?}; EP has \
broken down and further iterations cannot recover"
)
}
Self::InvalidParameter { name, value } => {
write!(f, "{name} is invalid: {value}")
Self::NonFiniteSkill { mu, sigma } => {
write!(
f,
"a prediction read a skill with no usable mean or variance \
(mu = {mu}, sigma = {sigma}); the fit did not converge, and \
`converge` reports that"
)
}
Self::InvalidParameter { parameter, value } => {
write!(f, "{parameter} {} (got {value})", parameter.range())
}
Self::ConflictingCompetitorConfig { competitor, field } => {
write!(
@@ -346,9 +559,25 @@ impl fmt::Display for InferenceError {
one grid. Use predict_win_probabilities, which is accurate here"
)
}
Self::JointUnavailable { reason } => {
write!(f, "no exact joint posterior is available: {reason}")
Self::EmptyHistory => {
f.write_str("no exact joint posterior is available: the history has no events")
}
Self::JointRequiresScoredEvents => f.write_str(
"no exact joint posterior is available: the history contains ranked \
events, whose EP factors are not retained after convergence. Use \
predict_win_probabilities for a ranked history",
),
Self::NotPositiveDefinite => f.write_str(
"the joint precision matrix is not positive-definite; the usual cause \
is a competitor with neither a proper prior nor any evidence, but an \
extreme prior or drift can also make the assembled matrix indefinite \
in floating point",
),
Self::NoPerformanceVariance => f.write_str(
"beta is zero and every skill in this matchup is a point mass, so \
there is no performance distribution to predict from; give beta a \
positive value, or a competitor a prior with positive sigma",
),
Self::NotEnoughTeams { got } => {
write!(f, "prediction needs at least 2 teams, got {got}")
}
@@ -364,3 +593,81 @@ impl fmt::Display for InferenceError {
}
impl std::error::Error for InferenceError {}
#[cfg(test)]
mod message_tests {
use super::*;
/// Every message must name the problem *and* what to do, which is the
/// standard the good ones set and the three #74 called out did not meet.
#[test]
fn messages_are_actionable() {
let cases = [
InferenceError::InvalidParameter {
parameter: Parameter::Alpha,
value: 0.0,
},
InferenceError::InvalidParameter {
parameter: Parameter::DriftVariance,
value: f64::NAN,
},
InferenceError::InvalidParameter {
parameter: Parameter::PDraw,
value: 1.5,
},
InferenceError::MismatchedShape {
shape: Shape::OutcomeVsTeams,
expected: 3,
got: 2,
},
InferenceError::WrongOutcomeKind {
expected: OutcomeKind::Ranked,
got: OutcomeKind::Scored,
},
InferenceError::EmptyHistory,
InferenceError::JointRequiresScoredEvents,
InferenceError::NotPositiveDefinite,
InferenceError::NoPerformanceVariance,
InferenceError::NonFiniteSkill {
mu: f64::NAN,
sigma: f64::NAN,
},
];
for case in &cases {
let rendered = case.to_string();
eprintln!("{rendered}");
// `InvalidParameter` used to render `drift variance is invalid: NaN`
// — no range, no remedy, no location. Every message must at least
// be a sentence.
assert!(
rendered.len() > 30,
"message is too terse to act on: {rendered}"
);
assert!(!rendered.contains("is invalid:"), "{rendered}");
}
// The three that #74 singled out now state a range or a next step.
assert!(
InferenceError::InvalidParameter {
parameter: Parameter::DriftVariance,
value: f64::NAN,
}
.to_string()
.contains("must be finite and non-negative")
);
assert!(
InferenceError::WrongOutcomeKind {
expected: OutcomeKind::Ranked,
got: OutcomeKind::Scored,
}
.to_string()
.contains("Game::scored")
);
assert!(
InferenceError::JointRequiresScoredEvents
.to_string()
.contains("predict_win_probabilities")
);
}
}
+1 -1
View File
@@ -144,7 +144,7 @@ where
if ws.len() != team.members.len() {
self.error.get_or_insert(InferenceError::MismatchedShape {
kind: "weights",
shape: crate::Shape::Weights,
expected: team.members.len(),
got: ws.len(),
});
+12 -13
View File
@@ -555,7 +555,7 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
/// - `InvalidParameter` if `options.convergence` is out of range — an
/// `alpha` of zero would leave every EP update unapplied and silently
/// return the priors.
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
/// - `InvalidParameter` for a `p_draw` outside `[0.0, 1.0)`.
/// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`.
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
@@ -571,13 +571,14 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
options.convergence.validate()?;
Self::validate_teams(teams)?;
if !(0.0..1.0).contains(&options.p_draw) {
return Err(crate::InferenceError::InvalidProbability {
return Err(crate::InferenceError::InvalidParameter {
parameter: crate::Parameter::PDraw,
value: options.p_draw,
});
}
if outcome.team_count() != teams.len() {
return Err(crate::InferenceError::MismatchedShape {
kind: "outcome ranks vs teams",
shape: crate::Shape::OutcomeVsTeams,
expected: teams.len(),
got: outcome.team_count(),
});
@@ -586,9 +587,8 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
let ranks = outcome
.as_ranks()
.ok_or(crate::InferenceError::WrongOutcomeKind {
context: "Game::ranked",
expected: "Outcome::Ranked",
got: "Outcome::Scored",
expected: crate::OutcomeKind::Ranked,
got: crate::OutcomeKind::Scored,
})?;
let tied = if options.p_draw == 0.0 {
@@ -638,13 +638,13 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
Self::validate_teams(teams)?;
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
return Err(crate::InferenceError::InvalidParameter {
name: "score_sigma",
parameter: crate::Parameter::ScoreSigma,
value: options.score_sigma,
});
}
if outcome.team_count() != teams.len() {
return Err(crate::InferenceError::MismatchedShape {
kind: "outcome scores vs teams",
shape: crate::Shape::OutcomeVsTeams,
expected: teams.len(),
got: outcome.team_count(),
});
@@ -652,9 +652,8 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
let scores = outcome
.as_scores()
.ok_or(crate::InferenceError::WrongOutcomeKind {
context: "Game::scored",
expected: "Outcome::Scored",
got: "Outcome::Ranked",
expected: crate::OutcomeKind::Scored,
got: crate::OutcomeKind::Ranked,
})?
.to_vec();
// A non-finite score poisons the chain rather than failing it. Ranks
@@ -662,7 +661,7 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
for value in &scores {
if !value.is_finite() {
return Err(crate::InferenceError::InvalidParameter {
name: "score",
parameter: crate::Parameter::Score,
value: *value,
});
}
@@ -1368,7 +1367,7 @@ mod tests {
assert!(matches!(
err,
crate::InferenceError::InvalidParameter {
name: "score_sigma",
parameter: crate::Parameter::ScoreSigma,
..
}
));
+2 -2
View File
@@ -22,7 +22,7 @@ impl Gaussian {
///
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a
/// broken fit produces one, and `converge` reports that as
/// `NonFiniteResult` rather than panicking mid-inference.
/// `NonFiniteStep` rather than panicking mid-inference.
///
/// A negative sigma used to be accepted and returned results **bit
/// identical** to its absolute value, because sigma only ever enters as
@@ -46,7 +46,7 @@ impl Gaussian {
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
// NaN is admitted on purpose. A broken fit legitimately produces a NaN
// sigma — `sqrt` of a negative truncated variance — and the design is
// to propagate that to `converge`'s `NonFiniteResult` guard, not to
// to propagate that to `converge`'s `NonFiniteStep` guard, not to
// panic inside inference. Rejecting it here turned that reporting path
// into a crash, which two tests caught immediately.
assert!(
+57 -71
View File
@@ -72,7 +72,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
/// # Panics
///
/// Panics if `mu` is not finite. A non-finite prior mean poisons every
/// posterior derived from it: `converge` reports `NonFiniteResult`, but a
/// posterior derived from it: `converge` reports `NonFiniteStep`, but a
/// caller who reads `current_skill` first is handed `tau: NaN`.
pub fn mu(mut self, mu: f64) -> Self {
assert!(mu.is_finite(), "mu must be finite (got {mu})");
@@ -855,14 +855,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
{
if member.weight != 1.0 {
return Err(InferenceError::InvalidParameter {
name: "weight",
parameter: crate::Parameter::Weight,
value: member.weight,
});
}
if let Some(scale) = member.drift_scale {
if !scale.is_finite() || scale < 0.0 {
return Err(InferenceError::InvalidParameter {
name: "drift_scale",
parameter: crate::Parameter::DriftScale,
value: scale,
});
}
@@ -1296,7 +1296,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
// `converge` refuses to report a NaN fit, but nothing stopped a
// caller ignoring that error and predicting anyway. Measured on
// a point-mass-prior history with `beta(0.0)`, after `converge`
// returned `NonFiniteResult`: `quality` gave `Ok(NaN)`,
// returned `NonFiniteStep`: `quality` gave `Ok(NaN)`,
// `predict_outcome().total()` gave `NaN`, and
// `predict_win_probabilities` gave `Ok([0.0, 0.0])` — finite,
// plausible, and summing to zero against a doc that promises
@@ -1317,10 +1317,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
// stored posterior today, and a prediction from an
// uninformative skill would be meaningless if it did.
if !skill.mu().is_finite() || !skill.sigma().is_finite() {
return Err(InferenceError::NonFiniteResult {
context: "prediction read a skill with no usable mean or \
variance; the fit did not converge",
step: (skill.mu(), skill.sigma()),
return Err(InferenceError::NonFiniteSkill {
mu: skill.mu(),
sigma: skill.sigma(),
});
}
@@ -1349,10 +1348,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
// time and it is a property of the parameters, not a numerical
// accident.
if self.beta == 0.0 && gathered.iter().flatten().all(|g| g.sigma() == 0.0) {
return Err(InferenceError::InvalidParameter {
name: "beta with point-mass skills",
value: 0.0,
});
return Err(InferenceError::NoPerformanceVariance);
}
Ok(gathered)
@@ -1440,9 +1436,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
/// `NotEnoughTeams`, `EmptyTeam` or `UnknownKey`.
///
/// Every prediction reads skills through one gate, which adds two errors to
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
/// every skill is a point mass, leaving no performance distribution to
/// predict from.
pub fn quality<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
where
@@ -1481,7 +1477,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
/// stored `f64` before the factorisation ever runs. Measured, `drift_scale =
/// 1e-10` returned a posterior variance **12 000x too small** — a 111x
/// overconfident interval — as `Ok`, and the band just above it returned a
/// misleading `JointUnavailable`.
/// misleading `NotPositiveDefinite`.
///
/// Solved exactly in high precision the same system is perfectly well
/// conditioned: it converges smoothly onto the collapsed value and is flat from
@@ -1706,19 +1702,15 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
///
/// # Errors
///
/// `JointUnavailable` if the history is empty, contains ranked events, or
/// yields a precision matrix that is not positive-definite.
/// `EmptyHistory` for a history with no events, `JointRequiresScoredEvents`
/// for one containing ranked events, and `NotPositiveDefinite` if the
/// assembled matrix is indefinite.
pub fn joint(&self) -> Result<Joint<'_, K, T, D, O, R>, InferenceError> {
if self.time_slices.is_empty() {
return Err(InferenceError::JointUnavailable {
reason: "the history has no events",
});
return Err(InferenceError::EmptyHistory);
}
if !self.time_slices.iter().all(TimeSlice::all_scored) {
return Err(InferenceError::JointUnavailable {
reason: "the history contains ranked events, whose EP factors are \
not retained after convergence",
});
return Err(InferenceError::JointRequiresScoredEvents);
}
let TimeExpanded {
@@ -1728,14 +1720,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
width,
} = self.time_expanded_joint();
let cholesky = crate::joint::Cholesky::factor(lambda, width).ok_or(
InferenceError::JointUnavailable {
reason: "the precision matrix is not positive-definite; the usual \
cause is a competitor with neither a proper prior nor any \
evidence, but an extreme prior or drift can also make the \
assembled matrix indefinite in floating point",
},
)?;
let cholesky = crate::joint::Cholesky::factor(lambda, width)
.ok_or(InferenceError::NotPositiveDefinite)?;
Ok(Joint {
history: self,
@@ -1771,8 +1757,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
///
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam`,
/// `UnknownKey` under [`UnknownKeys::Reject`](crate::UnknownKeys::Reject),
/// and `JointUnavailable` if the history is empty or holds ranked events in
/// *any* slice — not merely the latest one.
/// and `EmptyHistory` / `JointRequiresScoredEvents` — the latter if *any*
/// slice holds ranked events, not merely the latest one.
pub fn predict_margin<Q>(&self, teams: &[&[&Q]]) -> Result<Gaussian, InferenceError>
where
K: Borrow<Q>,
@@ -1780,7 +1766,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
{
if teams.len() != 2 {
return Err(InferenceError::MismatchedShape {
kind: "predict_margin takes exactly 2 teams",
shape: crate::Shape::Teams,
expected: 2,
got: teams.len(),
});
@@ -1847,9 +1833,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
/// for a hypothetical outcome.
///
/// Every prediction reads skills through one gate, which adds two errors to
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
/// every skill is a point mass, leaving no performance distribution to
/// predict from.
pub fn expected_information_gain<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
where
@@ -1908,9 +1894,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
/// `NotEnoughTeams`, `EmptyTeam`, or `UnknownKey`.
///
/// Every prediction reads skills through one gate, which adds two errors to
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
/// every skill is a point mass, leaving no performance distribution to
/// predict from.
pub fn predict_win_probabilities<Q>(&self, teams: &[&[&Q]]) -> Result<Vec<f64>, InferenceError>
where
@@ -1963,9 +1949,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
/// integrate on one grid.
///
/// Every prediction reads skills through one gate, which adds two errors to
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
/// every skill is a point mass, leaving no performance distribution to
/// predict from.
pub fn predict_outcome<Q>(&self, teams: &[&[&Q]]) -> Result<Prediction, InferenceError>
where
@@ -2013,9 +1999,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
/// performance sigmas are too far apart to integrate on one grid.
///
/// Every prediction reads skills through one gate, which adds two errors to
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
/// every skill is a point mass, leaving no performance distribution to
/// predict from.
pub fn predict_ranking<Q>(&self, teams: &[&[&Q]], ranks: &[u32]) -> Result<f64, InferenceError>
where
@@ -2024,7 +2010,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
{
if ranks.len() != teams.len() {
return Err(InferenceError::MismatchedShape {
kind: "ranks vs teams",
shape: crate::Shape::OutcomeVsTeams,
expected: teams.len(),
got: ranks.len(),
});
@@ -2060,7 +2046,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
/// `NotConverged` if the sweep hits `max_iter` with the step still above
/// `epsilon`.
///
/// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has
/// `NonFiniteStep` if a sweep produces a NaN or infinite step. EP has
/// broken down at that point and further iterations cannot recover, so the
/// loop stops rather than reporting a NaN step as convergence.
///
@@ -2092,7 +2078,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
///
/// # Errors
///
/// `NonFiniteResult` if a sweep produces a NaN or infinite step.
/// `NonFiniteStep` if a sweep produces a NaN or infinite step.
///
/// `InvalidParameter` if a competitor's drift model yields a negative or
/// non-finite variance. Checked here, before any sweeping, so it applies to
@@ -2123,7 +2109,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
.drift_variance_for_elapsed(elapsed);
if !drift.is_finite() || drift < 0.0 {
return Err(InferenceError::InvalidParameter {
name: "drift variance",
parameter: crate::Parameter::DriftVariance,
value: drift,
});
}
@@ -2160,7 +2146,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
if !crate::step_is_finite(step) {
self.observer.on_converged(i, step, false);
return Err(InferenceError::NonFiniteResult {
return Err(InferenceError::NonFiniteStep {
context: "History::converge",
step,
});
@@ -2198,14 +2184,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
let got = results.as_ref().map_or(0, Vec::len);
return Err(InferenceError::MismatchedShape {
kind: "results",
shape: crate::Shape::Internal,
expected: composition.len(),
got,
});
}
if times.len() != composition.len() {
return Err(InferenceError::MismatchedShape {
kind: "times",
shape: crate::Shape::Internal,
expected: composition.len(),
got: times.len(),
});
@@ -2217,14 +2203,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
let got = weights.as_ref().map_or(0, Vec::len);
return Err(InferenceError::MismatchedShape {
kind: "weights",
shape: crate::Shape::Weights,
expected: composition.len(),
got,
});
}
if kinds.len() != composition.len() {
return Err(InferenceError::MismatchedShape {
kind: "kinds",
shape: crate::Shape::Internal,
expected: composition.len(),
got: kinds.len(),
});
@@ -2254,19 +2240,19 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
}
// A non-finite outcome poisons the history rather than failing it:
// `converge` does report `NonFiniteResult`, but a caller who reads
// `converge` does report `NonFiniteStep`, but a caller who reads
// `current_skill` before converging is handed a NaN posterior with
// nothing to say it is one.
if let Some(results) = results.as_ref() {
for (event_results, kind) in results.iter().zip(kinds.iter()) {
let name = match kind {
EventKind::Ranked => "rank",
EventKind::Scored { .. } => "score",
let parameter = match kind {
EventKind::Ranked => crate::Parameter::Rank,
EventKind::Scored { .. } => crate::Parameter::Score,
};
for value in event_results {
if !value.is_finite() {
return Err(InferenceError::InvalidParameter {
name,
parameter,
value: *value,
});
}
@@ -2289,7 +2275,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
for weight in team_weights {
if !weight.is_finite() {
return Err(InferenceError::InvalidParameter {
name: "weight",
parameter: crate::Parameter::Weight,
value: *weight,
});
}
@@ -2338,7 +2324,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
if existing != new {
return Err(InferenceError::ConflictingCompetitorConfig {
competitor: competitor.get(),
field: "prior",
field: crate::CompetitorField::Prior,
});
}
}
@@ -2346,7 +2332,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
if existing != new {
return Err(InferenceError::ConflictingCompetitorConfig {
competitor: competitor.get(),
field: "drift_scale",
field: crate::CompetitorField::DriftScale,
});
}
}
@@ -2677,7 +2663,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
for ev in events {
if ev.outcome.team_count() != ev.teams.len() {
return Err(InferenceError::MismatchedShape {
kind: "outcome vs teams",
shape: crate::Shape::OutcomeVsTeams,
expected: ev.teams.len(),
got: ev.outcome.team_count(),
});
@@ -2700,7 +2686,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
// accept a sign the caller cannot have meant.
if !scale.is_finite() || scale < 0.0 {
return Err(InferenceError::InvalidParameter {
name: "drift_scale",
parameter: crate::Parameter::DriftScale,
value: scale,
});
}
@@ -2724,7 +2710,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
if entry.prior.is_some_and(|held| held != prior) {
return Err(InferenceError::ConflictingCompetitorConfig {
competitor: idx.get(),
field: "prior",
field: crate::CompetitorField::Prior,
});
}
entry.prior = Some(prior);
@@ -2733,7 +2719,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
if entry.drift_scale.is_some_and(|held| held != scale) {
return Err(InferenceError::ConflictingCompetitorConfig {
competitor: idx.get(),
field: "drift_scale",
field: crate::CompetitorField::DriftScale,
});
}
entry.drift_scale = Some(scale);
@@ -2759,7 +2745,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
let resolved = score_sigma.unwrap_or(self.score_sigma);
if resolved <= 0.0 || resolved.is_nan() {
return Err(InferenceError::InvalidParameter {
name: "score_sigma",
parameter: crate::Parameter::ScoreSigma,
value: resolved,
});
}
@@ -3011,7 +2997,7 @@ impl<K: Eq + Hash + Clone, T: Time, D: Drift<T>, O: Observer<T>, R: RatingRule<K
{
if teams.len() != 2 {
return Err(InferenceError::MismatchedShape {
kind: "expected_variance_reduction takes exactly 2 teams",
shape: crate::Shape::Teams,
expected: 2,
got: teams.len(),
});
+5 -1
View File
@@ -9,6 +9,10 @@
//! This is a Rust port of
//! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
//!
//! Upgrading? `MIGRATING.md` in the repository root covers every breaking
//! change, with the ones that alter what an existing call *returns* called out
//! first.
//!
//! # Getting started
//!
//! Record results, converge, then read off skills:
@@ -152,7 +156,7 @@ mod time_slice;
pub use acquisition::expected_information_gain;
pub use convergence::{ConvergenceOptions, ConvergenceReport};
pub use drift::{ConstantDrift, Drift};
pub use error::{InferenceError, UnknownKeys};
pub use error::{CompetitorField, InferenceError, OutcomeKind, Parameter, Shape, UnknownKeys};
pub use event::{Event, Member, Team};
pub use event_builder::EventBuilder;
pub use game::{Game, GameOptions};
+1 -1
View File
@@ -84,7 +84,7 @@ impl Outcome {
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
if winner >= n {
return Err(crate::InferenceError::InvalidParameter {
name: "winner",
parameter: crate::Parameter::WinnerIndex,
value: f64::from(winner),
});
}
+4 -1
View File
@@ -184,7 +184,10 @@ fn a_batch_declaring_two_different_priors_is_rejected() {
assert!(
matches!(
err,
InferenceError::ConflictingCompetitorConfig { field: "prior", .. }
InferenceError::ConflictingCompetitorConfig {
field: trueskill_tt::CompetitorField::Prior,
..
}
),
"got {err:?}"
);
+2 -2
View File
@@ -157,7 +157,7 @@ fn event_builder_rejects_a_weights_length_mismatch() {
matches!(
err,
InferenceError::MismatchedShape {
kind: "weights",
shape: trueskill_tt::Shape::Weights,
expected: 1,
got: 2,
..
@@ -228,7 +228,7 @@ fn scored_event_rejects_non_positive_sigma() {
assert!(matches!(
err,
InferenceError::InvalidParameter {
name: "score_sigma",
parameter: trueskill_tt::Parameter::ScoreSigma,
..
}
));
+3 -3
View File
@@ -279,7 +279,7 @@ fn reject(scale: f64) -> InferenceError {
fn negative_scale_is_rejected() {
assert!(matches!(
reject(-1.0),
InferenceError::InvalidParameter { name: "drift_scale", value, .. }
InferenceError::InvalidParameter { parameter: trueskill_tt::Parameter::DriftScale, value, .. }
if value == -1.0
));
}
@@ -291,7 +291,7 @@ fn non_finite_scale_is_rejected() {
matches!(
reject(scale),
InferenceError::InvalidParameter {
name: "drift_scale",
parameter: trueskill_tt::Parameter::DriftScale,
..
}
),
@@ -488,7 +488,7 @@ fn a_batch_that_contradicts_itself_is_rejected() {
matches!(
err,
InferenceError::ConflictingCompetitorConfig {
field: "drift_scale",
field: trueskill_tt::CompetitorField::DriftScale,
..
}
),
+2 -2
View File
@@ -136,7 +136,7 @@ fn weights_still_guards_a_members_team() {
matches!(
err,
InferenceError::MismatchedShape {
kind: "weights",
shape: trueskill_tt::Shape::Weights,
expected: 2,
got: 1,
..
@@ -164,7 +164,7 @@ fn an_invalid_drift_scale_surfaces_from_commit() {
matches!(
err,
InferenceError::InvalidParameter {
name: "drift_scale",
parameter: trueskill_tt::Parameter::DriftScale,
..
}
),
+8 -2
View File
@@ -57,7 +57,7 @@ fn game_ranked_rejects_bad_p_draw() {
},
)
.unwrap_err();
assert!(matches!(err, InferenceError::InvalidProbability { .. }));
assert!(matches!(err, InferenceError::InvalidParameter { .. }));
}
#[test]
@@ -227,7 +227,13 @@ mod malformed_games {
)
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Score,
..
}
),
"{bad}: {err:?}"
);
}
+14 -2
View File
@@ -112,7 +112,13 @@ fn a_non_finite_score_is_rejected_at_ingestion() {
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Score,
..
}
),
"{bad}: {err:?}"
);
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
@@ -136,7 +142,13 @@ fn a_non_finite_weight_is_rejected_at_ingestion() {
.commit()
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Weight,
..
}
),
"{bad}: {err:?}"
);
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
+6 -2
View File
@@ -231,16 +231,20 @@ fn a_ranked_history_has_no_exact_joint() {
let _ = h.converge().unwrap();
assert!(matches!(
h.joint().unwrap_err(),
InferenceError::JointUnavailable { .. }
InferenceError::JointRequiresScoredEvents
));
}
/// Distinguishable from the ranked case, which is the point of splitting
/// `JointUnavailable { reason: &str }` into three variants (#74): "add events"
/// and "use predict_win_probabilities" are different instructions, and telling
/// them apart used to mean matching on English prose.
#[test]
fn an_empty_history_has_no_joint() {
let h = history(UnknownKeys::Reject);
assert!(matches!(
h.joint().unwrap_err(),
InferenceError::JointUnavailable { .. }
InferenceError::EmptyHistory
));
}
+4 -4
View File
@@ -56,7 +56,7 @@ fn overflow_during_inference_is_reported_not_hidden() {
for (name, sigma, beta, score_sigma, scores) in cases {
match scored_fit(sigma, beta, score_sigma, scores) {
Err(InferenceError::NonFiniteResult { context, step, .. }) => {
Err(InferenceError::NonFiniteStep { context, step, .. }) => {
assert_eq!(context, "History::converge", "{name}");
assert!(
!step.0.is_finite() || !step.1.is_finite(),
@@ -86,7 +86,7 @@ fn a_broken_fit_is_never_reported_as_converged() {
let err = h.converge().unwrap_err();
assert!(
matches!(err, InferenceError::NonFiniteResult { .. }),
matches!(err, InferenceError::NonFiniteStep { .. }),
"a breakdown must not be reported as convergence: {err:?}"
);
@@ -104,7 +104,7 @@ fn a_broken_fit_is_never_reported_as_converged() {
.unwrap();
assert!(matches!(
h2.converge_partial().unwrap_err(),
InferenceError::NonFiniteResult { .. }
InferenceError::NonFiniteStep { .. }
));
}
@@ -161,7 +161,7 @@ fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
.converge()
.expect_err("a NaN fit must never be reported as converged");
assert!(
matches!(err, InferenceError::NonFiniteResult { .. }),
matches!(err, InferenceError::NonFiniteStep { .. }),
"{err:?}"
);
}
+3 -3
View File
@@ -49,7 +49,7 @@ fn nan_poisoned() -> H {
);
let err = h.converge().expect_err("this fixture must not converge");
assert!(
matches!(err, InferenceError::NonFiniteResult { .. }),
matches!(err, InferenceError::NonFiniteStep { .. }),
"{err:?}"
);
h
@@ -101,7 +101,7 @@ fn a_nan_poisoned_fit_is_refused_by_every_prediction_path() {
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
match r {
Err(InferenceError::NonFiniteResult { .. }) => {}
Err(InferenceError::NonFiniteSkill { .. }) => {}
other => panic!("{name} answered from a NaN fit: {other:?}"),
}
});
@@ -121,7 +121,7 @@ fn degenerate_performances_are_refused_rather_than_answered_wrongly() {
// and every skill is a point mass.
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
match r {
Err(InferenceError::InvalidParameter { .. }) => {}
Err(InferenceError::NoPerformanceVariance) => {}
other => panic!("{name} predicted from a degenerate fit: {other:?}"),
}
});
+10 -4
View File
@@ -172,7 +172,13 @@ fn a_weight_on_a_registration_is_rejected() {
.register(Member::new("layout").with_weight(0.5))
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Weight,
..
}
),
"{err:?}"
);
}
@@ -188,7 +194,7 @@ fn an_invalid_drift_scale_on_a_registration_is_rejected() {
matches!(
err,
InferenceError::InvalidParameter {
name: "drift_scale",
parameter: trueskill_tt::Parameter::DriftScale,
..
}
),
@@ -280,7 +286,7 @@ mod conflicting_configuration {
matches!(
err,
InferenceError::ConflictingCompetitorConfig {
field: "drift_scale",
field: trueskill_tt::CompetitorField::DriftScale,
..
}
),
@@ -297,7 +303,7 @@ mod conflicting_configuration {
matches!(
err,
InferenceError::ConflictingCompetitorConfig {
field: "drift_scale",
field: trueskill_tt::CompetitorField::DriftScale,
..
}
),
+22 -4
View File
@@ -49,7 +49,13 @@ fn ranked_rejects_a_zero_damping_factor() {
)
.expect_err("alpha = 0 must be rejected");
assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"got {err:?}"
);
}
@@ -65,7 +71,13 @@ fn ranked_rejects_an_out_of_range_damping_factor() {
)
.expect_err("alpha out of (0, 1] must be rejected");
assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"alpha={alpha}: got {err:?}"
);
}
@@ -81,7 +93,13 @@ fn scored_rejects_a_bad_damping_factor() {
)
.expect_err("alpha = 0 must be rejected");
assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Alpha,
..
}
),
"got {err:?}"
);
}
@@ -360,7 +378,7 @@ mod constructor_parameters {
matches!(
err,
InferenceError::InvalidParameter {
name: "drift variance",
parameter: trueskill_tt::Parameter::DriftVariance,
..
}
),