fix!: propagate NaN through the convergence reduction

`tuple_max` compared with a plain `>`, which is false against NaN, so a
NaN accumulator was replaced by the next finite delta. The fold runs over
`TimeSlice::posteriors()`, a HashMap, so whether a NaN survived to `step`
depended on per-process hash order.

Measured before, four competitors in one slice with one pathological
pair, same binary and input, 30 separate processes:

  16  Ok  converged=true, iterations=1, a = Gaussian { pi: NaN, tau: NaN }
  14  Err NonFiniteResult

After: 30/30 Err. A coin flip on whether a NaN fit was reported as an
error or as a successful, converged fit — inside the guard whose entire
purpose is "NaN is never convergence".

`f64::max` would not have fixed it. It also ignores NaN by design, which
is the same defect wearing a standard-library name, and a test pins that
we do not use it.

`Gaussian::delta` had to be fixed FIRST, and that ordering is the whole
subtlety. Two identical improper messages produced `(0.0, NaN)` — not
from `mu()`, which is guarded and returns 0.0, but from `inf - inf` in
the sigma component. That NaN is reachable in ordinary healthy inference:
once a pairing is more than about nine cavity-sigma apart the truncation
is a no-op and the chain compares one identity message against another.
Propagating NaN without fixing `delta` would therefore have turned
correct fits into NonFiniteResult errors. `delta` now answers the
identical-message case in natural space before touching the accessors.

My first version of the `delta` test asserted `mu()` was NaN. It is not;
the accessor guards `pi <= 0.0`. The test caught my own wrong premise,
and the doc comment is corrected to match.

BREAKING CHANGE: a fit that produced NaN in a non-final reduction
position previously returned `Ok` with `converged: true` and a NaN
posterior; it now returns `Err(NonFiniteResult)`. That was always the
documented intent.

Closes #58

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
2026-09-09 16:59:56 +02:00
co-authored by Claude Opus 5
parent 7da2328692
commit c65373f476
3 changed files with 169 additions and 2 deletions
+48
View File
@@ -115,3 +115,51 @@ fn merely_extreme_parameters_still_converge() {
assert!(scored_fit(6.0, 1.0, 1e6, [3.0, 1.0]).unwrap());
assert!(scored_fit(6.0, 1.0, 1.0, [1e150, -1e150]).unwrap());
}
/// A NaN in one competitor must not be masked by a healthy competitor reduced
/// after it.
///
/// The convergence step is a fold over a `HashMap`, so which competitor is
/// reduced last is per-process hash order. Before the fix, `tuple_max` dropped
/// a NaN accumulator in favour of the next finite delta and this returned
/// `Ok(converged: true)` with a NaN posterior in **16 of 30 runs** on identical
/// input. Deterministic now, but note this test can only ever sample one hash
/// order per run — the ordering guarantee itself is pinned by
/// `tuple_max_propagates_a_nan_from_any_position` in the crate's unit tests.
#[test]
fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
let mut h = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.p_draw(0.1)
.build();
h.add_events(vec![
Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, 1e-200))]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
},
// A healthy pair in the same slice, to be reduced alongside the NaN.
Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("c")]),
Team::with_members([Member::new("d")]),
],
outcome: Outcome::winner(0, 2),
},
])
.unwrap();
let err = h
.converge()
.expect_err("a NaN fit must never be reported as converged");
assert!(
matches!(err, InferenceError::NonFiniteResult { .. }),
"{err:?}"
);
}