Closes #33. The field was public, hardcoded to 0 at both construction sites, and had no route to ever being non-zero. It was added in T3 as the reporting surface for dirty-bit slice skipping. That feature is #4, closed as unworkable — the ceiling measured at ~6% against a projected 5-50x, on top of three independent soundness blockers. #32, which reattributed the cost to ingestion, is closed too: the re-convergence is necessary work rather than waste, because appending one event genuinely moves the involved competitors ~1.2 sigma across their whole history. Nothing left would ever populate it. This is the same defect class as #19, where this arc started: a public surface that looks implemented, reports a plausible value, and is inert. A caller reading `slices_skipped: 0` reasonably concludes "no slices were skipped this run", not "this feature does not exist". Removed rather than documented as reserved. Its only value was as a hook for a plan that no longer exists, and keeping it preserves the shape of that plan. Breaking, but ConvergenceReport is returned rather than constructed by callers, so the only breakage is code reading a constant zero. Also added a test asserting every remaining field carries real information — iterations non-zero, final_step finite, log_evidence a finite negative log probability, and per_iteration_time holding one duration per iteration. Mutation-proved: pinning per_iteration_time to an empty SmallVec fails it. The next always-constant member now has to survive an assertion rather than just a reviewer's attention, which is the actual lesson of #19 and #33. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
293 lines
7.5 KiB
Rust
293 lines
7.5 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();
|
|
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());
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|