Two names that described the wrong thing.
`scores_with_sigma(scores, sigma)` reads as "these scores have prior
sigma 2.0". The quantity is observation noise on the score *margin*, in
the units of the scores, and it is spelled `score_sigma` at every config
site — `HistoryBuilder::score_sigma`, `GameOptions::score_sigma`,
`EventKind::Scored { score_sigma }` — so this was the one place the
crate used a third meaning of "sigma" for it. Its own doc had to
disambiguate itself: "`sigma` overrides `HistoryBuilder::score_sigma`".
`scores_with_noise(scores, score_sigma)` on both `Outcome` and
`EventBuilder`.
`predict_quality` predicts nothing. Its own doc says it answers "is this
matchup *fair*", not "what will happen", and the `predict_*` family is
otherwise exactly the methods returning a probability or a distribution
over outcomes. `History::quality` also makes the free/method pair
consistent: free `quality` pairs with `History::quality` the way free
`expected_information_gain` already pairs with
`History::expected_information_gain`. The rule that was already being
followed and never stated — a free function scores a hypothetical from
explicit parameters, the same-named method asks it against the fit — is
now written on the method.
Closes #75. Refs #78 (part 4).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
254 lines
9.1 KiB
Rust
254 lines
9.1 KiB
Rust
//! Outcome of a match.
|
|
//!
|
|
//! `Ranked(ranks)` for ordinal results; `Scored { scores, score_sigma }` for
|
|
//! continuous per-team scores (engages `MarginFactor` in the engine).
|
|
|
|
use smallvec::SmallVec;
|
|
|
|
/// Final outcome of a match.
|
|
///
|
|
/// `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, 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.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
#[non_exhaustive]
|
|
#[must_use]
|
|
pub enum Outcome {
|
|
/// An ordinal finish: one rank per team, in the order the teams were given.
|
|
///
|
|
/// Lower is better, `0` is first, and equal values are a tie between those
|
|
/// teams — which needs `p_draw > 0`, or ingestion rejects the event with
|
|
/// [`InferenceError::TieWithoutDrawProbability`](crate::InferenceError::TieWithoutDrawProbability).
|
|
///
|
|
/// Only the ordering and the equalities are used. Ranks need not be dense
|
|
/// or start at zero: inference sorts the teams and compares rank-adjacent
|
|
/// pairs against a margin set by `p_draw`, so `[0, 1, 2]` and `[0, 5, 90]`
|
|
/// are the same observation. A gap does not mean a bigger win — use
|
|
/// `Scored` when the size of the difference is evidence.
|
|
Ranked(SmallVec<[u32; 4]>),
|
|
/// A continuous finish: one score per team, higher is better.
|
|
///
|
|
/// 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 `score_sigma`,
|
|
/// so
|
|
/// beating a team by ten says more than beating them by one.
|
|
#[non_exhaustive]
|
|
Scored {
|
|
/// Per-team scores, in the order the teams were given; higher is
|
|
/// better. Must have one entry per team, and every entry finite.
|
|
scores: SmallVec<[f64; 4]>,
|
|
/// Per-event noise override. `None` means inherit
|
|
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
|
|
score_sigma: Option<f64>,
|
|
},
|
|
}
|
|
|
|
impl Outcome {
|
|
/// `n`-team outcome where team `winner` won and everyone else tied for last.
|
|
///
|
|
/// Note this ties every loser, so for `n >= 3` it needs a positive
|
|
/// `p_draw` — see `InferenceError::TieWithoutDrawProbability`.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if `winner >= n`. Use [`Outcome::try_winner`] when the index
|
|
/// comes from data rather than a literal.
|
|
///
|
|
/// This is the one constructor here that validates, and deliberately so.
|
|
/// Its siblings build freely and let ingestion reject what it cannot use,
|
|
/// which works because a malformed rank vector stays recognisable. An
|
|
/// out-of-range winner does not: `winner(5, 2)` would produce ranks
|
|
/// `[1, 1]`, an all-tied draw that ingestion accepts without complaint when
|
|
/// `p_draw > 0`. Asking "team 5 won" and silently getting "everyone drew"
|
|
/// is exactly the class of quiet wrong answer this crate keeps removing, so
|
|
/// the check happens here where the mistake is.
|
|
pub fn winner(winner: u32, n: u32) -> Self {
|
|
Self::try_winner(winner, n)
|
|
.unwrap_or_else(|_| panic!("winner index {winner} out of range 0..{n}"))
|
|
}
|
|
|
|
/// `n`-team outcome where team `winner` won, or an error if `winner` is not
|
|
/// a valid team index.
|
|
///
|
|
/// The fallible form of [`Outcome::winner`], for when the index is computed
|
|
/// or parsed rather than written literally.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// `InvalidParameter` if `winner >= n`.
|
|
pub fn try_winner(winner: u32, n: u32) -> Result<Self, crate::InferenceError> {
|
|
if winner >= n {
|
|
return Err(crate::InferenceError::InvalidParameter {
|
|
name: "winner",
|
|
value: f64::from(winner),
|
|
});
|
|
}
|
|
let ranks: SmallVec<[u32; 4]> = (0..n).map(|i| if i == winner { 0 } else { 1 }).collect();
|
|
Ok(Self::Ranked(ranks))
|
|
}
|
|
|
|
/// All `n` teams tied.
|
|
pub fn draw(n: u32) -> Self {
|
|
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
|
|
}
|
|
|
|
/// Explicit per-team ranking.
|
|
pub fn ranking<I: IntoIterator<Item = u32>>(ranks: I) -> Self {
|
|
Self::Ranked(ranks.into_iter().collect())
|
|
}
|
|
|
|
/// Explicit per-team continuous scores; higher = better.
|
|
/// Inherits `HistoryBuilder::score_sigma` for the noise model.
|
|
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
|
|
Self::Scored {
|
|
scores: scores.into_iter().collect(),
|
|
score_sigma: None,
|
|
}
|
|
}
|
|
|
|
/// Explicit per-team continuous scores with a per-event noise override.
|
|
///
|
|
/// The noise is on the *observed score margin*, in the units of the scores
|
|
/// themselves — it is not a skill sigma, which is what the old name
|
|
/// `scores_with_sigma` read as. It overrides `HistoryBuilder::score_sigma`
|
|
/// for this event only.
|
|
///
|
|
/// `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_noise<I: IntoIterator<Item = f64>>(scores: I, score_sigma: f64) -> Self {
|
|
Self::Scored {
|
|
scores: scores.into_iter().collect(),
|
|
score_sigma: Some(score_sigma),
|
|
}
|
|
}
|
|
|
|
/// How many teams this outcome describes — the number of ranks, or of
|
|
/// scores.
|
|
///
|
|
/// Ingestion checks it against the event's own team list and rejects a
|
|
/// disagreement with `MismatchedShape`, so this is the cheap way to check
|
|
/// an outcome built elsewhere before committing the event.
|
|
#[must_use]
|
|
pub fn team_count(&self) -> usize {
|
|
match self {
|
|
Self::Ranked(r) => r.len(),
|
|
Self::Scored { scores, .. } => scores.len(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn as_ranks(&self) -> Option<&[u32]> {
|
|
match self {
|
|
Self::Ranked(r) => Some(r),
|
|
Self::Scored { .. } => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn as_scores(&self) -> Option<&[f64]> {
|
|
match self {
|
|
Self::Scored { scores, .. } => Some(scores),
|
|
Self::Ranked(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn winner_two_teams() {
|
|
let o = Outcome::winner(0, 2);
|
|
assert_eq!(o.as_ranks(), Some(&[0u32, 1][..]));
|
|
assert_eq!(o.team_count(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn winner_three_teams_second_wins() {
|
|
let o = Outcome::winner(1, 3);
|
|
assert_eq!(o.as_ranks(), Some(&[1u32, 0, 1][..]));
|
|
}
|
|
|
|
#[test]
|
|
fn draw_three_teams() {
|
|
let o = Outcome::draw(3);
|
|
assert_eq!(o.as_ranks(), Some(&[0u32, 0, 0][..]));
|
|
}
|
|
|
|
#[test]
|
|
fn ranking_from_iter() {
|
|
let o = Outcome::ranking([2, 0, 1]);
|
|
assert_eq!(o.as_ranks(), Some(&[2u32, 0, 1][..]));
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "winner index 2 out of range")]
|
|
fn winner_out_of_range_panics() {
|
|
let _ = Outcome::winner(2, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn scored_two_teams() {
|
|
let o = Outcome::scores([10.0, 4.0]);
|
|
assert_eq!(o.team_count(), 2);
|
|
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
|
|
assert_eq!(o.as_ranks(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn scored_team_count_matches_input() {
|
|
let o = Outcome::scores([3.0, 1.0, 2.0, 0.0]);
|
|
assert_eq!(o.team_count(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn ranked_as_scores_returns_none() {
|
|
let o = Outcome::winner(0, 2);
|
|
assert!(o.as_scores().is_none());
|
|
assert!(o.as_ranks().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn scores_with_sigma_round_trips() {
|
|
let o = Outcome::scores_with_noise([10.0, 4.0], 0.5);
|
|
assert_eq!(o.team_count(), 2);
|
|
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
|
|
}
|
|
|
|
#[test]
|
|
fn scores_constructor_leaves_sigma_unset() {
|
|
let o = Outcome::scores([3.0, 1.0]);
|
|
match o {
|
|
Outcome::Scored { score_sigma, .. } => assert!(score_sigma.is_none()),
|
|
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn scores_with_sigma_sets_sigma_some() {
|
|
let o = Outcome::scores_with_noise([3.0, 1.0], 2.0);
|
|
match o {
|
|
Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(2.0)),
|
|
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
|
}
|
|
}
|
|
|
|
/// Construction accepts any sigma; the value is validated at ingestion so
|
|
/// callers receive an `InferenceError` rather than a panic. See
|
|
/// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`.
|
|
#[test]
|
|
fn scores_with_sigma_defers_validation_to_ingestion() {
|
|
let o = Outcome::scores_with_noise([3.0, 1.0], 0.0);
|
|
match o {
|
|
Outcome::Scored { score_sigma, .. } => assert_eq!(score_sigma, Some(0.0)),
|
|
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
|
}
|
|
}
|
|
}
|