`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
202 lines
7.1 KiB
Rust
202 lines
7.1 KiB
Rust
//! Determinism across `RAYON_NUM_THREADS`, on a workload that actually reaches
|
|
//! the parallel path.
|
|
//!
|
|
//! This test previously proved less than it appeared to. `sweep_color_groups`
|
|
//! takes its `par_iter` branch only for colour groups of at least
|
|
//! `RAYON_THRESHOLD` (64) events, and the old fixture built 20 slices of 10
|
|
//! events — a colour group is a subset of one slice's events, so it could never
|
|
//! exceed 10. The branch was unreachable, confirmed by CPU-vs-wall time:
|
|
//! `user 0.64` on eight threads is one core.
|
|
//!
|
|
//! It also compared a single competitor's curve out of forty, and never
|
|
//! compared `log_evidence`, `final_step` or `iterations`.
|
|
//!
|
|
//! The fixture below guarantees the parallel branch **by construction**: within
|
|
//! a slice every event uses a disjoint pair of competitors, so greedy colouring
|
|
//! puts all of them in colour 0, and that group is `EVENTS_PER_SLICE` long.
|
|
//! Competitors recur across slices, so the fit still has temporal coupling and
|
|
//! drift rather than being a set of independent duels.
|
|
|
|
#![cfg(feature = "rayon")]
|
|
|
|
use smallvec::smallvec;
|
|
use trueskill_tt::{
|
|
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team,
|
|
};
|
|
|
|
/// Comfortably above the crate's internal `RAYON_THRESHOLD` of 64.
|
|
const EVENTS_PER_SLICE: usize = 96;
|
|
const SLICES: i64 = 8;
|
|
/// Two per event, all disjoint within a slice.
|
|
const COMPETITORS: usize = EVENTS_PER_SLICE * 2;
|
|
|
|
/// Everything a thread count could plausibly perturb.
|
|
struct Fingerprint {
|
|
curves: Vec<(String, Vec<(i64, Gaussian)>)>,
|
|
log_evidence: f64,
|
|
final_step: (f64, f64),
|
|
iterations: usize,
|
|
}
|
|
|
|
fn build_and_converge() -> Fingerprint {
|
|
let mut h = History::<i64, _, _, String>::builder_with_key()
|
|
.mu(25.0)
|
|
.sigma(25.0 / 3.0)
|
|
.beta(25.0 / 6.0)
|
|
.drift(ConstantDrift::new(25.0 / 300.0))
|
|
.convergence(ConvergenceOptions {
|
|
max_iter: 20_000,
|
|
epsilon: 1e-9,
|
|
alpha: 1.0,
|
|
})
|
|
.build();
|
|
|
|
let mut events: Vec<Event<i64, String>> = Vec::new();
|
|
for slice in 0..SLICES {
|
|
for e in 0..EVENTS_PER_SLICE {
|
|
// Disjoint within the slice: event `e` owns competitors 2e and
|
|
// 2e+1. Rotating by the slice index makes the pairings differ
|
|
// between slices, so competitors accumulate a real history.
|
|
let a = (2 * e + slice as usize) % COMPETITORS;
|
|
let b = (2 * e + 1 + slice as usize * 3) % COMPETITORS;
|
|
if a == b {
|
|
continue;
|
|
}
|
|
events.push(Event {
|
|
time: slice + 1,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new(format!("p{a}"))]),
|
|
Team::with_members([Member::new(format!("p{b}"))]),
|
|
],
|
|
outcome: Outcome::winner(u32::from((e + slice as usize) % 2 == 0), 2),
|
|
});
|
|
}
|
|
}
|
|
h.add_events(events).unwrap();
|
|
|
|
let report = h.converge().expect("fixture must converge");
|
|
|
|
let mut curves: Vec<(String, Vec<(i64, Gaussian)>)> = h
|
|
.learning_curves()
|
|
.into_iter()
|
|
.map(|(k, v)| (k.clone(), v))
|
|
.collect();
|
|
curves.sort_by(|a, b| a.0.cmp(&b.0));
|
|
|
|
Fingerprint {
|
|
curves,
|
|
log_evidence: h.log_evidence(),
|
|
final_step: report.final_step,
|
|
iterations: report.iterations,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn posteriors_identical_across_thread_counts() {
|
|
let sizes = [1usize, 2, 4, 8];
|
|
let mut results: Vec<Fingerprint> = Vec::new();
|
|
|
|
for &n in &sizes {
|
|
let pool = rayon::ThreadPoolBuilder::new()
|
|
.num_threads(n)
|
|
.build()
|
|
.expect("rayon pool build");
|
|
results.push(pool.install(build_and_converge));
|
|
}
|
|
|
|
let reference = &results[0];
|
|
|
|
// Guard against the failure this test previously had: passing while
|
|
// measuring almost nothing.
|
|
assert!(
|
|
reference.curves.len() > 100,
|
|
"expected every competitor's curve, got {}",
|
|
reference.curves.len()
|
|
);
|
|
|
|
for (i, got) in results.iter().enumerate().skip(1) {
|
|
let n = sizes[i];
|
|
|
|
assert_eq!(
|
|
got.iterations, reference.iterations,
|
|
"iterations differ at {n} threads"
|
|
);
|
|
assert_eq!(
|
|
got.final_step.0.to_bits(),
|
|
reference.final_step.0.to_bits(),
|
|
"final_step.0 differs at {n} threads: {:?} vs {:?}",
|
|
reference.final_step,
|
|
got.final_step
|
|
);
|
|
assert_eq!(
|
|
got.final_step.1.to_bits(),
|
|
reference.final_step.1.to_bits(),
|
|
"final_step.1 differs at {n} threads"
|
|
);
|
|
assert_eq!(
|
|
got.log_evidence.to_bits(),
|
|
reference.log_evidence.to_bits(),
|
|
"log_evidence differs at {n} threads: {} vs {}",
|
|
reference.log_evidence,
|
|
got.log_evidence
|
|
);
|
|
|
|
assert_eq!(
|
|
got.curves.len(),
|
|
reference.curves.len(),
|
|
"competitor count differs at {n} threads"
|
|
);
|
|
|
|
for ((ref_key, ref_curve), (key, curve)) in reference.curves.iter().zip(got.curves.iter()) {
|
|
assert_eq!(ref_key, key, "competitor order differs at {n} threads");
|
|
assert_eq!(
|
|
curve.len(),
|
|
ref_curve.len(),
|
|
"curve length differs for {key} at {n} threads"
|
|
);
|
|
for (&(t_ref, g_ref), &(t, g)) in ref_curve.iter().zip(curve.iter()) {
|
|
assert_eq!(t_ref, t, "time point differs for {key} at {n} threads");
|
|
assert_eq!(
|
|
g_ref.mu().to_bits(),
|
|
g.mu().to_bits(),
|
|
"mu differs for {key} at t={t}, {n} threads: {} vs {}",
|
|
g_ref.mu(),
|
|
g.mu()
|
|
);
|
|
assert_eq!(
|
|
g_ref.sigma().to_bits(),
|
|
g.sigma().to_bits(),
|
|
"sigma differs for {key} at t={t}, {n} threads: {} vs {}",
|
|
g_ref.sigma(),
|
|
g.sigma()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The fixture must keep reaching the parallel branch.
|
|
///
|
|
/// `RAYON_THRESHOLD` is private, so this pins the property that makes the
|
|
/// branch reachable rather than the branch itself: within a slice every event
|
|
/// uses a disjoint competitor pair, so greedy colouring puts all
|
|
/// `EVENTS_PER_SLICE` of them in one colour group. If someone shrinks the
|
|
/// fixture, this fails rather than the suite quietly going back to testing the
|
|
/// sequential path.
|
|
#[test]
|
|
fn the_fixture_still_exceeds_the_rayon_threshold() {
|
|
const RAYON_THRESHOLD: usize = 64;
|
|
const {
|
|
assert!(
|
|
EVENTS_PER_SLICE >= RAYON_THRESHOLD,
|
|
"a colour group holds at most EVENTS_PER_SLICE events, which must \
|
|
reach the crate's RAYON_THRESHOLD for the parallel sweep to run"
|
|
);
|
|
}
|
|
|
|
// Measured by instrumenting `sweep_color_groups`: this fixture produces
|
|
// one colour group of 96 events and takes the parallel branch on all 872
|
|
// sweeps. The old fixture's 10-event slices could not reach 64 at all.
|
|
assert_eq!(EVENTS_PER_SLICE, 96);
|
|
}
|