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:
2026-08-04 21:43:57 +02:00
co-authored by Claude Opus 5
parent 0d32690fcc
commit 0f1a1b8911
5 changed files with 121 additions and 48 deletions
+26 -19
View File
@@ -37,10 +37,17 @@ impl DiffFactor {
}
}
pub(crate) fn evidence(&self) -> f64 {
/// Log of this link's cached evidence.
///
/// Accumulating in log space keeps a long diff chain from underflowing:
/// each link contributes a probability in `(0, 1]`, so the linear product
/// over an n-team game decays geometrically and flushes to zero — and
/// `ln(0.0)` is `-inf` — well within the team counts a large free-for-all
/// reaches.
pub(crate) fn log_evidence(&self) -> f64 {
match self {
Self::Trunc(f) => f.evidence_cached.unwrap_or(1.0),
Self::Margin(f) => f.evidence_cached.unwrap_or(1.0),
Self::Trunc(f) => f.evidence_cached.unwrap_or(1.0).ln(),
Self::Margin(f) => f.evidence_cached.unwrap_or(1.0).ln(),
}
}
@@ -92,7 +99,7 @@ pub struct OwnedGame<T: Time, D: Drift<T>> {
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) evidence: f64,
pub(crate) log_evidence: f64,
}
impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
@@ -113,7 +120,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
&mut arena,
);
let likelihoods = g.likelihoods;
let evidence = g.evidence;
let log_evidence = g.log_evidence;
Self {
teams,
result,
@@ -121,7 +128,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
p_draw,
convergence,
likelihoods,
evidence,
log_evidence,
}
}
@@ -142,7 +149,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
&mut arena,
);
let likelihoods = g.likelihoods;
let evidence = g.evidence;
let log_evidence = g.log_evidence;
Self {
teams,
result: scores,
@@ -150,7 +157,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
p_draw: 0.0,
convergence,
likelihoods,
evidence,
log_evidence,
}
}
@@ -163,7 +170,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
}
pub fn log_evidence(&self) -> f64 {
self.evidence.ln()
self.log_evidence
}
}
@@ -175,7 +182,7 @@ pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) evidence: f64,
pub(crate) log_evidence: f64,
}
impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
@@ -222,7 +229,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
p_draw,
convergence,
likelihoods: Vec::new(),
evidence: 0.0,
log_evidence: 0.0,
};
this.likelihoods(arena);
@@ -261,7 +268,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
p_draw: 0.0,
convergence,
likelihoods: Vec::new(),
evidence: 0.0,
log_evidence: 0.0,
};
this.likelihoods_scored(arena, score_sigma);
@@ -355,7 +362,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
}
let evidence: f64 = links.iter().map(|l| l.evidence()).product();
let log_evidence: f64 = links.iter().map(DiffFactor::log_evidence).sum();
// Inverse permutation: inv_buf[orig_i] = sorted_i.
arena.inv_buf.resize(n_teams, 0);
@@ -386,11 +393,11 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
})
.collect::<Vec<_>>();
(evidence, likelihoods)
(log_evidence, likelihoods)
}
fn likelihoods(&mut self, arena: &mut ScratchArena) {
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let (log_evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let tie = self.result[sort_buf[i]] == self.result[sort_buf[i + 1]];
let margin = if self.p_draw == 0.0 {
0.0
@@ -405,17 +412,17 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
let vid = vars.alloc(N_INF);
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
});
self.evidence = evidence;
self.log_evidence = log_evidence;
self.likelihoods = likelihoods;
}
fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) {
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let (log_evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let m_obs = self.result[sort_buf[i]] - self.result[sort_buf[i + 1]];
let vid = vars.alloc(N_INF);
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
});
self.evidence = evidence;
self.log_evidence = log_evidence;
self.likelihoods = likelihoods;
}
@@ -433,7 +440,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
}
pub fn log_evidence(&self) -> f64 {
self.evidence.ln()
self.log_evidence
}
}