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
+99
View File
@@ -0,0 +1,99 @@
//! Posterior of a linear combination of competitors.
//!
//! Every accessor on `History` returns a per-competitor marginal, and almost
//! nothing a consumer publishes is one competitor: "can we tell these two
//! apart" is a difference, "what was this round worth" is a sum. Combining
//! marginals means assuming the competitors are independent, and they are
//! correlated through every event they share — which is the mechanism the model
//! exists to exploit.
//!
//! Measured on a five-competitor round robin, the exact correlation is +0.857,
//! so `sqrt(sa^2 + sb^2)` overstates the width of a difference by 2.6x.
/// Solve `A z = b` for a symmetric positive-definite `A`, by Cholesky.
///
/// `a` is row-major and is consumed as scratch.
///
/// Returns `None` if the matrix is not positive-definite, which for a precision
/// matrix means the model is improper — a competitor with no prior and no
/// evidence.
pub(crate) fn solve_spd(mut a: Vec<f64>, b: &[f64]) -> Option<Vec<f64>> {
let n = b.len();
debug_assert_eq!(a.len(), n * n);
// In-place Cholesky: A = L L^T, lower triangle.
for j in 0..n {
let mut d = a[j * n + j];
for k in 0..j {
d -= a[j * n + k] * a[j * n + k];
}
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here too,
// and a negated comparison would let it through as "not positive".
if d.is_nan() || d <= 0.0 {
return None;
}
let d = d.sqrt();
a[j * n + j] = d;
for i in j + 1..n {
let mut s = a[i * n + j];
for k in 0..j {
s -= a[i * n + k] * a[j * n + k];
}
a[i * n + j] = s / d;
}
}
// Forward substitution, then back substitution.
let mut z = b.to_vec();
for i in 0..n {
let mut s = z[i];
for k in 0..i {
s -= a[i * n + k] * z[k];
}
z[i] = s / a[i * n + i];
}
for i in (0..n).rev() {
let mut s = z[i];
for k in i + 1..n {
s -= a[k * n + i] * z[k];
}
z[i] = s / a[i * n + i];
}
Some(z)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn solves_a_known_system() {
// [[4, 1], [1, 3]] z = [1, 2] => z = [1/11, 7/11]
let a = vec![4.0, 1.0, 1.0, 3.0];
let z = solve_spd(a, &[1.0, 2.0]).unwrap();
assert!((z[0] - 1.0 / 11.0).abs() < 1e-12, "{z:?}");
assert!((z[1] - 7.0 / 11.0).abs() < 1e-12, "{z:?}");
}
#[test]
fn recovers_the_inverse_diagonal() {
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
// [0.75, 1.0, 0.75].
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
let mut e = vec![0.0; 3];
e[i] = 1.0;
let z = solve_spd(a.clone(), &e).unwrap();
assert!((z[i] - expected).abs() < 1e-12, "row {i}: {z:?}");
}
}
#[test]
fn rejects_a_non_positive_definite_matrix() {
// Singular: the second row is a multiple of the first.
let a = vec![1.0, 2.0, 2.0, 4.0];
assert!(solve_spd(a, &[1.0, 1.0]).is_none());
}
}