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
+5 -1
View File
@@ -64,9 +64,13 @@ impl Factor for MarginFactor {
}
}
/// Density of the observed margin under the cavity, clamped to a positive
/// floor so a far-out observation cannot underflow to `0.0` and make
/// `log_evidence` `-inf`.
fn cavity_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
let combined_sigma = (cavity.sigma().powi(2) + sigma.powi(2)).sqrt();
pdf(m_obs, cavity.mu(), combined_sigma)
pdf(m_obs, cavity.mu(), combined_sigma).max(f64::MIN_POSITIVE)
}
#[cfg(test)]
+10 -2
View File
@@ -72,12 +72,20 @@ impl Factor for TruncFactor {
}
/// P(diff > margin) for non-tie, P(|diff| < margin) for tie.
///
/// Clamped to a positive floor: for a near-certain outcome the tail rounds to
/// exactly 0.0, and the `erfc` approximation used by `cdf` carries ~1e-7 error
/// so it can even return slightly more than 1.0, making the difference
/// negative. Either would send `log_evidence` to `-inf` or NaN and poison the
/// sum across the whole history.
fn cavity_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
if tie {
let raw = if tie {
cdf(margin, diff.mu(), diff.sigma()) - cdf(-margin, diff.mu(), diff.sigma())
} else {
1.0 - cdf(margin, diff.mu(), diff.sigma())
}
};
raw.clamp(f64::MIN_POSITIVE, 1.0)
}
#[cfg(test)]
+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
}
}
+14 -12
View File
@@ -87,7 +87,7 @@ struct Team {
#[derive(Debug)]
pub(crate) struct Event {
teams: Vec<Team>,
evidence: f64,
log_evidence: f64,
weights: Vec<Vec<f64>>,
kind: EventKind,
}
@@ -161,7 +161,7 @@ impl Event {
}
}
self.evidence = g.evidence;
self.log_evidence = g.log_evidence;
}
}
@@ -296,7 +296,7 @@ impl<T: Time> TimeSlice<T> {
Event {
teams,
evidence: 0.0,
log_evidence: 0.0,
weights,
kind: kinds[e],
}
@@ -353,7 +353,7 @@ impl<T: Time> TimeSlice<T> {
}
}
event.evidence = g.evidence;
event.log_evidence = g.log_evidence;
}
} else {
self.sweep_color_groups(agents);
@@ -530,7 +530,8 @@ impl<T: Time> TimeSlice<T> {
let teams = event.within_priors(online, forward, &self.skills, agents);
let result = event.outputs();
match event.kind {
EventKind::Ranked => Game::ranked_with_arena(
EventKind::Ranked => {
Game::ranked_with_arena(
teams,
&result,
&event.weights,
@@ -538,9 +539,10 @@ impl<T: Time> TimeSlice<T> {
self.convergence,
arena,
)
.evidence
.ln(),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
.log_evidence
}
EventKind::Scored { score_sigma } => {
Game::scored_with_arena(
teams,
&result,
&event.weights,
@@ -548,8 +550,8 @@ impl<T: Time> TimeSlice<T> {
self.convergence,
arena,
)
.evidence
.ln(),
.log_evidence
}
}
};
@@ -560,7 +562,7 @@ impl<T: Time> TimeSlice<T> {
.map(|event| run_event(event, &mut arena))
.sum()
} else {
self.events.iter().map(|event| event.evidence.ln()).sum()
self.events.iter().map(|event| event.log_evidence).sum()
}
} else if online || forward {
self.events
@@ -584,7 +586,7 @@ impl<T: Time> TimeSlice<T> {
.flat_map(|team| &team.items)
.any(|item| targets.contains(&item.agent))
})
.map(|event| event.evidence.ln())
.map(|event| event.log_evidence)
.sum()
}
}
+52
View File
@@ -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()
);
}