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:
@@ -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 {
|
fn cavity_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
|
||||||
let combined_sigma = (cavity.sigma().powi(2) + sigma.powi(2)).sqrt();
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
+10
-2
@@ -72,12 +72,20 @@ impl Factor for TruncFactor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// P(diff > margin) for non-tie, P(|diff| < margin) for tie.
|
/// 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 {
|
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())
|
cdf(margin, diff.mu(), diff.sigma()) - cdf(-margin, diff.mu(), diff.sigma())
|
||||||
} else {
|
} else {
|
||||||
1.0 - cdf(margin, diff.mu(), diff.sigma())
|
1.0 - cdf(margin, diff.mu(), diff.sigma())
|
||||||
}
|
};
|
||||||
|
|
||||||
|
raw.clamp(f64::MIN_POSITIVE, 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+26
-19
@@ -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 {
|
match self {
|
||||||
Self::Trunc(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),
|
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,
|
p_draw: f64,
|
||||||
pub(crate) convergence: crate::ConvergenceOptions,
|
pub(crate) convergence: crate::ConvergenceOptions,
|
||||||
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
||||||
pub(crate) evidence: f64,
|
pub(crate) log_evidence: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||||
@@ -113,7 +120,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
|||||||
&mut arena,
|
&mut arena,
|
||||||
);
|
);
|
||||||
let likelihoods = g.likelihoods;
|
let likelihoods = g.likelihoods;
|
||||||
let evidence = g.evidence;
|
let log_evidence = g.log_evidence;
|
||||||
Self {
|
Self {
|
||||||
teams,
|
teams,
|
||||||
result,
|
result,
|
||||||
@@ -121,7 +128,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
|||||||
p_draw,
|
p_draw,
|
||||||
convergence,
|
convergence,
|
||||||
likelihoods,
|
likelihoods,
|
||||||
evidence,
|
log_evidence,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +149,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
|||||||
&mut arena,
|
&mut arena,
|
||||||
);
|
);
|
||||||
let likelihoods = g.likelihoods;
|
let likelihoods = g.likelihoods;
|
||||||
let evidence = g.evidence;
|
let log_evidence = g.log_evidence;
|
||||||
Self {
|
Self {
|
||||||
teams,
|
teams,
|
||||||
result: scores,
|
result: scores,
|
||||||
@@ -150,7 +157,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
|||||||
p_draw: 0.0,
|
p_draw: 0.0,
|
||||||
convergence,
|
convergence,
|
||||||
likelihoods,
|
likelihoods,
|
||||||
evidence,
|
log_evidence,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +170,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn log_evidence(&self) -> f64 {
|
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,
|
p_draw: f64,
|
||||||
pub(crate) convergence: crate::ConvergenceOptions,
|
pub(crate) convergence: crate::ConvergenceOptions,
|
||||||
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
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> {
|
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,
|
p_draw,
|
||||||
convergence,
|
convergence,
|
||||||
likelihoods: Vec::new(),
|
likelihoods: Vec::new(),
|
||||||
evidence: 0.0,
|
log_evidence: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.likelihoods(arena);
|
this.likelihoods(arena);
|
||||||
@@ -261,7 +268,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
p_draw: 0.0,
|
p_draw: 0.0,
|
||||||
convergence,
|
convergence,
|
||||||
likelihoods: Vec::new(),
|
likelihoods: Vec::new(),
|
||||||
evidence: 0.0,
|
log_evidence: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.likelihoods_scored(arena, score_sigma);
|
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();
|
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.
|
// Inverse permutation: inv_buf[orig_i] = sorted_i.
|
||||||
arena.inv_buf.resize(n_teams, 0);
|
arena.inv_buf.resize(n_teams, 0);
|
||||||
@@ -386,11 +393,11 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
(evidence, likelihoods)
|
(log_evidence, likelihoods)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn likelihoods(&mut self, arena: &mut ScratchArena) {
|
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 tie = self.result[sort_buf[i]] == self.result[sort_buf[i + 1]];
|
||||||
let margin = if self.p_draw == 0.0 {
|
let margin = if self.p_draw == 0.0 {
|
||||||
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);
|
let vid = vars.alloc(N_INF);
|
||||||
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
|
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
|
||||||
});
|
});
|
||||||
self.evidence = evidence;
|
self.log_evidence = log_evidence;
|
||||||
self.likelihoods = likelihoods;
|
self.likelihoods = likelihoods;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) {
|
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 m_obs = self.result[sort_buf[i]] - self.result[sort_buf[i + 1]];
|
||||||
let vid = vars.alloc(N_INF);
|
let vid = vars.alloc(N_INF);
|
||||||
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
|
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
|
||||||
});
|
});
|
||||||
self.evidence = evidence;
|
self.log_evidence = log_evidence;
|
||||||
self.likelihoods = likelihoods;
|
self.likelihoods = likelihoods;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +440,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn log_evidence(&self) -> f64 {
|
pub fn log_evidence(&self) -> f64 {
|
||||||
self.evidence.ln()
|
self.log_evidence
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-26
@@ -87,7 +87,7 @@ struct Team {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct Event {
|
pub(crate) struct Event {
|
||||||
teams: Vec<Team>,
|
teams: Vec<Team>,
|
||||||
evidence: f64,
|
log_evidence: f64,
|
||||||
weights: Vec<Vec<f64>>,
|
weights: Vec<Vec<f64>>,
|
||||||
kind: EventKind,
|
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 {
|
Event {
|
||||||
teams,
|
teams,
|
||||||
evidence: 0.0,
|
log_evidence: 0.0,
|
||||||
weights,
|
weights,
|
||||||
kind: kinds[e],
|
kind: kinds[e],
|
||||||
}
|
}
|
||||||
@@ -353,7 +353,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
event.evidence = g.evidence;
|
event.log_evidence = g.log_evidence;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.sweep_color_groups(agents);
|
self.sweep_color_groups(agents);
|
||||||
@@ -530,26 +530,28 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
let teams = event.within_priors(online, forward, &self.skills, agents);
|
let teams = event.within_priors(online, forward, &self.skills, agents);
|
||||||
let result = event.outputs();
|
let result = event.outputs();
|
||||||
match event.kind {
|
match event.kind {
|
||||||
EventKind::Ranked => Game::ranked_with_arena(
|
EventKind::Ranked => {
|
||||||
teams,
|
Game::ranked_with_arena(
|
||||||
&result,
|
teams,
|
||||||
&event.weights,
|
&result,
|
||||||
self.p_draw,
|
&event.weights,
|
||||||
self.convergence,
|
self.p_draw,
|
||||||
arena,
|
self.convergence,
|
||||||
)
|
arena,
|
||||||
.evidence
|
)
|
||||||
.ln(),
|
.log_evidence
|
||||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
}
|
||||||
teams,
|
EventKind::Scored { score_sigma } => {
|
||||||
&result,
|
Game::scored_with_arena(
|
||||||
&event.weights,
|
teams,
|
||||||
score_sigma,
|
&result,
|
||||||
self.convergence,
|
&event.weights,
|
||||||
arena,
|
score_sigma,
|
||||||
)
|
self.convergence,
|
||||||
.evidence
|
arena,
|
||||||
.ln(),
|
)
|
||||||
|
.log_evidence
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -560,7 +562,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
.map(|event| run_event(event, &mut arena))
|
.map(|event| run_event(event, &mut arena))
|
||||||
.sum()
|
.sum()
|
||||||
} else {
|
} else {
|
||||||
self.events.iter().map(|event| event.evidence.ln()).sum()
|
self.events.iter().map(|event| event.log_evidence).sum()
|
||||||
}
|
}
|
||||||
} else if online || forward {
|
} else if online || forward {
|
||||||
self.events
|
self.events
|
||||||
@@ -584,7 +586,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
.flat_map(|team| &team.items)
|
.flat_map(|team| &team.items)
|
||||||
.any(|item| targets.contains(&item.agent))
|
.any(|item| targets.contains(&item.agent))
|
||||||
})
|
})
|
||||||
.map(|event| event.evidence.ln())
|
.map(|event| event.log_evidence)
|
||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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