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>
249 lines
6.2 KiB
Rust
249 lines
6.2 KiB
Rust
//! Tests for the new T2 public API surface: typed add_events(iter) and the
|
|
//! fluent event builder (added in Task 16).
|
|
|
|
use smallvec::smallvec;
|
|
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
|
|
|
#[test]
|
|
fn add_events_bulk_via_iter() {
|
|
let mut h = History::builder()
|
|
.mu(0.0)
|
|
.sigma(2.0)
|
|
.beta(1.0)
|
|
.p_draw(0.0)
|
|
.drift(ConstantDrift(0.0))
|
|
.convergence(ConvergenceOptions {
|
|
max_iter: 30,
|
|
epsilon: 1e-6,
|
|
})
|
|
.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::winner(0, 2),
|
|
},
|
|
Event {
|
|
time: 2,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("b")]),
|
|
Team::with_members([Member::new("c")]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
},
|
|
];
|
|
|
|
h.add_events(events).unwrap();
|
|
let report = h.converge().unwrap();
|
|
assert!(report.converged);
|
|
assert!(h.lookup(&"a").is_some());
|
|
assert!(h.lookup(&"b").is_some());
|
|
assert!(h.lookup(&"c").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn add_events_draw() {
|
|
let mut h = History::builder()
|
|
.mu(25.0)
|
|
.sigma(25.0 / 3.0)
|
|
.beta(25.0 / 6.0)
|
|
.p_draw(0.25)
|
|
.drift(ConstantDrift(25.0 / 300.0))
|
|
.build();
|
|
|
|
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
|
time: 1,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("alice")]),
|
|
Team::with_members([Member::new("bob")]),
|
|
],
|
|
outcome: Outcome::draw(2),
|
|
}];
|
|
h.add_events(events).unwrap();
|
|
h.converge().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn add_events_rejects_mismatched_outcome_ranks() {
|
|
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::ranking([0, 1, 2]), // 3 ranks but 2 teams
|
|
}];
|
|
let err = h.add_events(events).unwrap_err();
|
|
assert!(matches!(err, InferenceError::MismatchedShape { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn fluent_event_builder_basic() {
|
|
let mut h = History::builder()
|
|
.mu(25.0)
|
|
.sigma(25.0 / 3.0)
|
|
.beta(25.0 / 6.0)
|
|
.p_draw(0.0)
|
|
.build();
|
|
|
|
h.event(1)
|
|
.team(["alice", "bob"])
|
|
.weights([1.0, 0.7])
|
|
.team(["carol"])
|
|
.ranking([1, 0])
|
|
.commit()
|
|
.unwrap();
|
|
|
|
let report = h.converge().unwrap();
|
|
assert!(report.converged);
|
|
assert!(h.lookup(&"alice").is_some());
|
|
assert!(h.lookup(&"bob").is_some());
|
|
assert!(h.lookup(&"carol").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn fluent_event_builder_winner_convenience() {
|
|
let mut h = History::builder()
|
|
.mu(25.0)
|
|
.sigma(25.0 / 3.0)
|
|
.beta(25.0 / 6.0)
|
|
.p_draw(0.0)
|
|
.build();
|
|
|
|
h.event(1)
|
|
.team(["alice"])
|
|
.team(["bob"])
|
|
.winner(0)
|
|
.commit()
|
|
.unwrap();
|
|
h.converge().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn fluent_event_builder_draw() {
|
|
let mut h = History::builder()
|
|
.mu(25.0)
|
|
.sigma(25.0 / 3.0)
|
|
.beta(25.0 / 6.0)
|
|
.p_draw(0.25)
|
|
.build();
|
|
|
|
h.event(1)
|
|
.team(["alice"])
|
|
.team(["bob"])
|
|
.draw()
|
|
.commit()
|
|
.unwrap();
|
|
h.converge().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn current_skill_and_learning_curve() {
|
|
use trueskill_tt::History;
|
|
let mut h = History::builder()
|
|
.mu(25.0)
|
|
.sigma(25.0 / 3.0)
|
|
.beta(25.0 / 6.0)
|
|
.p_draw(0.0)
|
|
.build();
|
|
h.record_winner(&"a", &"b", 1).unwrap();
|
|
h.record_winner(&"a", &"b", 2).unwrap();
|
|
h.converge().unwrap();
|
|
|
|
let a = h.current_skill(&"a").unwrap();
|
|
assert!(a.mu() > 25.0);
|
|
let b = h.current_skill(&"b").unwrap();
|
|
assert!(b.mu() < 25.0);
|
|
|
|
let a_curve = h.learning_curve(&"a");
|
|
assert_eq!(a_curve.len(), 2);
|
|
assert_eq!(a_curve[0].0, 1);
|
|
assert_eq!(a_curve[1].0, 2);
|
|
|
|
let all = h.learning_curves();
|
|
assert_eq!(all.len(), 2);
|
|
assert!(all.contains_key("a"));
|
|
assert!(all.contains_key("b"));
|
|
}
|
|
|
|
#[test]
|
|
fn log_evidence_total_vs_subset() {
|
|
use trueskill_tt::{ConstantDrift, History};
|
|
let mut h = History::builder()
|
|
.mu(0.0)
|
|
.sigma(6.0)
|
|
.beta(1.0)
|
|
.p_draw(0.0)
|
|
.drift(ConstantDrift(0.0))
|
|
.build();
|
|
h.record_winner(&"a", &"b", 1).unwrap();
|
|
h.record_winner(&"b", &"a", 2).unwrap();
|
|
let total = h.log_evidence();
|
|
let a_only = h.log_evidence_for(&[&"a"]);
|
|
assert!(total.is_finite());
|
|
assert!(a_only.is_finite());
|
|
}
|
|
|
|
#[test]
|
|
fn predict_quality_two_teams() {
|
|
use trueskill_tt::History;
|
|
let mut h = History::builder()
|
|
.mu(25.0)
|
|
.sigma(25.0 / 3.0)
|
|
.beta(25.0 / 6.0)
|
|
.p_draw(0.0)
|
|
.build();
|
|
h.record_winner(&"a", &"b", 1).unwrap();
|
|
h.converge().unwrap();
|
|
|
|
let q = h.predict_quality(&[&[&"a"], &[&"b"]]);
|
|
assert!(q > 0.0 && q <= 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn predict_outcome_two_teams_sums_to_one() {
|
|
use trueskill_tt::History;
|
|
let mut h = History::builder()
|
|
.mu(25.0)
|
|
.sigma(25.0 / 3.0)
|
|
.beta(25.0 / 6.0)
|
|
.p_draw(0.0)
|
|
.build();
|
|
h.record_winner(&"a", &"b", 1).unwrap();
|
|
h.converge().unwrap();
|
|
|
|
let p = h.predict_outcome(&[&[&"a"], &[&"b"]]);
|
|
assert_eq!(p.len(), 2);
|
|
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());
|
|
}
|