docs!: one name for score noise, and say which of beta/sigma to turn

"sigma" named three unrelated quantities: the prior standard deviation,
a distribution's own SD, and the observation noise on an observed score
margin. The third was already `score_sigma` at every config site —
`HistoryBuilder::score_sigma`, `GameOptions::score_sigma`,
`EventKind::Scored { score_sigma }` — and plain `sigma` only on
`Outcome::Scored`'s field and constructor parameter, whose own doc had
to disambiguate itself with "`sigma` overrides
`HistoryBuilder::score_sigma`". Now `score_sigma` everywhere.

The `Outcome::scores_with_sigma` / `EventBuilder::scores_with_sigma`
*method* names are left alone: renaming them is a naming choice rather
than a consistency fix, and #75 offers two candidates.

`HistoryBuilder::beta` and `::sigma` now say which is which. #75 calls
this the single most load-bearing undocumented distinction in the crate,
and it is right: nothing told a reader that `sigma` is epistemic — what
the model does not yet know, which evidence shrinks — while `beta` is
aleatoric, the day-to-day scatter no amount of evidence removes. Both
docs now name the symptom that should send you to that knob rather than
the other.

Refs #75.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
2026-09-09 21:58:26 +02:00
co-authored by Claude Opus 5
parent 251211f134
commit 055575a6f4
2 changed files with 47 additions and 16 deletions
+34 -4
View File
@@ -72,7 +72,22 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
self 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 /// # Panics
/// ///
@@ -91,7 +106,19 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
self 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 /// # Panics
/// ///
@@ -2685,8 +2712,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
kinds.push(EventKind::Ranked); kinds.push(EventKind::Ranked);
ranks.iter().map(|&r| max_rank - r as f64).collect() ranks.iter().map(|&r| max_rank - r as f64).collect()
} }
crate::Outcome::Scored { scores, sigma } => { crate::Outcome::Scored {
let resolved = sigma.unwrap_or(self.score_sigma); scores,
score_sigma,
} => {
let resolved = score_sigma.unwrap_or(self.score_sigma);
if resolved <= 0.0 || resolved.is_nan() { if resolved <= 0.0 || resolved.is_nan() {
return Err(InferenceError::InvalidParameter { return Err(InferenceError::InvalidParameter {
name: "score_sigma", name: "score_sigma",
+13 -12
View File
@@ -1,6 +1,6 @@
//! Outcome of a match. //! 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). //! continuous per-team scores (engages `MarginFactor` in the engine).
use smallvec::SmallVec; use smallvec::SmallVec;
@@ -10,7 +10,7 @@ use smallvec::SmallVec;
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those /// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
/// teams. `ranks.len()` must equal the number of teams in the event. /// 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 /// feed observed margins to `MarginFactor`. `scores.len()` must equal the
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma` /// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
/// when `Some`; `None` inherits the history default. /// 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 /// 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 /// 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. /// beating a team by ten says more than beating them by one.
#[non_exhaustive] #[non_exhaustive]
Scored { Scored {
@@ -43,7 +44,7 @@ pub enum Outcome {
scores: SmallVec<[f64; 4]>, scores: SmallVec<[f64; 4]>,
/// Per-event noise override. `None` means inherit /// Per-event noise override. `None` means inherit
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`. /// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
sigma: Option<f64>, score_sigma: Option<f64>,
}, },
} }
@@ -106,20 +107,20 @@ impl Outcome {
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self { pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
Self::Scored { Self::Scored {
scores: scores.into_iter().collect(), scores: scores.into_iter().collect(),
sigma: None, score_sigma: None,
} }
} }
/// Explicit per-team continuous scores with a per-event noise override. /// Explicit per-team continuous scores with a per-event noise override.
/// ///
/// `sigma` must be `> 0.0`. Constructing an `Outcome` with a non-positive /// `score_sigma` must be `> 0.0`. Constructing an `Outcome` with a
/// or NaN sigma is allowed; the value is rejected with /// non-positive or NaN value is allowed; the value is rejected with
/// `InferenceError::InvalidParameter` when the event is ingested, so /// `InferenceError::InvalidParameter` when the event is ingested, so
/// callers get an error rather than a panic. /// callers get an error rather than a panic.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self { pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, score_sigma: f64) -> Self {
Self::Scored { Self::Scored {
scores: scores.into_iter().collect(), 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() { fn scores_constructor_leaves_sigma_unset() {
let o = Outcome::scores([3.0, 1.0]); let o = Outcome::scores([3.0, 1.0]);
match o { 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"), Outcome::Ranked(_) => panic!("expected Scored variant"),
} }
} }
@@ -228,7 +229,7 @@ mod tests {
fn scores_with_sigma_sets_sigma_some() { fn scores_with_sigma_sets_sigma_some() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0); let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0);
match o { 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"), Outcome::Ranked(_) => panic!("expected Scored variant"),
} }
} }
@@ -240,7 +241,7 @@ mod tests {
fn scores_with_sigma_defers_validation_to_ingestion() { fn scores_with_sigma_defers_validation_to_ingestion() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 0.0); let o = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
match o { 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"), Outcome::Ranked(_) => panic!("expected Scored variant"),
} }
} }