Merge branch 'fix/non-finite-weights'

Reject non-finite weights at ingestion, completing the malformed-input
boundary.

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-08 21:03:17 +02:00
co-authored by Claude Opus 5
2 changed files with 66 additions and 0 deletions
+23
View File
@@ -1753,6 +1753,29 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
} }
} }
// A non-finite weight is not a weight. Measured, it behaves exactly as
// `0.0` — the member contributes nothing — while `converge` reports
// `converged: true` after one iteration with a step of `(0.0, 0.0)`.
// So a NaN arriving from a division or a parse is indistinguishable
// from a deliberate zero, and looks like a clean fit.
//
// Zero and negative weights stay accepted: both are expressible
// choices about how much a member contributes, and
// `tests/degenerate_inputs.rs` pins them deliberately. Only the values
// that are not quantities at all are rejected.
if let Some(weights) = weights.as_ref() {
for team_weights in weights.iter().flatten() {
for weight in team_weights {
if !weight.is_finite() {
return Err(InferenceError::InvalidParameter {
name: "weight",
value: *weight,
});
}
}
}
}
// Chokepoint for tie validation: every ingestion route lands here, // Chokepoint for tie validation: every ingestion route lands here,
// including `record_draw`, which builds its results directly rather // including `record_draw`, which builds its results directly rather
// than going through `Outcome`. // than going through `Outcome`.
+43
View File
@@ -120,6 +120,49 @@ fn a_non_finite_score_is_rejected_at_ingestion() {
} }
} }
/// A non-finite weight behaved exactly as `0.0` — the member contributed
/// nothing — while `converge` reported `converged: true` after one iteration
/// with a step of `(0.0, 0.0)`. So a NaN arriving from a division or a parse
/// was indistinguishable from a deliberate zero, and looked like a clean fit.
#[test]
fn a_non_finite_weight_is_rejected_at_ingestion() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut h = history();
let err = h
.event(1)
.team(["a"])
.weights([bad])
.team(["b"])
.winner(0)
.commit()
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
"{bad}: {err:?}"
);
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
}
}
/// Zero and negative weights are expressible choices about how much a member
/// contributes, not malformed input, and `tests/degenerate_inputs.rs` pins
/// their behaviour deliberately. Rejecting non-finite values must not catch
/// them too.
#[test]
fn zero_and_negative_weights_still_ingest() {
for w in [0.0, -1.0, 0.5] {
let mut h = history();
h.event(1)
.team(["a"])
.weights([w])
.team(["b"])
.winner(0)
.commit()
.unwrap_or_else(|e| panic!("weight {w} should ingest: {e:?}"));
assert!(h.current_skill(&"a").is_some(), "weight {w}");
}
}
/// The fluent builder routes through the same chokepoint, so it inherits the /// The fluent builder routes through the same chokepoint, so it inherits the
/// checks rather than needing its own. /// checks rather than needing its own.
#[test] #[test]