fix!: make the joint span slices, not just the latest one
`posterior_of` shipped in 0.5.0 reading a single slice. Measured against a real Through-Time history that answers almost nothing: ustat's round fit is 76 per-day slices whose last one holds a solo round, so 0 of 55 pair differences resolved and the single node that did was degenerate — a one-competitor slice has no correlation to account for and returns the marginal unchanged. That was my mistake, and the fixture chose it. I validated against single-slice histories, which is exactly the shape that cannot reveal the problem. In a library whose premise is skill over time, competitors are read at *their own* last appearance and those are different slices by construction. The joint is now time-expanded: one variable per appearance, linked by the prior on a first appearance, the drift between consecutive ones, and the within-slice event contrasts. Consecutive appearances with no drift between them are the same variable rather than two joined by an infinite precision, which keeps the matrix positive-definite when a competitor is pinned with `drift_scale = 0`. `posterior_of` now reads each competitor at their own latest appearance, which is where `current_skill` reads them, so the two agree about which posterior they describe. Adds `posterior_of_at(time, terms)` for a comparison anchored to a moment, matching `learning_curve`'s reading. Validated against a hand-written exact posterior for a two-competitor, two-slice history — the precision matrix is spelled out in the test rather than obtained from the crate, so it is an independent check rather than a restatement. Also pinned: competitors last seen in different slices now compare at all, means still agree with the marginals, zero drift makes slice layout irrelevant, and more drift widens a comparison across time. BREAKING CHANGE: `posterior_of` and `expected_variance_reduction` now consider the whole history rather than its latest slice, so results change for any multi-slice history. `JointUnavailable` is now returned when *any* slice holds ranked events, not just the last. Refs #46, #47 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+27
-42
@@ -810,40 +810,26 @@ pub(crate) fn compute_elapsed<T: Time>(last: Option<&T>, current: &T) -> i64 {
|
||||
}
|
||||
|
||||
impl<T: Time> TimeSlice<T> {
|
||||
/// Precision matrix of the joint posterior over this slice's competitors.
|
||||
/// This slice's scored event factors, as contrasts over competitors.
|
||||
///
|
||||
/// Message passing produces per-competitor marginals and throws the
|
||||
/// correlation away — `Item::likelihood` is already the projection of an
|
||||
/// event's factor down onto one competitor. So the joint has to be rebuilt
|
||||
/// from the factor structure rather than recovered from the messages.
|
||||
/// event's factor onto one competitor. So a joint has to be rebuilt from
|
||||
/// the factor structure rather than recovered from the messages.
|
||||
///
|
||||
/// Usefully, a precision matrix depends only on *structure* — who played
|
||||
/// whom, with what weights and what observation noise — and not on the
|
||||
/// observed outcomes. The means are already exact (Gaussian belief
|
||||
/// propagation gets those right even with cycles), so only the second
|
||||
/// observed outcomes. The means are already exact, so only the second
|
||||
/// moment needs rebuilding.
|
||||
///
|
||||
/// Returns the competitor order and the dense matrix in row-major order.
|
||||
/// Only scored events contribute their factors exactly; see the caller.
|
||||
pub(crate) fn joint_precision<D: Drift<T>>(
|
||||
/// Each entry is a contrast and the observation variance that sits on it.
|
||||
/// Ranked events contribute nothing: their truncation factors are EP
|
||||
/// approximations that inference does not retain.
|
||||
pub(crate) fn scored_contrasts<D: Drift<T>>(
|
||||
&self,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
) -> (Vec<Index>, Vec<f64>) {
|
||||
let order: Vec<Index> = self.skills.keys().collect();
|
||||
let n = order.len();
|
||||
let mut row_of: HashMap<Index, usize> = HashMap::with_capacity(n);
|
||||
for (r, idx) in order.iter().enumerate() {
|
||||
row_of.insert(*idx, r);
|
||||
}
|
||||
|
||||
let mut lambda = vec![0.0; n * n];
|
||||
|
||||
// Everything outside this slice enters as each competitor's forward and
|
||||
// backward messages, which message passing treats as independent.
|
||||
for (r, idx) in order.iter().enumerate() {
|
||||
let skill = self.skills.get(*idx).expect("slice key has a skill");
|
||||
lambda[r * n + r] += (skill.forward * skill.backward).pi();
|
||||
}
|
||||
) -> Vec<(Vec<(Index, f64)>, f64)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
for event in &self.events {
|
||||
let EventKind::Scored { score_sigma } = event.kind else {
|
||||
@@ -851,49 +837,48 @@ impl<T: Time> TimeSlice<T> {
|
||||
};
|
||||
|
||||
// Teams best-first, matching the diff chain inference builds.
|
||||
let mut order_idx: Vec<usize> = (0..event.teams.len()).collect();
|
||||
order_idx.sort_by(|&a, &b| {
|
||||
let mut order: Vec<usize> = (0..event.teams.len()).collect();
|
||||
order.sort_by(|&a, &b| {
|
||||
event.teams[b]
|
||||
.output
|
||||
.partial_cmp(&event.teams[a].output)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
for pair in order_idx.windows(2) {
|
||||
for pair in order.windows(2) {
|
||||
let (hi, lo) = (pair[0], pair[1]);
|
||||
|
||||
// Contrast vector, and the observation noise that sits on top
|
||||
// of the skills: per-member performance noise plus the score
|
||||
// noise itself.
|
||||
let mut contrast: HashMap<usize, f64> = HashMap::new();
|
||||
let mut contrast: Vec<(Index, f64)> = Vec::new();
|
||||
let mut noise = score_sigma * score_sigma;
|
||||
|
||||
for (team, sign) in [(hi, 1.0), (lo, -1.0)] {
|
||||
for (m, item) in event.teams[team].items.iter().enumerate() {
|
||||
let w = event.weights[team][m];
|
||||
let beta = agents[item.agent].rating.beta;
|
||||
noise += w * w * beta * beta;
|
||||
*contrast.entry(row_of[&item.agent]).or_insert(0.0) += sign * w;
|
||||
noise += w * w * agents[item.agent].rating.beta.powi(2);
|
||||
contrast.push((item.agent, sign * w));
|
||||
}
|
||||
}
|
||||
|
||||
for (&i, &ci) in &contrast {
|
||||
for (&j, &cj) in &contrast {
|
||||
lambda[i * n + j] += ci * cj / noise;
|
||||
}
|
||||
}
|
||||
out.push((contrast, noise));
|
||||
}
|
||||
}
|
||||
|
||||
(order, lambda)
|
||||
out
|
||||
}
|
||||
|
||||
/// True when every event here is scored, so `joint_precision` is exact.
|
||||
/// True when every event here is scored, so the joint is exact.
|
||||
pub(crate) fn all_scored(&self) -> bool {
|
||||
self.events
|
||||
.iter()
|
||||
.all(|e| matches!(e.kind, EventKind::Scored { .. }))
|
||||
}
|
||||
|
||||
/// The competitors appearing in this slice, with the elapsed count since
|
||||
/// each one's previous appearance.
|
||||
pub(crate) fn appearances(&self) -> impl Iterator<Item = (Index, i64)> + '_ {
|
||||
self.skills
|
||||
.keys()
|
||||
.map(|idx| (idx, self.skills.get(idx).expect("slice key").elapsed))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user