fix(evidence): accumulate in log space and floor the per-link value
Per-link evidence was multiplied in linear space and logged only at the end. Each link contributes a probability in (0, 1], so the product over an n-team game decays geometrically: around a thousand links it flushes to exactly 0.0 and `ln(0.0)` is `-inf`, which then propagates through the sum in `History::log_evidence_internal` and takes the whole history with it. `Game::free_for_all` builds one team per player, so this is reachable at the competitor counts the T3 benchmarks target. `Game`, `OwnedGame`, and `time_slice::Event` now carry `log_evidence` directly, summed over links rather than multiplied then logged. The cached per-link evidence is also floored at `f64::MIN_POSITIVE`. It could legitimately reach zero or go negative: `1.0 - cdf(..)` rounds to zero for a near-certain outcome, and the `erfc` approximation carries ~1e-7 error so `cdf` can exceed 1.0 and make the difference negative — `ln` of which is NaN. Existing log-evidence goldens are unchanged, confirming the accumulation is numerically equivalent in the range where the old form worked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
This commit is contained in:
@@ -193,3 +193,55 @@ fn convergence_reports_are_finite_across_many_teams() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A long diff chain underflows a linear evidence product: each link
|
||||
/// contributes a probability in (0, 1], so ~1000 links flush the product to
|
||||
/// exactly 0.0 and `ln(0.0)` is `-inf`. Accumulating in log space keeps it
|
||||
/// finite.
|
||||
#[test]
|
||||
fn log_evidence_survives_a_long_diff_chain() {
|
||||
let holders: Vec<[R; 1]> = (0..1200).map(|_| [rating()]).collect();
|
||||
let teams: Vec<&[R]> = holders.iter().map(|t| t.as_slice()).collect();
|
||||
let game = Game::ranked(
|
||||
&teams,
|
||||
Outcome::ranking(0..holders.len() as u32),
|
||||
&GameOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let log_evidence = game.log_evidence();
|
||||
assert!(
|
||||
log_evidence.is_finite(),
|
||||
"1200-team log-evidence must be finite, got {log_evidence}"
|
||||
);
|
||||
assert!(
|
||||
log_evidence < 0.0,
|
||||
"log-evidence of a probability must be negative, got {log_evidence}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A near-certain outcome rounds the losing tail to exactly zero in the
|
||||
/// `erfc` approximation; the evidence floor keeps `ln` finite.
|
||||
#[test]
|
||||
fn log_evidence_finite_for_near_certain_outcome() {
|
||||
let overwhelming = R::new(Gaussian::from_ms(5_000.0, 0.5), 1.0, ConstantDrift(0.0));
|
||||
let hopeless = R::new(Gaussian::from_ms(-5_000.0, 0.5), 1.0, ConstantDrift(0.0));
|
||||
let a = [overwhelming];
|
||||
let b = [hopeless];
|
||||
let teams: Vec<&[R]> = vec![&a, &b];
|
||||
|
||||
let game = Game::ranked(&teams, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
|
||||
assert!(
|
||||
game.log_evidence().is_finite(),
|
||||
"got {}",
|
||||
game.log_evidence()
|
||||
);
|
||||
|
||||
// And the reverse — a colossal upset — must also stay finite.
|
||||
let upset = Game::ranked(&teams, Outcome::winner(1, 2), &GameOptions::default()).unwrap();
|
||||
assert!(
|
||||
upset.log_evidence().is_finite(),
|
||||
"upset log-evidence must be finite, got {}",
|
||||
upset.log_evidence()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user