feat: add History::predict_margin for scored matchups

#48: every predict_* answers "who wins", and a consumer recording scores
never asks that. It wants the interval on the result, and having none it
hand-fitted a noise law whose fitted node weight came out at 0.0 — so
the quoted sigma was 5.83 whether the competitor had forty rounds or
none, against real residual spreads of 5.8 and 12.44.

`predict_margin` composes the three things that make a scored result
uncertain: the joint posterior over the competitors, their per-event
performance noise, and the observation noise on the score. It widens as
the model knows less — measured, sigma 2.48 against an opponent with
forty rounds, 3.38 against one seen once, wider still against one never
seen — which is the property the hand-fitted law lost.

It is a margin, not a score, and that is not a shortcut. Scored
ingestion reduces every event to `score_a - score_b` before inference,
so the absolute level is discarded: shifting every score in a history by
+100 or -1000 produces a bit-identical fit, verified. There is no
information from which to predict what a competitor will *score*.
Returning one would be a number derived entirely from the prior, which
is exactly the plausible constant this crate keeps finding and removing.

`posterior_of` now honours `UnknownKeys::Prior`, which gives #48 its
second requirement — "I have never seen this competitor, here is the
prior-informed answer". An unseen competitor shares no event with the
slice, so it is independent by construction and its variance is additive
rather than part of the solve.

Worth recording for expectations: for a *margin* the joint buys little
over adding marginals (2.4798 against 2.5112 here), because a margin is
a difference and differences are where the loopy underestimate and the
ignored correlation cancel. The gain here is having a predictive
distribution at all. `posterior_of`'s correlation handling earns its
keep on sums and single nodes instead — see tests/additive_model.rs.

Closes #48

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-08 02:02:15 +02:00
co-authored by Claude Opus 5
parent 1f791bcddd
commit c866210c65
2 changed files with 255 additions and 19 deletions
+102 -19
View File
@@ -813,25 +813,42 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let mut contrast = vec![0.0; order.len()];
let mut mean = 0.0;
// A competitor the slice has never seen shares no event with anything
// in it, so it is independent by construction and its contribution is
// simply additive rather than part of the solve.
let mut independent_variance = 0.0;
for (member, (key, coefficient)) in terms.iter().enumerate() {
let index = self.keys.get(*key).ok_or(InferenceError::UnknownKey {
team: 0,
member,
key: format!("{key:?}"),
})?;
let row = *row_of.get(&index).ok_or(InferenceError::UnknownKey {
team: 0,
member,
key: format!("{key:?}"),
})?;
contrast[row] += coefficient;
mean += coefficient
* slice
.skills
.get(index)
.expect("index came from this slice")
.posterior()
.mu();
let row = self
.keys
.get(*key)
.and_then(|index| row_of.get(&index).map(|row| (index, *row)));
match row {
Some((index, row)) => {
contrast[row] += coefficient;
mean += coefficient
* slice
.skills
.get(index)
.expect("index came from this slice")
.posterior()
.mu();
}
None => match self.unknown_keys {
crate::UnknownKeys::Prior => {
mean += coefficient * self.mu;
independent_variance += coefficient * coefficient * self.sigma * self.sigma;
}
_ => {
return Err(InferenceError::UnknownKey {
team: 0,
member,
key: format!("{key:?}"),
});
}
},
}
}
let z =
@@ -839,11 +856,77 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
reason: "the precision matrix is not positive-definite, which means \
a competitor has neither a proper prior nor any evidence",
})?;
let variance: f64 = contrast.iter().zip(&z).map(|(c, z)| c * z).sum();
let variance: f64 =
contrast.iter().zip(&z).map(|(c, z)| c * z).sum::<f64>() + independent_variance;
Ok(Gaussian::from_mv(mean, variance))
}
/// Predictive distribution of the score margin between two teams.
///
/// Answers "what will the gap be, and how wide is that interval" for a
/// scored matchup, composing the three things that make it uncertain: how
/// unsure the model is about the competitors, their per-event performance
/// noise, and the observation noise on the score itself.
///
/// The interval widens as the model knows less. Measured on a fixture where
/// one opponent has forty rounds and another has one, the margin's sigma
/// goes from 2.48 to 3.38 — which is the property a caller most needs and
/// the one a hand-fitted noise law tends to lose.
///
/// # Why a margin rather than a score
///
/// The model never sees an absolute score. Scored ingestion reduces each
/// event to `score_a - score_b` before inference, so shifting every score
/// in a history by a constant produces a bit-identical fit. There is
/// therefore no information from which to predict what a competitor will
/// *score*; only what the gap between two of them will be. Asking for an
/// absolute score would return a number derived entirely from the prior,
/// which is the kind of plausible constant this crate tries not to hand out.
///
/// # Errors
///
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam`,
/// `UnknownKey` under [`UnknownKeys::Reject`](crate::UnknownKeys::Reject),
/// and `JointUnavailable` if the latest slice holds ranked events.
pub fn predict_margin(&self, teams: &[&[&K]]) -> Result<Gaussian, InferenceError>
where
K: std::fmt::Debug,
{
if teams.len() != 2 {
return Err(InferenceError::MismatchedShape {
kind: "predict_margin takes exactly 2 teams",
expected: 2,
got: teams.len(),
});
}
let mut terms: Vec<(&K, f64)> = Vec::new();
let mut performance_noise = 0.0;
for (team_idx, team) in teams.iter().enumerate() {
if team.is_empty() {
return Err(InferenceError::EmptyTeam { team: team_idx });
}
let sign = if team_idx == 0 { 1.0 } else { -1.0 };
for key in team.iter() {
terms.push((*key, sign));
// Each member contributes its own performance noise to the
// margin regardless of which side it is on.
let beta = self
.keys
.get(*key)
.map_or(self.beta, |index| self.agents[index].rating.beta);
performance_noise += beta * beta;
}
}
let skill_gap = self.posterior_of(&terms)?;
let variance = skill_gap.sigma().powi(2) + performance_noise + self.score_sigma.powi(2);
Ok(Gaussian::from_mv(skill_gap.mu(), variance))
}
/// Expected information gain of running this matchup, in nats.
///
/// Answers "which comparison should I run next" rather than "who will