then Game::log_evidence is self.evidence.ln() (src/game.rs:436), and TimeSlice::log_evidence does event.evidence.ln() per event (src/time_slice.rs:563).
Each link's evidence is a probability in (0, 1]. An n-team game has n-1 links, so the product decays geometrically in the team count. With typical per-link evidence around 0.5, the product hits the f64 subnormal floor at roughly 1000 links and flushes to exactly 0.0 — ln(0.0) is -inf, which then propagates through the sum() in History::log_evidence_internal (src/history.rs:350-367) and makes the whole history's log-evidence -inf.
Well before full underflow, the product loses precision: multiplying many small floats accumulates relative error that summing logs does not.
Where it bites
Large free-for-alls and ranked fields — Game::free_for_all (src/game.rs:527) builds one team per player, so a 1000-player FFA is a 999-link chain. That is exactly the "5000 events / 50000 competitors" scale the T3 benchmarks were reaching for.
Fix
Accumulate in log space throughout:
Store log_evidence: f64 on Game/OwnedGame instead of evidence: f64, summing l.evidence().ln() over links.
TimeSlice::log_evidence then sums stored log values directly instead of .ln()-ing a product.
DiffFactor::evidence() (src/game.rs:40) can keep returning linear evidence from the factor, or the factors' cached values can move to log space too — TruncFactor::log_evidence and MarginFactor::log_evidence already do .ln() at the boundary (src/factor/trunc.rs:70, src/factor/margin.rs:63).
Related robustness issue in the same code path
The cached evidence can legitimately be zero or negative, and nothing guards ln():
TruncFactor's cavity_evidence computes 1.0 - cdf(margin, mu, sigma) (src/factor/trunc.rs:79). For a heavily one-sided diff, cdf rounds to exactly 1.0 and the evidence becomes 0.0 → ln(0.0) = -inf. The erfc approximation in use (src/lib.rs:72) has ~1e-7 accuracy, so cdf can even exceed 1.0 slightly, making the evidence negative → ln of a negative is NaN.
The tie branch cdf(margin, …) - cdf(-margin, …) can likewise round to 0.0 for a narrow margin far from the mean.
Both should be clamped to a small positive floor (or computed via a log-space complementary formulation) so log-evidence stays finite.
log_evidence also returns evidence_cached.unwrap_or(1.0).ln() — i.e. silently 0.0 for a factor that was never propagated. That should be unreachable, and an explicit debug_assert!/expect would say so.
Acceptance
Log-evidence stays finite for a 2000-team ranked game and for a history with thousands of events.
A test constructs a near-certain outcome (very large diff mean relative to sigma) and asserts finite, non-NaN log-evidence.
Numerical parity with the current implementation on the existing goldens, within test epsilons.
Per-link evidence is multiplied in linear space and only logged at the very end (`src/game.rs:358`):
```rust
let evidence: f64 = links.iter().map(|l| l.evidence()).product();
```
then `Game::log_evidence` is `self.evidence.ln()` (`src/game.rs:436`), and `TimeSlice::log_evidence` does `event.evidence.ln()` per event (`src/time_slice.rs:563`).
Each link's evidence is a probability in `(0, 1]`. An `n`-team game has `n-1` links, so the product decays geometrically in the team count. With typical per-link evidence around 0.5, the product hits the `f64` subnormal floor at roughly 1000 links and flushes to exactly `0.0` — `ln(0.0)` is `-inf`, which then propagates through the `sum()` in `History::log_evidence_internal` (`src/history.rs:350-367`) and makes the whole history's log-evidence `-inf`.
Well before full underflow, the product loses precision: multiplying many small floats accumulates relative error that summing logs does not.
## Where it bites
Large free-for-alls and ranked fields — `Game::free_for_all` (`src/game.rs:527`) builds one team per player, so a 1000-player FFA is a 999-link chain. That is exactly the "5000 events / 50000 competitors" scale the T3 benchmarks were reaching for.
## Fix
Accumulate in log space throughout:
- Store `log_evidence: f64` on `Game`/`OwnedGame` instead of `evidence: f64`, summing `l.evidence().ln()` over links.
- `TimeSlice::log_evidence` then sums stored log values directly instead of `.ln()`-ing a product.
- `DiffFactor::evidence()` (`src/game.rs:40`) can keep returning linear evidence from the factor, or the factors' cached values can move to log space too — `TruncFactor::log_evidence` and `MarginFactor::log_evidence` already do `.ln()` at the boundary (`src/factor/trunc.rs:70`, `src/factor/margin.rs:63`).
## Related robustness issue in the same code path
The cached evidence can legitimately be zero or negative, and nothing guards `ln()`:
- `TruncFactor`'s `cavity_evidence` computes `1.0 - cdf(margin, mu, sigma)` (`src/factor/trunc.rs:79`). For a heavily one-sided diff, `cdf` rounds to exactly `1.0` and the evidence becomes `0.0` → `ln(0.0) = -inf`. The `erfc` approximation in use (`src/lib.rs:72`) has ~1e-7 accuracy, so `cdf` can even exceed 1.0 slightly, making the evidence *negative* → `ln` of a negative is `NaN`.
- The tie branch `cdf(margin, …) - cdf(-margin, …)` can likewise round to `0.0` for a narrow margin far from the mean.
Both should be clamped to a small positive floor (or computed via a log-space complementary formulation) so log-evidence stays finite.
`log_evidence` also returns `evidence_cached.unwrap_or(1.0).ln()` — i.e. silently `0.0` for a factor that was never propagated. That should be unreachable, and an explicit `debug_assert!`/`expect` would say so.
## Acceptance
- Log-evidence stays finite for a 2000-team ranked game and for a history with thousands of events.
- A test constructs a near-certain outcome (very large diff mean relative to sigma) and asserts finite, non-NaN log-evidence.
- Numerical parity with the current implementation on the existing goldens, within test epsilons.
Fixed in 0f1a1b8. Game, OwnedGame and time_slice::Event carry log_evidence directly, summed over links rather than multiplied then logged.
The per-link value is also floored at f64::MIN_POSITIVE at its source, covering both failure modes noted above: 1.0 - cdf(..) rounding to zero for a near-certain outcome, and the erfc approximation's ~1e-7 error pushing cdf past 1.0 so the difference goes negative and ln returns NaN.
Existing log-evidence goldens are unchanged, which confirms the accumulation is numerically equivalent over the range where the old form worked. Two regression tests in tests/degenerate_inputs.rs: a 1200-team chain (which underflowed the linear product to exactly zero) and a ±5000-mu blowout in both directions.
Fixed in 0f1a1b8. `Game`, `OwnedGame` and `time_slice::Event` carry `log_evidence` directly, summed over links rather than multiplied then logged.
The per-link value is also floored at `f64::MIN_POSITIVE` at its source, covering both failure modes noted above: `1.0 - cdf(..)` rounding to zero for a near-certain outcome, and the `erfc` approximation's ~1e-7 error pushing `cdf` past 1.0 so the difference goes negative and `ln` returns NaN.
Existing log-evidence goldens are unchanged, which confirms the accumulation is numerically equivalent over the range where the old form worked. Two regression tests in `tests/degenerate_inputs.rs`: a 1200-team chain (which underflowed the linear product to exactly zero) and a ±5000-mu blowout in both directions.
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.
Per-link evidence is multiplied in linear space and only logged at the very end (
src/game.rs:358):then
Game::log_evidenceisself.evidence.ln()(src/game.rs:436), andTimeSlice::log_evidencedoesevent.evidence.ln()per event (src/time_slice.rs:563).Each link's evidence is a probability in
(0, 1]. Ann-team game hasn-1links, so the product decays geometrically in the team count. With typical per-link evidence around 0.5, the product hits thef64subnormal floor at roughly 1000 links and flushes to exactly0.0—ln(0.0)is-inf, which then propagates through thesum()inHistory::log_evidence_internal(src/history.rs:350-367) and makes the whole history's log-evidence-inf.Well before full underflow, the product loses precision: multiplying many small floats accumulates relative error that summing logs does not.
Where it bites
Large free-for-alls and ranked fields —
Game::free_for_all(src/game.rs:527) builds one team per player, so a 1000-player FFA is a 999-link chain. That is exactly the "5000 events / 50000 competitors" scale the T3 benchmarks were reaching for.Fix
Accumulate in log space throughout:
log_evidence: f64onGame/OwnedGameinstead ofevidence: f64, summingl.evidence().ln()over links.TimeSlice::log_evidencethen sums stored log values directly instead of.ln()-ing a product.DiffFactor::evidence()(src/game.rs:40) can keep returning linear evidence from the factor, or the factors' cached values can move to log space too —TruncFactor::log_evidenceandMarginFactor::log_evidencealready do.ln()at the boundary (src/factor/trunc.rs:70,src/factor/margin.rs:63).Related robustness issue in the same code path
The cached evidence can legitimately be zero or negative, and nothing guards
ln():TruncFactor'scavity_evidencecomputes1.0 - cdf(margin, mu, sigma)(src/factor/trunc.rs:79). For a heavily one-sided diff,cdfrounds to exactly1.0and the evidence becomes0.0→ln(0.0) = -inf. Theerfcapproximation in use (src/lib.rs:72) has ~1e-7 accuracy, socdfcan even exceed 1.0 slightly, making the evidence negative →lnof a negative isNaN.cdf(margin, …) - cdf(-margin, …)can likewise round to0.0for a narrow margin far from the mean.Both should be clamped to a small positive floor (or computed via a log-space complementary formulation) so log-evidence stays finite.
log_evidencealso returnsevidence_cached.unwrap_or(1.0).ln()— i.e. silently0.0for a factor that was never propagated. That should be unreachable, and an explicitdebug_assert!/expectwould say so.Acceptance
Fixed in
0f1a1b8.Game,OwnedGameandtime_slice::Eventcarrylog_evidencedirectly, summed over links rather than multiplied then logged.The per-link value is also floored at
f64::MIN_POSITIVEat its source, covering both failure modes noted above:1.0 - cdf(..)rounding to zero for a near-certain outcome, and theerfcapproximation's ~1e-7 error pushingcdfpast 1.0 so the difference goes negative andlnreturns NaN.Existing log-evidence goldens are unchanged, which confirms the accumulation is numerically equivalent over the range where the old form worked. Two regression tests in
tests/degenerate_inputs.rs: a 1200-team chain (which underflowed the linear product to exactly zero) and a ±5000-mu blowout in both directions.