Status: fixed in v0.1.2 (commit on main). Filing for the record.
Symptom
History::converge() produces all-NaN skills once a single time slice holds more than ~75 distinct competitors (finite at ≤70, NaN at ≥80; ~300 1v1 events, even in a single time bucket). 0.1.0 was finite on identical data. This broke a downstream consumer's production ratings (hundreds of players → 100% NaN) when it upgraded 0.1.0 → 0.1.1.
Reproducer (deterministic, no real data)
N competitors, ~4 random 1v1 games each, one time slice, default config (β=1, σ=6, drift=0.1, max_iter=30). At N≥75 every current_skill() is NaN. (Added as tests/large_history_converges_finite.rs.)
Root cause
0.1.1 switched Gaussian to natural-param storage (pi, tau) with sigma() = 1/sqrt(pi) computed lazily, but mu()/sigma() only special-cased pi == 0.0, not pi < 0. EP message cancellation in the game diff-chain (TruncFactor) leaves an outgoing message precision at a tiny negative value (~-5.5e-17 — round-off of exactly zero). sigma() = 1/sqrt(negative) = NaN, which the moment-space Gaussian - Gaussian in run_chain's boundary update propagates into lhood_win/lhood_lose → every skill in the slice goes NaN. Larger slices give the EP more refinement passes, so a message reliably lands on the round-off-negative precision past the threshold. 0.1.0 stored sigma as a plain field, so it never hit 1/sqrt(pi).
Fix
Guard pi <= 0.0 (not just == 0.0) in Gaussian::mu() (→ 0) and sigma() (→ ∞) — an improper/uninformative Gaussian, matching the existing pi == 0 handling. Verified: reproducer 0 NaN at 12–250 competitors; full suite green; a downstream real-data fit (3442 votes, 669 ranked) is finite end-to-end.
Prevention
Added two regression tests: a Gaussian accessor unit test (non-positive precision → improper, not NaN) and a ≥75-competitor converge() finiteness test.
**Status:** fixed in v0.1.2 (commit on `main`). Filing for the record.
## Symptom
`History::converge()` produces **all-NaN** skills once a single time slice holds more than ~75 distinct competitors (finite at ≤70, NaN at ≥80; ~300 1v1 events, even in a single time bucket). 0.1.0 was finite on identical data. This broke a downstream consumer's production ratings (hundreds of players → 100% NaN) when it upgraded 0.1.0 → 0.1.1.
## Reproducer (deterministic, no real data)
N competitors, ~4 random 1v1 games each, one time slice, default config (β=1, σ=6, drift=0.1, max_iter=30). At N≥75 every `current_skill()` is NaN. (Added as `tests/large_history_converges_finite.rs`.)
## Root cause
0.1.1 switched `Gaussian` to natural-param storage (`pi`, `tau`) with `sigma() = 1/sqrt(pi)` computed lazily, but `mu()`/`sigma()` only special-cased `pi == 0.0`, not `pi < 0`. EP message cancellation in the game diff-chain (`TruncFactor`) leaves an outgoing message precision at a **tiny negative value (~-5.5e-17 — round-off of exactly zero)**. `sigma() = 1/sqrt(negative) = NaN`, which the moment-space `Gaussian - Gaussian` in `run_chain`'s boundary update propagates into `lhood_win`/`lhood_lose` → every skill in the slice goes NaN. Larger slices give the EP more refinement passes, so a message reliably lands on the round-off-negative precision past the threshold. 0.1.0 stored `sigma` as a plain field, so it never hit `1/sqrt(pi)`.
## Fix
Guard `pi <= 0.0` (not just `== 0.0`) in `Gaussian::mu()` (→ 0) and `sigma()` (→ ∞) — an improper/uninformative Gaussian, matching the existing `pi == 0` handling. Verified: reproducer 0 NaN at 12–250 competitors; full suite green; a downstream real-data fit (3442 votes, 669 ranked) is finite end-to-end.
## Prevention
Added two regression tests: a `Gaussian` accessor unit test (non-positive precision → improper, not NaN) and a ≥75-competitor `converge()` finiteness test.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Status: fixed in v0.1.2 (commit on
main). Filing for the record.Symptom
History::converge()produces all-NaN skills once a single time slice holds more than ~75 distinct competitors (finite at ≤70, NaN at ≥80; ~300 1v1 events, even in a single time bucket). 0.1.0 was finite on identical data. This broke a downstream consumer's production ratings (hundreds of players → 100% NaN) when it upgraded 0.1.0 → 0.1.1.Reproducer (deterministic, no real data)
N competitors, ~4 random 1v1 games each, one time slice, default config (β=1, σ=6, drift=0.1, max_iter=30). At N≥75 every
current_skill()is NaN. (Added astests/large_history_converges_finite.rs.)Root cause
0.1.1 switched
Gaussianto natural-param storage (pi,tau) withsigma() = 1/sqrt(pi)computed lazily, butmu()/sigma()only special-casedpi == 0.0, notpi < 0. EP message cancellation in the game diff-chain (TruncFactor) leaves an outgoing message precision at a tiny negative value (~-5.5e-17 — round-off of exactly zero).sigma() = 1/sqrt(negative) = NaN, which the moment-spaceGaussian - Gaussianinrun_chain's boundary update propagates intolhood_win/lhood_lose→ every skill in the slice goes NaN. Larger slices give the EP more refinement passes, so a message reliably lands on the round-off-negative precision past the threshold. 0.1.0 storedsigmaas a plain field, so it never hit1/sqrt(pi).Fix
Guard
pi <= 0.0(not just== 0.0) inGaussian::mu()(→ 0) andsigma()(→ ∞) — an improper/uninformative Gaussian, matching the existingpi == 0handling. Verified: reproducer 0 NaN at 12–250 competitors; full suite green; a downstream real-data fit (3442 votes, 669 ranked) is finite end-to-end.Prevention
Added two regression tests: a
Gaussianaccessor unit test (non-positive precision → improper, not NaN) and a ≥75-competitorconverge()finiteness test.🤖 Generated with Claude Code