`&[&[&K]]` was the worst shape in the API. At `K = String` — the
realistic case, where names arrive owned from a database or CSV — a
string literal was *impossible*, and asking "who wins" cost six lines
and four allocations of temporaries that all had to outlive the call:
let ta = vec![a.to_string()];
let ra: Vec<&String> = ta.iter().collect();
...
self.history.predict_win_probabilities(&teams)
All seven `predict_*` / `expected_*` methods, `posterior_of`,
`posterior_of_at` and the `Joint` mirrors are now generic over the
borrowed key, the same way `current_skill` and `learning_curve` already
were. `member_skills` and `resolve_terms` only ever did two things with
a key — `keys.get` and `format!("{key:?}")` — and neither needed `K`.
h.predict_win_probabilities(&[&["alice"], &["bob"]]) // K = String
h.predict_win_probabilities(&[&["alice"], &["bob"]]) // K = &'static str
h.posterior_of(&[("alice", 1.0), ("bob", -1.0)])
One spelling for both key types, and `K: Debug` becomes `Q: Debug`, so a
key type no longer has to be `Debug` to run a prediction. The old
`&[&[&"a"]]` spelling still compiles at the default key type, where `Q`
infers to `&str` and the two shapes coincide.
The one cost: `predict_outcome(&[])` can no longer infer `Q` — nothing
in an empty slice names it. It needs an annotation, and only on that
degenerate call.
`lookup` carried `ToOwned<Owned = K>`, copy-pasted from `intern`, which
genuinely needs it to create the entry. `lookup` never creates, and its
five neighbours all accept `h.f("alice")` already. Dropping the bound
strictly widens what compiles.
`HistoryBuilder::gamma` is shorthand for `.drift(ConstantDrift::new(g))`.
Drift is the most-tuned parameter after `sigma` and `GAMMA` is a public
constant, but setting it meant first discovering `ConstantDrift`, a type
a caller has no other reason to name. On the `ConstantDrift` builder
only, since `gamma` is that model's parameter rather than something
every `Drift` has, and rejecting a negative value for the same reason as
`sigma` and `beta`: it enters squared.
Refs #72 (items 2 and 3, plus the gamma shorthand; the type-parameter
reorder in item 1 is still open).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
120 lines
4.1 KiB
Rust
120 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}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn lookup_accepts_a_borrowed_key_like_its_neighbours() {
|
|
// `lookup` carried `ToOwned<Owned = K>`, copy-pasted from `intern`, which
|
|
// genuinely needs it to create the entry. `lookup` never creates.
|
|
let h = owned();
|
|
assert!(h.lookup("alice").is_some());
|
|
assert!(h.lookup("nobody").is_none());
|
|
|
|
// Control: its neighbours already accepted this and must still.
|
|
assert!(h.current_skill("alice").is_some());
|
|
assert!(h.rating("alice").is_some());
|
|
}
|
|
|
|
#[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();
|
|
}
|