refactor!: the joint is reached through Joint, not mirrored on History

`posterior_of`, `posterior_of_at` and `expected_variance_reduction`
existed twice: once on `Joint`, and once on `History` as one-shot
wrappers whose whole body was `self.joint()?.<same>(..)`.

The wrappers re-factorised on every call — their own docs said so,
warning the reader to take a `Joint` instead — and they were what
smuggled the scored-only precondition onto the flat surface. A user
following the quickstart builds a ranked history, sees `posterior_of` in
the method list, and it never works. `h.joint()?.posterior_of(..)` is
one call longer and tells the truth: you need a joint, and a joint needs
a scored history.

That leaves three tiers instead of a flat surface with a hidden
precondition: `History` fits and reads, `predict_*` forecasts, `Joint`
answers exact joint questions.

`predict_margin` was itself calling `self.posterior_of`; it goes through
`self.joint()?` directly now.

The `Joint` methods' docs referred back to the wrappers for their real
content ("Identical to `History::posterior_of`, without re-paying the
factorisation"), so they now carry it: what a linear functional means,
which appearance each competitor is read at, and why
`expected_variance_reduction` belongs on the handle.

`tests/joint_handle.rs` had three tests comparing the wrapper against
the handle. That comparison is gone, but the property behind it is not —
they now compare a *reused* joint against a *fresh* one per question,
which is the actual correctness claim behind caching the factorisation
(#51), without the wrapper in the middle.

Closes #78.

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-10 06:40:31 +02:00
co-authored by Claude Opus 5
parent 56193609f7
commit e72bf3894c
10 changed files with 188 additions and 200 deletions
+29 -156
View File
@@ -1529,156 +1529,16 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
})
}
/// 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.
///
/// # Which appearance each competitor is read at
///
/// Each competitor is read at *their own* latest appearance, which is where
/// [`History::current_skill`] reads them too, so the two agree about which
/// posterior they describe. That matters in a Through-Time history: with
/// per-day or per-event slices, competitors are rarely all present in any
/// one of them. Use [`History::posterior_of_at`] to pin a time instead.
///
/// # Asking more than one question
///
/// This factorises the joint, uses it once, and throws it away. The
/// factorisation is the expensive part and it depends only on the fit, so
/// asking `n` questions this way pays for it `n` times. Take a
/// [`Joint`] with [`History::joint`] instead — the answers are identical,
/// and only the first one pays.
///
/// # Cost
///
/// A dense solve over the history's *appearances*, not its competitors. A
/// drift-free competitor collapses to a single variable however long the
/// history, so the same events can differ enormously in cost depending on
/// the drift configuration — see [`Joint`], which also amortises this
/// across many questions.
///
/// # Limitations
///
/// Exact only for a history 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 —
/// so a history containing ranked events returns `JointUnavailable` rather
/// than a plausible wrong number.
///
/// # Errors
///
/// `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<Q>(&self, terms: &[(&Q, f64)]) -> Result<Gaussian, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
self.joint()?.posterior_of(terms)
}
/// Posterior of a linear combination, read as of `time`.
///
/// Each competitor is taken at their latest appearance at or before `time`,
/// which is the same reading [`History::learning_curve`] gives. Use this
/// when a comparison must be anchored to a moment — "how did these two
/// stand at the end of last season" — rather than to wherever each
/// competitor was last seen.
///
/// As with [`History::posterior_of`], this factorises the joint for one
/// question; [`History::joint`] amortises that across many.
///
/// # Errors
///
/// As [`History::posterior_of`], plus `UnknownKey` for a competitor with no
/// appearance at or before `time`.
pub fn posterior_of_at<Q>(
&self,
time: T,
terms: &[(&Q, f64)],
) -> Result<Gaussian, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
self.joint()?.posterior_of_at(time, terms)
}
/// How much observing this matchup would shrink the variance of `target`.
///
/// `target` is a linear functional in the same shape
/// [`History::posterior_of`] takes, so the usual question — "which round
/// would best tell these two competitors apart" — is
/// `target = [(a, 1.0), (b, -1.0)]` scored across candidate matchups.
///
/// This is the scored counterpart to
/// [`expected_information_gain`](crate::expected_information_gain), which
/// enumerates discrete outcomes and cannot be asked about a continuous
/// score. It is also far cheaper: one linear solve rather than a full
/// inference pass per possible outcome.
///
/// Scoring a field of candidates is the whole point of this call, and each
/// candidate is one question against an unchanged fit — so use
/// [`Joint::expected_variance_reduction`] for anything past a single
/// candidate, or pay for the factorisation once per candidate.
///
/// # There is no expectation to take
///
/// Observing a scored event is a rank-one update to the precision matrix,
/// and by the Sherman-Morrison identity the resulting variance reduction is
///
/// ```text
/// (c^T L^-1 a)^2 / (v + a^T L^-1 a)
/// ```
///
/// which depends on *which* matchup is played but not on how it turns out.
/// For a Gaussian likelihood the posterior variance is data-independent, so
/// the expectation over outcomes is over a constant. The name keeps the
/// term the active-learning literature uses; no averaging happens.
///
/// Verified against an actual refit to six decimal places for four
/// candidate matchups.
///
/// # Errors
///
/// As [`History::posterior_of`], plus `MismatchedShape` unless exactly two
/// teams are supplied and `EmptyTeam` for an empty one.
pub fn expected_variance_reduction<Q>(
&self,
teams: &[&[&Q]],
target: &[(&Q, f64)],
) -> Result<f64, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
self.joint()?.expected_variance_reduction(teams, target)
}
/// Factorise the joint posterior once, to answer many questions against it.
///
/// [`History::posterior_of`] and its neighbours each build and factorise
/// the joint, use it once, and drop it. The factorisation is `O(n^3)` in
/// the history's *appearances* and depends only on the fit, so a caller
/// asking about every pair in a standings table, every cell in a grid, or
/// every candidate in an active-learning sweep pays for the same
/// factorisation once per question.
/// The factorisation is `O(n^3)` in the history's *appearances* and
/// depends only on the fit, so a caller asking about every pair in a
/// standings table, every cell in a grid, or every candidate in an
/// active-learning sweep should pay for it once rather than once per
/// question. This handle is the only way to ask those questions: `History`
/// carried one-shot wrappers that re-factorised every call, and they were
/// removed in #78 precisely because the borrow here is what makes the cost
/// visible.
///
/// A `Joint` pays it once. Each subsequent query is `O(n^2)` — one forward
/// substitution — and returns exactly what the one-shot call would.
@@ -1815,7 +1675,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}
}
let skill_gap = self.posterior_of(&terms)?;
let skill_gap = self.joint()?.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))
@@ -2911,8 +2771,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
/// Posterior of a linear combination of competitors' skills.
///
/// Identical to [`History::posterior_of`], including which appearance each
/// competitor is read at, without re-paying the factorisation.
/// `terms` pairs each competitor with a coefficient, so
/// `[(a, 1.0), (b, -1.0)]` is the skill *gap* between them — with the
/// covariance between the two accounted for, which is the whole reason to
/// go through the joint rather than subtract two marginals.
///
/// Each competitor is read at their own latest appearance. Use
/// [`Joint::posterior_of_at`] to anchor the reading to a moment instead.
///
/// # Errors
///
@@ -2930,8 +2795,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
/// Posterior of a linear combination, read as of `time`.
///
/// Identical to [`History::posterior_of_at`] without re-paying the
/// factorisation.
/// As [`Joint::posterior_of`], but every competitor is read at their
/// latest appearance at or before `time` — the same reading
/// [`History::learning_curve`] gives. Use it when a comparison must be
/// anchored to a moment ("how did these two stand at the end of last
/// season") rather than to wherever each competitor was last seen.
///
/// # Errors
///
@@ -2970,9 +2838,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
/// How much observing this matchup would shrink the variance of `target`.
///
/// Identical to [`History::expected_variance_reduction`] without re-paying
/// the factorisation, which is the shape this call is normally used in:
/// one target, a field of candidate matchups, one unchanged fit.
/// `target` is a linear functional in the same shape
/// [`Joint::posterior_of`] takes: the question you want sharpened. The
/// answer is how much observing this matchup would shrink that question's
/// variance.
///
/// This is the shape the call is normally used in — one target, a field of
/// candidate matchups, one unchanged fit — which is why it lives on the
/// handle and the factorisation is paid once for the whole field.
///
/// # Errors
///