`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
150 lines
4.7 KiB
Rust
150 lines
4.7 KiB
Rust
//! The evidence accessors span two independent axes — smoothed vs forward-only,
|
|
//! all-keys vs key-restricted — and all four corners must exist and differ.
|
|
//!
|
|
//! `filtered_log_evidence_for` was the missing corner: the one a per-competitor
|
|
//! prequential score needs.
|
|
|
|
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
|
|
|
|
type H = History;
|
|
|
|
/// Two disjoint cohorts, so a key restriction is guaranteed to leave events out.
|
|
fn two_cohorts() -> H {
|
|
let mut h = H::default();
|
|
let mut events = Vec::new();
|
|
for t in 1..=6 {
|
|
for (x, y) in [("a", "b"), ("c", "d")] {
|
|
events.push(Event {
|
|
time: t,
|
|
teams: [
|
|
Team::with_members([Member::new(x)]),
|
|
Team::with_members([Member::new(y)]),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
outcome: Outcome::winner(0, 2),
|
|
});
|
|
}
|
|
}
|
|
h.add_events(events).expect("fixture ingests");
|
|
h.converge().expect("fixture converges");
|
|
h
|
|
}
|
|
|
|
#[test]
|
|
fn all_four_corners_are_distinct_quantities() {
|
|
let h = two_cohorts();
|
|
|
|
let smoothed_all = h.log_evidence();
|
|
let smoothed_ab = h.log_evidence_for(&[&"a", &"b"]).unwrap();
|
|
let filtered_all = h.filtered_log_evidence();
|
|
let filtered_ab = h.filtered_log_evidence_for(&[&"a", &"b"]).unwrap();
|
|
|
|
for (name, v) in [
|
|
("smoothed_all", smoothed_all),
|
|
("smoothed_ab", smoothed_ab),
|
|
("filtered_all", filtered_all),
|
|
("filtered_ab", filtered_ab),
|
|
] {
|
|
assert!(
|
|
v.is_finite() && v <= 0.0,
|
|
"{name} = {v} is not a log probability"
|
|
);
|
|
}
|
|
|
|
// Restricting to one cohort must drop the other cohort's events. Half the
|
|
// events, and the two cohorts are symmetric, so it lands near half.
|
|
assert!(
|
|
smoothed_ab > smoothed_all,
|
|
"restricting must drop evidence terms: {smoothed_ab} vs {smoothed_all}"
|
|
);
|
|
assert!(filtered_ab > filtered_all);
|
|
|
|
// The forward-only corner is a genuinely different quantity from the
|
|
// smoothed one, not an alias for it.
|
|
assert!(
|
|
(filtered_ab - smoothed_ab).abs() > 1e-9,
|
|
"filtered and smoothed restricted evidence coincide ({filtered_ab} vs {smoothed_ab}); \
|
|
one of them is not computing what it claims"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn restricting_to_both_cohorts_recovers_the_unrestricted_value() {
|
|
let h = two_cohorts();
|
|
|
|
// Control on the filter itself: naming every competitor must restrict
|
|
// nothing, so this catches a filter that drops events it should keep.
|
|
let all_named = h
|
|
.filtered_log_evidence_for(&[&"a", &"b", &"c", &"d"])
|
|
.unwrap();
|
|
assert!(
|
|
(all_named - h.filtered_log_evidence()).abs() < 1e-12,
|
|
"naming everyone changed the answer: {all_named} vs {}",
|
|
h.filtered_log_evidence()
|
|
);
|
|
}
|
|
|
|
/// The restriction selects *events*, not competitors: naming one member of a
|
|
/// pair that only ever plays each other selects the same events as naming both.
|
|
#[test]
|
|
fn naming_either_member_of_a_pair_selects_the_same_events() {
|
|
let h = two_cohorts();
|
|
|
|
let ab = h.filtered_log_evidence_for(&[&"a"]).unwrap();
|
|
let ab_pair = h.filtered_log_evidence_for(&[&"a", &"b"]).unwrap();
|
|
assert!(
|
|
(ab - ab_pair).abs() < 1e-12,
|
|
"a and b only ever play each other, so naming either or both selects \
|
|
the same events: {ab} vs {ab_pair}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_key_is_an_error_here_too() {
|
|
let h = two_cohorts();
|
|
|
|
let err = h
|
|
.filtered_log_evidence_for(&[&"typo"])
|
|
.expect_err("unknown key");
|
|
assert!(matches!(err, InferenceError::UnknownKey { .. }), "{err:?}");
|
|
|
|
// Control: the same call on a known key succeeds.
|
|
h.filtered_log_evidence_for(&[&"a"]).expect("a is known");
|
|
}
|
|
|
|
#[test]
|
|
fn current_skills_agrees_with_current_skill() {
|
|
let h = two_cohorts();
|
|
|
|
let all = h.current_skills();
|
|
assert_eq!(all.len(), 4, "four competitors played");
|
|
|
|
for key in ["a", "b", "c", "d"] {
|
|
let one = h.current_skill(key).expect("played");
|
|
let from_map = all[key];
|
|
assert_eq!(
|
|
(one.mu(), one.sigma()),
|
|
(from_map.mu(), from_map.sigma()),
|
|
"current_skills disagrees with current_skill for {key}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn current_skills_omits_a_registered_but_unplayed_competitor() {
|
|
let mut h = two_cohorts();
|
|
h.register(Member::new("e")).expect("e is new");
|
|
|
|
let all = h.current_skills();
|
|
assert!(
|
|
!all.contains_key("e"),
|
|
"a competitor with no appearances has no posterior to report"
|
|
);
|
|
assert!(
|
|
h.current_skill("e").is_none(),
|
|
"control: the singular agrees"
|
|
);
|
|
assert_eq!(all.len(), 4);
|
|
}
|