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