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:
2026-08-04 21:38:29 +02:00
co-authored by Claude Opus 5
parent 2b5d3b1687
commit f4e2922d59
6 changed files with 371 additions and 19 deletions
+41
View File
@@ -1,6 +1,7 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum InferenceError {
/// Expected and actual lengths of some array-shaped input differ.
MismatchedShape {
@@ -8,15 +9,35 @@ pub enum InferenceError {
expected: usize,
got: usize,
},
/// An `Outcome` of the wrong variant was supplied for the requested inference.
WrongOutcomeKind {
context: &'static str,
expected: &'static str,
got: &'static str,
},
/// A probability value is outside `[0, 1]`.
InvalidProbability { value: f64 },
/// A scalar parameter is outside its valid range.
InvalidParameter { name: &'static str, value: f64 },
/// An event contains tied teams, but the draw probability is zero.
///
/// A zero draw probability asserts that draws cannot occur, so a tied
/// result has no representable likelihood. Configure a positive `p_draw`
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
TieWithoutDrawProbability { teams: (usize, usize) },
/// Convergence exceeded `max_iter` without falling below `epsilon`.
ConvergenceFailed {
last_step: (f64, f64),
iterations: usize,
},
/// 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.
NonFiniteResult {
context: &'static str,
step: (f64, f64),
},
/// Negative precision: a Gaussian with `pi < 0` slipped into an API call.
NegativePrecision { pi: f64 },
}
@@ -31,9 +52,29 @@ impl fmt::Display for InferenceError {
} => {
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::NonFiniteResult { context, step } => {
write!(
f,
"{context}: inference produced a non-finite result (step = {step:?})"
)
}
Self::InvalidParameter { name, value } => {
write!(f, "{name} is invalid: {value}")
}