From cbf652eb1d89824d8b32449e785bb322324e1ddd Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 24 Apr 2026 13:59:33 +0200 Subject: [PATCH] test: assert bit-identical posteriors across RAYON_NUM_THREADS tests/determinism.rs runs the same deterministic 200-event history at thread counts {1, 2, 4, 8} via rayon::ThreadPoolBuilder::install and asserts every (time, posterior) pair has bit-identical mu and sigma across all configurations. Cfg-gated to the rayon feature; no-op under --features approx alone. Verifies the T3 determinism invariant that the ordered-reduce strategy (per-slice parallel, sequential sum) produces thread-count- independent results. Part of T3. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/determinism.rs | 100 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/determinism.rs diff --git a/tests/determinism.rs b/tests/determinism.rs new file mode 100644 index 0000000..2f336d2 --- /dev/null +++ b/tests/determinism.rs @@ -0,0 +1,100 @@ +//! Determinism tests: identical posteriors across RAYON_NUM_THREADS +//! values. Only compiled with the `rayon` feature. + +#![cfg(feature = "rayon")] + +use smallvec::smallvec; +use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team}; + +/// Build a deterministic workload using a simple LCG (no external rand crate). +fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> { + let mut h = History::::builder_with_key() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .drift(ConstantDrift(25.0 / 300.0)) + .convergence(ConvergenceOptions { + max_iter: 30, + epsilon: 1e-6, + }) + .build(); + + // LCG for deterministic pseudo-random ints. + let mut rng = seed; + let mut next = || { + rng = rng + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + rng + }; + + let mut events: Vec> = Vec::with_capacity(200); + for ev_i in 0..200 { + let a = (next() % 40) as usize; + let mut b = (next() % 40) as usize; + while b == a { + b = (next() % 40) as usize; + } + // ~10 events per slice so color groups have material parallelism. + events.push(Event { + time: (ev_i as i64 / 10) + 1, + teams: smallvec![ + Team::with_members([Member::new(format!("p{a}"))]), + Team::with_members([Member::new(format!("p{b}"))]), + ], + outcome: Outcome::winner((next() % 2) as u32, 2), + }); + } + h.add_events(events).unwrap(); + h.converge().unwrap(); + // Sample one competitor's curve for the comparison. + h.learning_curve("p0") +} + +#[test] +fn posteriors_identical_across_thread_counts() { + let sizes = [1usize, 2, 4, 8]; + let mut results: Vec> = Vec::new(); + for &n in &sizes { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(n) + .build() + .expect("rayon pool build"); + let curve = pool.install(|| build_and_converge(42)); + results.push(curve); + } + + let reference = &results[0]; + for (i, curve) in results.iter().enumerate().skip(1) { + assert_eq!( + curve.len(), + reference.len(), + "curve length differs at {n} threads", + n = sizes[i], + ); + for (j, (&(t_ref, g_ref), &(t, g))) in reference.iter().zip(curve.iter()).enumerate() { + assert_eq!( + t_ref, + t, + "time point {j} differs at {n} threads: ref={t_ref} vs got={t}", + n = sizes[i], + ); + assert_eq!( + g_ref.mu().to_bits(), + g.mu().to_bits(), + "mu bits differ at {n} threads, time {t}: ref={ref_mu} got={got_mu}", + n = sizes[i], + ref_mu = g_ref.mu(), + got_mu = g.mu(), + ); + assert_eq!( + g_ref.sigma().to_bits(), + g.sigma().to_bits(), + "sigma bits differ at {n} threads, time {t}: ref={ref_sigma} got={got_sigma}", + n = sizes[i], + ref_sigma = g_ref.sigma(), + got_sigma = g.sigma(), + ); + } + } +}