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
367 lines
16 KiB
Rust
367 lines
16 KiB
Rust
use std::fmt;
|
|
|
|
/// How a prediction should treat a key the history has never seen.
|
|
///
|
|
/// Configured once per history via
|
|
/// [`HistoryBuilder::unknown_keys`](crate::HistoryBuilder::unknown_keys).
|
|
/// Neither known consumer wants this to vary between queries — one predicts
|
|
/// thousands of candidate matchups in a loop, the other's headline feature is
|
|
/// predicting a competitor nobody has faced — so it is a property of how you
|
|
/// intend to use the model rather than an argument on five call sites.
|
|
///
|
|
/// # There is deliberately no `Skip`
|
|
///
|
|
/// Dropping an unknown member is the obvious third option and it is wrong. A
|
|
/// team's performance is the *sum* of its members, so removing one removes its
|
|
/// variance too: measured on a two-member team with one unknown, skipping gives
|
|
/// a performance sigma of 2.37 where treating the member as unknown gives 6.53.
|
|
/// An unknown competitor would make the model *more* certain, which is
|
|
/// backwards. `Prior` is also the answer the model already gives for a
|
|
/// competitor it knows about but has no evidence for, so it corresponds to a
|
|
/// state the model can actually be in; skipping does not.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
#[non_exhaustive]
|
|
pub enum UnknownKeys {
|
|
/// Reject the prediction with [`InferenceError::UnknownKey`].
|
|
///
|
|
/// The default, and the right one when every key is expected to be known:
|
|
/// a team of strangers should not silently produce a confident-looking
|
|
/// answer.
|
|
#[default]
|
|
Reject,
|
|
/// Treat an unknown competitor as one sitting at the history's configured
|
|
/// prior.
|
|
///
|
|
/// This is the honest Bayesian reading — a competitor you have never
|
|
/// observed is exactly the prior — and it makes "predict a matchup
|
|
/// involving someone new" a first-class question rather than something a
|
|
/// caller fakes with a neutral constant.
|
|
Prior,
|
|
}
|
|
|
|
/// 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 {
|
|
/// 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 {
|
|
/// 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 {
|
|
/// Positions in the event's team list of the first tied pair, lowest
|
|
/// index first. Only one pair is reported — the event is rejected
|
|
/// whole, so enumerating the rest would add nothing.
|
|
teams: (usize, usize),
|
|
},
|
|
/// The convergence sweep hit `max_iter` with the step still above
|
|
/// `epsilon`.
|
|
///
|
|
/// A fit that stops short is wrong by a little, which is the worst
|
|
/// available failure: every posterior is finite, the ordering looks sensible,
|
|
/// and nothing in the numbers says they were still moving. Reported rather
|
|
/// than returned as a flag on an `Ok`, because a flag has to be checked
|
|
/// and `let _ = h.converge()` is the natural way not to.
|
|
///
|
|
/// Either the history needs more iterations — raise `max_iter` — or it is
|
|
/// oscillating rather than converging, in which case `alpha < 1.0` damps
|
|
/// the within-game EP loop. [`History::converge_partial`](crate::History::converge_partial)
|
|
/// returns the short fit instead when that is genuinely what is wanted.
|
|
#[non_exhaustive]
|
|
NotConverged {
|
|
/// Full forward+backward sweeps run before the loop gave up.
|
|
iterations: usize,
|
|
/// How far the last sweep still moved the fit, as
|
|
/// `(largest change in a mean, largest change in a standard
|
|
/// deviation)` over every competitor posterior it touched — the same
|
|
/// quantity as
|
|
/// [`ConvergenceReport::final_step`](crate::ConvergenceReport).
|
|
final_step: (f64, f64),
|
|
/// The threshold both components of `final_step` had to reach.
|
|
epsilon: f64,
|
|
},
|
|
/// Inference produced a non-finite value (NaN or infinity).
|
|
///
|
|
/// Indicates numerical breakdown; the resulting skills are meaningless
|
|
/// 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
|
|
/// configuration.
|
|
///
|
|
/// `prior` and `drift_scale` configure a competitor, not an event, so a
|
|
/// batch that sets one of them twice with different values has no
|
|
/// well-defined meaning: events within a batch are not ordered, so
|
|
/// "last one wins" would make the result depend on iteration order.
|
|
/// Declaring the same value repeatedly is fine and is the expected shape
|
|
/// when a competitor's configuration is a property of the domain.
|
|
#[non_exhaustive]
|
|
ConflictingCompetitorConfig {
|
|
/// The competitor's interned [`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.
|
|
///
|
|
/// Reported rather than skipped: dropping unknown keys turns a team of
|
|
/// strangers into a confident-looking probability about nobody.
|
|
///
|
|
/// `key` is the offending key's `Debug` rendering. It is carried because
|
|
/// the indices alone are not actionable: a caller that logs
|
|
/// `UnknownKey { team: 0, member: 0 }` learns nothing about *which* of its
|
|
/// keys the history has not seen, and the natural handling — fall back to a
|
|
/// neutral value — turns the whole thing into a plausible constant.
|
|
#[non_exhaustive]
|
|
UnknownKey {
|
|
/// Position of the offending team in the supplied matchup. `0` on the
|
|
/// queries that take a flat list of keys rather than teams, where
|
|
/// there is only one list to index into.
|
|
team: usize,
|
|
/// Position of the offending key within that team, or within the flat
|
|
/// key list.
|
|
member: usize,
|
|
/// The key's `Debug` rendering, captured because `K` is only required
|
|
/// to be `Debug` — see the variant docs for why the indices alone are
|
|
/// not enough.
|
|
key: String,
|
|
},
|
|
/// `History::register` was called for a competitor that already exists.
|
|
///
|
|
/// Registration states a competitor's configuration before anything has
|
|
/// been observed about them, so a competitor that already exists has
|
|
/// already been configured — by an earlier `register`, or by an event that
|
|
/// created them. Silently overwriting would reintroduce exactly the
|
|
/// order-dependence registration exists to remove.
|
|
///
|
|
/// To change an existing competitor's configuration, supply it on an event
|
|
/// through `Member`; that refits the whole history.
|
|
#[non_exhaustive]
|
|
AlreadyRegistered {
|
|
/// The already-known competitor's key, in its `Debug` rendering.
|
|
key: String,
|
|
},
|
|
/// A prediction was given a team with no members.
|
|
#[non_exhaustive]
|
|
EmptyTeam {
|
|
/// Position of the memberless team in the supplied list.
|
|
team: usize,
|
|
},
|
|
/// The prediction grid cannot resolve the narrowest feature in the matchup.
|
|
///
|
|
/// `predict_outcome` and `predict_ranking` integrate every team's density
|
|
/// on one shared grid, whose resolution is set by the narrowest sigma (or a
|
|
/// narrower draw margin). When the widest and narrowest are far enough
|
|
/// apart, resolving the narrow one across the wide one's support needs more
|
|
/// nodes than the grid is allowed to hold.
|
|
///
|
|
/// Reported rather than clamped. Clamping is what this replaced, and it
|
|
/// returned probabilities greater than one — measured, a `P` of 2.79 and a
|
|
/// `Prediction::total()` of 5.41 — because the trapezoid rule stops
|
|
/// resolving a density once the step exceeds roughly 1.7 of its sigma.
|
|
///
|
|
/// `predict_win_probabilities` answers the same matchup through adaptive
|
|
/// quadrature and is accurate here; use it when only the per-team win
|
|
/// probabilities are needed.
|
|
#[non_exhaustive]
|
|
GridTooCoarse {
|
|
/// Nodes required to resolve the narrowest feature.
|
|
needed: usize,
|
|
/// Nodes the grid may hold.
|
|
max: usize,
|
|
},
|
|
/// A joint posterior was requested 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,
|
|
},
|
|
/// Fewer than two teams were supplied to a prediction.
|
|
#[non_exhaustive]
|
|
NotEnoughTeams {
|
|
/// How many teams the prediction was actually given. Two is the
|
|
/// minimum: there is nothing to compare against with fewer.
|
|
got: usize,
|
|
},
|
|
/// The full outcome distribution was requested for too many teams.
|
|
///
|
|
/// Each realisation sorts into exactly one (order, tie-pattern) event, so
|
|
/// the space holds `n! * 2^(n-1)` members — 1_920 at five teams, 23_040 at
|
|
/// six, 322_560 at seven. Past `max` this stops being something to
|
|
/// enumerate on a caller's behalf; ask for individual rankings with
|
|
/// `predict_ranking`, or for `predict_win_probabilities`, both of which
|
|
/// stay cheap at any team count.
|
|
#[non_exhaustive]
|
|
TooManyTeams {
|
|
/// How many teams the outcome distribution was asked for.
|
|
got: usize,
|
|
/// The largest team count that will be enumerated,
|
|
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
|
|
max: usize,
|
|
},
|
|
}
|
|
|
|
impl fmt::Display for InferenceError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::MismatchedShape {
|
|
kind,
|
|
expected,
|
|
got,
|
|
} => {
|
|
write!(f, "{kind}: expected length {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::TieWithoutDrawProbability { teams } => {
|
|
write!(
|
|
f,
|
|
"teams {} and {} are tied, but p_draw is 0.0; set a positive draw probability to admit ties",
|
|
teams.0, teams.1
|
|
)
|
|
}
|
|
Self::NotConverged {
|
|
iterations,
|
|
final_step,
|
|
epsilon,
|
|
} => {
|
|
write!(
|
|
f,
|
|
"did not converge in {iterations} iterations: final step {final_step:?} \
|
|
is still above epsilon {epsilon}; raise max_iter, or damp with \
|
|
alpha < 1.0 if it is oscillating"
|
|
)
|
|
}
|
|
Self::NonFiniteResult { context, step } => {
|
|
write!(
|
|
f,
|
|
"{context}: inference produced a non-finite result (step = {step:?})"
|
|
)
|
|
}
|
|
Self::InvalidParameter { name, value } => {
|
|
write!(f, "{name} is invalid: {value}")
|
|
}
|
|
Self::ConflictingCompetitorConfig { competitor, field } => {
|
|
write!(
|
|
f,
|
|
"competitor {competitor}: this batch sets {field} to two different values"
|
|
)
|
|
}
|
|
Self::UnknownKey { team, member, key } => {
|
|
write!(
|
|
f,
|
|
"team {team}, member {member}: no skill recorded for key {key} \
|
|
(every key must already be known to the history; pre-filter \
|
|
with `lookup` or `current_skill` if that is not guaranteed)"
|
|
)
|
|
}
|
|
Self::AlreadyRegistered { key } => {
|
|
write!(
|
|
f,
|
|
"competitor {key} is already registered; registration states \
|
|
configuration before anything is observed, so re-registering \
|
|
would silently overwrite it"
|
|
)
|
|
}
|
|
Self::EmptyTeam { team } => {
|
|
write!(f, "team {team} has no members")
|
|
}
|
|
Self::GridTooCoarse { needed, max } => {
|
|
write!(
|
|
f,
|
|
"the prediction grid needs {needed} nodes to resolve the narrowest \
|
|
team's density across the widest team's support, but may hold only \
|
|
{max}; the sigmas in this matchup are too far apart to integrate on \
|
|
one grid. Use predict_win_probabilities, which is accurate here"
|
|
)
|
|
}
|
|
Self::JointUnavailable { reason } => {
|
|
write!(f, "no exact joint posterior is available: {reason}")
|
|
}
|
|
Self::NotEnoughTeams { got } => {
|
|
write!(f, "prediction needs at least 2 teams, got {got}")
|
|
}
|
|
Self::TooManyTeams { got, max } => {
|
|
write!(
|
|
f,
|
|
"the outcome distribution over {got} teams is too large to enumerate (limit {max}); \
|
|
use predict_ranking or predict_win_probabilities instead"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for InferenceError {}
|