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:
+50
@@ -184,6 +184,56 @@ pub(crate) fn tuple_gt(t: (f64, f64), e: f64) -> bool {
|
||||
t.0 > e || t.1 > e
|
||||
}
|
||||
|
||||
/// Whether a convergence step is finite in both components.
|
||||
///
|
||||
/// A NaN step means EP broke down numerically. Because every comparison
|
||||
/// against NaN is false, `tuple_gt` reads NaN as "below epsilon" — so
|
||||
/// convergence checks must test finiteness explicitly rather than inferring
|
||||
/// success from `!tuple_gt(..)`.
|
||||
pub(crate) fn step_is_finite(t: (f64, f64)) -> bool {
|
||||
t.0.is_finite() && t.1.is_finite()
|
||||
}
|
||||
|
||||
/// Whether a step counts as converged: finite *and* within `epsilon`.
|
||||
pub(crate) fn step_converged(t: (f64, f64), epsilon: f64) -> bool {
|
||||
step_is_finite(t) && !tuple_gt(t, epsilon)
|
||||
}
|
||||
|
||||
/// Indices of the first pair of teams sharing a rank, if any.
|
||||
///
|
||||
/// A tie is only representable when the draw probability is positive: with
|
||||
/// `p_draw == 0.0` the truncation margin collapses to zero and the two-sided
|
||||
/// tie update evaluates `0/0`. Callers use this to reject such events before
|
||||
/// they reach inference.
|
||||
pub(crate) fn first_tied_pair(ranks: &[u32]) -> Option<(usize, usize)> {
|
||||
for (i, a) in ranks.iter().enumerate() {
|
||||
for (j, b) in ranks.iter().enumerate().skip(i + 1) {
|
||||
if a == b {
|
||||
return Some((i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// As `first_tied_pair`, but over the engine's internal `f64` outputs.
|
||||
///
|
||||
/// Ranks reach the engine already converted to descending `f64` outputs, and
|
||||
/// `Game` decides a tie by exact equality of those values — so this mirrors
|
||||
/// the comparison inference itself performs.
|
||||
pub(crate) fn first_tied_output(outputs: &[f64]) -> Option<(usize, usize)> {
|
||||
for (i, a) in outputs.iter().enumerate() {
|
||||
for (j, b) in outputs.iter().enumerate().skip(i + 1) {
|
||||
if a == b {
|
||||
return Some((i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
|
||||
let mut x: Vec<(usize, T)> = xs.iter().enumerate().map(|(i, &t)| (i, t)).collect();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user