From 0f1a1b89117e6df80811aadedb944ecf499357b8 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 4 Aug 2026 21:43:57 +0200 Subject: [PATCH] fix(evidence): accumulate in log space and floor the per-link value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej --- src/factor/margin.rs | 6 ++++- src/factor/trunc.rs | 12 +++++++-- src/game.rs | 45 +++++++++++++++++-------------- src/time_slice.rs | 54 ++++++++++++++++++++------------------ tests/degenerate_inputs.rs | 52 ++++++++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 48 deletions(-) diff --git a/src/factor/margin.rs b/src/factor/margin.rs index 4b67124..4d5d078 100644 --- a/src/factor/margin.rs +++ b/src/factor/margin.rs @@ -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)] diff --git a/src/factor/trunc.rs b/src/factor/trunc.rs index 4b1aaa2..4e825a9 100644 --- a/src/factor/trunc.rs +++ b/src/factor/trunc.rs @@ -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)] diff --git a/src/game.rs b/src/game.rs index e00f29f..704a739 100644 --- a/src/game.rs +++ b/src/game.rs @@ -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> { p_draw: f64, pub(crate) convergence: crate::ConvergenceOptions, pub(crate) likelihoods: Vec>, - pub(crate) evidence: f64, + pub(crate) log_evidence: f64, } impl> OwnedGame { @@ -113,7 +120,7 @@ impl> OwnedGame { &mut arena, ); let likelihoods = g.likelihoods; - let evidence = g.evidence; + let log_evidence = g.log_evidence; Self { teams, result, @@ -121,7 +128,7 @@ impl> OwnedGame { p_draw, convergence, likelihoods, - evidence, + log_evidence, } } @@ -142,7 +149,7 @@ impl> OwnedGame { &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> OwnedGame { p_draw: 0.0, convergence, likelihoods, - evidence, + log_evidence, } } @@ -163,7 +170,7 @@ impl> OwnedGame { } 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 = crate::drift::ConstantDrift> { p_draw: f64, pub(crate) convergence: crate::ConvergenceOptions, pub(crate) likelihoods: Vec>, - pub(crate) evidence: f64, + pub(crate) log_evidence: f64, } impl<'a, T: Time, D: Drift> Game<'a, T, D> { @@ -222,7 +229,7 @@ impl<'a, T: Time, D: Drift> 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> 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> 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> Game<'a, T, D> { }) .collect::>(); - (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> 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> Game<'a, T, D> { } pub fn log_evidence(&self) -> f64 { - self.evidence.ln() + self.log_evidence } } diff --git a/src/time_slice.rs b/src/time_slice.rs index dbdaaaa..1aed80b 100644 --- a/src/time_slice.rs +++ b/src/time_slice.rs @@ -87,7 +87,7 @@ struct Team { #[derive(Debug)] pub(crate) struct Event { teams: Vec, - evidence: f64, + log_evidence: f64, weights: Vec>, kind: EventKind, } @@ -161,7 +161,7 @@ impl Event { } } - self.evidence = g.evidence; + self.log_evidence = g.log_evidence; } } @@ -296,7 +296,7 @@ impl TimeSlice { Event { teams, - evidence: 0.0, + log_evidence: 0.0, weights, kind: kinds[e], } @@ -353,7 +353,7 @@ impl TimeSlice { } } - event.evidence = g.evidence; + event.log_evidence = g.log_evidence; } } else { self.sweep_color_groups(agents); @@ -530,26 +530,28 @@ impl TimeSlice { let teams = event.within_priors(online, forward, &self.skills, agents); let result = event.outputs(); match event.kind { - EventKind::Ranked => Game::ranked_with_arena( - teams, - &result, - &event.weights, - self.p_draw, - self.convergence, - arena, - ) - .evidence - .ln(), - EventKind::Scored { score_sigma } => Game::scored_with_arena( - teams, - &result, - &event.weights, - score_sigma, - self.convergence, - arena, - ) - .evidence - .ln(), + EventKind::Ranked => { + Game::ranked_with_arena( + teams, + &result, + &event.weights, + self.p_draw, + self.convergence, + arena, + ) + .log_evidence + } + EventKind::Scored { score_sigma } => { + Game::scored_with_arena( + teams, + &result, + &event.weights, + score_sigma, + self.convergence, + arena, + ) + .log_evidence + } } }; @@ -560,7 +562,7 @@ impl TimeSlice { .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 TimeSlice { .flat_map(|team| &team.items) .any(|item| targets.contains(&item.agent)) }) - .map(|event| event.evidence.ln()) + .map(|event| event.log_evidence) .sum() } } diff --git a/tests/degenerate_inputs.rs b/tests/degenerate_inputs.rs index 4ad7ebd..bd666a6 100644 --- a/tests/degenerate_inputs.rs +++ b/tests/degenerate_inputs.rs @@ -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() + ); +}