Three issues from two downstream consumers, all small, all sharing a theme: the crate had the information and would not hand it over. #44 — `UnknownKey { team: 0, member: 0 }` did not say which key. A consumer upgrading 0.1.2 -> 0.4.1 had every one of 5591 predictions return this error, fell back to a neutral 0.5, and lost its entire metadata model for a day. Nothing crashed and nothing logged; it was found by sweeping an unrelated parameter and noticing the output did not move. The 0.4.0 change that made unknown keys an error was right — the error was just too anonymous to act on. It now carries the key's `Debug` rendering, and its `Display` says what to do about it. The precondition is documented on every prediction entry point, which the reporter said would alone have saved the day. #43 — `cdf` was `pub(crate)`, so a consumer asking "is this competitor below the cutoff" approximated it with a `mu + z * sigma` band and had no way to say what confidence any `z` bought. Adds `Gaussian::probability_below` / `probability_above`. The second is separate on purpose: `1 - cdf` collapses to exactly zero past ~8.3 sigma, and a stopping rule is evaluated precisely there. Both route through the survival function added in 0.4.1, so this is visibility rather than new numerics. #50 — `ConvergenceReport` was not `#[must_use]`, so the one signal that a fit stopped short was trivially discarded. It now is, and that immediately found 78 sites doing exactly that — including this crate's own ATP example, which was capped at 10 sweeps when the history needs 30. The example now reads the report and says so. `ITERATIONS = 30` is documented as the floor it is, with the three measurements to hand: 400 events over 100 competitors already stops there at ~7e-3 against a 1e-6 tolerance, the ATP example needs 30 at a much looser one, and a consumer's 2000-node model needs 76 to 161. BREAKING CHANGE: `InferenceError::UnknownKey` gains a `key` field, and the prediction methods now require `K: Debug` in order to fill it. Closes #43, #50. Refs #44 — its third ask, an opt-in `UnknownKeys::Skip` mode, is a live API question and deliberately not answered here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
297 lines
7.8 KiB
Rust
297 lines
7.8 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,
|
|
alpha: 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::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();
|
|
let _ = 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();
|
|
let _ = 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();
|
|
let _ = 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();
|
|
let _ = 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();
|
|
let _ = h.converge().unwrap();
|
|
|
|
let q = h.predict_quality(&[&[&"a"], &[&"b"]]).unwrap();
|
|
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();
|
|
let _ = h.converge().unwrap();
|
|
|
|
let p = h.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
|
|
let wins = p.win_probabilities();
|
|
assert_eq!(wins.len(), 2);
|
|
// With p_draw == 0 there is no draw outcome, so the two win
|
|
// probabilities are the whole space.
|
|
assert!((p.total() - 1.0).abs() < 1e-9, "total = {}", p.total());
|
|
assert!((wins[0] + wins[1] - 1.0).abs() < 1e-9);
|
|
assert!(wins[0] > wins[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();
|
|
let _ = h.converge().unwrap();
|
|
|
|
let a = h.current_skill(&"alice").unwrap();
|
|
let b = h.current_skill(&"bob").unwrap();
|
|
assert!(a.mu() > b.mu());
|
|
}
|
|
|
|
/// Every field of `ConvergenceReport` must carry real information.
|
|
///
|
|
/// `slices_skipped` was public, hardcoded to `0`, and reported a plausible
|
|
/// value for a feature that never existed — the same shape as the inert
|
|
/// `online` flag in #19. It was removed in #33. This pins the remaining fields
|
|
/// so the next always-constant member has to survive an assertion rather than
|
|
/// just a reviewer's attention.
|
|
#[test]
|
|
fn every_convergence_report_field_is_populated() {
|
|
let mut h = History::builder().build();
|
|
|
|
for time in 1..=6i64 {
|
|
h.record_winner(&"a", &"b", time).unwrap();
|
|
}
|
|
|
|
let report = h.converge().unwrap();
|
|
|
|
assert!(
|
|
report.iterations > 0,
|
|
"iterations is zero on a real converge"
|
|
);
|
|
|
|
assert!(report.converged, "fixture must converge");
|
|
|
|
assert!(
|
|
report.final_step.0.is_finite() && report.final_step.1.is_finite(),
|
|
"final_step is not finite: {:?}",
|
|
report.final_step
|
|
);
|
|
|
|
assert!(
|
|
report.log_evidence.is_finite() && report.log_evidence < 0.0,
|
|
"log_evidence is not a finite negative log probability: {}",
|
|
report.log_evidence
|
|
);
|
|
|
|
assert_eq!(
|
|
report.per_iteration_time.len(),
|
|
report.iterations,
|
|
"per_iteration_time must carry one duration per iteration"
|
|
);
|
|
}
|