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:
+53
-5
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user