//! Outcome of a match. //! //! `Ranked(ranks)` for ordinal results; `Scored { scores, 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, 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] pub enum Outcome { Ranked(SmallVec<[u32; 4]>), Scored { scores: SmallVec<[f64; 4]>, /// Per-event noise override. `None` means inherit /// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`. sigma: Option, }, } 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. #[must_use] 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 { 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. #[must_use] pub fn draw(n: u32) -> Self { Self::Ranked(SmallVec::from_vec(vec![0; n as usize])) } /// Explicit per-team ranking. pub fn ranking>(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>(scores: I) -> Self { Self::Scored { scores: scores.into_iter().collect(), 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 /// `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 { Self::Scored { scores: scores.into_iter().collect(), sigma: Some(sigma), } } #[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_sigma([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 { scores: _, sigma } => assert!(sigma.is_none()), Outcome::Ranked(_) => panic!("expected Scored variant"), } } #[test] 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::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_sigma([3.0, 1.0], 0.0); match o { Outcome::Scored { sigma, .. } => assert_eq!(sigma, Some(0.0)), Outcome::Ranked(_) => panic!("expected Scored variant"), } } }