fix!: validate the constructors below HistoryBuilder
0.8.0 closed the sign-absorption defect at `HistoryBuilder::mu/sigma/beta`
and at both ingestion paths. It was still open one layer down, in the
constructors those paths call. Measured, all bit identical to their
positive counterparts:
Gaussian::from_ms(25.0, -8.33) == from_ms(25.0, +8.33)
Rating::new(_, -4.17, _) == Rating::new(_, +4.17, _)
ConstantDrift(-0.0833) == ConstantDrift(+0.0833)
sigma, beta and gamma enter only as squares, so the sign vanished without
comment. Worst of the set: `Rating::new(_, NaN, _)` reached `Game::ranked`
which returned **Ok** carrying `Gaussian { pi: NaN, tau: NaN }` — no
`converge` on that path to catch it.
`from_ms` and `Rating::new` now reject. `ConstantDrift` cannot: the field
is public and positional, so there is no constructor to intercept, and
sealing it would break every `ConstantDrift(x)` for a case whose resulting
model is perfectly valid. Documented instead. Its non-finite half IS
rejected — `converge` validates the drift variance each competitor
accumulates, which also covers a custom `Drift` impl.
Two things the tests caught that I had wrong:
NaN sigma must PASS `from_ms`. My first version rejected it, and two
existing tests went red immediately: a broken fit legitimately produces a
NaN sigma from `sqrt` of a negative truncated variance, and the design is
to propagate that to `NonFiniteResult`. Rejecting it turned the reporting
path into a panic inside inference. Written as
`sigma >= 0.0 || sigma.is_nan()` so the intent is explicit rather than
hidden in a negated comparison.
Very small sigma is also not rejected, and that is deliberate: `approx`
produces small truncated sigmas legitimately. `pi = 1/sigma^2` leaves
f64's range below ~1.5e-154 and `tau = mu*pi` overflows sooner, at a
threshold that depends on mu — so there is a band where pi is finite and
only tau is not. Both land on the existing point-mass representation.
Documented, including that such a Gaussian is not equal to itself and can
make two identical declarations report as conflicting.
BREAKING CHANGE: `Gaussian::from_ms` panics on a negative sigma, and
`Rating::new` panics unless beta is finite and non-negative. `converge`
returns `InvalidParameter` for a non-finite drift variance.
Closes #61
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
@@ -21,6 +21,23 @@ 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
|
||||
///
|
||||
/// `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.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// A non-finite `gamma` is a different matter and **is** rejected:
|
||||
/// `History::converge` validates the drift variance each competitor actually
|
||||
/// accumulates, which also covers a custom [`Drift`] implementation, and
|
||||
/// reports `InferenceError::InvalidParameter`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ConstantDrift(pub f64);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user