Files
logaritmiskandClaude Opus 5 e4a68ba1a7 fix!: per-key queries report unknown keys instead of a plausible constant
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
2026-09-09 21:13:35 +02:00

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()
);
}
}