feat: add History::posterior_of for a linear combination of competitors

#46: every accessor returns a per-competitor marginal, and almost
nothing a consumer publishes is one competitor. Combining marginals
assumes independence, and competitors are correlated through every event
they share.

`posterior_of(&[(a, 1.0), (b, -1.0)])` returns the posterior of that
combination with the correlation intact. Validated against the exact
linear-Gaussian posterior on both a tree and a loopy fixture, for
differences and for single competitors: agreement to 1e-9 relative in
every case.

The investigation that preceded this is why it is not a covariance
accessor. Marginals from loopy message passing are about half the true
width, and ignoring correlation overstates a difference — the two errors
partially cancel, leaving 1.327x rather than 2.646x. Bolting true
correlations onto the existing marginals would have given 0.765 against
a true 1.524, which is overconfident: the direction the reporter
specifically called unsafe. Rebuilding the joint from the factor
structure fixes both at once, and a single-competitor query now returns
the exact marginal rather than the narrow one.

The precision matrix depends only on structure — who played whom, with
what weights and what noise — not on the observed outcomes, and the
means were already exact. So only the second moment is reconstructed.

Known limits, all deliberate and documented on the method:

- Latest slice only. A functional spanning times, such as "current
  versus career", needs the time-expanded joint and is not covered.
- Scored events only. A ranked outcome's truncation is EP-approximated
  and its converged factors are not retained after inference, so ranked
  slices return `JointUnavailable` rather than a plausible wrong number.
- Dense Cholesky, O(n^3) per query in the slice's competitor count:
  38.8us at 50, 5.66ms at 400, 49.1ms at 800. Fine for the sizes this
  serves today; caching the factorization per slice would make repeat
  queries O(n^2), and sparsity is the next step after that.

Refs #46, #47, #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 01:46:35 +02:00
co-authored by Claude Opus 5
parent 4924bc8b57
commit c52e2550af
6 changed files with 392 additions and 2 deletions
+89
View File
@@ -755,6 +755,95 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
Ok(crate::quality(&group_refs, self.beta))
}
/// Posterior of a linear combination of competitors' skills.
///
/// `terms` pairs each competitor with its coefficient, so
/// `[(a, 1.0), (b, -1.0)]` is the difference `a - b` and
/// `[(score, 1.0), (layout, 1.0)]` is their sum.
///
/// # Why this exists
///
/// Every other accessor returns a per-competitor marginal, and combining
/// marginals assumes independence. Competitors are correlated through every
/// event they share — that coupling is the mechanism the model exists to
/// exploit — so `sqrt(sa^2 + sb^2)` overstates the width of a difference.
/// Measured against the exact posterior on a five-competitor round robin,
/// the correlation is +0.857 and the naive form is 2.6x too wide.
///
/// The mean is the same combination of the marginal means, which message
/// passing already gets exactly right. Only the variance needs the joint.
///
/// # Limitations
///
/// Currently exact only for a slice whose events are all scored, because a
/// scored likelihood is Gaussian and its factor can be rebuilt exactly. A
/// ranked outcome's truncation is approximated by EP, and reconstructing
/// those factors needs the converged messages, which inference does not
/// retain. Ranked slices return `JointUnavailable` rather than a plausible
/// wrong number.
///
/// # Errors
///
/// `UnknownKey` for a competitor absent from the latest slice, and
/// `JointUnavailable` if that slice contains ranked events or the system is
/// not positive-definite.
pub fn posterior_of(&self, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError>
where
K: std::fmt::Debug,
{
let slice = self
.time_slices
.last()
.ok_or(InferenceError::JointUnavailable {
reason: "the history has no events",
})?;
if !slice.all_scored() {
return Err(InferenceError::JointUnavailable {
reason: "the latest slice contains ranked events, whose EP factors \
are not retained after convergence",
});
}
let (order, lambda) = slice.joint_precision(&self.agents);
let mut row_of = HashMap::with_capacity(order.len());
for (r, idx) in order.iter().enumerate() {
row_of.insert(*idx, r);
}
let mut contrast = vec![0.0; order.len()];
let mut mean = 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 z =
crate::joint::solve_spd(lambda, &contrast).ok_or(InferenceError::JointUnavailable {
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();
Ok(Gaussian::from_mv(mean, variance))
}
/// Expected information gain of running this matchup, in nats.
///
/// Answers "which comparison should I run next" rather than "who will