//! Determinism across *processes*, which an in-process test cannot see. //! //! Rust seeds its default hasher once per process, so every `HashMap` //! iteration order is fixed for a run and varies between runs. A test that //! compares results within one process therefore cannot detect a float sum //! whose order comes from a map — all its samples share one seed. //! //! That is not hypothetical. `tests/determinism.rs` compares four thread counts //! inside one process and passed throughout, while `posterior_of` was returning //! two distinct bit patterns across 40 separate runs on identical input. //! //! This re-executes the test binary and compares `f64::to_bits`. use std::{env, process::Command}; use smallvec::smallvec; use trueskill_tt::{ ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team, UnknownKeys, }; /// Set in the child so it reports instead of re-spawning. const CHILD: &str = "TSTT_DETERMINISM_CHILD"; const RUNS: usize = 40; type H = History; fn fitted() -> H { let mut h: H = History::builder_with_key() .mu(0.0) .sigma(6.0) .beta(1.0) .score_sigma(2.0) .drift(ConstantDrift::new(0.05)) .unknown_keys(UnknownKeys::Prior) .convergence(ConvergenceOptions { max_iter: 20_000, epsilon: 1e-13, alpha: 1.0, }) .build(); let mut events = Vec::new(); for t in 0..12i64 { for k in 0..6usize { let a = format!("p{}", (t as usize * 6 + k) % 10); let b = format!("p{}", (t as usize * 6 + k + 4) % 10); events.push(Event { time: t, teams: smallvec![ Team::with_members([Member::new(a)]), Team::with_members([Member::new(b)]), ], outcome: Outcome::scores([3.0, 1.0]), }); } } h.add_events(events).unwrap(); assert!(h.converge().unwrap().converged); h } /// Every quantity that could plausibly depend on iteration order, as bits. fn fingerprint() -> String { let h = fitted(); // Unknown keys with UNEQUAL but COMPARABLE coefficients, which is what // makes the sum order-sensitive. // // Equal terms sum order-independently and would make this pass vacuously. // Terms of wildly different magnitudes are no better: the small ones fall // below the running total's ULP and are absorbed whatever the order — // measured, spreading these over nine decades dropped the detection rate // to roughly one run in forty. Comparable sizes keep every term able to // change the last bits. let ghosts: Vec = (0..24).map(|i| format!("ghost{i}")).collect(); let mut terms: Vec<(&String, f64)> = ghosts .iter() .enumerate() .map(|(i, k)| (k, 1.0 + i as f64 * 0.37)) .collect(); let known = "p0".to_string(); terms.push((&known, -1.0)); let posterior = h.posterior_of(&terms).unwrap(); let a = "p0".to_string(); let b = "p1".to_string(); let target = [(&a, 1.0), (&b, -1.0)]; let teams: [&[&String]; 2] = [&[&a], &[&b]]; let evr = h.expected_variance_reduction(&teams, &target).unwrap(); let curves = h.learning_curves(); let mut curve_bits: u64 = 0; let mut keys: Vec<&String> = curves.keys().collect(); keys.sort(); for key in keys { for (t, g) in &curves[key] { curve_bits ^= (*t as u64).rotate_left(17) ^ g.mu().to_bits().rotate_left(31) ^ g.sigma().to_bits(); } } format!( "post={:016x} evr={:016x} le={:016x} curves={curve_bits:016x}", posterior.sigma().to_bits(), evr.to_bits(), h.log_evidence().to_bits(), ) } #[test] fn results_are_identical_across_processes() { if env::var(CHILD).is_ok() { println!("FINGERPRINT {}", fingerprint()); return; } let exe = env::current_exe().expect("current exe"); let mut seen: Vec = Vec::new(); for run in 0..RUNS { let out = Command::new(&exe) .args([ "results_are_identical_across_processes", "--exact", "--nocapture", ]) .env(CHILD, "1") .output() .expect("spawn child"); assert!( out.status.success(), "child {run} failed: {}", String::from_utf8_lossy(&out.stderr) ); let stdout = String::from_utf8_lossy(&out.stdout); let line = stdout .lines() .find_map(|l| l.strip_prefix("FINGERPRINT ")) .unwrap_or_else(|| panic!("child {run} printed no fingerprint:\n{stdout}")) .to_string(); seen.push(line); } let first = &seen[0]; let differing: Vec<&String> = seen.iter().filter(|s| *s != first).collect(); assert!( differing.is_empty(), "results differ across processes on identical input.\n {} of {RUNS} runs differed\n \ first: {first}\n differing: {}", differing.len(), differing[0] ); }