`K` is the one type parameter people change, and it was last. Naming a
history in a struct field meant writing all four to say one thing:
struct Ladder { history: History<i64, ConstantDrift, NullObserver, String> }
struct Analysis<'h> { joint: Joint<'h, i64, ConstantDrift, NullObserver, &'static str> }
Now:
struct Ladder { history: History<String> }
struct Analysis<'h> { joint: Joint<'h> }
`History<K, T, D, O>`, all four defaulted. Bounds may reference later
parameters, so `D: Drift<T> = ConstantDrift` is legal in third position.
`Joint` gains the same defaults, so `Joint<'h, String>` spells it.
72 call sites swapped, and the reorder makes most of them shorter: 18
now read `History<String>` and the `&'static str` ones read `History`.
The two turbofished builders shrink from
`HistoryBuilder::<Untimed, _, _, String>::new()` to
`HistoryBuilder::<String, Untimed>::new()`.
`Joint` keeps `O` structurally, defaulted rather than removed. #72 notes
it never touches the observer, which is true — but it borrows the whole
`&'h History<K, T, D, O>` and calls `History::resolve_terms`, so dropping
the parameter means either a view type or moving that method off
`History`. The default already buys the entire user-visible benefit,
which was the spelling.
Refs #72.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
228 lines
7.2 KiB
Rust
228 lines
7.2 KiB
Rust
//! Ingesting the same events must give the same answer however they were
|
|
//! batched.
|
|
//!
|
|
//! The numerical goldens all ingest in a single call with one slice per
|
|
//! timestamp, so they never exercise the "append to an existing slice" path.
|
|
//! These do.
|
|
|
|
use smallvec::smallvec;
|
|
use trueskill_tt::{ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team};
|
|
|
|
/// Converge tightly: the default cap of 30 iterations leaves a residual around
|
|
/// 1e-6, which would swamp the comparison. Both paths must reach the same
|
|
/// fixed point, so drive both well past it.
|
|
fn tight() -> ConvergenceOptions {
|
|
ConvergenceOptions {
|
|
max_iter: 2_000,
|
|
epsilon: 1e-12,
|
|
..ConvergenceOptions::default()
|
|
}
|
|
}
|
|
|
|
fn event(a: &str, b: &str, time: i64) -> Event<i64, String> {
|
|
Event {
|
|
time,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new(a.to_string())]),
|
|
Team::with_members([Member::new(b.to_string())]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
}
|
|
}
|
|
|
|
/// Like [`event`], but `a` carries competitor configuration.
|
|
///
|
|
/// `prior` and `drift_scale` configure the competitor rather than the event, so
|
|
/// they are the part of ingestion most exposed to order: they are consumed once,
|
|
/// where the competitor's state is written.
|
|
fn configured_event(a: &str, b: &str, time: i64, scale: f64) -> Event<i64, String> {
|
|
Event {
|
|
time,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new(a.to_string()).with_drift_scale(scale)]),
|
|
Team::with_members([Member::new(b.to_string())]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
}
|
|
}
|
|
|
|
fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
|
|
let mut h: History<String> = History::builder()
|
|
.key_type::<String>()
|
|
.convergence(tight())
|
|
.build();
|
|
|
|
if batched {
|
|
h.add_events(events).unwrap();
|
|
} else {
|
|
for ev in events {
|
|
h.add_events(std::iter::once(ev)).unwrap();
|
|
}
|
|
}
|
|
|
|
let report = h.converge().unwrap();
|
|
assert!(
|
|
report.converged,
|
|
"fixture must converge before results can be compared; final step {:?}",
|
|
report.final_step
|
|
);
|
|
|
|
let mut skills: Vec<(String, Gaussian)> = h
|
|
.learning_curves()
|
|
.into_iter()
|
|
.map(|(key, curve)| (key, curve.last().unwrap().1))
|
|
.collect();
|
|
skills.sort_by(|a, b| a.0.cmp(&b.0));
|
|
skills
|
|
}
|
|
|
|
fn assert_same(batched: &[(String, Gaussian)], incremental: &[(String, Gaussian)], what: &str) {
|
|
assert_eq!(
|
|
batched.len(),
|
|
incremental.len(),
|
|
"{what}: competitor count differs"
|
|
);
|
|
|
|
for ((kb, gb), (ki, gi)) in batched.iter().zip(incremental.iter()) {
|
|
assert_eq!(kb, ki, "{what}: key order differs");
|
|
assert!(
|
|
(gb.mu() - gi.mu()).abs() < 1e-8 && (gb.sigma() - gi.sigma()).abs() < 1e-8,
|
|
"{what}: {kb} differs — batched mu={} sigma={}, incremental mu={} sigma={}",
|
|
gb.mu(),
|
|
gb.sigma(),
|
|
gi.mu(),
|
|
gi.sigma()
|
|
);
|
|
}
|
|
}
|
|
|
|
/// All events share one timestamp, so incremental ingestion repeatedly appends
|
|
/// to an existing slice.
|
|
#[test]
|
|
fn same_slice_incremental_matches_batched() {
|
|
let events = vec![
|
|
event("a", "b", 1),
|
|
event("c", "d", 1),
|
|
event("e", "f", 1),
|
|
event("a", "c", 1),
|
|
event("b", "e", 1),
|
|
];
|
|
|
|
let batched = converged_skills(events.clone(), true);
|
|
let incremental = converged_skills(events, false);
|
|
assert_same(&batched, &incremental, "single shared slice");
|
|
}
|
|
|
|
/// Distinct timestamps, so each append lands in a fresh slice appended after
|
|
/// the existing ones.
|
|
#[test]
|
|
fn distinct_slices_incremental_matches_batched() {
|
|
let events = vec![
|
|
event("a", "b", 1),
|
|
event("b", "c", 2),
|
|
event("c", "a", 3),
|
|
event("a", "c", 4),
|
|
];
|
|
|
|
let batched = converged_skills(events.clone(), true);
|
|
let incremental = converged_skills(events, false);
|
|
assert_same(&batched, &incremental, "distinct slices");
|
|
}
|
|
|
|
/// Several events per timestamp across several timestamps — appends to
|
|
/// existing slices interleaved with new ones.
|
|
#[test]
|
|
fn mixed_slices_incremental_matches_batched() {
|
|
let events = vec![
|
|
event("a", "b", 1),
|
|
event("c", "d", 1),
|
|
event("a", "c", 2),
|
|
event("b", "d", 2),
|
|
event("a", "d", 3),
|
|
event("b", "c", 3),
|
|
];
|
|
|
|
let batched = converged_skills(events.clone(), true);
|
|
let incremental = converged_skills(events, false);
|
|
assert_same(&batched, &incremental, "mixed slices");
|
|
}
|
|
|
|
/// Appending an event to a slice that is *not* the most recent one exercises
|
|
/// the forward refresh of every later slice.
|
|
#[test]
|
|
fn back_dated_event_matches_batched() {
|
|
let events = vec![
|
|
event("a", "b", 1),
|
|
event("b", "c", 5),
|
|
event("c", "a", 9),
|
|
// arrives last, but belongs to the middle slice
|
|
event("a", "c", 5),
|
|
];
|
|
|
|
let batched = converged_skills(events.clone(), true);
|
|
let incremental = converged_skills(events, false);
|
|
assert_same(&batched, &incremental, "back-dated event");
|
|
}
|
|
|
|
/// The invariant this file protects was only ever checked for *unconfigured*
|
|
/// competitors — every helper above built members with `Member::new`.
|
|
///
|
|
/// Configuration is the part most exposed to ordering, because it is consumed
|
|
/// once at the point the competitor's state is written rather than replayed per
|
|
/// event. These cover it.
|
|
#[test]
|
|
fn configured_competitors_are_order_independent() {
|
|
let events = vec![
|
|
configured_event("a", "b", 0, 0.0),
|
|
configured_event("a", "c", 1, 0.0),
|
|
configured_event("a", "b", 2, 0.0),
|
|
event("b", "c", 3),
|
|
];
|
|
|
|
assert_same(
|
|
&converged_skills(events.clone(), true),
|
|
&converged_skills(events, false),
|
|
"configuration repeated on every appearance",
|
|
);
|
|
}
|
|
|
|
/// Configuration supplied only on a *later* event is the case that used to be
|
|
/// silently dropped. It must now reach the same fit either way it is ingested.
|
|
#[test]
|
|
fn late_configuration_is_order_independent() {
|
|
let events = vec![
|
|
event("a", "b", 0),
|
|
configured_event("a", "c", 1, 0.0),
|
|
event("a", "b", 2),
|
|
];
|
|
|
|
assert_same(
|
|
&converged_skills(events.clone(), true),
|
|
&converged_skills(events, false),
|
|
"configuration supplied after first appearance",
|
|
);
|
|
}
|
|
|
|
/// And it must actually be doing something — an implementation that dropped
|
|
/// configuration entirely would pass both tests above.
|
|
#[test]
|
|
fn configuration_changes_the_fit_however_it_is_ingested() {
|
|
let configured = vec![
|
|
event("a", "b", 0),
|
|
configured_event("a", "c", 1, 0.0),
|
|
event("a", "b", 2),
|
|
];
|
|
let plain = vec![event("a", "b", 0), event("a", "c", 1), event("a", "b", 2)];
|
|
|
|
for batched in [true, false] {
|
|
let with = converged_skills(configured.clone(), batched);
|
|
let without = converged_skills(plain.clone(), batched);
|
|
assert!(
|
|
with.iter()
|
|
.zip(&without)
|
|
.any(|((_, x), (_, y))| (x.sigma() - y.sigma()).abs() > 1e-9),
|
|
"batched={batched}: configuration had no effect, so the order tests are vacuous"
|
|
);
|
|
}
|
|
}
|