Two decisions taken before cutting 0.5.0. #42 — the `Schedule` trait was public API the engine never called. Its only call site was `Game::custom`, itself `#[doc(hidden)]`, and `EpsilonOrMax` was never constructed anywhere. Removing that surface showed the problem was larger than the issue described: with `custom` gone, the compiler found `Factor`, `BuiltinFactor`, `RankDiffFactor` and `TeamSumFactor` all dead too. `Game::run_chain` drives a local `DiffFactor` enum and bypasses the whole T1 abstraction — it has done since it was written. So this is not just an unused extension point but the machinery it was built on, and `CLAUDE.md` was documenting it as live architecture. Removed: `graph` module, `Schedule`, `EpsilonOrMax`, `ScheduleReport`, `Game::custom`, `Factor`, `BuiltinFactor`, `RankDiffFactor`, `TeamSumFactor`. `TruncFactor`, `MarginFactor`, `VarStore` and `VarId` stay — inference uses those. The measurement behind choosing removal over wiring is in #42: the within-game loop converges in 1 to 8 iterations against a cap of 30, so a `Residual` schedule has no headroom to reclaim, and `Damped` already shipped as `ConvergenceOptions::alpha`. #20 — `Outcome::winner` panicking on an out-of-range index. Kept, and the reasoning is now on the method. It is the only constructor here that validates, which looks inconsistent until you try deferring like its siblings: `winner(5, 2)` produces 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 the exact failure this crate keeps removing, so the check belongs where the mistake is. Adds `Outcome::try_winner` for indices that are computed or parsed rather than written literally, following the `new`/`try_new` convention. That is additive; the panicking form stays because every call site in this repo, its tests and its README passes literals, where a `?` would be noise. BREAKING CHANGE: the `graph` module and everything it exported are removed, as are `Game::custom` and `ScheduleReport`. Closes #42. Closes #20. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
223 lines
7.2 KiB
Rust
223 lines
7.2 KiB
Rust
//! 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<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.
|
|
#[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<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.
|
|
#[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<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(),
|
|
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<I: IntoIterator<Item = f64>>(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"),
|
|
}
|
|
}
|
|
}
|