Severity: critical. Silent data corruption on a default-configuration happy path, in release builds, with no error and no panic.
Reproduction
All of these run clean and return NaN (verified on main @ 2b5d3b1, --release):
// 1. record_draw on a default history
letmuth=History::default();h.record_draw(&"a",&"b",1).unwrap();letr=h.converge().unwrap();// h.current_skill("a") == Some(Gaussian { pi: NaN, tau: NaN })
// r.log_evidence == NaN
// r.converged == true <-- reports success
// 2. the fluent builder
h.event(1).team(["a"]).team(["b"]).draw().commit().unwrap();// -> NaN
// 3. any 3+ team event with a single winner, because the losers tie
Game::ranked(&teams,Outcome::winner(0,3),&GameOptions::default())// -> all three posteriors NaN
Outcome::winner(w, n) for n >= 3 assigns rank 0 to the winner and rank 1 to everyone else (src/outcome.rs:35), so every free-for-all with more than two teams hits this on default options.
Root cause
Two independent defects that compound.
(a) The tie branch divides by zero when margin == 0.Game::likelihoods sets margin = 0.0 whenever p_draw == 0.0 (src/game.rs:395-404), and tie is set from equal results (src/game.rs:394). In v_w (src/lib.rs:147-158) the tie branch computes:
alpha == beta exactly, so both numerator and denominator are exactly zero. The NaN propagates through trunc → approx → the diff chain → every skill in the slice.
HistoryBuilder::default() uses p_draw: P_DRAW where P_DRAW = 0.0 (src/lib.rs:53), and GameOptions::default() does the same — so the default configuration is exactly the broken one.
(b) NaN is then misreported as convergence.tuple_gt (src/lib.rs:183) is t.0 > e || t.1 > e. NaN comparisons are always false, so tuple_gt((NaN, NaN), eps) == false, and History::converge (src/history.rs:441-448) both exits its loop immediately and sets converged = !tuple_gt(step, opts.epsilon) → true. A history that has been poisoned end-to-end reports a successful convergence in 1 iteration.
Defect (b) is worth fixing on its own: it turns any NaN anywhere in the engine into a false success signal.
Why it wasn't caught
Every existing draw test passes p_draw > 0.0 — test_1vs1_draw uses 0.25 (src/game.rs:726), test_1vs1vs1_draw likewise. Game::ranked_with_arena has a debug_assert! for exactly this ("draw must be > 0.0 if there are teams with draw", src/game.rs:205-212), so debug-build tests would catch it — but the public Game::ranked constructor does not perform that check, and nothing on the History ingestion path does either.
Fix
Decide the intended semantics first — the two options are not equivalent:
Reject. Return InferenceError when an event contains tied teams and p_draw == 0.0, from Game::ranked, History::add_events, and History::record_draw. Promotes the existing debug_assert! to a real check. Simple, but makes Outcome::draw and Outcome::winner(_, n>=3) unusable on default options — arguably correct, since a zero draw probability genuinely says "draws cannot happen".
Handle the degenerate limit. Define the margin == 0 tie case analytically (the limit of the two-sided truncation as the window closes) rather than evaluating 0/0, so a draw with p_draw == 0 is treated as the point-observation "these two performances are equal".
Option 1 plus a clear error message is the smaller change and matches the assert that is already written down. Option 2 makes the default configuration do something sensible instead of erroring, which is friendlier for Outcome::winner(0, n).
Independently of the choice, guard the convergence check against NaN so a poisoned run can never report converged: true — e.g. treat a non-finite step as "not converged" and surface it (InferenceError, or a converged: false report with the non-finite step recorded).
Acceptance
A draw on a default-configured History either errors cleanly or yields finite posteriors — never NaN.
Outcome::winner(0, 3) on GameOptions::default() likewise.
A test asserts that a NaN step is never reported as converged: true.
Regression tests covering record_draw, EventBuilder::draw(), Outcome::draw, and Outcome::winner(_, n) for n in 3..=5, all with p_draw == 0.0.
**Severity: critical.** Silent data corruption on a default-configuration happy path, in release builds, with no error and no panic.
## Reproduction
All of these run clean and return NaN (verified on `main` @ 2b5d3b1, `--release`):
```rust
// 1. record_draw on a default history
let mut h = History::default();
h.record_draw(&"a", &"b", 1).unwrap();
let r = h.converge().unwrap();
// h.current_skill("a") == Some(Gaussian { pi: NaN, tau: NaN })
// r.log_evidence == NaN
// r.converged == true <-- reports success
// 2. the fluent builder
h.event(1).team(["a"]).team(["b"]).draw().commit().unwrap();
// -> NaN
// 3. any 3+ team event with a single winner, because the losers tie
Game::ranked(&teams, Outcome::winner(0, 3), &GameOptions::default())
// -> all three posteriors NaN
```
`Outcome::winner(w, n)` for `n >= 3` assigns rank 0 to the winner and rank 1 to *everyone else* (`src/outcome.rs:35`), so every free-for-all with more than two teams hits this on default options.
## Root cause
Two independent defects that compound.
**(a) The tie branch divides by zero when `margin == 0`.** `Game::likelihoods` sets `margin = 0.0` whenever `p_draw == 0.0` (`src/game.rs:395-404`), and `tie` is set from equal results (`src/game.rs:394`). In `v_w` (`src/lib.rs:147-158`) the tie branch computes:
```rust
let alpha = (-margin - mu) / sigma; // margin == 0 -> alpha == -mu/sigma
let beta = ( margin - mu) / sigma; // margin == 0 -> beta == -mu/sigma (identical)
let v = (pdf(alpha) - pdf(beta)) / (cdf(beta) - cdf(alpha)); // 0.0 / 0.0 = NaN
```
`alpha == beta` exactly, so both numerator and denominator are exactly zero. The NaN propagates through `trunc` → `approx` → the diff chain → every skill in the slice.
`HistoryBuilder::default()` uses `p_draw: P_DRAW` where `P_DRAW = 0.0` (`src/lib.rs:53`), and `GameOptions::default()` does the same — so the default configuration is exactly the broken one.
**(b) NaN is then misreported as convergence.** `tuple_gt` (`src/lib.rs:183`) is `t.0 > e || t.1 > e`. NaN comparisons are always false, so `tuple_gt((NaN, NaN), eps) == false`, and `History::converge` (`src/history.rs:441-448`) both exits its loop immediately and sets `converged = !tuple_gt(step, opts.epsilon)` → `true`. A history that has been poisoned end-to-end reports a successful convergence in 1 iteration.
Defect (b) is worth fixing on its own: it turns *any* NaN anywhere in the engine into a false success signal.
## Why it wasn't caught
Every existing draw test passes `p_draw > 0.0` — `test_1vs1_draw` uses `0.25` (`src/game.rs:726`), `test_1vs1vs1_draw` likewise. `Game::ranked_with_arena` has a `debug_assert!` for exactly this ("draw must be > 0.0 if there are teams with draw", `src/game.rs:205-212`), so debug-build tests would catch it — but the public `Game::ranked` constructor does not perform that check, and nothing on the `History` ingestion path does either.
## Fix
Decide the intended semantics first — the two options are not equivalent:
1. **Reject.** Return `InferenceError` when an event contains tied teams and `p_draw == 0.0`, from `Game::ranked`, `History::add_events`, and `History::record_draw`. Promotes the existing `debug_assert!` to a real check. Simple, but makes `Outcome::draw` and `Outcome::winner(_, n>=3)` unusable on default options — arguably correct, since a zero draw probability genuinely says "draws cannot happen".
2. **Handle the degenerate limit.** Define the `margin == 0` tie case analytically (the limit of the two-sided truncation as the window closes) rather than evaluating `0/0`, so a draw with `p_draw == 0` is treated as the point-observation "these two performances are equal".
Option 1 plus a clear error message is the smaller change and matches the assert that is already written down. Option 2 makes the default configuration do something sensible instead of erroring, which is friendlier for `Outcome::winner(0, n)`.
Independently of the choice, **guard the convergence check against NaN** so a poisoned run can never report `converged: true` — e.g. treat a non-finite step as "not converged" and surface it (`InferenceError`, or a `converged: false` report with the non-finite step recorded).
## Acceptance
- A draw on a default-configured `History` either errors cleanly or yields finite posteriors — never NaN.
- `Outcome::winner(0, 3)` on `GameOptions::default()` likewise.
- A test asserts that a NaN step is never reported as `converged: true`.
- Regression tests covering `record_draw`, `EventBuilder::draw()`, `Outcome::draw`, and `Outcome::winner(_, n)` for `n in 3..=5`, all with `p_draw == 0.0`.
Took option 1 — reject — since it implements the intent the debug_assert! already stated. Validation sits in add_events_with_prior, the chokepoint every ingestion route reaches, because record_draw builds its results directly and never goes through Outcome; a check on the typed path alone would have missed it.
Defect (b) is fixed independently and matters on its own: a non-finite step now ends the loop and returns InferenceError::NonFiniteResult instead of being read as convergence. step_is_finite / step_converged replace bare !tuple_gt(..).
Consequence worth restating: Outcome::winner(w, n) for n >= 3 ties every loser, so those events now require a positive p_draw. They previously returned NaN, so nothing that worked has stopped working.
Covered by tests/degenerate_inputs.rs — record_draw, EventBuilder::draw(), Outcome::draw, and winner(_, n) for n in 3..=5, all at p_draw == 0.0, plus the positive-p_draw paths staying finite. These run in release too, where the original debug_assert! was compiled out.
Option 2 (defining the degenerate tie analytically, so a draw with p_draw == 0 means "these performances were equal") remains available if you'd rather the default configuration accept draws than reject them. Worth a fresh issue if so.
Fixed in f4e2922 (merged to `main`).
Took option 1 — reject — since it implements the intent the `debug_assert!` already stated. Validation sits in `add_events_with_prior`, the chokepoint every ingestion route reaches, because `record_draw` builds its results directly and never goes through `Outcome`; a check on the typed path alone would have missed it.
Defect (b) is fixed independently and matters on its own: a non-finite step now ends the loop and returns `InferenceError::NonFiniteResult` instead of being read as convergence. `step_is_finite` / `step_converged` replace bare `!tuple_gt(..)`.
Consequence worth restating: `Outcome::winner(w, n)` for `n >= 3` ties every loser, so those events now require a positive `p_draw`. They previously returned NaN, so nothing that worked has stopped working.
Covered by `tests/degenerate_inputs.rs` — `record_draw`, `EventBuilder::draw()`, `Outcome::draw`, and `winner(_, n)` for n in 3..=5, all at `p_draw == 0.0`, plus the positive-`p_draw` paths staying finite. These run in release too, where the original `debug_assert!` was compiled out.
Option 2 (defining the degenerate tie analytically, so a draw with `p_draw == 0` means "these performances were equal") remains available if you'd rather the default configuration accept draws than reject them. Worth a fresh issue if so.
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.
Severity: critical. Silent data corruption on a default-configuration happy path, in release builds, with no error and no panic.
Reproduction
All of these run clean and return NaN (verified on
main@2b5d3b1,--release):Outcome::winner(w, n)forn >= 3assigns rank 0 to the winner and rank 1 to everyone else (src/outcome.rs:35), so every free-for-all with more than two teams hits this on default options.Root cause
Two independent defects that compound.
(a) The tie branch divides by zero when
margin == 0.Game::likelihoodssetsmargin = 0.0wheneverp_draw == 0.0(src/game.rs:395-404), andtieis set from equal results (src/game.rs:394). Inv_w(src/lib.rs:147-158) the tie branch computes:alpha == betaexactly, so both numerator and denominator are exactly zero. The NaN propagates throughtrunc→approx→ the diff chain → every skill in the slice.HistoryBuilder::default()usesp_draw: P_DRAWwhereP_DRAW = 0.0(src/lib.rs:53), andGameOptions::default()does the same — so the default configuration is exactly the broken one.(b) NaN is then misreported as convergence.
tuple_gt(src/lib.rs:183) ist.0 > e || t.1 > e. NaN comparisons are always false, sotuple_gt((NaN, NaN), eps) == false, andHistory::converge(src/history.rs:441-448) both exits its loop immediately and setsconverged = !tuple_gt(step, opts.epsilon)→true. A history that has been poisoned end-to-end reports a successful convergence in 1 iteration.Defect (b) is worth fixing on its own: it turns any NaN anywhere in the engine into a false success signal.
Why it wasn't caught
Every existing draw test passes
p_draw > 0.0—test_1vs1_drawuses0.25(src/game.rs:726),test_1vs1vs1_drawlikewise.Game::ranked_with_arenahas adebug_assert!for exactly this ("draw must be > 0.0 if there are teams with draw",src/game.rs:205-212), so debug-build tests would catch it — but the publicGame::rankedconstructor does not perform that check, and nothing on theHistoryingestion path does either.Fix
Decide the intended semantics first — the two options are not equivalent:
InferenceErrorwhen an event contains tied teams andp_draw == 0.0, fromGame::ranked,History::add_events, andHistory::record_draw. Promotes the existingdebug_assert!to a real check. Simple, but makesOutcome::drawandOutcome::winner(_, n>=3)unusable on default options — arguably correct, since a zero draw probability genuinely says "draws cannot happen".margin == 0tie case analytically (the limit of the two-sided truncation as the window closes) rather than evaluating0/0, so a draw withp_draw == 0is treated as the point-observation "these two performances are equal".Option 1 plus a clear error message is the smaller change and matches the assert that is already written down. Option 2 makes the default configuration do something sensible instead of erroring, which is friendlier for
Outcome::winner(0, n).Independently of the choice, guard the convergence check against NaN so a poisoned run can never report
converged: true— e.g. treat a non-finite step as "not converged" and surface it (InferenceError, or aconverged: falsereport with the non-finite step recorded).Acceptance
Historyeither errors cleanly or yields finite posteriors — never NaN.Outcome::winner(0, 3)onGameOptions::default()likewise.converged: true.record_draw,EventBuilder::draw(),Outcome::draw, andOutcome::winner(_, n)forn in 3..=5, all withp_draw == 0.0.Fixed in
f4e2922(merged tomain).Took option 1 — reject — since it implements the intent the
debug_assert!already stated. Validation sits inadd_events_with_prior, the chokepoint every ingestion route reaches, becauserecord_drawbuilds its results directly and never goes throughOutcome; a check on the typed path alone would have missed it.Defect (b) is fixed independently and matters on its own: a non-finite step now ends the loop and returns
InferenceError::NonFiniteResultinstead of being read as convergence.step_is_finite/step_convergedreplace bare!tuple_gt(..).Consequence worth restating:
Outcome::winner(w, n)forn >= 3ties every loser, so those events now require a positivep_draw. They previously returned NaN, so nothing that worked has stopped working.Covered by
tests/degenerate_inputs.rs—record_draw,EventBuilder::draw(),Outcome::draw, andwinner(_, n)for n in 3..=5, all atp_draw == 0.0, plus the positive-p_drawpaths staying finite. These run in release too, where the originaldebug_assert!was compiled out.Option 2 (defining the degenerate tie analytically, so a draw with
p_draw == 0means "these performances were equal") remains available if you'd rather the default configuration accept draws than reject them. Worth a fresh issue if so.