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:
@@ -223,3 +223,26 @@ fn predict_outcome_two_teams_sums_to_one() {
|
||||
assert!((p[0] + p[1] - 1.0).abs() < 1e-9);
|
||||
assert!(p[0] > p[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluent_event_builder_scores() {
|
||||
use trueskill_tt::ConstantDrift;
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.build();
|
||||
|
||||
h.event(1)
|
||||
.team(["alice"])
|
||||
.team(["bob"])
|
||||
.scores([12.0, 4.0])
|
||||
.commit()
|
||||
.unwrap();
|
||||
h.converge().unwrap();
|
||||
|
||||
let a = h.current_skill(&"alice").unwrap();
|
||||
let b = h.current_skill(&"bob").unwrap();
|
||||
assert!(a.mu() > b.mu());
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ fn game_1v1_draw_golden() {
|
||||
Outcome::draw(2),
|
||||
&GameOptions {
|
||||
p_draw: 0.25,
|
||||
score_sigma: 1.0,
|
||||
convergence: Default::default(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -45,6 +45,7 @@ fn game_ranked_rejects_bad_p_draw() {
|
||||
Outcome::winner(0, 2),
|
||||
&GameOptions {
|
||||
p_draw: 1.5,
|
||||
score_sigma: 1.0,
|
||||
convergence: ConvergenceOptions::default(),
|
||||
},
|
||||
)
|
||||
|
||||
139
tests/scored.rs
Normal file
139
tests/scored.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
//! Integration tests for `Outcome::Scored` routing through `History::add_events`.
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
|
||||
|
||||
#[test]
|
||||
fn scored_two_team_one_event_pulls_winner_up() {
|
||||
let mut h: History = History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.score_sigma(1.0)
|
||||
.build();
|
||||
|
||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||
time: 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a")]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores([10.0, 4.0]),
|
||||
}];
|
||||
h.add_events(events).unwrap();
|
||||
|
||||
let mu_a = h.current_skill(&"a").unwrap().mu();
|
||||
let mu_b = h.current_skill(&"b").unwrap().mu();
|
||||
|
||||
assert!(
|
||||
mu_a > 0.0,
|
||||
"winner mu should be pulled up; got mu_a = {mu_a}"
|
||||
);
|
||||
assert!(
|
||||
mu_b < 0.0,
|
||||
"loser mu should be pulled down; got mu_b = {mu_b}"
|
||||
);
|
||||
assert!(
|
||||
mu_a > mu_b,
|
||||
"winner mu should exceed loser mu; got mu_a = {mu_a}, mu_b = {mu_b}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scored_zero_margin_treats_as_tie() {
|
||||
let mut h: History = History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.score_sigma(1.0)
|
||||
.build();
|
||||
|
||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||
time: 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a")]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores([5.0, 5.0]),
|
||||
}];
|
||||
h.add_events(events).unwrap();
|
||||
|
||||
let mu_a = h.current_skill(&"a").unwrap().mu();
|
||||
let mu_b = h.current_skill(&"b").unwrap().mu();
|
||||
let sigma_a = h.current_skill(&"a").unwrap().sigma();
|
||||
|
||||
// Equal scores: posterior means stay symmetric around the prior mean.
|
||||
assert!(
|
||||
(mu_a - mu_b).abs() < 1e-9,
|
||||
"equal scores should leave mu_a == mu_b; got {mu_a} vs {mu_b}"
|
||||
);
|
||||
assert!(
|
||||
mu_a.abs() < 1e-9,
|
||||
"equal scores against equal priors should leave mu near zero; got {mu_a}"
|
||||
);
|
||||
|
||||
// A zero-margin scored event still reduces uncertainty.
|
||||
assert!(
|
||||
sigma_a < 2.0,
|
||||
"expected sigma to tighten below prior 2.0; got {}",
|
||||
sigma_a
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scored_three_team_partial_order() {
|
||||
let mut h: History = History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.score_sigma(1.0)
|
||||
.build();
|
||||
|
||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||
time: 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a")]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
Team::with_members([Member::new("c")]),
|
||||
],
|
||||
outcome: Outcome::scores([9.0, 5.0, 1.0]),
|
||||
}];
|
||||
h.add_events(events).unwrap();
|
||||
|
||||
let mu_a = h.current_skill(&"a").unwrap().mu();
|
||||
let mu_b = h.current_skill(&"b").unwrap().mu();
|
||||
let mu_c = h.current_skill(&"c").unwrap().mu();
|
||||
|
||||
assert!(
|
||||
mu_a > mu_b,
|
||||
"team with highest score should rank highest; mu_a = {mu_a}, mu_b = {mu_b}"
|
||||
);
|
||||
assert!(
|
||||
mu_b > mu_c,
|
||||
"middle score should outrank lowest; mu_b = {mu_b}, mu_c = {mu_c}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scored_rejects_outcome_team_count_mismatch() {
|
||||
use trueskill_tt::InferenceError;
|
||||
|
||||
let mut h: History = History::builder().build();
|
||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||
time: 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a")]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores([10.0, 4.0, 1.0]), // 3 scores, 2 teams
|
||||
}];
|
||||
|
||||
let err = h.add_events(events).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::MismatchedShape { .. }),
|
||||
"expected MismatchedShape error, got {err:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user