`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
153 lines
5.3 KiB
Rust
153 lines
5.3 KiB
Rust
//! The `Time` generic, exercised end to end.
|
|
//!
|
|
//! `History<T: Time, ..>` has always been generic over the time axis, `Untimed`
|
|
//! has always been exported, and `Drift<T>` is generic specifically so that
|
|
//! "seasonal or calendar-aware drift is expressible without going through
|
|
//! `i64`". None of it was reachable: every construction route pinned `T = i64`,
|
|
//! `HistoryBuilder`'s fields are private, and its `Default` existed only for the
|
|
//! `i64` instantiation.
|
|
//!
|
|
//! Nothing in the repository constructed a non-`i64` history, which is why that
|
|
//! went unnoticed. This file is the guard against it recurring — it is as much
|
|
//! about the generic being *exercised* as about any single assertion.
|
|
|
|
use trueskill_tt::{ConstantDrift, Drift, History, HistoryBuilder, Time, Untimed};
|
|
|
|
/// A domain time type: a season number. Exactly what the `Time` trait exists
|
|
/// to support, and what a consumer with `chrono` dates would write.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
struct Season(u16);
|
|
|
|
impl Time for Season {
|
|
fn elapsed_to(&self, later: &Self) -> i64 {
|
|
i64::from(later.0.saturating_sub(self.0))
|
|
}
|
|
}
|
|
|
|
/// Drift that only accumulates between seasons, not within one — the
|
|
/// calendar-aware case the trait's own docs cite.
|
|
#[derive(Copy, Clone, Debug)]
|
|
struct SeasonalDrift {
|
|
per_season: f64,
|
|
}
|
|
|
|
impl Drift<Season> for SeasonalDrift {
|
|
fn variance_delta(&self, from: &Season, to: &Season) -> f64 {
|
|
self.variance_for_elapsed(from.elapsed_to(to))
|
|
}
|
|
|
|
fn variance_for_elapsed(&self, elapsed: i64) -> f64 {
|
|
elapsed.max(0) as f64 * self.per_season * self.per_season
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_untimed_history_fits_through_the_builder() {
|
|
let mut h = History::builder().time_type::<Untimed>().build();
|
|
for _ in 0..5 {
|
|
h.record_winner(&"alice", &"bob", Untimed).unwrap();
|
|
}
|
|
assert!(h.converge().unwrap().converged);
|
|
|
|
let alice = h.current_skill(&"alice").unwrap();
|
|
let bob = h.current_skill(&"bob").unwrap();
|
|
assert!(alice.mu() > bob.mu(), "{alice:?} vs {bob:?}");
|
|
assert!(alice.sigma().is_finite() && alice.sigma() > 0.0);
|
|
}
|
|
|
|
/// `Untimed::elapsed_to` is always 0, so no drift accumulates however many
|
|
/// events there are. That is the property the type exists for, and it had never
|
|
/// been checked.
|
|
#[test]
|
|
fn untimed_accumulates_no_drift() {
|
|
fn final_sigma<T: Time + Copy>(time: T, drift: ConstantDrift) -> f64 {
|
|
let mut h = History::builder().time_type::<T>().drift(drift).build();
|
|
for _ in 0..8 {
|
|
h.record_winner(&"a", &"b", time).unwrap();
|
|
}
|
|
let _ = h.converge().unwrap();
|
|
h.current_skill(&"a").unwrap().sigma()
|
|
}
|
|
|
|
// Under Untimed the drift setting cannot matter, because elapsed is always 0.
|
|
let none = final_sigma(Untimed, ConstantDrift::new(0.0));
|
|
let large = final_sigma(Untimed, ConstantDrift::new(5.0));
|
|
assert_eq!(
|
|
none.to_bits(),
|
|
large.to_bits(),
|
|
"Untimed must ignore drift entirely: {none} vs {large}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_custom_time_type_and_a_custom_drift_work_together() {
|
|
let mut h = History::builder()
|
|
.time_type::<Season>()
|
|
.drift(SeasonalDrift { per_season: 0.5 })
|
|
.build();
|
|
|
|
for season in 1..=4u16 {
|
|
for _ in 0..3 {
|
|
h.record_winner(&"veteran", &"rookie", Season(season))
|
|
.unwrap();
|
|
}
|
|
}
|
|
assert!(h.converge().unwrap().converged);
|
|
|
|
let curve = h.learning_curve(&"veteran").unwrap();
|
|
assert_eq!(curve.len(), 4, "one point per season: {curve:?}");
|
|
for (season, g) in &curve {
|
|
assert!(
|
|
g.mu().is_finite() && g.sigma() > 0.0,
|
|
"season {season:?}: {g:?}"
|
|
);
|
|
}
|
|
// Times come back as the domain type, not as an integer.
|
|
assert_eq!(curve[0].0, Season(1));
|
|
assert_eq!(curve[3].0, Season(4));
|
|
}
|
|
|
|
/// Seasonal drift must actually widen a gap across seasons — otherwise the
|
|
/// custom `Drift` is being ignored and the test above would pass regardless.
|
|
#[test]
|
|
fn a_custom_drift_is_actually_consulted() {
|
|
fn sigma_with(per_season: f64) -> f64 {
|
|
let mut h = History::builder()
|
|
.time_type::<Season>()
|
|
.drift(SeasonalDrift { per_season })
|
|
.build();
|
|
for season in 1..=6u16 {
|
|
h.record_winner(&"a", &"b", Season(season)).unwrap();
|
|
}
|
|
let _ = h.converge().unwrap();
|
|
h.current_skill(&"a").unwrap().sigma()
|
|
}
|
|
|
|
let still = sigma_with(0.0);
|
|
let drifting = sigma_with(2.0);
|
|
assert!(
|
|
drifting > still * 1.05,
|
|
"a drifting fit must be less certain: {drifting} vs {still}"
|
|
);
|
|
}
|
|
|
|
/// The other axis: a custom key type, through the same mechanism.
|
|
#[test]
|
|
fn key_type_replaces_builder_with_key() {
|
|
let mut h = History::builder().key_type::<String>().build();
|
|
h.record_winner(&"alice".to_string(), &"bob".to_string(), 1)
|
|
.unwrap();
|
|
assert!(h.converge().unwrap().converged);
|
|
assert!(h.current_skill("alice").is_some());
|
|
}
|
|
|
|
/// Both axes at once, via the explicit constructor rather than the setters.
|
|
#[test]
|
|
fn new_constructs_on_any_axis_directly() {
|
|
let mut h = HistoryBuilder::<String, Season>::new().build();
|
|
h.record_winner(&"a".to_string(), &"b".to_string(), Season(7))
|
|
.unwrap();
|
|
assert!(h.converge().unwrap().converged);
|
|
assert_eq!(h.learning_curve("a").unwrap()[0].0, Season(7));
|
|
}
|