fix: reject ties without draw probability; never report NaN as converged
A tie with `p_draw == 0.0` produced NaN posteriors in release builds and `converge()` reported `converged: true`, because every comparison against NaN is false and `tuple_gt` therefore read NaN as "below epsilon". Two independent defects, fixed together: - Ingestion now rejects tied outcomes when the draw probability is zero, promoting the existing `debug_assert!` in `Game::ranked_with_arena` to a real `InferenceError::TieWithoutDrawProbability`. Validation sits in `add_events_with_prior`, the chokepoint every route reaches — including `record_draw`, which bypasses `Outcome` entirely. - `converge()` treats a non-finite step as failure and returns `InferenceError::NonFiniteResult` rather than claiming convergence. Also in this change: - `History::converge()` on an empty history returned a `usize` underflow panic from `0..len()-1`; it now short-circuits to a zero-iteration report. - `Outcome::scores_with_sigma` no longer panics on a non-positive sigma; the value is validated at ingestion so callers get an error instead. - `InferenceError` gains `WrongOutcomeKind`, replacing the misuse of `MismatchedShape` for variant mismatches (which rendered as the nonsense "expected length 0, got 0"), and is now `#[non_exhaustive]`. Note `Outcome::winner(w, n)` for n >= 3 ties every loser, so those events now require a positive `p_draw`. They previously returned NaN. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
This commit is contained in:
+19
-9
@@ -458,11 +458,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
|
||||
let ranks = outcome
|
||||
.as_ranks()
|
||||
.ok_or(crate::InferenceError::MismatchedShape {
|
||||
kind: "Game::ranked requires Outcome::Ranked",
|
||||
expected: 0,
|
||||
got: 0,
|
||||
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
||||
context: "Game::ranked",
|
||||
expected: "Outcome::Ranked",
|
||||
got: "Outcome::Scored",
|
||||
})?;
|
||||
|
||||
if options.p_draw == 0.0
|
||||
&& let Some(tied) = crate::first_tied_pair(ranks)
|
||||
{
|
||||
return Err(crate::InferenceError::TieWithoutDrawProbability { teams: tied });
|
||||
}
|
||||
|
||||
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
|
||||
let result: Vec<f64> = ranks.iter().map(|&r| max_rank - r as f64).collect();
|
||||
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
|
||||
@@ -497,10 +504,10 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
}
|
||||
let scores = outcome
|
||||
.as_scores()
|
||||
.ok_or(crate::InferenceError::MismatchedShape {
|
||||
kind: "Game::scored requires Outcome::Scored",
|
||||
expected: 0,
|
||||
got: 0,
|
||||
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
||||
context: "Game::scored",
|
||||
expected: "Outcome::Scored",
|
||||
got: "Outcome::Ranked",
|
||||
})?
|
||||
.to_vec();
|
||||
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
|
||||
@@ -1124,7 +1131,10 @@ mod tests {
|
||||
&GameOptions::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, crate::InferenceError::MismatchedShape { .. }));
|
||||
assert!(matches!(
|
||||
err,
|
||||
crate::InferenceError::WrongOutcomeKind { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user