diff --git a/src/history.rs b/src/history.rs index fa1bf70..5612cf5 100644 --- a/src/history.rs +++ b/src/history.rs @@ -1,4 +1,9 @@ -use std::{borrow::Borrow, collections::HashMap, hash::Hash, marker::PhantomData}; +use std::{ + borrow::Borrow, + collections::{BTreeMap, HashMap}, + hash::Hash, + marker::PhantomData, +}; use crate::{ BETA, GAMMA, Index, MU, P_DRAW, SIGMA, @@ -261,7 +266,15 @@ struct ResolvedTerms { contrast: Vec, /// Coefficients of competitors the slice has never seen, keyed by their /// rendering. Independent of everything in the slice by construction. - unseen: HashMap, + /// + /// A `BTreeMap` rather than a `HashMap`, and that is load-bearing. These + /// coefficients are summed, addition is not associative, and Rust seeds its + /// default hasher per process — so iterating a `HashMap` here made + /// `posterior_of` return different bits run to run on identical input. + /// Measured over 40 processes: two distinct sigma bit patterns, and five + /// distinct values from `expected_variance_reduction` spanning ~7 ULP. + /// Ordered iteration makes the sum reproducible. + unseen: BTreeMap, mean: f64, } @@ -1067,7 +1080,7 @@ impl, O: Observer, K: Eq + Hash + Clone> History = HashMap::new(); + let mut unseen: BTreeMap = BTreeMap::new(); let mut mean = 0.0; for (member, (key, coefficient)) in terms.iter().enumerate() { @@ -1858,7 +1871,16 @@ impl, O: Observer, K: Eq + Hash + Clone> History = priors.keys().copied().collect(); + conflict_scan.sort_unstable(); + + for agent in &conflict_scan { + let batch = priors[agent]; let held = self.declared.get(agent).copied().unwrap_or_default(); if let (Some(existing), Some(new)) = (held.prior, batch.prior) { diff --git a/tests/cross_process_determinism.rs b/tests/cross_process_determinism.rs new file mode 100644 index 0000000..da9b5ed --- /dev/null +++ b/tests/cross_process_determinism.rs @@ -0,0 +1,157 @@ +//! 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(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] + ); +}