diff --git a/tests/reconvergence_equivalence.rs b/tests/reconvergence_equivalence.rs new file mode 100644 index 0000000..b0cab65 --- /dev/null +++ b/tests/reconvergence_equivalence.rs @@ -0,0 +1,162 @@ +//! 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() + ); + } +} + +fn fixture() -> (Vec>, Vec>) { + 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); +}