src/lib.rs:572. The crate's stated invariant is "NaN is never convergence". step_converged and step_is_finite are each individually correct — the defect is one level up, in the reduction that produces the step they are handed.
Every call site is tuple_max(accumulator, new). NaN > x is false, so a NaN accumulator is discarded in favour of any later finite delta. A NaN reaches step only if the competitor that produced it happens to be reduced last.
It is folded over TimeSlice::posteriors(), a HashMap<Index, Gaussian> (src/time_slice.rs:382). Rust's default hasher is seeded per process, so "last" is random per run.
Measured
Four competitors in one slice: one pathological pair (Member::with_prior(Gaussian::from_ms(0.0, 1e-200))) and one healthy pair. Same binary, same input, 30 separate processes:
16 Ok converged=true, iterations=1, a = Gaussian { pi: NaN, tau: NaN }
14 Err NonFiniteResult
A coin flip on whether a NaN fit is reported as an error or as a successful, converged fit. In the Ok half, converged: true is returned alongside a NaN posterior and current_skill hands the NaN straight back.
This is the defect class this crate keeps producing — a public surface reporting a plausible-looking answer — sitting inside the guard that exists to prevent it. It also means the two-competitor case erroring is luck rather than design: with a single pair there is nothing finite to overwrite the NaN with.
tuple_max must propagate NaN. f64::max is not the fix — it also ignores NaN by design (f64::max(NaN, 1.0) == 1.0), which is the identical bug wearing a standard-library name. It needs an explicit test:
Once the reduction propagates, step_is_finite fires deterministically and the existing guards do what they document.
Test that would have caught it
A NaN reduced from a non-final position. Existing coverage only ever produces a NaN that is either the whole fit or the last term — precisely the case that passes. Pin tuple_max directly at all three orderings (NaN first, middle, last) rather than only end to end, since the end-to-end version is the one that flakes and would be a maddening intermittent failure in CI.
Related, worth doing in the same pass
src/game.rs:301 — while tuple_gt(step, epsilon) && iter < max_iter is the only one of the three convergence loops with no step_is_finite break beside it; history.rs:1627 and time_slice.rs:557 both have one. Note an A/B measurement showed swapping in !step_converged there makes a 4-team chain run 20 000 iterations instead of 4 while producing bit-identical posteriors, so the fix there belongs in Gaussian::delta (returning 0.0 for the sigma component when both operands are improper), not in the loop condition.
Found by a floating-point audit, 2026-09-09. Verified independently of the agent that reported it.
`src/lib.rs:572`. The crate's stated invariant is "NaN is never convergence". `step_converged` and `step_is_finite` are each individually correct — the defect is one level up, in the reduction that produces the step they are handed.
```rust
pub(crate) fn tuple_max(v1: (f64, f64), v2: (f64, f64)) -> (f64, f64) {
(
if v1.0 > v2.0 { v1.0 } else { v2.0 },
if v1.1 > v2.1 { v1.1 } else { v2.1 },
)
}
```
Every call site is `tuple_max(accumulator, new)`. `NaN > x` is false, so **a NaN accumulator is discarded in favour of any later finite delta**. A NaN reaches `step` only if the competitor that produced it happens to be reduced last.
It is folded over `TimeSlice::posteriors()`, a `HashMap<Index, Gaussian>` (`src/time_slice.rs:382`). Rust's default hasher is seeded per process, so "last" is random per run.
## Measured
Four competitors in one slice: one pathological pair (`Member::with_prior(Gaussian::from_ms(0.0, 1e-200))`) and one healthy pair. Same binary, same input, 30 separate processes:
```
16 Ok converged=true, iterations=1, a = Gaussian { pi: NaN, tau: NaN }
14 Err NonFiniteResult
```
**A coin flip on whether a NaN fit is reported as an error or as a successful, converged fit.** In the `Ok` half, `converged: true` is returned alongside a NaN posterior and `current_skill` hands the NaN straight back.
This is the defect class this crate keeps producing — a public surface reporting a plausible-looking answer — sitting inside the guard that exists to prevent it. It also means the two-competitor case erroring is luck rather than design: with a single pair there is nothing finite to overwrite the NaN with.
## Affected call sites
`src/game.rs:310,313,324,327`, `src/history.rs:407,431,448`, `src/time_slice.rs:565`.
## Fix
`tuple_max` must propagate NaN. **`f64::max` is not the fix** — it also ignores NaN by design (`f64::max(NaN, 1.0) == 1.0`), which is the identical bug wearing a standard-library name. It needs an explicit test:
```rust
fn max_propagating_nan(a: f64, b: f64) -> f64 {
if a.is_nan() || b.is_nan() { f64::NAN } else if a > b { a } else { b }
}
```
Once the reduction propagates, `step_is_finite` fires deterministically and the existing guards do what they document.
## Test that would have caught it
A NaN reduced from a **non-final** position. Existing coverage only ever produces a NaN that is either the whole fit or the last term — precisely the case that passes. Pin `tuple_max` directly at all three orderings (NaN first, middle, last) rather than only end to end, since the end-to-end version is the one that flakes and would be a maddening intermittent failure in CI.
## Related, worth doing in the same pass
`src/game.rs:301` — `while tuple_gt(step, epsilon) && iter < max_iter` is the only one of the three convergence loops with no `step_is_finite` break beside it; `history.rs:1627` and `time_slice.rs:557` both have one. Note an A/B measurement showed swapping in `!step_converged` there makes a 4-team chain run 20 000 iterations instead of 4 while producing **bit-identical** posteriors, so the fix there belongs in `Gaussian::delta` (returning `0.0` for the sigma component when both operands are improper), not in the loop condition.
Found by a floating-point audit, 2026-09-09. Verified independently of the agent that reported it.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
src/lib.rs:572. The crate's stated invariant is "NaN is never convergence".step_convergedandstep_is_finiteare each individually correct — the defect is one level up, in the reduction that produces the step they are handed.Every call site is
tuple_max(accumulator, new).NaN > xis false, so a NaN accumulator is discarded in favour of any later finite delta. A NaN reachessteponly if the competitor that produced it happens to be reduced last.It is folded over
TimeSlice::posteriors(), aHashMap<Index, Gaussian>(src/time_slice.rs:382). Rust's default hasher is seeded per process, so "last" is random per run.Measured
Four competitors in one slice: one pathological pair (
Member::with_prior(Gaussian::from_ms(0.0, 1e-200))) and one healthy pair. Same binary, same input, 30 separate processes:A coin flip on whether a NaN fit is reported as an error or as a successful, converged fit. In the
Okhalf,converged: trueis returned alongside a NaN posterior andcurrent_skillhands the NaN straight back.This is the defect class this crate keeps producing — a public surface reporting a plausible-looking answer — sitting inside the guard that exists to prevent it. It also means the two-competitor case erroring is luck rather than design: with a single pair there is nothing finite to overwrite the NaN with.
Affected call sites
src/game.rs:310,313,324,327,src/history.rs:407,431,448,src/time_slice.rs:565.Fix
tuple_maxmust propagate NaN.f64::maxis not the fix — it also ignores NaN by design (f64::max(NaN, 1.0) == 1.0), which is the identical bug wearing a standard-library name. It needs an explicit test:Once the reduction propagates,
step_is_finitefires deterministically and the existing guards do what they document.Test that would have caught it
A NaN reduced from a non-final position. Existing coverage only ever produces a NaN that is either the whole fit or the last term — precisely the case that passes. Pin
tuple_maxdirectly at all three orderings (NaN first, middle, last) rather than only end to end, since the end-to-end version is the one that flakes and would be a maddening intermittent failure in CI.Related, worth doing in the same pass
src/game.rs:301—while tuple_gt(step, epsilon) && iter < max_iteris the only one of the three convergence loops with nostep_is_finitebreak beside it;history.rs:1627andtime_slice.rs:557both have one. Note an A/B measurement showed swapping in!step_convergedthere makes a 4-team chain run 20 000 iterations instead of 4 while producing bit-identical posteriors, so the fix there belongs inGaussian::delta(returning0.0for the sigma component when both operands are improper), not in the loop condition.Found by a floating-point audit, 2026-09-09. Verified independently of the agent that reported it.