diff --git a/src/history.rs b/src/history.rs index b531507..cd96865 100644 --- a/src/history.rs +++ b/src/history.rs @@ -72,7 +72,22 @@ impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder< self } - /// Prior standard deviation. + /// Prior standard deviation: how unsure the model is about a competitor's + /// **skill** before it has seen them play. + /// + /// The first of the two noise knobs, and the one people reach for by + /// mistake. `sigma` is *epistemic* — it is what the model does not yet + /// know, and evidence shrinks it. [`HistoryBuilder::beta`] is *aleatoric* + /// — how much a single showing scatters around the skill, which no amount + /// of evidence removes. + /// + /// So: results move ratings too slowly for your taste → raise `sigma` (or + /// `gamma`, if the problem is that skill genuinely moves). A single upset + /// swings ratings too far → raise `beta`, because you are telling the model + /// that one result is weaker evidence than it assumed. + /// + /// The default is six betas, deliberately wide: a new competitor's first + /// result should move them a long way. /// /// # Panics /// @@ -91,7 +106,19 @@ impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder< self } - /// Per-event performance noise. + /// Per-event performance noise: how much a single showing scatters around + /// a competitor's **skill**. + /// + /// The second noise knob, and the one that sets the scale of the whole + /// system — [`SIGMA`](crate::SIGMA) and [`GAMMA`](crate::GAMMA) are both + /// defined as multiples of it. Unlike + /// [`sigma`](HistoryBuilder::sigma), this is *aleatoric*: it is the + /// irreducible day-to-day variation, so evidence never shrinks it. It is + /// also what makes an upset possible at all — with `beta == 0` the better + /// competitor always wins. + /// + /// Larger `beta` means each result carries less information, so ratings + /// move less per game and the draw margin implied by `p_draw` is wider. /// /// # Panics /// @@ -2685,8 +2712,11 @@ impl, O: Observer, K: Eq + Hash + Clone> History { - let resolved = sigma.unwrap_or(self.score_sigma); + crate::Outcome::Scored { + scores, + score_sigma, + } => { + let resolved = score_sigma.unwrap_or(self.score_sigma); if resolved <= 0.0 || resolved.is_nan() { return Err(InferenceError::InvalidParameter { name: "score_sigma", diff --git a/src/outcome.rs b/src/outcome.rs index 899200b..f0a730f 100644 --- a/src/outcome.rs +++ b/src/outcome.rs @@ -1,6 +1,6 @@ //! Outcome of a match. //! -//! `Ranked(ranks)` for ordinal results; `Scored { scores, sigma }` for +//! `Ranked(ranks)` for ordinal results; `Scored { scores, score_sigma }` for //! continuous per-team scores (engages `MarginFactor` in the engine). use smallvec::SmallVec; @@ -10,7 +10,7 @@ use smallvec::SmallVec; /// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those /// teams. `ranks.len()` must equal the number of teams in the event. /// -/// `Scored { scores, sigma }`: higher score = better. Adjacent (sorted) pairs +/// `Scored { scores, score_sigma }`: higher score = better. Adjacent (sorted) pairs /// feed observed margins to `MarginFactor`. `scores.len()` must equal the /// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma` /// when `Some`; `None` inherits the history default. @@ -34,7 +34,8 @@ pub enum Outcome { /// /// Unlike `Ranked`, the *sizes* of the differences are evidence. Teams are /// sorted by score and each adjacent pair's observed gap is fed to a - /// `MarginFactor` as a measurement with standard deviation `sigma`, so + /// `MarginFactor` as a measurement with standard deviation `score_sigma`, + /// so /// beating a team by ten says more than beating them by one. #[non_exhaustive] Scored { @@ -43,7 +44,7 @@ pub enum Outcome { scores: SmallVec<[f64; 4]>, /// Per-event noise override. `None` means inherit /// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`. - sigma: Option, + score_sigma: Option, }, } @@ -106,20 +107,20 @@ impl Outcome { pub fn scores>(scores: I) -> Self { Self::Scored { scores: scores.into_iter().collect(), - sigma: None, + score_sigma: None, } } /// Explicit per-team continuous scores with a per-event noise override. /// - /// `sigma` must be `> 0.0`. Constructing an `Outcome` with a non-positive - /// or NaN sigma is allowed; the value is rejected with + /// `score_sigma` must be `> 0.0`. Constructing an `Outcome` with a + /// non-positive or NaN value is allowed; the value is rejected with /// `InferenceError::InvalidParameter` when the event is ingested, so /// callers get an error rather than a panic. - pub fn scores_with_sigma>(scores: I, sigma: f64) -> Self { + pub fn scores_with_sigma>(scores: I, score_sigma: f64) -> Self { Self::Scored { scores: scores.into_iter().collect(), - sigma: Some(sigma), + score_sigma: Some(score_sigma), } } @@ -219,7 +220,7 @@ mod tests { fn scores_constructor_leaves_sigma_unset() { let o = Outcome::scores([3.0, 1.0]); match o { - Outcome::Scored { scores: _, sigma } => assert!(sigma.is_none()), + Outcome::Scored { score_sigma, .. } => assert!(score_sigma.is_none()), Outcome::Ranked(_) => panic!("expected Scored variant"), } } @@ -228,7 +229,7 @@ mod tests { fn scores_with_sigma_sets_sigma_some() { let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0); match o { - Outcome::Scored { scores: _, sigma } => assert_eq!(sigma, Some(2.0)), + Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(2.0)), Outcome::Ranked(_) => panic!("expected Scored variant"), } } @@ -240,7 +241,7 @@ mod tests { fn scores_with_sigma_defers_validation_to_ingestion() { let o = Outcome::scores_with_sigma([3.0, 1.0], 0.0); match o { - Outcome::Scored { sigma, .. } => assert_eq!(sigma, Some(0.0)), + Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(0.0)), Outcome::Ranked(_) => panic!("expected Scored variant"), } }