fix: reject convergence options that silently disable inference

`Game::ranked` and `Game::scored` validated `p_draw` and `score_sigma`
but never `convergence`. `ConvergenceOptions` has public fields and
`GameOptions` carries one, so a caller could hand the engine a set that
`HistoryBuilder`'s eager asserts never saw. Past that, the only guard
was a `debug_assert!`, which is gone in the profile users ship.

An `alpha` of zero is the bad case, and it fails silently rather than
loudly. Measured in release before the fix:

    likelihoods: [[Gaussian { pi: 0.0, tau: 0.0 }],
                  [Gaussian { pi: 0.0, tau: 0.0 }]]

Every EP update unapplied, every likelihood uninformative, inference
returning the priors it was given — and an `OwnedGame` that looks
entirely ordinary to the caller. `HistoryBuilder::convergence` already
documents exactly this hazard; the `Game` constructors just did not
share the check.

Adds `ConvergenceOptions::validate`, called by both constructors.
Rejects `alpha` outside `(0.0, 1.0]` and negative `epsilon`; NaN fails
both comparisons and is rejected too.

`tests/validation.rs` states the release-mode guarantee for the whole
public surface, not just this hole, and CI already runs the suite in
release. Probing the other conditions #18 lists found five of eight
already enforced — ties without a draw probability, per-event score
sigma, weight/team dimensions, draw-probability range, score-sigma
range — so this closes the remaining gap rather than the whole issue.
The engine keeps its `debug_assert!`s as invariant documentation.

Refs #18

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
2026-09-07 15:48:31 +02:00
co-authored by Claude Opus 5
parent 8c087ad015
commit d4f91fd221
3 changed files with 224 additions and 2 deletions
+31
View File
@@ -20,6 +20,37 @@ pub struct ConvergenceOptions {
pub alpha: f64,
}
impl ConvergenceOptions {
/// Reject values that would make inference silently meaningless.
///
/// `HistoryBuilder::convergence` asserts these eagerly, but the fields are
/// public and `GameOptions` carries a `ConvergenceOptions` — so a caller
/// can hand `Game::ranked` a set the builder never saw. In release the
/// engine's `debug_assert!`s are gone, and an `alpha` of zero leaves every
/// EP update unapplied: inference returns the priors, with every likelihood
/// uninformative and nothing to indicate anything went wrong.
///
/// # Errors
///
/// `InvalidParameter` if `alpha` is outside `(0.0, 1.0]` or `epsilon` is
/// negative. NaN fails both comparisons and is rejected.
pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> {
if !(self.alpha > 0.0 && self.alpha <= 1.0) {
return Err(crate::InferenceError::InvalidParameter {
name: "alpha",
value: self.alpha,
});
}
if self.epsilon.is_nan() || self.epsilon < 0.0 {
return Err(crate::InferenceError::InvalidParameter {
name: "epsilon",
value: self.epsilon,
});
}
Ok(())
}
}
impl Default for ConvergenceOptions {
fn default() -> Self {
Self {