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
This commit is contained in:
@@ -121,7 +121,7 @@ for everything that accumulates.
|
|||||||
## `converge` is strict
|
## `converge` is strict
|
||||||
|
|
||||||
`converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the
|
`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
|
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,
|
shape. A fit that stops short is *wrong by a little*: every posterior is finite,
|
||||||
|
|||||||
+7
-3
@@ -123,7 +123,7 @@ fn u_minus_ln1p(u: f64) -> f64 {
|
|||||||
/// - `EmptyTeam` if any team has no members.
|
/// - `EmptyTeam` if any team has no members.
|
||||||
/// - `TooManyTeams` if the outcome space is too large to enumerate; see
|
/// - `TooManyTeams` if the outcome space is too large to enumerate; see
|
||||||
/// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS).
|
/// [`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
|
/// - `GridTooCoarse` when the performance sigmas are too far apart to
|
||||||
/// integrate on one grid. This comes from `outcome_distribution`, which runs
|
/// integrate on one grid. This comes from `outcome_distribution`, which runs
|
||||||
/// before any inference — so it is not covered by "anything `Game::ranked`
|
/// 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) {
|
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,
|
value: options.p_draw,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -367,7 +368,10 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
expected_information_gain(&[&a, &a], &options(1.5)),
|
expected_information_gain(&[&a, &a], &options(1.5)),
|
||||||
Err(InferenceError::InvalidProbability { .. })
|
Err(InferenceError::InvalidParameter {
|
||||||
|
parameter: crate::Parameter::PDraw,
|
||||||
|
..
|
||||||
|
})
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -66,13 +66,13 @@ impl ConvergenceOptions {
|
|||||||
pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> {
|
pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> {
|
||||||
if !(self.alpha > 0.0 && self.alpha <= 1.0) {
|
if !(self.alpha > 0.0 && self.alpha <= 1.0) {
|
||||||
return Err(crate::InferenceError::InvalidParameter {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
name: "alpha",
|
parameter: crate::Parameter::Alpha,
|
||||||
value: self.alpha,
|
value: self.alpha,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if self.epsilon.is_nan() || self.epsilon < 0.0 {
|
if self.epsilon.is_nan() || self.epsilon < 0.0 {
|
||||||
return Err(crate::InferenceError::InvalidParameter {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
name: "epsilon",
|
parameter: crate::Parameter::Epsilon,
|
||||||
value: self.epsilon,
|
value: self.epsilon,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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
|
/// `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`
|
/// 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
|
/// 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
|
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
|
||||||
/// back with [`ConstantDrift::gamma`].
|
/// back with [`ConstantDrift::gamma`].
|
||||||
|
|||||||
+365
-58
@@ -39,6 +39,182 @@ pub enum UnknownKeys {
|
|||||||
Prior,
|
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.
|
/// Every way ingestion, inference or prediction can refuse to answer.
|
||||||
///
|
///
|
||||||
/// The crate reports rather than repairs. An input it cannot represent, a fit
|
/// 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.
|
/// Expected and actual lengths of some array-shaped input differ.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
MismatchedShape {
|
MismatchedShape {
|
||||||
/// Which input disagreed, as a short label — `"ranks vs teams"`,
|
/// Which pair of lengths disagreed.
|
||||||
/// `"weights"`, `"times"`.
|
shape: Shape,
|
||||||
kind: &'static str,
|
|
||||||
/// The length it had to have, taken from whatever it must line up with
|
/// The length it had to have, taken from whatever it must line up with
|
||||||
/// (usually the event's team count).
|
/// (usually the event's team count).
|
||||||
expected: usize,
|
expected: usize,
|
||||||
@@ -68,28 +243,18 @@ pub enum InferenceError {
|
|||||||
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
WrongOutcomeKind {
|
WrongOutcomeKind {
|
||||||
/// The call that rejected the outcome, e.g. `"Game::ranked"`.
|
/// The variant the call needs.
|
||||||
context: &'static str,
|
expected: OutcomeKind,
|
||||||
/// The [`Outcome`](crate::Outcome) variant that call needs, by name.
|
/// The variant actually supplied.
|
||||||
expected: &'static str,
|
got: OutcomeKind,
|
||||||
/// 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.
|
/// A scalar parameter is outside its valid range.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
InvalidParameter {
|
InvalidParameter {
|
||||||
/// The parameter, spelled as the API spells it — `"alpha"`,
|
/// Which parameter. `Display` states its valid range.
|
||||||
/// `"epsilon"`, `"score_sigma"`, `"drift_scale"`, `"drift variance"`.
|
parameter: Parameter,
|
||||||
name: &'static str,
|
/// The value supplied for it: outside that range, or NaN, which fails
|
||||||
/// The value supplied for it. Out of that parameter's range, or NaN,
|
/// every range comparison and is rejected on that basis.
|
||||||
/// which fails every range comparison and is rejected on that basis.
|
|
||||||
value: f64,
|
value: f64,
|
||||||
},
|
},
|
||||||
/// An event contains tied teams, but the draw probability is zero.
|
/// 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.
|
/// The threshold both components of `final_step` had to reach.
|
||||||
epsilon: f64,
|
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
|
/// EP has broken down; the resulting skills are meaningless and must not
|
||||||
/// and must not be treated as a converged estimate.
|
/// be treated as a converged estimate. Further iterations cannot recover,
|
||||||
|
/// so the loop stops rather than reporting a NaN step as convergence.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
NonFiniteResult {
|
NonFiniteStep {
|
||||||
/// Where the breakdown was caught — `"History::converge"` for a sweep,
|
/// Where the breakdown was caught, e.g. `"History::converge"`.
|
||||||
/// or a phrase naming the prediction that read an unusable skill.
|
|
||||||
context: &'static str,
|
context: &'static str,
|
||||||
/// The offending pair, at least one component of which is NaN or
|
/// The offending step as `(|d mu|, |d sigma|)`, at least one component
|
||||||
/// infinite. From `converge` it is the sweep's step; from a prediction
|
/// of which is NaN or infinite.
|
||||||
/// it is the skill's own `(mu, sigma)`.
|
|
||||||
step: (f64, f64),
|
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
|
/// One batch declared two different values for the same competitor's
|
||||||
/// configuration.
|
/// configuration.
|
||||||
///
|
///
|
||||||
@@ -159,9 +348,8 @@ pub enum InferenceError {
|
|||||||
/// not the user key — the batch is already flattened to indices by the
|
/// not the user key — the batch is already flattened to indices by the
|
||||||
/// time the conflict is detectable.
|
/// time the conflict is detectable.
|
||||||
competitor: usize,
|
competitor: usize,
|
||||||
/// Which piece of configuration was declared twice: `"prior"` or
|
/// Which piece of configuration was declared twice.
|
||||||
/// `"drift_scale"`.
|
field: CompetitorField,
|
||||||
field: &'static str,
|
|
||||||
},
|
},
|
||||||
/// A prediction referenced a key the history has no skill for.
|
/// A prediction referenced a key the history has no skill for.
|
||||||
///
|
///
|
||||||
@@ -231,14 +419,32 @@ pub enum InferenceError {
|
|||||||
/// Nodes the grid may hold.
|
/// Nodes the grid may hold.
|
||||||
max: usize,
|
max: usize,
|
||||||
},
|
},
|
||||||
/// A joint posterior was requested where one cannot be formed exactly.
|
/// A joint posterior was requested from a history with no events.
|
||||||
#[non_exhaustive]
|
///
|
||||||
JointUnavailable {
|
/// Split out of a single `JointUnavailable { reason: &str }` (#74): the
|
||||||
/// Why no exact joint exists here: the history has no events, it holds
|
/// three reasons are conditions a caller branches on differently, and
|
||||||
/// ranked events whose EP factors are not retained past convergence, or
|
/// distinguishing them used to mean matching on English prose. This one
|
||||||
/// the assembled precision matrix is not positive-definite.
|
/// means "add events".
|
||||||
reason: &'static str,
|
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.
|
/// Fewer than two teams were supplied to a prediction.
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
NotEnoughTeams {
|
NotEnoughTeams {
|
||||||
@@ -268,21 +474,19 @@ impl fmt::Display for InferenceError {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::MismatchedShape {
|
Self::MismatchedShape {
|
||||||
kind,
|
shape,
|
||||||
expected,
|
expected,
|
||||||
got,
|
got,
|
||||||
} => {
|
} => {
|
||||||
write!(f, "{kind}: expected length {expected}, got {got}")
|
write!(f, "{shape}: expected {expected}, got {got}")
|
||||||
}
|
}
|
||||||
Self::WrongOutcomeKind {
|
Self::WrongOutcomeKind { expected, got } => {
|
||||||
context,
|
write!(
|
||||||
expected,
|
f,
|
||||||
got,
|
"expected {expected}, got {got}; call {} for a {} outcome",
|
||||||
} => {
|
got.constructor(),
|
||||||
write!(f, "{context}: expected {expected}, got {got}")
|
got.adjective()
|
||||||
}
|
)
|
||||||
Self::InvalidProbability { value } => {
|
|
||||||
write!(f, "probability must be in [0, 1]; got {value}")
|
|
||||||
}
|
}
|
||||||
Self::TieWithoutDrawProbability { teams } => {
|
Self::TieWithoutDrawProbability { teams } => {
|
||||||
write!(
|
write!(
|
||||||
@@ -303,14 +507,23 @@ impl fmt::Display for InferenceError {
|
|||||||
alpha < 1.0 if it is oscillating"
|
alpha < 1.0 if it is oscillating"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Self::NonFiniteResult { context, step } => {
|
Self::NonFiniteStep { context, step } => {
|
||||||
write!(
|
write!(
|
||||||
f,
|
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 } => {
|
Self::NonFiniteSkill { mu, sigma } => {
|
||||||
write!(f, "{name} is invalid: {value}")
|
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 } => {
|
Self::ConflictingCompetitorConfig { competitor, field } => {
|
||||||
write!(
|
write!(
|
||||||
@@ -346,9 +559,25 @@ impl fmt::Display for InferenceError {
|
|||||||
one grid. Use predict_win_probabilities, which is accurate here"
|
one grid. Use predict_win_probabilities, which is accurate here"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Self::JointUnavailable { reason } => {
|
Self::EmptyHistory => {
|
||||||
write!(f, "no exact joint posterior is available: {reason}")
|
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 } => {
|
Self::NotEnoughTeams { got } => {
|
||||||
write!(f, "prediction needs at least 2 teams, got {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 {}
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ where
|
|||||||
|
|
||||||
if ws.len() != team.members.len() {
|
if ws.len() != team.members.len() {
|
||||||
self.error.get_or_insert(InferenceError::MismatchedShape {
|
self.error.get_or_insert(InferenceError::MismatchedShape {
|
||||||
kind: "weights",
|
shape: crate::Shape::Weights,
|
||||||
expected: team.members.len(),
|
expected: team.members.len(),
|
||||||
got: ws.len(),
|
got: ws.len(),
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-13
@@ -555,7 +555,7 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
|
|||||||
/// - `InvalidParameter` if `options.convergence` is out of range — an
|
/// - `InvalidParameter` if `options.convergence` is out of range — an
|
||||||
/// `alpha` of zero would leave every EP update unapplied and silently
|
/// `alpha` of zero would leave every EP update unapplied and silently
|
||||||
/// return the priors.
|
/// 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()`.
|
/// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`.
|
||||||
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
|
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
|
||||||
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
|
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
|
||||||
@@ -571,13 +571,14 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
|
|||||||
options.convergence.validate()?;
|
options.convergence.validate()?;
|
||||||
Self::validate_teams(teams)?;
|
Self::validate_teams(teams)?;
|
||||||
if !(0.0..1.0).contains(&options.p_draw) {
|
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,
|
value: options.p_draw,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if outcome.team_count() != teams.len() {
|
if outcome.team_count() != teams.len() {
|
||||||
return Err(crate::InferenceError::MismatchedShape {
|
return Err(crate::InferenceError::MismatchedShape {
|
||||||
kind: "outcome ranks vs teams",
|
shape: crate::Shape::OutcomeVsTeams,
|
||||||
expected: teams.len(),
|
expected: teams.len(),
|
||||||
got: outcome.team_count(),
|
got: outcome.team_count(),
|
||||||
});
|
});
|
||||||
@@ -586,9 +587,8 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
|
|||||||
let ranks = outcome
|
let ranks = outcome
|
||||||
.as_ranks()
|
.as_ranks()
|
||||||
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
||||||
context: "Game::ranked",
|
expected: crate::OutcomeKind::Ranked,
|
||||||
expected: "Outcome::Ranked",
|
got: crate::OutcomeKind::Scored,
|
||||||
got: "Outcome::Scored",
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let tied = if options.p_draw == 0.0 {
|
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)?;
|
Self::validate_teams(teams)?;
|
||||||
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
|
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
|
||||||
return Err(crate::InferenceError::InvalidParameter {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
name: "score_sigma",
|
parameter: crate::Parameter::ScoreSigma,
|
||||||
value: options.score_sigma,
|
value: options.score_sigma,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if outcome.team_count() != teams.len() {
|
if outcome.team_count() != teams.len() {
|
||||||
return Err(crate::InferenceError::MismatchedShape {
|
return Err(crate::InferenceError::MismatchedShape {
|
||||||
kind: "outcome scores vs teams",
|
shape: crate::Shape::OutcomeVsTeams,
|
||||||
expected: teams.len(),
|
expected: teams.len(),
|
||||||
got: outcome.team_count(),
|
got: outcome.team_count(),
|
||||||
});
|
});
|
||||||
@@ -652,9 +652,8 @@ impl<T: Time, D: Drift<T>> Game<T, D> {
|
|||||||
let scores = outcome
|
let scores = outcome
|
||||||
.as_scores()
|
.as_scores()
|
||||||
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
||||||
context: "Game::scored",
|
expected: crate::OutcomeKind::Scored,
|
||||||
expected: "Outcome::Scored",
|
got: crate::OutcomeKind::Ranked,
|
||||||
got: "Outcome::Ranked",
|
|
||||||
})?
|
})?
|
||||||
.to_vec();
|
.to_vec();
|
||||||
// A non-finite score poisons the chain rather than failing it. Ranks
|
// 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 {
|
for value in &scores {
|
||||||
if !value.is_finite() {
|
if !value.is_finite() {
|
||||||
return Err(crate::InferenceError::InvalidParameter {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
name: "score",
|
parameter: crate::Parameter::Score,
|
||||||
value: *value,
|
value: *value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1368,7 +1367,7 @@ mod tests {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
crate::InferenceError::InvalidParameter {
|
crate::InferenceError::InvalidParameter {
|
||||||
name: "score_sigma",
|
parameter: crate::Parameter::ScoreSigma,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,7 @@ impl Gaussian {
|
|||||||
///
|
///
|
||||||
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a
|
/// Panics if `sigma` is negative. NaN is deliberately allowed through: a
|
||||||
/// broken fit produces one, and `converge` reports that as
|
/// 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
|
/// A negative sigma used to be accepted and returned results **bit
|
||||||
/// identical** to its absolute value, because sigma only ever enters as
|
/// 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 {
|
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
||||||
// NaN is admitted on purpose. A broken fit legitimately produces a NaN
|
// NaN is admitted on purpose. A broken fit legitimately produces a NaN
|
||||||
// sigma — `sqrt` of a negative truncated variance — and the design is
|
// 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
|
// panic inside inference. Rejecting it here turned that reporting path
|
||||||
// into a crash, which two tests caught immediately.
|
// into a crash, which two tests caught immediately.
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+57
-71
@@ -72,7 +72,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
|
|||||||
/// # Panics
|
/// # Panics
|
||||||
///
|
///
|
||||||
/// Panics if `mu` is not finite. A non-finite prior mean poisons every
|
/// 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`.
|
/// caller who reads `current_skill` first is handed `tau: NaN`.
|
||||||
pub fn mu(mut self, mu: f64) -> Self {
|
pub fn mu(mut self, mu: f64) -> Self {
|
||||||
assert!(mu.is_finite(), "mu must be finite (got {mu})");
|
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 {
|
if member.weight != 1.0 {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "weight",
|
parameter: crate::Parameter::Weight,
|
||||||
value: member.weight,
|
value: member.weight,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(scale) = member.drift_scale {
|
if let Some(scale) = member.drift_scale {
|
||||||
if !scale.is_finite() || scale < 0.0 {
|
if !scale.is_finite() || scale < 0.0 {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "drift_scale",
|
parameter: crate::Parameter::DriftScale,
|
||||||
value: scale,
|
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
|
// `converge` refuses to report a NaN fit, but nothing stopped a
|
||||||
// caller ignoring that error and predicting anyway. Measured on
|
// caller ignoring that error and predicting anyway. Measured on
|
||||||
// a point-mass-prior history with `beta(0.0)`, after `converge`
|
// 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_outcome().total()` gave `NaN`, and
|
||||||
// `predict_win_probabilities` gave `Ok([0.0, 0.0])` — finite,
|
// `predict_win_probabilities` gave `Ok([0.0, 0.0])` — finite,
|
||||||
// plausible, and summing to zero against a doc that promises
|
// 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
|
// stored posterior today, and a prediction from an
|
||||||
// uninformative skill would be meaningless if it did.
|
// uninformative skill would be meaningless if it did.
|
||||||
if !skill.mu().is_finite() || !skill.sigma().is_finite() {
|
if !skill.mu().is_finite() || !skill.sigma().is_finite() {
|
||||||
return Err(InferenceError::NonFiniteResult {
|
return Err(InferenceError::NonFiniteSkill {
|
||||||
context: "prediction read a skill with no usable mean or \
|
mu: skill.mu(),
|
||||||
variance; the fit did not converge",
|
sigma: skill.sigma(),
|
||||||
step: (skill.mu(), 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
|
// time and it is a property of the parameters, not a numerical
|
||||||
// accident.
|
// accident.
|
||||||
if self.beta == 0.0 && gathered.iter().flatten().all(|g| g.sigma() == 0.0) {
|
if self.beta == 0.0 && gathered.iter().flatten().all(|g| g.sigma() == 0.0) {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::NoPerformanceVariance);
|
||||||
name: "beta with point-mass skills",
|
|
||||||
value: 0.0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(gathered)
|
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`.
|
/// `NotEnoughTeams`, `EmptyTeam` or `UnknownKey`.
|
||||||
///
|
///
|
||||||
/// Every prediction reads skills through one gate, which adds two errors to
|
/// 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
|
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
|
||||||
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
|
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
|
||||||
/// and every skill is a point mass, leaving no performance distribution to
|
/// every skill is a point mass, leaving no performance distribution to
|
||||||
/// predict from.
|
/// predict from.
|
||||||
pub fn quality<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
|
pub fn quality<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
|
||||||
where
|
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 =
|
/// stored `f64` before the factorisation ever runs. Measured, `drift_scale =
|
||||||
/// 1e-10` returned a posterior variance **12 000x too small** — a 111x
|
/// 1e-10` returned a posterior variance **12 000x too small** — a 111x
|
||||||
/// overconfident interval — as `Ok`, and the band just above it returned a
|
/// 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
|
/// Solved exactly in high precision the same system is perfectly well
|
||||||
/// conditioned: it converges smoothly onto the collapsed value and is flat from
|
/// 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
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// `JointUnavailable` if the history is empty, contains ranked events, or
|
/// `EmptyHistory` for a history with no events, `JointRequiresScoredEvents`
|
||||||
/// yields a precision matrix that is not positive-definite.
|
/// 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> {
|
pub fn joint(&self) -> Result<Joint<'_, K, T, D, O, R>, InferenceError> {
|
||||||
if self.time_slices.is_empty() {
|
if self.time_slices.is_empty() {
|
||||||
return Err(InferenceError::JointUnavailable {
|
return Err(InferenceError::EmptyHistory);
|
||||||
reason: "the history has no events",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if !self.time_slices.iter().all(TimeSlice::all_scored) {
|
if !self.time_slices.iter().all(TimeSlice::all_scored) {
|
||||||
return Err(InferenceError::JointUnavailable {
|
return Err(InferenceError::JointRequiresScoredEvents);
|
||||||
reason: "the history contains ranked events, whose EP factors are \
|
|
||||||
not retained after convergence",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let TimeExpanded {
|
let TimeExpanded {
|
||||||
@@ -1728,14 +1720,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K
|
|||||||
width,
|
width,
|
||||||
} = self.time_expanded_joint();
|
} = self.time_expanded_joint();
|
||||||
|
|
||||||
let cholesky = crate::joint::Cholesky::factor(lambda, width).ok_or(
|
let cholesky = crate::joint::Cholesky::factor(lambda, width)
|
||||||
InferenceError::JointUnavailable {
|
.ok_or(InferenceError::NotPositiveDefinite)?;
|
||||||
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",
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(Joint {
|
Ok(Joint {
|
||||||
history: self,
|
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`,
|
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam`,
|
||||||
/// `UnknownKey` under [`UnknownKeys::Reject`](crate::UnknownKeys::Reject),
|
/// `UnknownKey` under [`UnknownKeys::Reject`](crate::UnknownKeys::Reject),
|
||||||
/// and `JointUnavailable` if the history is empty or holds ranked events in
|
/// and `EmptyHistory` / `JointRequiresScoredEvents` — the latter if *any*
|
||||||
/// *any* slice — not merely the latest one.
|
/// slice holds ranked events, not merely the latest one.
|
||||||
pub fn predict_margin<Q>(&self, teams: &[&[&Q]]) -> Result<Gaussian, InferenceError>
|
pub fn predict_margin<Q>(&self, teams: &[&[&Q]]) -> Result<Gaussian, InferenceError>
|
||||||
where
|
where
|
||||||
K: Borrow<Q>,
|
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 {
|
if teams.len() != 2 {
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "predict_margin takes exactly 2 teams",
|
shape: crate::Shape::Teams,
|
||||||
expected: 2,
|
expected: 2,
|
||||||
got: teams.len(),
|
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.
|
/// for a hypothetical outcome.
|
||||||
///
|
///
|
||||||
/// Every prediction reads skills through one gate, which adds two errors to
|
/// 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
|
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
|
||||||
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
|
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
|
||||||
/// and every skill is a point mass, leaving no performance distribution to
|
/// every skill is a point mass, leaving no performance distribution to
|
||||||
/// predict from.
|
/// predict from.
|
||||||
pub fn expected_information_gain<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
|
pub fn expected_information_gain<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
|
||||||
where
|
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`.
|
/// `NotEnoughTeams`, `EmptyTeam`, or `UnknownKey`.
|
||||||
///
|
///
|
||||||
/// Every prediction reads skills through one gate, which adds two errors to
|
/// 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
|
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
|
||||||
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
|
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
|
||||||
/// and every skill is a point mass, leaving no performance distribution to
|
/// every skill is a point mass, leaving no performance distribution to
|
||||||
/// predict from.
|
/// predict from.
|
||||||
pub fn predict_win_probabilities<Q>(&self, teams: &[&[&Q]]) -> Result<Vec<f64>, InferenceError>
|
pub fn predict_win_probabilities<Q>(&self, teams: &[&[&Q]]) -> Result<Vec<f64>, InferenceError>
|
||||||
where
|
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.
|
/// integrate on one grid.
|
||||||
///
|
///
|
||||||
/// Every prediction reads skills through one gate, which adds two errors to
|
/// 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
|
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
|
||||||
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
|
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
|
||||||
/// and every skill is a point mass, leaving no performance distribution to
|
/// every skill is a point mass, leaving no performance distribution to
|
||||||
/// predict from.
|
/// predict from.
|
||||||
pub fn predict_outcome<Q>(&self, teams: &[&[&Q]]) -> Result<Prediction, InferenceError>
|
pub fn predict_outcome<Q>(&self, teams: &[&[&Q]]) -> Result<Prediction, InferenceError>
|
||||||
where
|
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.
|
/// performance sigmas are too far apart to integrate on one grid.
|
||||||
///
|
///
|
||||||
/// Every prediction reads skills through one gate, which adds two errors to
|
/// 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
|
/// all of them: `NonFiniteSkill` if a skill has no usable mean or variance
|
||||||
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
|
/// — the fit did not converge — and `NoPerformanceVariance` if `beta` is zero and
|
||||||
/// and every skill is a point mass, leaving no performance distribution to
|
/// every skill is a point mass, leaving no performance distribution to
|
||||||
/// predict from.
|
/// predict from.
|
||||||
pub fn predict_ranking<Q>(&self, teams: &[&[&Q]], ranks: &[u32]) -> Result<f64, InferenceError>
|
pub fn predict_ranking<Q>(&self, teams: &[&[&Q]], ranks: &[u32]) -> Result<f64, InferenceError>
|
||||||
where
|
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() {
|
if ranks.len() != teams.len() {
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "ranks vs teams",
|
shape: crate::Shape::OutcomeVsTeams,
|
||||||
expected: teams.len(),
|
expected: teams.len(),
|
||||||
got: ranks.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
|
/// `NotConverged` if the sweep hits `max_iter` with the step still above
|
||||||
/// `epsilon`.
|
/// `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
|
/// broken down at that point and further iterations cannot recover, so the
|
||||||
/// loop stops rather than reporting a NaN step as convergence.
|
/// 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
|
/// # 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
|
/// `InvalidParameter` if a competitor's drift model yields a negative or
|
||||||
/// non-finite variance. Checked here, before any sweeping, so it applies to
|
/// 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);
|
.drift_variance_for_elapsed(elapsed);
|
||||||
if !drift.is_finite() || drift < 0.0 {
|
if !drift.is_finite() || drift < 0.0 {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "drift variance",
|
parameter: crate::Parameter::DriftVariance,
|
||||||
value: drift,
|
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) {
|
if !crate::step_is_finite(step) {
|
||||||
self.observer.on_converged(i, step, false);
|
self.observer.on_converged(i, step, false);
|
||||||
|
|
||||||
return Err(InferenceError::NonFiniteResult {
|
return Err(InferenceError::NonFiniteStep {
|
||||||
context: "History::converge",
|
context: "History::converge",
|
||||||
step,
|
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);
|
let got = results.as_ref().map_or(0, Vec::len);
|
||||||
|
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "results",
|
shape: crate::Shape::Internal,
|
||||||
expected: composition.len(),
|
expected: composition.len(),
|
||||||
got,
|
got,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if times.len() != composition.len() {
|
if times.len() != composition.len() {
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "times",
|
shape: crate::Shape::Internal,
|
||||||
expected: composition.len(),
|
expected: composition.len(),
|
||||||
got: times.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);
|
let got = weights.as_ref().map_or(0, Vec::len);
|
||||||
|
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "weights",
|
shape: crate::Shape::Weights,
|
||||||
expected: composition.len(),
|
expected: composition.len(),
|
||||||
got,
|
got,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if kinds.len() != composition.len() {
|
if kinds.len() != composition.len() {
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "kinds",
|
shape: crate::Shape::Internal,
|
||||||
expected: composition.len(),
|
expected: composition.len(),
|
||||||
got: kinds.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:
|
// 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
|
// `current_skill` before converging is handed a NaN posterior with
|
||||||
// nothing to say it is one.
|
// nothing to say it is one.
|
||||||
if let Some(results) = results.as_ref() {
|
if let Some(results) = results.as_ref() {
|
||||||
for (event_results, kind) in results.iter().zip(kinds.iter()) {
|
for (event_results, kind) in results.iter().zip(kinds.iter()) {
|
||||||
let name = match kind {
|
let parameter = match kind {
|
||||||
EventKind::Ranked => "rank",
|
EventKind::Ranked => crate::Parameter::Rank,
|
||||||
EventKind::Scored { .. } => "score",
|
EventKind::Scored { .. } => crate::Parameter::Score,
|
||||||
};
|
};
|
||||||
for value in event_results {
|
for value in event_results {
|
||||||
if !value.is_finite() {
|
if !value.is_finite() {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name,
|
parameter,
|
||||||
value: *value,
|
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 {
|
for weight in team_weights {
|
||||||
if !weight.is_finite() {
|
if !weight.is_finite() {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "weight",
|
parameter: crate::Parameter::Weight,
|
||||||
value: *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 {
|
if existing != new {
|
||||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||||
competitor: competitor.get(),
|
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 {
|
if existing != new {
|
||||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||||
competitor: competitor.get(),
|
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 {
|
for ev in events {
|
||||||
if ev.outcome.team_count() != ev.teams.len() {
|
if ev.outcome.team_count() != ev.teams.len() {
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "outcome vs teams",
|
shape: crate::Shape::OutcomeVsTeams,
|
||||||
expected: ev.teams.len(),
|
expected: ev.teams.len(),
|
||||||
got: ev.outcome.team_count(),
|
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.
|
// accept a sign the caller cannot have meant.
|
||||||
if !scale.is_finite() || scale < 0.0 {
|
if !scale.is_finite() || scale < 0.0 {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "drift_scale",
|
parameter: crate::Parameter::DriftScale,
|
||||||
value: scale,
|
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) {
|
if entry.prior.is_some_and(|held| held != prior) {
|
||||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||||
competitor: idx.get(),
|
competitor: idx.get(),
|
||||||
field: "prior",
|
field: crate::CompetitorField::Prior,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
entry.prior = Some(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) {
|
if entry.drift_scale.is_some_and(|held| held != scale) {
|
||||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||||
competitor: idx.get(),
|
competitor: idx.get(),
|
||||||
field: "drift_scale",
|
field: crate::CompetitorField::DriftScale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
entry.drift_scale = Some(scale);
|
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);
|
let resolved = score_sigma.unwrap_or(self.score_sigma);
|
||||||
if resolved <= 0.0 || resolved.is_nan() {
|
if resolved <= 0.0 || resolved.is_nan() {
|
||||||
return Err(InferenceError::InvalidParameter {
|
return Err(InferenceError::InvalidParameter {
|
||||||
name: "score_sigma",
|
parameter: crate::Parameter::ScoreSigma,
|
||||||
value: resolved,
|
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 {
|
if teams.len() != 2 {
|
||||||
return Err(InferenceError::MismatchedShape {
|
return Err(InferenceError::MismatchedShape {
|
||||||
kind: "expected_variance_reduction takes exactly 2 teams",
|
shape: crate::Shape::Teams,
|
||||||
expected: 2,
|
expected: 2,
|
||||||
got: teams.len(),
|
got: teams.len(),
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -152,7 +152,7 @@ mod time_slice;
|
|||||||
pub use acquisition::expected_information_gain;
|
pub use acquisition::expected_information_gain;
|
||||||
pub use convergence::{ConvergenceOptions, ConvergenceReport};
|
pub use convergence::{ConvergenceOptions, ConvergenceReport};
|
||||||
pub use drift::{ConstantDrift, Drift};
|
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::{Event, Member, Team};
|
||||||
pub use event_builder::EventBuilder;
|
pub use event_builder::EventBuilder;
|
||||||
pub use game::{Game, GameOptions};
|
pub use game::{Game, GameOptions};
|
||||||
|
|||||||
+1
-1
@@ -84,7 +84,7 @@ impl Outcome {
|
|||||||
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
|
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
|
||||||
if winner >= n {
|
if winner >= n {
|
||||||
return Err(crate::InferenceError::InvalidParameter {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
name: "winner",
|
parameter: crate::Parameter::WinnerIndex,
|
||||||
value: f64::from(winner),
|
value: f64::from(winner),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,10 @@ fn a_batch_declaring_two_different_priors_is_rejected() {
|
|||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::ConflictingCompetitorConfig { field: "prior", .. }
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
|
field: trueskill_tt::CompetitorField::Prior,
|
||||||
|
..
|
||||||
|
}
|
||||||
),
|
),
|
||||||
"got {err:?}"
|
"got {err:?}"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ fn event_builder_rejects_a_weights_length_mismatch() {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::MismatchedShape {
|
InferenceError::MismatchedShape {
|
||||||
kind: "weights",
|
shape: trueskill_tt::Shape::Weights,
|
||||||
expected: 1,
|
expected: 1,
|
||||||
got: 2,
|
got: 2,
|
||||||
..
|
..
|
||||||
@@ -228,7 +228,7 @@ fn scored_event_rejects_non_positive_sigma() {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter {
|
||||||
name: "score_sigma",
|
parameter: trueskill_tt::Parameter::ScoreSigma,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ fn reject(scale: f64) -> InferenceError {
|
|||||||
fn negative_scale_is_rejected() {
|
fn negative_scale_is_rejected() {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
reject(-1.0),
|
reject(-1.0),
|
||||||
InferenceError::InvalidParameter { name: "drift_scale", value, .. }
|
InferenceError::InvalidParameter { parameter: trueskill_tt::Parameter::DriftScale, value, .. }
|
||||||
if value == -1.0
|
if value == -1.0
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -291,7 +291,7 @@ fn non_finite_scale_is_rejected() {
|
|||||||
matches!(
|
matches!(
|
||||||
reject(scale),
|
reject(scale),
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter {
|
||||||
name: "drift_scale",
|
parameter: trueskill_tt::Parameter::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -488,7 +488,7 @@ fn a_batch_that_contradicts_itself_is_rejected() {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::ConflictingCompetitorConfig {
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
field: "drift_scale",
|
field: trueskill_tt::CompetitorField::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ fn weights_still_guards_a_members_team() {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::MismatchedShape {
|
InferenceError::MismatchedShape {
|
||||||
kind: "weights",
|
shape: trueskill_tt::Shape::Weights,
|
||||||
expected: 2,
|
expected: 2,
|
||||||
got: 1,
|
got: 1,
|
||||||
..
|
..
|
||||||
@@ -164,7 +164,7 @@ fn an_invalid_drift_scale_surfaces_from_commit() {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter {
|
||||||
name: "drift_scale",
|
parameter: trueskill_tt::Parameter::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
+8
-2
@@ -57,7 +57,7 @@ fn game_ranked_rejects_bad_p_draw() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, InferenceError::InvalidProbability { .. }));
|
assert!(matches!(err, InferenceError::InvalidParameter { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -227,7 +227,13 @@ mod malformed_games {
|
|||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Score,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"{bad}: {err:?}"
|
"{bad}: {err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,13 @@ fn a_non_finite_score_is_rejected_at_ingestion() {
|
|||||||
}])
|
}])
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Score,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"{bad}: {err:?}"
|
"{bad}: {err:?}"
|
||||||
);
|
);
|
||||||
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
|
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()
|
.commit()
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Weight,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"{bad}: {err:?}"
|
"{bad}: {err:?}"
|
||||||
);
|
);
|
||||||
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
|
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
|
||||||
|
|||||||
@@ -231,16 +231,20 @@ fn a_ranked_history_has_no_exact_joint() {
|
|||||||
let _ = h.converge().unwrap();
|
let _ = h.converge().unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h.joint().unwrap_err(),
|
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]
|
#[test]
|
||||||
fn an_empty_history_has_no_joint() {
|
fn an_empty_history_has_no_joint() {
|
||||||
let h = history(UnknownKeys::Reject);
|
let h = history(UnknownKeys::Reject);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h.joint().unwrap_err(),
|
h.joint().unwrap_err(),
|
||||||
InferenceError::JointUnavailable { .. }
|
InferenceError::EmptyHistory
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ fn overflow_during_inference_is_reported_not_hidden() {
|
|||||||
|
|
||||||
for (name, sigma, beta, score_sigma, scores) in cases {
|
for (name, sigma, beta, score_sigma, scores) in cases {
|
||||||
match scored_fit(sigma, beta, score_sigma, scores) {
|
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_eq!(context, "History::converge", "{name}");
|
||||||
assert!(
|
assert!(
|
||||||
!step.0.is_finite() || !step.1.is_finite(),
|
!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();
|
let err = h.converge().unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::NonFiniteResult { .. }),
|
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||||
"a breakdown must not be reported as convergence: {err:?}"
|
"a breakdown must not be reported as convergence: {err:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ fn a_broken_fit_is_never_reported_as_converged() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
h2.converge_partial().unwrap_err(),
|
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()
|
.converge()
|
||||||
.expect_err("a NaN fit must never be reported as converged");
|
.expect_err("a NaN fit must never be reported as converged");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::NonFiniteResult { .. }),
|
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ fn nan_poisoned() -> H {
|
|||||||
);
|
);
|
||||||
let err = h.converge().expect_err("this fixture must not converge");
|
let err = h.converge().expect_err("this fixture must not converge");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::NonFiniteResult { .. }),
|
matches!(err, InferenceError::NonFiniteStep { .. }),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
h
|
h
|
||||||
@@ -101,7 +101,7 @@ fn a_nan_poisoned_fit_is_refused_by_every_prediction_path() {
|
|||||||
|
|
||||||
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
||||||
match r {
|
match r {
|
||||||
Err(InferenceError::NonFiniteResult { .. }) => {}
|
Err(InferenceError::NonFiniteSkill { .. }) => {}
|
||||||
other => panic!("{name} answered from a NaN fit: {other:?}"),
|
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.
|
// and every skill is a point mass.
|
||||||
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
all_predictions!(h, |name: &str, r: Result<(), InferenceError>| {
|
||||||
match r {
|
match r {
|
||||||
Err(InferenceError::InvalidParameter { .. }) => {}
|
Err(InferenceError::NoPerformanceVariance) => {}
|
||||||
other => panic!("{name} predicted from a degenerate fit: {other:?}"),
|
other => panic!("{name} predicted from a degenerate fit: {other:?}"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-4
@@ -172,7 +172,13 @@ fn a_weight_on_a_registration_is_rejected() {
|
|||||||
.register(Member::new("layout").with_weight(0.5))
|
.register(Member::new("layout").with_weight(0.5))
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Weight,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"{err:?}"
|
"{err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,7 +194,7 @@ fn an_invalid_drift_scale_on_a_registration_is_rejected() {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter {
|
||||||
name: "drift_scale",
|
parameter: trueskill_tt::Parameter::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -280,7 +286,7 @@ mod conflicting_configuration {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::ConflictingCompetitorConfig {
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
field: "drift_scale",
|
field: trueskill_tt::CompetitorField::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -297,7 +303,7 @@ mod conflicting_configuration {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::ConflictingCompetitorConfig {
|
InferenceError::ConflictingCompetitorConfig {
|
||||||
field: "drift_scale",
|
field: trueskill_tt::CompetitorField::DriftScale,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
+22
-4
@@ -49,7 +49,13 @@ fn ranked_rejects_a_zero_damping_factor() {
|
|||||||
)
|
)
|
||||||
.expect_err("alpha = 0 must be rejected");
|
.expect_err("alpha = 0 must be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Alpha,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"got {err:?}"
|
"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");
|
.expect_err("alpha out of (0, 1] must be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Alpha,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"alpha={alpha}: got {err:?}"
|
"alpha={alpha}: got {err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -81,7 +93,13 @@ fn scored_rejects_a_bad_damping_factor() {
|
|||||||
)
|
)
|
||||||
.expect_err("alpha = 0 must be rejected");
|
.expect_err("alpha = 0 must be rejected");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
|
matches!(
|
||||||
|
err,
|
||||||
|
InferenceError::InvalidParameter {
|
||||||
|
parameter: trueskill_tt::Parameter::Alpha,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
"got {err:?}"
|
"got {err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -360,7 +378,7 @@ mod constructor_parameters {
|
|||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
InferenceError::InvalidParameter {
|
InferenceError::InvalidParameter {
|
||||||
name: "drift variance",
|
parameter: trueskill_tt::Parameter::DriftVariance,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user