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 {
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> {
@@ -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>
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K> + ?Sized,
Q: Hash + Eq + ?Sized,
{
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
/// of strangers into a confident-looking prediction about nobody, which is
/// 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
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
if teams.len() < 2 {
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
///
/// 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
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
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
/// and every skill is a point mass, leaving no performance distribution to
/// predict from.
pub fn predict_quality(&self, teams: &[&[&K]]) -> Result<f64, InferenceError>
pub fn predict_quality<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
where
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let groups = self.member_skills(teams)?;
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 —
/// their latest, or the one at a given time.
fn resolve_terms(
fn resolve_terms<Q>(
&self,
terms: &[(&K, f64)],
terms: &[(&Q, f64)],
width: usize,
row_for: impl Fn(Index) -> Option<(usize, usize)>,
) -> Result<ResolvedTerms, InferenceError>
where
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let mut contrast = vec![0.0; width];
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
/// `JointUnavailable` for ranked events or a system that is not
/// 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
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
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
/// 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
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
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
/// teams are supplied and `EmptyTeam` for an empty one.
pub fn expected_variance_reduction(
pub fn expected_variance_reduction<Q>(
&self,
teams: &[&[&K]],
target: &[(&K, f64)],
teams: &[&[&Q]],
target: &[(&Q, f64)],
) -> Result<f64, InferenceError>
where
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
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),
/// and `JointUnavailable` if the history is empty or holds ranked events in
/// *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
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
if teams.len() != 2 {
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;
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
/// and every skill is a point mass, leaving no performance distribution to
/// 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
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
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
/// and every skill is a point mass, leaving no performance distribution to
/// 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
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let (performances, sizes) = self.performances(teams)?;
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
/// and every skill is a point mass, leaving no performance distribution to
/// predict from.
pub fn predict_outcome(&self, teams: &[&[&K]]) -> Result<Prediction, InferenceError>
pub fn predict_outcome<Q>(&self, teams: &[&[&Q]]) -> Result<Prediction, InferenceError>
where
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
if teams.len() > crate::MAX_PREDICTED_TEAMS {
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
/// and every skill is a point mass, leaving no performance distribution to
/// 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
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
if ranks.len() != teams.len() {
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
///
/// `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
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let resolved = self
.history
@@ -2895,9 +2941,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
/// # Errors
///
/// `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
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let as_of = self.rows_as_of(time);
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
/// an empty one, and `UnknownKey` for an unseen competitor.
pub fn expected_variance_reduction(
pub fn expected_variance_reduction<Q>(
&self,
teams: &[&[&K]],
target: &[(&K, f64)],
teams: &[&[&Q]],
target: &[(&Q, f64)],
) -> Result<f64, InferenceError>
where
K: std::fmt::Debug,
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
if teams.len() != 2 {
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
// 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;
for (team_idx, team) in teams.iter().enumerate() {
if team.is_empty() {