Two accessors answered a question about a key the history had never seen with a well-formed value indistinguishable from a real answer. `log_evidence_for` filter_map'd unknown keys away. An empty target list means "no restriction" downstream, so a list of *entirely* unknown keys returned the whole-history evidence: measured on a two-cohort fixture, `log_evidence_for(["typo"])` returned exactly `log_evidence()`. On the one workload it is documented for — leave-one-out cross-validation — that is the un-held-out score, a plausible number that silently invalidates the comparison it was computed for. It now returns `Err(UnknownKey)` naming the offending position. `learning_curve` and `filtered_learning_curve` returned an empty `Vec` both for a typo'd key and for a competitor who is registered but has not played yet. They now return `Option`, so `None` is "never heard of it" and `Some(vec![])` is "known, no appearances". Tests carry a control case in each direction, so they cannot pass by everything returning the same thing. Closes #66, closes #70. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
255 lines
7.8 KiB
Rust
255 lines
7.8 KiB
Rust
//! Forward-only (filtering) estimates: what the model knew at the time,
|
|
//! as opposed to the smoothed posteriors `learning_curve` reports.
|
|
|
|
use smallvec::smallvec;
|
|
use trueskill_tt::{ConvergenceOptions, Event, History, Member, Outcome, Team};
|
|
|
|
/// `games` one-on-one matches at successive times, won by "a" every time,
|
|
/// built with the given convergence options.
|
|
fn repeated_winner_with(games: i64, convergence: ConvergenceOptions) -> History {
|
|
let mut history = History::builder().convergence(convergence).build();
|
|
|
|
for time in 1..=games {
|
|
history
|
|
.add_events([Event {
|
|
time,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("a")]),
|
|
Team::with_members([Member::new("b")]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
}])
|
|
.unwrap();
|
|
}
|
|
|
|
history
|
|
}
|
|
|
|
/// `games` one-on-one matches at successive times, won by "a" every time.
|
|
///
|
|
/// This is the fixture from issue #19, where `online(true)` reported
|
|
/// `games * ln(0.5)`.
|
|
fn repeated_winner(games: i64) -> History {
|
|
repeated_winner_with(games, ConvergenceOptions::default())
|
|
}
|
|
|
|
/// The default 30-iteration cap leaves a residual around 1e-6, which would
|
|
/// swamp these comparisons. Drive both sides well past the fixed point.
|
|
fn tight() -> ConvergenceOptions {
|
|
ConvergenceOptions {
|
|
max_iter: 2_000,
|
|
epsilon: 1e-12,
|
|
..ConvergenceOptions::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn filtered_evidence_sits_between_coin_flip_and_batch() {
|
|
let mut history = repeated_winner(5);
|
|
|
|
let _ = history.converge().unwrap();
|
|
|
|
let coin_flip = 5.0 * 0.5f64.ln();
|
|
let batch = history.log_evidence();
|
|
let filtered = history.filtered_log_evidence();
|
|
|
|
assert!(
|
|
filtered > coin_flip,
|
|
"filtered evidence {filtered} is at or below {coin_flip}, the all-coin-flip \
|
|
value the inert online flag reported; game one is a coin flip but games two \
|
|
through five are not"
|
|
);
|
|
|
|
assert!(
|
|
filtered < batch,
|
|
"filtered evidence {filtered} is not below the smoothed {batch}; filtering \
|
|
scores each game on strictly less information than smoothing does"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn filtered_first_point_is_less_certain_than_smoothed() {
|
|
let mut history = repeated_winner(12);
|
|
|
|
let _ = history.converge().unwrap();
|
|
|
|
let smoothed = history.learning_curve("a").unwrap();
|
|
let filtered = history.filtered_learning_curve("a").unwrap();
|
|
|
|
assert_eq!(
|
|
smoothed.len(),
|
|
filtered.len(),
|
|
"both curves must cover the same time points"
|
|
);
|
|
|
|
let (smoothed_time, first_smoothed) = smoothed[0];
|
|
let (filtered_time, first_filtered) = filtered[0];
|
|
|
|
assert_eq!(smoothed_time, filtered_time);
|
|
|
|
assert!(
|
|
first_filtered.sigma() > first_smoothed.sigma(),
|
|
"filtered sigma {} at the first point is not above smoothed {}; the smoother \
|
|
collapses uncertainty before the first round is drawn, which is the whole \
|
|
reason this method exists",
|
|
first_filtered.sigma(),
|
|
first_smoothed.sigma()
|
|
);
|
|
|
|
assert!(
|
|
first_filtered.sigma() < trueskill_tt::SIGMA,
|
|
"filtered sigma {} at the first point is not below the prior {}; one game was \
|
|
played, so some uncertainty must have been resolved",
|
|
first_filtered.sigma(),
|
|
trueskill_tt::SIGMA
|
|
);
|
|
|
|
for pair in filtered.windows(2) {
|
|
assert!(
|
|
pair[1].1.mu() > pair[0].1.mu(),
|
|
"filtered mu must climb at every step for a competitor who wins every \
|
|
game: t={} mu={} then t={} mu={}",
|
|
pair[0].0,
|
|
pair[0].1.mu(),
|
|
pair[1].0,
|
|
pair[1].1.mu()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn filtered_curves_plural_agrees_with_singular() {
|
|
let mut history = repeated_winner(4);
|
|
|
|
let _ = history.converge().unwrap();
|
|
|
|
let curves = history.filtered_learning_curves();
|
|
|
|
assert_eq!(
|
|
curves["b"],
|
|
history.filtered_learning_curve("b").unwrap(),
|
|
"the plural form must agree with the singular for the same key"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn filtered_evidence_is_invariant_to_convergence() {
|
|
let mut history = repeated_winner_with(6, tight());
|
|
|
|
let before = history.filtered_log_evidence();
|
|
|
|
let report = history.converge().unwrap();
|
|
assert!(
|
|
report.converged,
|
|
"fixture must converge: {:?}",
|
|
report.final_step
|
|
);
|
|
|
|
let after = history.filtered_log_evidence();
|
|
|
|
assert!(
|
|
(before - after).abs() < 1e-8,
|
|
"filtered evidence moved across converge(): {before} -> {after}. The pass must \
|
|
carry its own forward messages; anything reading skill.forward shows exactly \
|
|
this drift, because converge() contaminates it with backward information."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn single_slice_filtered_matches_smoothed() {
|
|
let mut history = History::builder().convergence(tight()).build();
|
|
|
|
history
|
|
.add_events([
|
|
Event {
|
|
time: 1,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("a")]),
|
|
Team::with_members([Member::new("b")]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
},
|
|
Event {
|
|
time: 1,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("c")]),
|
|
Team::with_members([Member::new("d")]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
},
|
|
])
|
|
.unwrap();
|
|
|
|
let _ = history.converge().unwrap();
|
|
|
|
let smoothed = history.learning_curve("a").unwrap();
|
|
let filtered = history.filtered_learning_curve("a").unwrap();
|
|
|
|
assert_eq!(smoothed.len(), 1);
|
|
assert_eq!(filtered.len(), 1);
|
|
|
|
assert!(
|
|
(smoothed[0].1.mu() - filtered[0].1.mu()).abs() < 1e-8
|
|
&& (smoothed[0].1.sigma() - filtered[0].1.sigma()).abs() < 1e-8,
|
|
"one slice has no future to propagate back, so filtered and smoothed must \
|
|
agree: smoothed mu={} sigma={}, filtered mu={} sigma={}",
|
|
smoothed[0].1.mu(),
|
|
smoothed[0].1.sigma(),
|
|
filtered[0].1.mu(),
|
|
filtered[0].1.sigma()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn filtered_curves_do_not_depend_on_ingestion_order() {
|
|
let events = |time: i64, winner: &'static str, loser: &'static str| Event {
|
|
time,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new(winner)]),
|
|
Team::with_members([Member::new(loser)]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
};
|
|
|
|
let all = vec![
|
|
events(1, "a", "b"),
|
|
events(1, "c", "d"),
|
|
events(1, "a", "c"),
|
|
events(1, "b", "d"),
|
|
events(2, "a", "d"),
|
|
events(2, "b", "c"),
|
|
events(2, "a", "b"),
|
|
];
|
|
|
|
let mut batched = History::builder().convergence(tight()).build();
|
|
batched.add_events(all.clone()).unwrap();
|
|
let _ = batched.converge().unwrap();
|
|
|
|
let mut incremental = History::builder().convergence(tight()).build();
|
|
for event in all {
|
|
incremental.add_events([event]).unwrap();
|
|
}
|
|
let _ = incremental.converge().unwrap();
|
|
|
|
let from_batched = batched.filtered_learning_curve("a").unwrap();
|
|
let from_incremental = incremental.filtered_learning_curve("a").unwrap();
|
|
|
|
assert_eq!(from_batched.len(), from_incremental.len());
|
|
|
|
for ((time_b, gaussian_b), (time_i, gaussian_i)) in
|
|
from_batched.iter().zip(from_incremental.iter())
|
|
{
|
|
assert_eq!(time_b, time_i);
|
|
|
|
assert!(
|
|
(gaussian_b.mu() - gaussian_i.mu()).abs() < 1e-8
|
|
&& (gaussian_b.sigma() - gaussian_i.sigma()).abs() < 1e-8,
|
|
"at t={time_b}: batched mu={} sigma={}, incremental mu={} sigma={}",
|
|
gaussian_b.mu(),
|
|
gaussian_b.sigma(),
|
|
gaussian_i.mu(),
|
|
gaussian_i.sigma()
|
|
);
|
|
}
|
|
}
|