use std::fmt::Debug; use crate::time::Time; /// Governs how much a competitor's skill can drift between two time points. /// /// Generic over `T: Time` so seasonal or calendar-aware drift is expressible /// without going through `i64`. pub trait Drift: Copy + Debug + Send + Sync { /// Variance added to the skill prior for elapsed time `from -> to`. /// /// Called with `from <= to`; returning zero means no drift accumulates. fn variance_delta(&self, from: &T, to: &T) -> f64; /// Variance added for a pre-computed elapsed count (in the same units as /// `T::elapsed_to`). Used where the elapsed is already cached as `i64`. fn variance_for_elapsed(&self, elapsed: i64) -> f64; } /// Simple constant-per-unit-time drift. /// /// For `Time = i64`: variance added is `(to - from) * gamma^2`. /// For `Time = Untimed`: elapsed is always 0, so drift is always 0. /// /// # Why the field is private /// /// `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 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 `NonFiniteStep` reporting path into a crash. /// /// 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. #[derive(Clone, Copy, Debug, PartialEq)] 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 Drift for ConstantDrift { fn variance_delta(&self, from: &T, to: &T) -> f64 { let elapsed = from.elapsed_to(to).max(0) as f64; elapsed * self.0 * self.0 } fn variance_for_elapsed(&self, elapsed: i64) -> f64 { elapsed.max(0) as f64 * self.0 * self.0 } }