`gamma` enters only as `gamma * gamma`, so the sign was squared away: measured against the old public-field form, `ConstantDrift(-0.0833)` produced results bit identical to `ConstantDrift(0.0833)`. The sign was neither rejected nor honoured — it vanished. It could not be checked while the field was a public tuple position, because there was nothing to intercept. Validating inside `variance_for_elapsed` would have been worse: it runs in the sweep, so a construction-time mistake would panic mid-inference, and `Gaussian::from_ms` is a worked example of why that is the wrong place — rejecting NaN there turned the NonFiniteResult reporting path into a crash. So `ConstantDrift::new` is the only way in and it checks, with `gamma()` to read the value back. 129 call sites rewritten across src, tests, benches, examples and the README. The dated plan and spec documents under docs/superpowers are left alone: they record what was built at the time, and rewriting them would falsify that. tests/constructor_validation.rs is the more valuable half. This defect class was closed three times in one session and reopened twice, because each fix validated the layer it had just touched and inferred the rest — `HistoryBuilder`, then `Game`'s own entry points, then the constructors beneath both. A per-site fix cannot notice the site nobody thought of, so that file enumerates every public entry point taking a magnitude and asserts each refuses negative and non-finite values. It found an eleventh defect on its first run: `HistoryBuilder::score_sigma` accepted infinity, because `inf > 0.0` is true and the assert only tested positivity. Fixed, and its own `should_panic` message updated to match. `Gaussian::from_ms` is deliberately exempt from the non-finite half, for the reason above: a broken fit produces a NaN sigma legitimately and `converge` must be allowed to report it. The convergence-level drift-variance check stays and is now tested through a custom `Drift` implementation, since `ConstantDrift` can no longer reach it. That check is the only thing standing between a third-party `Drift` and a NaN fit. BREAKING CHANGE: `ConstantDrift`'s field is private. Replace `ConstantDrift(x)` with `ConstantDrift::new(x)`, and `drift().0` with `drift().gamma()`. `HistoryBuilder::score_sigma` now rejects infinity. Closes #65 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
158 lines
5.1 KiB
Rust
158 lines
5.1 KiB
Rust
//! 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<i64, ConstantDrift, NullObserver, String>;
|
|
|
|
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<String> = (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<String> = 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]
|
|
);
|
|
}
|