Three vocabulary collisions, from #75. **"rating" meant three things**, one the opposite of the exported type. `Rating` is documented as static *configuration* — "this returns what it was told", against every other accessor's "what inference inferred". But `quality`'s parameter was `rating_groups: &[&[Gaussian]]` and its prose said "rating groups" four times, where "rating" means a *posterior* — the one thing `Rating` is documented not to be. Two error messages used it that way too. So a reader who learned `Rating = config` passed `Rating` values to `quality`, which takes `Gaussian`; and one who learned "rating = what comes out" was baffled that `h.rating(&k)` is not their skill. "rating" is now reserved for the type. `quality(teams: &[&[Gaussian]])`, and "every rating is finite" became "every posterior is finite". **"agent" was a private fourth name for a competitor** — ~200 identifiers against 236 uses of "competitor", and it leaked into two `pub` signatures on `TimeSlice`. Now that #73 has made those internal this is a pure rename, so the crate has one word for the entity throughout. **"player" survived in one public signature** — `free_for_all(players:)` plus two doc lines. Renamed, along with three internal closure bindings. Doc examples that use "player" as a *key* are left alone: that is a user's data, not the crate's vocabulary. The panic-message expectations in tests/quality.rs moved with the prose, which is the point of asserting on message text — the tests caught the rename rather than papering over it. Not touched: "performance" (always skill widened by beta), "skill", "member" and "team" are each used for exactly one thing already. Refs #75 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
286 lines
12 KiB
Rust
286 lines
12 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,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
#[non_exhaustive]
|
|
pub enum InferenceError {
|
|
/// Expected and actual lengths of some array-shaped input differ.
|
|
#[non_exhaustive]
|
|
MismatchedShape {
|
|
kind: &'static str,
|
|
expected: usize,
|
|
got: usize,
|
|
},
|
|
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
|
#[non_exhaustive]
|
|
WrongOutcomeKind {
|
|
context: &'static str,
|
|
expected: &'static str,
|
|
got: &'static str,
|
|
},
|
|
/// A probability value is outside `[0, 1]`.
|
|
#[non_exhaustive]
|
|
InvalidProbability { value: f64 },
|
|
/// A scalar parameter is outside its valid range.
|
|
#[non_exhaustive]
|
|
InvalidParameter { name: &'static str, 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) },
|
|
/// 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 {
|
|
iterations: usize,
|
|
final_step: (f64, f64),
|
|
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 {
|
|
context: &'static str,
|
|
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 {
|
|
competitor: usize,
|
|
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 {
|
|
team: usize,
|
|
member: usize,
|
|
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 { key: String },
|
|
/// A prediction was given a team with no members.
|
|
#[non_exhaustive]
|
|
EmptyTeam { 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 { reason: &'static str },
|
|
/// Fewer than two teams were supplied to a prediction.
|
|
#[non_exhaustive]
|
|
NotEnoughTeams { 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 { got: usize, 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 {}
|