feat!: prediction and joint queries take borrowed keys

`&[&[&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
This commit is contained in:
2026-09-09 22:11:25 +02:00
co-authored by Claude Opus 5
parent da55d2a7d1
commit 92ae5fca17
3 changed files with 214 additions and 39 deletions
+90 -38
View File
@@ -507,6 +507,32 @@ impl<T: Time, K: Eq + Hash + Clone> HistoryBuilder<T, ConstantDrift, NullObserve
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
/// Set the drift rate, in skill units per unit of time.
///
/// Shorthand for `.drift(ConstantDrift::new(gamma))`. Drift is the
/// most-tuned parameter after `sigma` and [`GAMMA`](crate::GAMMA) is a
/// public constant, but reaching it otherwise means first discovering
/// [`ConstantDrift`] — a type a caller has no other reason to name.
///
/// Only on the `ConstantDrift` builder: `gamma` is that model's parameter,
/// not a property every [`Drift`] has. Use
/// [`drift`](HistoryBuilder::drift) for anything else.
///
/// # Panics
///
/// Panics unless `gamma` is finite and non-negative. Drift enters as
/// `gamma^2` per elapsed tick, so a negative value would behave as its
/// absolute value — the same sign-absorption already rejected for `sigma`
/// and `beta`.
pub fn gamma(self, gamma: f64) -> Self {
assert!(
gamma.is_finite() && gamma >= 0.0,
"gamma must be finite and non-negative (got {gamma}); it is only ever \
squared, so a negative value would silently behave as its absolute value"
);
self.drift(ConstantDrift::new(gamma))
}
} }
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> { impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
@@ -531,7 +557,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
pub fn lookup<Q>(&self, key: &Q) -> Option<Index> pub fn lookup<Q>(&self, key: &Q) -> Option<Index>
where where
K: Borrow<Q>, K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K> + ?Sized, Q: Hash + Eq + ?Sized,
{ {
self.keys.get(key) self.keys.get(key)
} }
@@ -1128,9 +1154,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// reported rather than dropped — silently skipping them would turn a team /// reported rather than dropped — silently skipping them would turn a team
/// of strangers into a confident-looking prediction about nobody, which is /// of strangers into a confident-looking prediction about nobody, which is
/// the failure this replaced. /// the failure this replaced.
fn member_skills(&self, teams: &[&[&K]]) -> Result<Vec<Vec<Gaussian>>, InferenceError> fn member_skills<Q>(&self, teams: &[&[&Q]]) -> Result<Vec<Vec<Gaussian>>, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
if teams.len() < 2 { if teams.len() < 2 {
return Err(InferenceError::NotEnoughTeams { got: teams.len() }); return Err(InferenceError::NotEnoughTeams { got: teams.len() });
@@ -1244,9 +1271,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// # Errors /// # Errors
/// ///
/// As `member_skills`. /// As `member_skills`.
fn performances(&self, teams: &[&[&K]]) -> Result<(Vec<Gaussian>, Vec<usize>), InferenceError> fn performances<Q>(
&self,
teams: &[&[&Q]],
) -> Result<(Vec<Gaussian>, Vec<usize>), InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
let skills = self.member_skills(teams)?; let skills = self.member_skills(teams)?;
@@ -1310,9 +1341,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero /// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to /// and every skill is a point mass, leaving no performance distribution to
/// predict from. /// predict from.
pub fn predict_quality(&self, teams: &[&[&K]]) -> Result<f64, InferenceError> pub fn predict_quality<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
let groups = self.member_skills(teams)?; let groups = self.member_skills(teams)?;
let group_refs: Vec<&[Gaussian]> = groups.iter().map(Vec::as_slice).collect(); let group_refs: Vec<&[Gaussian]> = groups.iter().map(Vec::as_slice).collect();
@@ -1448,14 +1480,15 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// ///
/// `row_for` picks which appearance of a competitor the caller means — /// `row_for` picks which appearance of a competitor the caller means —
/// their latest, or the one at a given time. /// their latest, or the one at a given time.
fn resolve_terms( fn resolve_terms<Q>(
&self, &self,
terms: &[(&K, f64)], terms: &[(&Q, f64)],
width: usize, width: usize,
row_for: impl Fn(Index) -> Option<(usize, usize)>, row_for: impl Fn(Index) -> Option<(usize, usize)>,
) -> Result<ResolvedTerms, InferenceError> ) -> Result<ResolvedTerms, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
let mut contrast = vec![0.0; width]; let mut contrast = vec![0.0; width];
let mut unseen: BTreeMap<String, f64> = BTreeMap::new(); let mut unseen: BTreeMap<String, f64> = BTreeMap::new();
@@ -1557,9 +1590,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// `UnknownKey` for a competitor the history has never seen, and /// `UnknownKey` for a competitor the history has never seen, and
/// `JointUnavailable` for ranked events or a system that is not /// `JointUnavailable` for ranked events or a system that is not
/// positive-definite. /// positive-definite.
pub fn posterior_of(&self, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError> pub fn posterior_of<Q>(&self, terms: &[(&Q, f64)]) -> Result<Gaussian, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
self.joint()?.posterior_of(terms) self.joint()?.posterior_of(terms)
} }
@@ -1579,9 +1613,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// ///
/// As [`History::posterior_of`], plus `UnknownKey` for a competitor with no /// As [`History::posterior_of`], plus `UnknownKey` for a competitor with no
/// appearance at or before `time`. /// appearance at or before `time`.
pub fn posterior_of_at(&self, time: T, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError> pub fn posterior_of_at<Q>(
&self,
time: T,
terms: &[(&Q, f64)],
) -> Result<Gaussian, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
self.joint()?.posterior_of_at(time, terms) self.joint()?.posterior_of_at(time, terms)
} }
@@ -1625,13 +1664,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// ///
/// As [`History::posterior_of`], plus `MismatchedShape` unless exactly two /// As [`History::posterior_of`], plus `MismatchedShape` unless exactly two
/// teams are supplied and `EmptyTeam` for an empty one. /// teams are supplied and `EmptyTeam` for an empty one.
pub fn expected_variance_reduction( pub fn expected_variance_reduction<Q>(
&self, &self,
teams: &[&[&K]], teams: &[&[&Q]],
target: &[(&K, f64)], target: &[(&Q, f64)],
) -> Result<f64, InferenceError> ) -> Result<f64, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
self.joint()?.expected_variance_reduction(teams, target) self.joint()?.expected_variance_reduction(teams, target)
} }
@@ -1747,9 +1787,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// `UnknownKey` under [`UnknownKeys::Reject`](crate::UnknownKeys::Reject), /// `UnknownKey` under [`UnknownKeys::Reject`](crate::UnknownKeys::Reject),
/// and `JointUnavailable` if the history is empty or holds ranked events in /// and `JointUnavailable` if the history is empty or holds ranked events in
/// *any* slice — not merely the latest one. /// *any* slice — not merely the latest one.
pub fn predict_margin(&self, teams: &[&[&K]]) -> Result<Gaussian, InferenceError> pub fn predict_margin<Q>(&self, teams: &[&[&Q]]) -> Result<Gaussian, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
if teams.len() != 2 { if teams.len() != 2 {
return Err(InferenceError::MismatchedShape { return Err(InferenceError::MismatchedShape {
@@ -1759,7 +1800,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}); });
} }
let mut terms: Vec<(&K, f64)> = Vec::new(); let mut terms: Vec<(&Q, f64)> = Vec::new();
let mut performance_noise = 0.0; let mut performance_noise = 0.0;
for (team_idx, team) in teams.iter().enumerate() { for (team_idx, team) in teams.iter().enumerate() {
@@ -1824,9 +1865,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero /// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to /// and every skill is a point mass, leaving no performance distribution to
/// predict from. /// predict from.
pub fn expected_information_gain(&self, teams: &[&[&K]]) -> Result<f64, InferenceError> pub fn expected_information_gain<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
let skills = self.member_skills(teams)?; let skills = self.member_skills(teams)?;
@@ -1884,9 +1926,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero /// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to /// and every skill is a point mass, leaving no performance distribution to
/// predict from. /// predict from.
pub fn predict_win_probabilities(&self, teams: &[&[&K]]) -> Result<Vec<f64>, InferenceError> pub fn predict_win_probabilities<Q>(&self, teams: &[&[&Q]]) -> Result<Vec<f64>, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
let (performances, sizes) = self.performances(teams)?; let (performances, sizes) = self.performances(teams)?;
Ok(crate::predict::win_probabilities( Ok(crate::predict::win_probabilities(
@@ -1938,9 +1981,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero /// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to /// and every skill is a point mass, leaving no performance distribution to
/// predict from. /// predict from.
pub fn predict_outcome(&self, teams: &[&[&K]]) -> Result<Prediction, InferenceError> pub fn predict_outcome<Q>(&self, teams: &[&[&Q]]) -> Result<Prediction, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
if teams.len() > crate::MAX_PREDICTED_TEAMS { if teams.len() > crate::MAX_PREDICTED_TEAMS {
return Err(InferenceError::TooManyTeams { return Err(InferenceError::TooManyTeams {
@@ -1987,9 +2031,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero /// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to /// and every skill is a point mass, leaving no performance distribution to
/// predict from. /// predict from.
pub fn predict_ranking(&self, teams: &[&[&K]], ranks: &[u32]) -> Result<f64, InferenceError> pub fn predict_ranking<Q>(&self, teams: &[&[&Q]], ranks: &[u32]) -> Result<f64, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
if ranks.len() != teams.len() { if ranks.len() != teams.len() {
return Err(InferenceError::MismatchedShape { return Err(InferenceError::MismatchedShape {
@@ -2877,9 +2922,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
/// # Errors /// # Errors
/// ///
/// `UnknownKey` for a competitor the history has never seen. /// `UnknownKey` for a competitor the history has never seen.
pub fn posterior_of(&self, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError> pub fn posterior_of<Q>(&self, terms: &[(&Q, f64)]) -> Result<Gaussian, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
let resolved = self let resolved = self
.history .history
@@ -2895,9 +2941,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
/// # Errors /// # Errors
/// ///
/// `UnknownKey` for a competitor with no appearance at or before `time`. /// `UnknownKey` for a competitor with no appearance at or before `time`.
pub fn posterior_of_at(&self, time: T, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError> pub fn posterior_of_at<Q>(
&self,
time: T,
terms: &[(&Q, f64)],
) -> Result<Gaussian, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
let as_of = self.rows_as_of(time); let as_of = self.rows_as_of(time);
let resolved = self let resolved = self
@@ -2932,13 +2983,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
/// ///
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam` for /// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam` for
/// an empty one, and `UnknownKey` for an unseen competitor. /// an empty one, and `UnknownKey` for an unseen competitor.
pub fn expected_variance_reduction( pub fn expected_variance_reduction<Q>(
&self, &self,
teams: &[&[&K]], teams: &[&[&Q]],
target: &[(&K, f64)], target: &[(&Q, f64)],
) -> Result<f64, InferenceError> ) -> Result<f64, InferenceError>
where where
K: std::fmt::Debug, K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{ {
if teams.len() != 2 { if teams.len() != 2 {
return Err(InferenceError::MismatchedShape { return Err(InferenceError::MismatchedShape {
@@ -2950,7 +3002,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
// The candidate matchup, expressed as the same kind of linear // The candidate matchup, expressed as the same kind of linear
// functional as the target. // functional as the target.
let mut matchup: Vec<(&K, f64)> = Vec::new(); let mut matchup: Vec<(&Q, f64)> = Vec::new();
let mut noise = self.history.score_sigma * self.history.score_sigma; let mut noise = self.history.score_sigma * self.history.score_sigma;
for (team_idx, team) in teams.iter().enumerate() { for (team_idx, team) in teams.iter().enumerate() {
if team.is_empty() { if team.is_empty() {
+119
View File
@@ -0,0 +1,119 @@
//! 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();
}
+5 -1
View File
@@ -60,8 +60,12 @@ fn degenerate_team_shapes_are_errors_rather_than_panics() {
h.predict_outcome(&[&[&"a"]]).unwrap_err(), h.predict_outcome(&[&[&"a"]]).unwrap_err(),
InferenceError::NotEnoughTeams { got: 1, .. } InferenceError::NotEnoughTeams { got: 1, .. }
),); ),);
// An empty team list cannot infer the key type — nothing in `&[]` names it.
// The annotation is the cost of `predict_*` being generic over the borrowed
// key, and it only bites on the degenerate call.
let none: &[&[&str]] = &[];
assert!(matches!( assert!(matches!(
h.predict_outcome(&[]).unwrap_err(), h.predict_outcome(none).unwrap_err(),
InferenceError::NotEnoughTeams { got: 0, .. } InferenceError::NotEnoughTeams { got: 0, .. }
),); ),);
assert!(matches!( assert!(matches!(