//! Forward-only (filtering) estimates: what the model knew at the time, //! as opposed to the smoothed posteriors `learning_curve` reports. use smallvec::smallvec; use trueskill_tt::{ConvergenceOptions, Event, History, Member, Outcome, Team}; /// `games` one-on-one matches at successive times, won by "a" every time, /// built with the given convergence options. fn repeated_winner_with(games: i64, convergence: ConvergenceOptions) -> History { let mut history = History::builder().convergence(convergence).build(); for time in 1..=games { history .add_events([Event { time, teams: smallvec![ Team::with_members([Member::new("a")]), Team::with_members([Member::new("b")]), ], outcome: Outcome::winner(0, 2), }]) .unwrap(); } history } /// `games` one-on-one matches at successive times, won by "a" every time. /// /// This is the fixture from issue #19, where `online(true)` reported /// `games * ln(0.5)`. fn repeated_winner(games: i64) -> History { repeated_winner_with(games, ConvergenceOptions::default()) } /// The default 30-iteration cap leaves a residual around 1e-6, which would /// swamp these comparisons. Drive both sides well past the fixed point. fn tight() -> ConvergenceOptions { ConvergenceOptions { max_iter: 2_000, epsilon: 1e-12, ..ConvergenceOptions::default() } } #[test] fn filtered_evidence_sits_between_coin_flip_and_batch() { let mut history = repeated_winner(5); let _ = history.converge().unwrap(); let coin_flip = 5.0 * 0.5f64.ln(); let batch = history.log_evidence(); let filtered = history.filtered_log_evidence(); assert!( filtered > coin_flip, "filtered evidence {filtered} is at or below {coin_flip}, the all-coin-flip \ value the inert online flag reported; game one is a coin flip but games two \ through five are not" ); assert!( filtered < batch, "filtered evidence {filtered} is not below the smoothed {batch}; filtering \ scores each game on strictly less information than smoothing does" ); } #[test] fn filtered_first_point_is_less_certain_than_smoothed() { let mut history = repeated_winner(12); let _ = history.converge().unwrap(); let smoothed = history.learning_curve("a"); let filtered = history.filtered_learning_curve("a"); assert_eq!( smoothed.len(), filtered.len(), "both curves must cover the same time points" ); let (smoothed_time, first_smoothed) = smoothed[0]; let (filtered_time, first_filtered) = filtered[0]; assert_eq!(smoothed_time, filtered_time); assert!( first_filtered.sigma() > first_smoothed.sigma(), "filtered sigma {} at the first point is not above smoothed {}; the smoother \ collapses uncertainty before the first round is drawn, which is the whole \ reason this method exists", first_filtered.sigma(), first_smoothed.sigma() ); assert!( first_filtered.sigma() < trueskill_tt::SIGMA, "filtered sigma {} at the first point is not below the prior {}; one game was \ played, so some uncertainty must have been resolved", first_filtered.sigma(), trueskill_tt::SIGMA ); for pair in filtered.windows(2) { assert!( pair[1].1.mu() > pair[0].1.mu(), "filtered mu must climb at every step for a competitor who wins every \ game: t={} mu={} then t={} mu={}", pair[0].0, pair[0].1.mu(), pair[1].0, pair[1].1.mu() ); } } #[test] fn filtered_curves_plural_agrees_with_singular() { let mut history = repeated_winner(4); let _ = history.converge().unwrap(); let curves = history.filtered_learning_curves(); assert_eq!( curves["b"], history.filtered_learning_curve("b"), "the plural form must agree with the singular for the same key" ); } #[test] fn filtered_evidence_is_invariant_to_convergence() { let mut history = repeated_winner_with(6, tight()); let before = history.filtered_log_evidence(); let report = history.converge().unwrap(); assert!( report.converged, "fixture must converge: {:?}", report.final_step ); let after = history.filtered_log_evidence(); assert!( (before - after).abs() < 1e-8, "filtered evidence moved across converge(): {before} -> {after}. The pass must \ carry its own forward messages; anything reading skill.forward shows exactly \ this drift, because converge() contaminates it with backward information." ); } #[test] fn single_slice_filtered_matches_smoothed() { let mut history = History::builder().convergence(tight()).build(); history .add_events([ Event { time: 1, teams: smallvec![ Team::with_members([Member::new("a")]), Team::with_members([Member::new("b")]), ], outcome: Outcome::winner(0, 2), }, Event { time: 1, teams: smallvec![ Team::with_members([Member::new("c")]), Team::with_members([Member::new("d")]), ], outcome: Outcome::winner(0, 2), }, ]) .unwrap(); let _ = history.converge().unwrap(); let smoothed = history.learning_curve("a"); let filtered = history.filtered_learning_curve("a"); assert_eq!(smoothed.len(), 1); assert_eq!(filtered.len(), 1); assert!( (smoothed[0].1.mu() - filtered[0].1.mu()).abs() < 1e-8 && (smoothed[0].1.sigma() - filtered[0].1.sigma()).abs() < 1e-8, "one slice has no future to propagate back, so filtered and smoothed must \ agree: smoothed mu={} sigma={}, filtered mu={} sigma={}", smoothed[0].1.mu(), smoothed[0].1.sigma(), filtered[0].1.mu(), filtered[0].1.sigma() ); } #[test] fn filtered_curves_do_not_depend_on_ingestion_order() { let events = |time: i64, winner: &'static str, loser: &'static str| Event { time, teams: smallvec![ Team::with_members([Member::new(winner)]), Team::with_members([Member::new(loser)]), ], outcome: Outcome::winner(0, 2), }; let all = vec![ events(1, "a", "b"), events(1, "c", "d"), events(1, "a", "c"), events(1, "b", "d"), events(2, "a", "d"), events(2, "b", "c"), events(2, "a", "b"), ]; let mut batched = History::builder().convergence(tight()).build(); batched.add_events(all.clone()).unwrap(); let _ = batched.converge().unwrap(); let mut incremental = History::builder().convergence(tight()).build(); for event in all { incremental.add_events([event]).unwrap(); } let _ = incremental.converge().unwrap(); let from_batched = batched.filtered_learning_curve("a"); let from_incremental = incremental.filtered_learning_curve("a"); assert_eq!(from_batched.len(), from_incremental.len()); for ((time_b, gaussian_b), (time_i, gaussian_i)) in from_batched.iter().zip(from_incremental.iter()) { assert_eq!(time_b, time_i); assert!( (gaussian_b.mu() - gaussian_i.mu()).abs() < 1e-8 && (gaussian_b.sigma() - gaussian_i.sigma()).abs() < 1e-8, "at t={time_b}: batched mu={} sigma={}, incremental mu={} sigma={}", gaussian_b.mu(), gaussian_b.sigma(), gaussian_i.mu(), gaussian_i.sigma() ); } }