refactor!: make Competitor::message an Option, and compute_elapsed loud

Two of the five remaining items on #23.

`Competitor.message` was a `Gaussian` using the improper `N_INF` as an "unset"
sentinel, so `message != N_INF` meant "has a message" and every reader had to
know that convention. It is now `Option<Gaussian>`, which makes "no message
yet" and "a legitimately improper message" distinguishable at the type level
instead of by float comparison.

Worth noting what the change surfaced: switching the type turned every read
site into a compile error, and there were eight — two in the convergence sweep,
five in ingestion, one in new_backward_info. The last is the interesting one:
`skill.backward = agents[agent].message` needed `unwrap_or(N_INF)` rather than
an unwrap, because an absent message genuinely does mean the improper identity
there. A sentinel-based refactor would have had to find that by reading.

This is a breaking change: `message` is a public field. It rides the next
minor bump.

`compute_elapsed` clamped a negative elapsed to zero silently. Negative elapsed
means slices are being visited out of time order, which would otherwise make
drift *reduce* uncertainty. Release still clamps, so a bad timestamp degrades
to "no drift" rather than corrupting a posterior, but debug now trips — getting
there is a slice-ordering bug, not something callers can cause with ordinary
data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
This commit is contained in:
2026-08-27 17:46:12 +02:00
co-authored by Claude Opus 5
parent 9b2c2b38c8
commit 06ed24b240
3 changed files with 51 additions and 27 deletions
+23 -17
View File
@@ -1,5 +1,4 @@
use crate::{ use crate::{
N_INF,
drift::{ConstantDrift, Drift}, drift::{ConstantDrift, Drift},
gaussian::Gaussian, gaussian::Gaussian,
rating::Rating, rating::Rating,
@@ -13,7 +12,14 @@ use crate::{
#[derive(Debug)] #[derive(Debug)]
pub struct Competitor<T: Time = i64, D: Drift<T> = ConstantDrift> { pub struct Competitor<T: Time = i64, D: Drift<T> = ConstantDrift> {
pub rating: Rating<T, D>, pub rating: Rating<T, D>,
pub message: Gaussian, /// The forward message carried from this competitor's last appearance, or
/// `None` before they have appeared anywhere.
///
/// Previously an improper `N_INF` served as the unset sentinel, which made
/// "no message yet" indistinguishable from "a legitimately improper
/// message" at the type level and required every reader to know the
/// convention.
pub message: Option<Gaussian>,
pub last_time: Option<T>, pub last_time: Option<T>,
} }
@@ -21,14 +27,16 @@ impl<T: Time, D: Drift<T>> Competitor<T, D> {
/// Compute the message received at time `now`, with drift accumulated /// Compute the message received at time `now`, with drift accumulated
/// from `self.last_time` (if any) to `now`. /// from `self.last_time` (if any) to `now`.
pub(crate) fn receive(&self, now: &T) -> Gaussian { pub(crate) fn receive(&self, now: &T) -> Gaussian {
if self.message != N_INF { match self.message {
let elapsed_variance = match &self.last_time { Some(message) => {
Some(last) => self.rating.drift.variance_delta(last, now), let elapsed_variance = match &self.last_time {
None => 0.0, Some(last) => self.rating.drift.variance_delta(last, now),
}; None => 0.0,
self.message.forget(elapsed_variance) };
} else {
self.rating.prior message.forget(elapsed_variance)
}
None => self.rating.prior,
} }
} }
@@ -37,11 +45,9 @@ impl<T: Time, D: Drift<T>> Competitor<T, D> {
/// Used in convergence sweeps where the elapsed was cached at slice-construction time /// Used in convergence sweeps where the elapsed was cached at slice-construction time
/// and should not be recomputed from `last_time` (which may have shifted). /// and should not be recomputed from `last_time` (which may have shifted).
pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian { pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian {
if self.message != N_INF { match self.message {
self.message Some(message) => message.forget(self.rating.drift.variance_for_elapsed(elapsed)),
.forget(self.rating.drift.variance_for_elapsed(elapsed)) None => self.rating.prior,
} else {
self.rating.prior
} }
} }
} }
@@ -50,7 +56,7 @@ impl Default for Competitor<i64, ConstantDrift> {
fn default() -> Self { fn default() -> Self {
Self { Self {
rating: Rating::default(), rating: Rating::default(),
message: N_INF, message: None,
last_time: None, last_time: None,
} }
} }
@@ -63,7 +69,7 @@ where
C: Iterator<Item = &'a mut Competitor<T, D>>, C: Iterator<Item = &'a mut Competitor<T, D>>,
{ {
for c in competitors { for c in competitors {
c.message = N_INF; c.message = None;
if last_time { if last_time {
c.last_time = None; c.last_time = None;
} }
+8 -8
View File
@@ -1,7 +1,7 @@
use std::{borrow::Borrow, collections::HashMap, hash::Hash, marker::PhantomData}; use std::{borrow::Borrow, collections::HashMap, hash::Hash, marker::PhantomData};
use crate::{ use crate::{
BETA, GAMMA, Index, MU, N_INF, P_DRAW, SIGMA, BETA, GAMMA, Index, MU, P_DRAW, SIGMA,
competitor::{self, Competitor}, competitor::{self, Competitor},
convergence::{ConvergenceOptions, ConvergenceReport}, convergence::{ConvergenceOptions, ConvergenceReport},
drift::{ConstantDrift, Drift}, drift::{ConstantDrift, Drift},
@@ -254,7 +254,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
for j in (0..self.time_slices.len() - 1).rev() { for j in (0..self.time_slices.len() - 1).rev() {
for agent in self.time_slices[j + 1].skills.keys() { for agent in self.time_slices[j + 1].skills.keys() {
self.agents.get_mut(agent).unwrap().message = self.agents.get_mut(agent).unwrap().message =
self.time_slices[j + 1].backward_prior_out(&agent, &self.agents); Some(self.time_slices[j + 1].backward_prior_out(&agent, &self.agents));
} }
let old = self.time_slices[j].posteriors(); let old = self.time_slices[j].posteriors();
@@ -273,7 +273,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
for j in 1..self.time_slices.len() { for j in 1..self.time_slices.len() {
for agent in self.time_slices[j - 1].skills.keys() { for agent in self.time_slices[j - 1].skills.keys() {
self.agents.get_mut(agent).unwrap().message = self.agents.get_mut(agent).unwrap().message =
self.time_slices[j - 1].forward_prior_out(&agent); Some(self.time_slices[j - 1].forward_prior_out(&agent));
} }
let old = self.time_slices[j].posteriors(); let old = self.time_slices[j].posteriors();
@@ -723,7 +723,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
self.drift, self.drift,
) )
}), }),
message: N_INF, message: None,
last_time: None, last_time: None,
}, },
); );
@@ -761,7 +761,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let agent = self.agents.get_mut(*agent_idx).unwrap(); let agent = self.agents.get_mut(*agent_idx).unwrap();
agent.last_time = Some(time_slice.time); agent.last_time = Some(time_slice.time);
agent.message = time_slice.forward_prior_out(agent_idx); agent.message = Some(time_slice.forward_prior_out(agent_idx));
} }
} }
@@ -794,7 +794,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let agent = self.agents.get_mut(agent_idx).unwrap(); let agent = self.agents.get_mut(agent_idx).unwrap();
agent.last_time = Some(t); agent.last_time = Some(t);
agent.message = time_slice.forward_prior_out(&agent_idx); agent.message = Some(time_slice.forward_prior_out(&agent_idx));
} }
k += 1; k += 1;
@@ -810,7 +810,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let agent = self.agents.get_mut(agent_idx).unwrap(); let agent = self.agents.get_mut(agent_idx).unwrap();
agent.last_time = Some(t); agent.last_time = Some(t);
agent.message = time_slice.forward_prior_out(&agent_idx); agent.message = Some(time_slice.forward_prior_out(&agent_idx));
} }
k += 1; k += 1;
@@ -834,7 +834,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let agent = self.agents.get_mut(*agent_idx).unwrap(); let agent = self.agents.get_mut(*agent_idx).unwrap();
agent.last_time = Some(time_slice.time); agent.last_time = Some(time_slice.time);
agent.message = time_slice.forward_prior_out(agent_idx); agent.message = Some(time_slice.forward_prior_out(agent_idx));
} }
} }
+20 -2
View File
@@ -581,7 +581,7 @@ impl<T: Time> TimeSlice<T> {
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) { pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
for (agent, skill) in self.skills.iter_mut() { for (agent, skill) in self.skills.iter_mut() {
skill.backward = agents[agent].message; skill.backward = agents[agent].message.unwrap_or(N_INF);
} }
self.iteration(0, agents); self.iteration(0, agents);
} }
@@ -761,8 +761,26 @@ impl<T: Time> TimeSlice<T> {
} }
} }
/// Elapsed time from a competitor's previous appearance to `current`.
///
/// A negative elapsed means slices are being visited out of time order, which
/// would make drift *reduce* uncertainty. Release builds clamp to zero so a
/// bad timestamp degrades to "no drift" rather than corrupting the posterior;
/// debug builds trip instead, because reaching here is a bug in slice ordering
/// rather than something callers can cause with ordinary data.
pub(crate) fn compute_elapsed<T: Time>(last: Option<&T>, current: &T) -> i64 { pub(crate) fn compute_elapsed<T: Time>(last: Option<&T>, current: &T) -> i64 {
last.map(|l| l.elapsed_to(current).max(0)).unwrap_or(0) let Some(last) = last else {
return 0;
};
let elapsed = last.elapsed_to(current);
debug_assert!(
elapsed >= 0,
"negative elapsed ({elapsed}) — slices visited out of time order"
);
elapsed.max(0)
} }
#[cfg(test)] #[cfg(test)]