//! Converging, appending, and converging again must reach the same fixed point //! as converging once over the whole event set. //! //! `tests/ingestion_equivalence.rs` covers a different question: it varies how //! events are *batched* but converges only at the end. This file converges //! between batches, which is the path a caller takes when it fits, serves for a //! while, then ingests more. //! //! The property matters beyond ergonomics. It says `converge` reaches a fixed //! point determined by the events, ratings and configuration alone — not by the //! message state it started from. That is what makes a restored snapshot safe: //! an inexact one cannot corrupt the answer, only cost an extra sweep. See #45. use smallvec::smallvec; use trueskill_tt::{ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team}; fn tight() -> ConvergenceOptions { ConvergenceOptions { max_iter: 5_000, epsilon: 1e-12, alpha: 1.0, } } fn ev(a: &str, b: &str, time: i64) -> Event { Event { time, teams: smallvec![ Team::with_members([Member::new(a.to_string())]), Team::with_members([Member::new(b.to_string())]), ], outcome: Outcome::winner(0, 2), } } /// Ingest each chunk in turn, converging fully after every one. fn fit_in_chunks(chunks: Vec) -> Vec<(String, Gaussian)> { let mut h: History = History::builder_with_key().convergence(tight()).build(); for chunk in chunks { h.add_events(chunk).unwrap(); let report = h.converge().unwrap(); assert!( report.converged, "a chunk failed to converge, so any comparison would be measuring \ truncation rather than the fixed point; final step {:?}", report.final_step ); } let mut skills: Vec<(String, Gaussian)> = h .learning_curves() .into_iter() .map(|(k, curve)| (k, curve.last().unwrap().1)) .collect(); skills.sort_by(|a, b| a.0.cmp(&b.0)); skills } fn assert_same(a: &[(String, Gaussian)], b: &[(String, Gaussian)], what: &str) { assert_eq!(a.len(), b.len(), "{what}: competitor count differs"); for ((ka, ga), (kb, gb)) in a.iter().zip(b) { assert_eq!(ka, kb, "{what}: key order differs"); // Measured: 6.2e-13 for a later append, 8.9e-11 for an interleaved one. // The bar is well clear of both but far under anything that would let a // genuine divergence through. assert!( (ga.mu() - gb.mu()).abs() < 1e-8 && (ga.sigma() - gb.sigma()).abs() < 1e-8, "{what}: {ka} differs — one-shot mu={} sigma={}, chunked mu={} sigma={}", ga.mu(), ga.sigma(), gb.mu(), gb.sigma() ); } } type Events = Vec>; /// Two chunks of events: the first at times 0..20, the second at 100..120. fn fixture() -> (Events, Events) { let names = ["a", "b", "c", "d", "e"]; let mut seed = 7u64; let mut rnd = move || { seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17; seed }; let (mut early, mut late) = (Vec::new(), Vec::new()); for t in 0..40i64 { let i = (rnd() % 5) as usize; let mut j = (rnd() % 5) as usize; if j == i { j = (j + 1) % 5; } if t < 20 { early.push(ev(names[i], names[j], t)); } else { late.push(ev(names[i], names[j], 100 + t)); } } (early, late) } /// The ordinary case: new events are strictly later than everything fitted. #[test] fn appending_later_events_matches_a_single_fit() { let (early, late) = fixture(); let all: Vec<_> = early.iter().cloned().chain(late.iter().cloned()).collect(); assert_same( &fit_in_chunks(vec![all]), &fit_in_chunks(vec![early, late]), "append strictly later", ); } /// The case the design question suspected might be weaker: appended events /// interleave with slices that are already fitted, so the append legitimately /// revises the past. It is not weaker — Through Time revises the past on every /// converge regardless, so there is nothing special about doing it in two steps. #[test] fn appending_interleaved_events_matches_a_single_fit() { let (early, late) = fixture(); let all: Vec<_> = early.iter().cloned().chain(late.iter().cloned()).collect(); // Split by parity so the second chunk is back-dated into the first's range. let first: Vec<_> = all.iter().step_by(2).cloned().collect(); let second: Vec<_> = all.iter().skip(1).step_by(2).cloned().collect(); let together: Vec<_> = first .iter() .cloned() .chain(second.iter().cloned()) .collect(); assert_same( &fit_in_chunks(vec![together]), &fit_in_chunks(vec![first, second]), "append interleaved", ); } /// Converging an already-converged history is a no-op, which is what makes a /// restored snapshot worth having: the work is skipped rather than redone. #[test] fn re_converging_an_unchanged_history_costs_one_iteration() { let (early, late) = fixture(); let all: Vec<_> = early.into_iter().chain(late).collect(); let mut h: History = History::builder_with_key().convergence(tight()).build(); h.add_events(all).unwrap(); let first = h.converge().unwrap(); assert!(first.converged); let again = h.converge().unwrap(); assert_eq!( again.iterations, 1, "a converged history should settle immediately, not re-grind" ); assert!(again.converged); }