From b46e7f068d3bcb6f9eaaae37536ee8ca7e34c239 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 8 May 2026 21:27:09 +0200 Subject: [PATCH] feat(outcome): per-event score_sigma override on Outcome::Scored Outcome::Scored shape changes from tuple to struct: { scores, sigma: Option }. New constructor scores_with_sigma sets sigma=Some(s) and debug-asserts s > 0.0; existing scores(I) constructor keeps its signature and builds with sigma=None internally. team_count, as_scores, as_ranks accessor pattern matches updated. History::add_events resolves sigma.unwrap_or(self.score_sigma) at the ingest arm, so downstream EventKind::Scored stays a plain f64 and TimeSlice / run_chain need zero changes. Breaking change to the public Outcome::Scored variant shape (acceptable in 0.1.x). Bit-equal for callers using the no-override path because the resolution falls through to self.score_sigma exactly as before. --- src/history.rs | 9 +++++-- src/outcome.rs | 72 +++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/src/history.rs b/src/history.rs index 3b08387..c6d1dd5 100644 --- a/src/history.rs +++ b/src/history.rs @@ -732,9 +732,14 @@ impl, O: Observer, K: Eq + Hash + Clone> History { + crate::Outcome::Scored { scores, sigma } => { + let resolved = sigma.unwrap_or(self.score_sigma); + debug_assert!( + resolved > 0.0, + "resolved score_sigma must be > 0.0 (got {resolved})" + ); kinds.push(EventKind::Scored { - score_sigma: self.score_sigma, + score_sigma: resolved, }); scores.to_vec() } diff --git a/src/outcome.rs b/src/outcome.rs index 917e154..51a78ac 100644 --- a/src/outcome.rs +++ b/src/outcome.rs @@ -1,7 +1,7 @@ //! Outcome of a match. //! -//! `Ranked(ranks)` for ordinal results; `Scored(scores)` for continuous -//! per-team scores (engages `MarginFactor` in the engine). +//! `Ranked(ranks)` for ordinal results; `Scored { scores, sigma }` for +//! continuous per-team scores (engages `MarginFactor` in the engine). use smallvec::SmallVec; @@ -10,14 +10,20 @@ 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)`: higher score = better. Adjacent (sorted) pairs feed -/// observed margins to `MarginFactor`. `scores.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(SmallVec<[f64; 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 { @@ -41,27 +47,42 @@ impl Outcome { } /// Explicit per-team continuous scores; higher = better. + /// Inherits `HistoryBuilder::score_sigma` for the noise model. pub fn scores>(scores: I) -> Self { - Self::Scored(scores.into_iter().collect()) + 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`; debug-asserts otherwise. + pub fn scores_with_sigma>(scores: I, sigma: f64) -> Self { + debug_assert!(sigma > 0.0, "score_sigma must be > 0.0 (got {sigma})"); + Self::Scored { + scores: scores.into_iter().collect(), + sigma: Some(sigma), + } } pub fn team_count(&self) -> usize { match self { Self::Ranked(r) => r.len(), - Self::Scored(s) => s.len(), + Self::Scored { scores, .. } => scores.len(), } } pub(crate) fn as_ranks(&self) -> Option<&[u32]> { match self { Self::Ranked(r) => Some(r), - Self::Scored(_) => None, + Self::Scored { .. } => None, } } pub(crate) fn as_scores(&self) -> Option<&[f64]> { match self { - Self::Scored(s) => Some(s), + Self::Scored { scores, .. } => Some(scores), Self::Ranked(_) => None, } } @@ -122,4 +143,35 @@ mod tests { 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"), + } + } + + #[test] + #[should_panic(expected = "score_sigma must be > 0.0")] + fn scores_with_sigma_rejects_zero() { + let _ = Outcome::scores_with_sigma([3.0, 1.0], 0.0); + } }