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:
+7
-2
@@ -433,6 +433,9 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
/// # Errors
|
||||
///
|
||||
/// - `InvalidParameter` if `options.convergence` is out of range — an
|
||||
/// `alpha` of zero would leave every EP update unapplied and silently
|
||||
/// return the priors.
|
||||
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
|
||||
/// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`.
|
||||
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
|
||||
@@ -444,6 +447,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||
options.convergence.validate()?;
|
||||
if !(0.0..1.0).contains(&options.p_draw) {
|
||||
return Err(crate::InferenceError::InvalidProbability {
|
||||
value: options.p_draw,
|
||||
@@ -491,8 +495,8 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// - `InvalidParameter` if `options.score_sigma` is not strictly positive,
|
||||
/// or is NaN.
|
||||
/// - `InvalidParameter` if `options.score_sigma` is not strictly positive
|
||||
/// or is NaN, or if `options.convergence` is out of range.
|
||||
/// - `MismatchedShape` if the outcome's score count differs from `teams.len()`.
|
||||
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Scored`.
|
||||
pub fn scored(
|
||||
@@ -500,6 +504,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||
options.convergence.validate()?;
|
||||
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
|
||||
return Err(crate::InferenceError::InvalidParameter {
|
||||
name: "score_sigma",
|
||||
|
||||
Reference in New Issue
Block a user