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
+28 -26
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,26 +530,28 @@ 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(
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<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()
}
}