The bracket test proves the feature works on one fixture. These pin the bug
class:
- Invariance to converge(). This is the one that matters. Reading
skill.forward instead of the carried message makes it fail immediately,
because converge() alternates sweeps and contaminates skill.forward with
backward information from the second iteration onward. That is the
property a stored field cannot have, and the reason issue #19's proposed
fix would not have worked.
- Invariance to ingestion order, the crate's standing invariant.
- One slice has no future to propagate back, so filtered equals smoothed.
- Empty history yields zero and empty maps.
Agreement is to 1e-8 under tight convergence rather than bit-identity:
iteration recomputes the colour partition only when from == 0, so an
incrementally built slice keeps insertion order until the first converge()
reorders it, and the scratch clone inherits whichever order it finds. Same
fixed point, different path to it.
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
A tie with `p_draw == 0.0` produced NaN posteriors in release builds and
`converge()` reported `converged: true`, because every comparison against
NaN is false and `tuple_gt` therefore read NaN as "below epsilon".
Two independent defects, fixed together:
- Ingestion now rejects tied outcomes when the draw probability is zero,
promoting the existing `debug_assert!` in `Game::ranked_with_arena` to a
real `InferenceError::TieWithoutDrawProbability`. Validation sits in
`add_events_with_prior`, the chokepoint every route reaches — including
`record_draw`, which bypasses `Outcome` entirely.
- `converge()` treats a non-finite step as failure and returns
`InferenceError::NonFiniteResult` rather than claiming convergence.
Also in this change:
- `History::converge()` on an empty history returned a `usize` underflow
panic from `0..len()-1`; it now short-circuits to a zero-iteration report.
- `Outcome::scores_with_sigma` no longer panics on a non-positive sigma;
the value is validated at ingestion so callers get an error instead.
- `InferenceError` gains `WrongOutcomeKind`, replacing the misuse of
`MismatchedShape` for variant mismatches (which rendered as the nonsense
"expected length 0, got 0"), and is now `#[non_exhaustive]`.
Note `Outcome::winner(w, n)` for n >= 3 ties every loser, so those events
now require a positive `p_draw`. They previously returned NaN.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej