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 {}