Files
trueskill-tt/src/outcome.rs
T
logaritmiskandClaude Opus 5 633a503900 refactor!: remove the factor-graph surface nothing used, add try_winner
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
2026-09-08 02:14:43 +02:00

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"),
}
}
}