Files
trueskill-tt/tests/key_ergonomics.rs
T
logaritmiskandClaude Opus 5 faa25fb3b1 refactor!: retire Index, intern and lookup
`Index` was public, `History::intern` and `History::lookup` returned
one, and no public method anywhere accepted one. It was a handle with
nowhere to go — and `key_table.rs` advertised the hot-path story it was
meant to enable ("power users can promote `&K` to `Index` and skip the
lookup"), which was never reachable through the public API.

It also shadowed `std::ops::Index`, which `CompetitorStore` implements,
so `use trueskill_tt::*` alongside `use std::ops::*` collided.

All three are `pub(crate)` now. `intern` stays internal because
ingestion needs it; `lookup` is gone entirely, since `current_skill`,
`rating` and `learning_curve` already answer "does this history know
this key" and all three take a borrowed key.

The three tests that used them asserted things a caller cannot observe.
They now assert what the interning bought:

- `record_winner_creates_two_competitors` compares posteriors instead of
  comparing two opaque indices for inequality.
- `intern_is_idempotent` becomes `a_repeated_key_is_one_competitor` — a
  key appearing in two events gives one competitor with a two-point
  learning curve, which is the observable form of the same claim.
- `lookup_returns_none_for_missing` becomes `an_unknown_key_is_unknown`.

Closes #73.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 23:18:54 +02:00

121 lines
4.1 KiB
Rust

//! The realistic program: keys arrive owned, queries are written with literals.
//!
//! Every prediction and joint query used to take `&[&[&K]]`, which at
//! `K = String` made a string literal *impossible* — the shape required three
//! levels of temporaries that all had to outlive the call. They are generic
//! over the borrowed key now, so one spelling works at both key types.
//!
//! Both key types are exercised in every test, because the point is that the
//! spelling is the same.
use trueskill_tt::{ConstantDrift, History, NullObserver};
type Owned = History<i64, ConstantDrift, NullObserver, String>;
type Borrowed = History<i64, ConstantDrift, NullObserver, &'static str>;
fn owned() -> Owned {
let mut h: Owned = History::builder().key_type::<String>().build();
for t in 1..=4 {
h.record_winner(&"alice".to_string(), &"bob".to_string(), t)
.expect("ingests");
}
h.converge().expect("converges");
h
}
fn borrowed() -> Borrowed {
let mut h = History::default();
for t in 1..=4 {
h.record_winner(&"alice", &"bob", t).expect("ingests");
}
h.converge().expect("converges");
h
}
#[test]
fn predictions_take_literals_at_either_key_type() {
let teams: &[&[&str]] = &[&["alice"], &["bob"]];
let a = owned()
.predict_win_probabilities(teams)
.expect("K = String");
let b = borrowed()
.predict_win_probabilities(teams)
.expect("K = &'static str");
assert_eq!(a, b, "the same fit through the same spelling");
assert!(a[0] > a[1], "alice won every game");
}
#[test]
fn every_team_shaped_query_accepts_the_same_slice() {
let h = owned();
let teams: &[&[&str]] = &[&["alice"], &["bob"]];
h.predict_quality(teams).expect("quality");
let _ = h.predict_outcome(teams).expect("outcome");
h.predict_ranking(teams, &[0, 1]).expect("ranking");
h.expected_information_gain(teams)
.expect("information gain");
}
#[test]
fn linear_combinations_take_bare_keys() {
// `&[(&K, f64)]` at `K = String` meant `&[(&String, f64)]` — no literals.
let h = owned();
let terms: &[(&str, f64)] = &[("alice", 1.0), ("bob", -1.0)];
// Ranked history, so the joint is unavailable — but the *call* compiles,
// which is what this pins. The error proves it reached the joint check
// rather than failing to resolve a key.
let err = h
.posterior_of(terms)
.expect_err("ranked history has no joint");
assert!(
format!("{err}").contains("ranked"),
"expected the joint-unavailable path, got {err}"
);
}
/// `lookup` is gone with `Index` (#73); the accessors that answer the same
/// question all take a borrowed key.
#[test]
fn membership_queries_accept_a_borrowed_key() {
let h = owned();
assert!(h.current_skill("alice").is_some());
assert!(h.rating("alice").is_some());
assert!(h.learning_curve("alice").is_some());
assert!(h.current_skill("nobody").is_none());
assert!(h.rating("nobody").is_none());
assert!(h.learning_curve("nobody").is_none());
}
#[test]
fn gamma_sets_drift_without_naming_constant_drift() {
let mut a: Borrowed = History::builder().gamma(0.5).build();
let mut b: Borrowed = History::builder().drift(ConstantDrift::new(0.5)).build();
for h in [&mut a, &mut b] {
h.record_winner(&"x", &"y", 1).unwrap();
h.record_winner(&"y", &"x", 100).unwrap();
h.converge().unwrap();
}
let (ga, gb) = (a.current_skill("x").unwrap(), b.current_skill("x").unwrap());
assert_eq!((ga.mu(), ga.sigma()), (gb.mu(), gb.sigma()));
// Control: the shorthand is not a no-op — a different gamma differs.
let mut c: Borrowed = History::builder().gamma(0.0).build();
c.record_winner(&"x", &"y", 1).unwrap();
c.record_winner(&"y", &"x", 100).unwrap();
c.converge().unwrap();
assert_ne!(c.current_skill("x").unwrap().sigma(), ga.sigma());
}
#[test]
#[should_panic(expected = "gamma must be finite and non-negative")]
fn a_negative_gamma_is_rejected_rather_than_squared_away() {
let _: Borrowed = History::builder().gamma(-0.5).build();
}