Trait coverage (#76), all additive: History Debug is still absent - see below HistoryBuilder + Debug (it derived Clone but not Debug) Rating + PartialEq (Gaussian had it; Rating is a Gaussian plus three scalars and had none) Event/Team/Member + PartialEq (input value types with no way to compare them, which made round-trip tests awkward) ConvergenceReport + PartialEq `#[must_use]` (#67). The coverage had no rule: `filtered_log_evidence` had it and `log_evidence` did not; `rating` had it and `current_skill` did not; `Rating::with_drift_scale` had it and `Member::with_drift_scale` did not. Now on the types — `EventBuilder`, `HistoryBuilder`, `Prediction`, `Gaussian`, `OwnedGame` — which covers most method returns at once, plus the `History` accessors individually. `EventBuilder` gets a message, because a dropped builder is the worst case in the set: measured, `h.event(1).team(["x"]).team(["y"]).winner(0)` without `.commit()` leaves `time_slices_len() == 0` and every skill `None`, with no warning at all. And `ConvergenceReport`'s `#[must_use]` moves off the TYPE onto `converge_partial`, where its stated reason is true. It read "from `converge_partial` this may describe a fit that stopped at max_iter" but fired on `converge` too — where that is false, since `converge` returns `Err(NotConverged)` in exactly that case. So the crate's own front-page example warned, and every quickstart had to write `let _ =`. Verified from a consumer crate: `h.converge()?;` now compiles clean. Marking the types made eight method-level attributes redundant, which clippy's `double_must_use` caught — that is the type-level marker doing its job, and the eight are removed. Refs #76, #67 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
118 lines
3.9 KiB
Rust
118 lines
3.9 KiB
Rust
use std::marker::PhantomData;
|
|
|
|
use crate::{
|
|
BETA, GAMMA,
|
|
drift::{ConstantDrift, Drift},
|
|
gaussian::Gaussian,
|
|
time::Time,
|
|
};
|
|
|
|
/// Static rating configuration: prior skill, performance noise `beta`, drift.
|
|
///
|
|
/// A configuration rather than a person: the per-history temporal state
|
|
/// (messages, last appearance) lives on `Competitor`.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
|
pub(crate) prior: Gaussian,
|
|
pub(crate) beta: f64,
|
|
pub(crate) drift: D,
|
|
/// Multiplier on the drift *variance* this competitor accumulates; 1.0 is
|
|
/// the neutral default. Set per competitor via `Member::with_drift_scale`.
|
|
pub(crate) drift_scale: f64,
|
|
pub(crate) _time: PhantomData<T>,
|
|
}
|
|
|
|
impl<T: Time, D: Drift<T>> Rating<T, D> {
|
|
/// # Panics
|
|
///
|
|
/// Panics unless `beta` is finite and non-negative, matching
|
|
/// `HistoryBuilder::beta`.
|
|
///
|
|
/// Zero is allowed and meaningful — performance is then exactly skill, and
|
|
/// the fit differs measurably from a positive beta rather than degenerating.
|
|
/// Negative is rejected because `beta` enters only as `beta^2`: measured, a
|
|
/// negative beta returned results **bit identical** to its absolute value,
|
|
/// and a NaN beta reached `Game::ranked`, which returned `Ok` carrying a
|
|
/// `Gaussian { pi: NaN, tau: NaN }` — there is no `converge` on that path to
|
|
/// catch it.
|
|
pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self {
|
|
assert!(
|
|
beta.is_finite() && beta >= 0.0,
|
|
"beta must be finite and non-negative (got {beta}); it is only ever \
|
|
squared, so a negative value would silently behave as its absolute value"
|
|
);
|
|
Self {
|
|
prior,
|
|
beta,
|
|
drift,
|
|
drift_scale: 1.0,
|
|
_time: PhantomData,
|
|
}
|
|
}
|
|
|
|
/// Scale how fast this competitor drifts, relative to `drift`.
|
|
///
|
|
/// Multiplies the drift *variance*, so the scale is in the same units as
|
|
/// `gamma`. `0.0` pins the competitor still.
|
|
#[must_use]
|
|
pub fn with_drift_scale(mut self, drift_scale: f64) -> Self {
|
|
self.drift_scale = drift_scale;
|
|
self
|
|
}
|
|
|
|
/// The configured prior skill estimate.
|
|
pub fn prior(&self) -> Gaussian {
|
|
self.prior
|
|
}
|
|
|
|
/// Performance noise: how much a single showing varies around the skill.
|
|
#[must_use]
|
|
pub fn beta(&self) -> f64 {
|
|
self.beta
|
|
}
|
|
|
|
/// The drift model governing how skill may move between events.
|
|
#[must_use]
|
|
pub fn drift(&self) -> D {
|
|
self.drift
|
|
}
|
|
|
|
/// This competitor's multiplier on the drift variance; 1.0 is neutral.
|
|
#[must_use]
|
|
pub fn drift_scale(&self) -> f64 {
|
|
self.drift_scale
|
|
}
|
|
|
|
/// Drift variance accumulated over `from -> to`, scaled for this competitor.
|
|
///
|
|
/// The single place the scale is applied for a `Time`-typed span. Callers
|
|
/// must go through this rather than `self.drift` directly, so a competitor's
|
|
/// scale cannot be silently skipped.
|
|
pub(crate) fn drift_variance_delta(&self, from: &T, to: &T) -> f64 {
|
|
self.drift.variance_delta(from, to) * self.drift_scale * self.drift_scale
|
|
}
|
|
|
|
/// Drift variance for a cached elapsed count, scaled for this competitor.
|
|
///
|
|
/// The counterpart of `drift_variance_delta` for the cached-elapsed paths.
|
|
pub(crate) fn drift_variance_for_elapsed(&self, elapsed: i64) -> f64 {
|
|
self.drift.variance_for_elapsed(elapsed) * self.drift_scale * self.drift_scale
|
|
}
|
|
|
|
pub(crate) fn performance(&self) -> Gaussian {
|
|
self.prior.forget(self.beta.powi(2))
|
|
}
|
|
}
|
|
|
|
impl Default for Rating<i64, ConstantDrift> {
|
|
fn default() -> Self {
|
|
Self {
|
|
prior: Gaussian::default(),
|
|
beta: BETA,
|
|
drift: ConstantDrift::new(GAMMA),
|
|
drift_scale: 1.0,
|
|
_time: PhantomData,
|
|
}
|
|
}
|
|
}
|