T4 (MarginFactor): scored outcomes via Gaussian-margin EP evidence
Adds soft Gaussian-observation evidence on the per-pair diff variable,
enabling continuous score margins as a richer alternative to ranks.
Public API:
- `Outcome::Scored([scores])` (non-breaking enum extension under
`#[non_exhaustive]`).
- `Game::scored(teams, outcome, options)` constructor parallel to
`Game::ranked`.
- `EventBuilder::scores([...])` fluent helper.
- `HistoryBuilder::score_sigma(σ)` knob (default 1.0, validated > 0).
- `GameOptions::score_sigma`.
- `EventKind` re-exported from `lib.rs` (annotated `#[non_exhaustive]`).
- New `InferenceError::InvalidParameter { name, value }` variant.
Internals:
- `MarginFactor` (`factor/margin.rs`): Gaussian observation factor that
closes in one EP step; cavity-cached log-evidence mirrors `TruncFactor`.
- `BuiltinFactor::Margin` dispatch arm.
- `DiffFactor` enum in `game.rs` lets `Game::likelihoods` and the new
`likelihoods_scored` share the per-pair link abstraction.
- Per-event `EventKind { Ranked, Scored { score_sigma } }` routed through
`TimeSlice::add_events`, `iteration_direct`, and `log_evidence`.
Tests: 88 lib + 27 integration (4 new in `tests/scored.rs`); existing
goldens byte-identical. Bench: `benches/scored.rs` baseline ~960µs for
60 events × 20-player pool with default convergence.
Plan: docs/superpowers/plans/2026-04-27-t4-margin-factor.md
Spec item marked Done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+49
-11
@@ -1,8 +1,7 @@
|
||||
//! Outcome of a match.
|
||||
//!
|
||||
//! In T2, only `Ranked` is supported; `Scored` will be added together with
|
||||
//! `MarginFactor` in T4. The enum is `#[non_exhaustive]` so adding `Scored`
|
||||
//! is non-breaking for downstream `match` expressions.
|
||||
//! `Ranked(ranks)` for ordinal results; `Scored(scores)` for continuous
|
||||
//! per-team scores (engages `MarginFactor` in the engine).
|
||||
|
||||
use smallvec::SmallVec;
|
||||
|
||||
@@ -10,14 +9,19 @@ 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.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Outcome {
|
||||
Ranked(SmallVec<[u32; 4]>),
|
||||
Scored(SmallVec<[f64; 4]>),
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
/// `N`-team outcome where team `winner` won and everyone else tied for last.
|
||||
/// `n`-team outcome where team `winner` won and everyone else tied for last.
|
||||
///
|
||||
/// Panics if `winner >= n`.
|
||||
pub fn winner(winner: u32, n: u32) -> Self {
|
||||
@@ -36,16 +40,29 @@ impl Outcome {
|
||||
Self::Ranked(ranks.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Explicit per-team continuous scores; higher = better.
|
||||
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
|
||||
Self::Scored(scores.into_iter().collect())
|
||||
}
|
||||
|
||||
pub fn team_count(&self) -> usize {
|
||||
match self {
|
||||
Self::Ranked(r) => r.len(),
|
||||
Self::Scored(s) => s.len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn as_ranks(&self) -> &[u32] {
|
||||
pub(crate) fn as_ranks(&self) -> Option<&[u32]> {
|
||||
match self {
|
||||
Self::Ranked(r) => r,
|
||||
Self::Ranked(r) => Some(r),
|
||||
Self::Scored(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn as_scores(&self) -> Option<&[f64]> {
|
||||
match self {
|
||||
Self::Scored(s) => Some(s),
|
||||
Self::Ranked(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,26 +74,26 @@ mod tests {
|
||||
#[test]
|
||||
fn winner_two_teams() {
|
||||
let o = Outcome::winner(0, 2);
|
||||
assert_eq!(o.as_ranks(), &[0u32, 1]);
|
||||
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(), &[1u32, 0, 1]);
|
||||
assert_eq!(o.as_ranks(), Some(&[1u32, 0, 1][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_teams() {
|
||||
let o = Outcome::draw(3);
|
||||
assert_eq!(o.as_ranks(), &[0u32, 0, 0]);
|
||||
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(), &[2u32, 0, 1]);
|
||||
assert_eq!(o.as_ranks(), Some(&[2u32, 0, 1][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -84,4 +101,25 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user