fix!: seal ConstantDrift's field so gamma can be validated
`gamma` enters only as `gamma * gamma`, so the sign was squared away: measured against the old public-field form, `ConstantDrift(-0.0833)` produced results bit identical to `ConstantDrift(0.0833)`. The sign was neither rejected nor honoured — it vanished. It could not be checked while the field was a public tuple position, because there was nothing to intercept. Validating inside `variance_for_elapsed` would have been worse: it runs in the sweep, so a construction-time mistake would panic mid-inference, and `Gaussian::from_ms` is a worked example of why that is the wrong place — rejecting NaN there turned the NonFiniteResult reporting path into a crash. So `ConstantDrift::new` is the only way in and it checks, with `gamma()` to read the value back. 129 call sites rewritten across src, tests, benches, examples and the README. The dated plan and spec documents under docs/superpowers are left alone: they record what was built at the time, and rewriting them would falsify that. tests/constructor_validation.rs is the more valuable half. This defect class was closed three times in one session and reopened twice, because each fix validated the layer it had just touched and inferred the rest — `HistoryBuilder`, then `Game`'s own entry points, then the constructors beneath both. A per-site fix cannot notice the site nobody thought of, so that file enumerates every public entry point taking a magnitude and asserts each refuses negative and non-finite values. It found an eleventh defect on its first run: `HistoryBuilder::score_sigma` accepted infinity, because `inf > 0.0` is true and the assert only tested positivity. Fixed, and its own `should_panic` message updated to match. `Gaussian::from_ms` is deliberately exempt from the non-finite half, for the reason above: a broken fit produces a NaN sigma legitimately and `converge` must be allowed to report it. The convergence-level drift-variance check stays and is now tested through a custom `Drift` implementation, since `ConstantDrift` can no longer reach it. That check is the only thing standing between a third-party `Drift` and a NaN fit. BREAKING CHANGE: `ConstantDrift`'s field is private. Replace `ConstantDrift(x)` with `ConstantDrift::new(x)`, and `drift().0` with `drift().gamma()`. `HistoryBuilder::score_sigma` now rejects infinity. Closes #65 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+46
-13
@@ -22,24 +22,57 @@ pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
|
||||
/// For `Time = i64`: variance added is `(to - from) * gamma^2`.
|
||||
/// For `Time = Untimed`: elapsed is always 0, so drift is always 0.
|
||||
///
|
||||
/// # The sign of `gamma` is not meaningful
|
||||
/// # Why the field is private
|
||||
///
|
||||
/// `gamma` enters only as `gamma * gamma`, so `ConstantDrift(-0.05)` produces
|
||||
/// results **bit identical** to `ConstantDrift(0.05)`. That is the same
|
||||
/// sign-absorption `HistoryBuilder::sigma`, `HistoryBuilder::beta`,
|
||||
/// `Gaussian::from_ms` and `Rating::new` all reject outright.
|
||||
/// `gamma` enters only as `gamma * gamma`, so a negative value is squared away:
|
||||
/// measured against the old public-field form, `ConstantDrift(-0.0833)` produced
|
||||
/// results **bit identical** to `ConstantDrift(0.0833)`. The sign was neither
|
||||
/// rejected nor honoured — it vanished. That is the same sign-absorption `HistoryBuilder::sigma`,
|
||||
/// `HistoryBuilder::beta`, `Gaussian::from_ms` and `Rating::new` all reject.
|
||||
///
|
||||
/// It is not rejected here because the field is public and positional, so
|
||||
/// there is no constructor to intercept — sealing it would break every
|
||||
/// `ConstantDrift(x)` in existence for a case whose *resulting model* is
|
||||
/// perfectly valid, just not the one a caller writing a minus sign expected.
|
||||
/// It could not be checked while the field was a public tuple position, because
|
||||
/// there was no constructor to intercept. Validating inside
|
||||
/// `variance_for_elapsed` would have been worse: it runs inside the sweep, so a
|
||||
/// construction-time mistake would panic mid-inference — and `Gaussian::from_ms`
|
||||
/// is a worked example of why that is the wrong place for a guard, where
|
||||
/// rejecting NaN turned the `NonFiniteResult` reporting path into a crash.
|
||||
///
|
||||
/// A non-finite `gamma` is a different matter and **is** rejected:
|
||||
/// So [`ConstantDrift::new`] is the only way in, and it checks. Read the value
|
||||
/// back with [`ConstantDrift::gamma`].
|
||||
///
|
||||
/// A non-finite gamma is caught a second time regardless:
|
||||
/// `History::converge` validates the drift variance each competitor actually
|
||||
/// accumulates, which also covers a custom [`Drift`] implementation, and
|
||||
/// reports `InferenceError::InvalidParameter`.
|
||||
/// accumulates, which also covers a custom [`Drift`] implementation.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ConstantDrift(pub f64);
|
||||
pub struct ConstantDrift(f64);
|
||||
|
||||
impl ConstantDrift {
|
||||
/// Drift of `gamma` standard deviations per unit time.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics unless `gamma` is finite and non-negative.
|
||||
///
|
||||
/// The field is private and this is the only constructor precisely so that
|
||||
/// there is somewhere to check. While it was a public tuple field there was
|
||||
/// nothing to intercept, and a negative gamma was silently squared away —
|
||||
/// see the type docs.
|
||||
#[must_use]
|
||||
pub fn new(gamma: f64) -> Self {
|
||||
assert!(
|
||||
gamma.is_finite() && gamma >= 0.0,
|
||||
"gamma must be finite and non-negative (got {gamma}); it is only ever \
|
||||
squared, so a negative value would silently behave as its absolute value"
|
||||
);
|
||||
Self(gamma)
|
||||
}
|
||||
|
||||
/// Standard deviations of drift accumulated per unit time.
|
||||
#[must_use]
|
||||
pub fn gamma(&self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Time> Drift<T> for ConstantDrift {
|
||||
fn variance_delta(&self, from: &T, to: &T) -> f64 {
|
||||
|
||||
Reference in New Issue
Block a user