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
+53 -5
View File
@@ -220,6 +220,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
fn iteration(&mut self) -> (f64, f64) {
let mut step = (0.0, 0.0);
if self.time_slices.is_empty() {
return step;
}
competitor::clean(self.agents.values_mut(), false);
for j in (0..self.time_slices.len() - 1).rev() {
@@ -435,6 +439,18 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
use smallvec::SmallVec;
let opts = self.convergence;
if self.time_slices.is_empty() {
return Ok(ConvergenceReport {
iterations: 0,
final_step: (0.0, 0.0),
log_evidence: 0.0,
converged: true,
per_iteration_time: SmallVec::new(),
slices_skipped: 0,
});
}
let mut step = (f64::INFINITY, f64::INFINITY);
let mut i = 0;
let mut per_iter: SmallVec<[std::time::Duration; 32]> = SmallVec::new();
@@ -444,8 +460,24 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
per_iter.push(t0.elapsed());
i += 1;
self.observer.on_iteration_end(i, step);
// A non-finite step means EP has broken down; further iterations
// cannot recover, and `tuple_gt` would read NaN as converged.
if !crate::step_is_finite(step) {
break;
}
}
let converged = !tuple_gt(step, opts.epsilon);
if !crate::step_is_finite(step) {
self.observer.on_converged(i, step, false);
return Err(InferenceError::NonFiniteResult {
context: "History::converge",
step,
});
}
let converged = crate::step_converged(step, opts.epsilon);
let log_evidence = self.log_evidence_internal(false, &[]);
self.observer.on_converged(i, step, converged);
Ok(ConvergenceReport {
@@ -498,6 +530,19 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
});
}
// Chokepoint for tie validation: every ingestion route lands here,
// including `record_draw`, which builds its results directly rather
// than going through `Outcome`.
if self.p_draw == 0.0 {
for (event_results, kind) in results.iter().zip(kinds.iter()) {
if matches!(kind, EventKind::Ranked)
&& let Some(tied) = crate::first_tied_output(event_results)
{
return Err(InferenceError::TieWithoutDrawProbability { teams: tied });
}
}
}
competitor::clean(self.agents.values_mut(), true);
let mut this_agent = Vec::with_capacity(1024);
@@ -734,10 +779,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}
crate::Outcome::Scored { scores, sigma } => {
let resolved = sigma.unwrap_or(self.score_sigma);
debug_assert!(
resolved > 0.0,
"resolved score_sigma must be > 0.0 (got {resolved})"
);
if !(resolved > 0.0) {
return Err(InferenceError::InvalidParameter {
name: "score_sigma",
value: resolved,
});
}
kinds.push(EventKind::Scored {
score_sigma: resolved,
});