diff --git a/src/competitor.rs b/src/competitor.rs index 78b44a5..a98de47 100644 --- a/src/competitor.rs +++ b/src/competitor.rs @@ -1,5 +1,4 @@ use crate::{ - N_INF, drift::{ConstantDrift, Drift}, gaussian::Gaussian, rating::Rating, @@ -13,7 +12,14 @@ use crate::{ #[derive(Debug)] pub struct Competitor = ConstantDrift> { pub rating: Rating, - 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, pub last_time: Option, } @@ -21,14 +27,16 @@ impl> Competitor { /// Compute the message received at time `now`, with drift accumulated /// from `self.last_time` (if any) to `now`. pub(crate) fn receive(&self, now: &T) -> Gaussian { - if self.message != N_INF { - let elapsed_variance = match &self.last_time { - Some(last) => self.rating.drift.variance_delta(last, now), - None => 0.0, - }; - self.message.forget(elapsed_variance) - } else { - self.rating.prior + match self.message { + Some(message) => { + let elapsed_variance = match &self.last_time { + Some(last) => self.rating.drift.variance_delta(last, now), + None => 0.0, + }; + + message.forget(elapsed_variance) + } + None => self.rating.prior, } } @@ -37,11 +45,9 @@ impl> Competitor { /// 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). pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian { - if self.message != N_INF { - self.message - .forget(self.rating.drift.variance_for_elapsed(elapsed)) - } else { - self.rating.prior + match self.message { + Some(message) => message.forget(self.rating.drift.variance_for_elapsed(elapsed)), + None => self.rating.prior, } } } @@ -50,7 +56,7 @@ impl Default for Competitor { fn default() -> Self { Self { rating: Rating::default(), - message: N_INF, + message: None, last_time: None, } } @@ -63,7 +69,7 @@ where C: Iterator>, { for c in competitors { - c.message = N_INF; + c.message = None; if last_time { c.last_time = None; } diff --git a/src/history.rs b/src/history.rs index 8f021c6..cd90079 100644 --- a/src/history.rs +++ b/src/history.rs @@ -1,7 +1,7 @@ use std::{borrow::Borrow, collections::HashMap, hash::Hash, marker::PhantomData}; use crate::{ - BETA, GAMMA, Index, MU, N_INF, P_DRAW, SIGMA, + BETA, GAMMA, Index, MU, P_DRAW, SIGMA, competitor::{self, Competitor}, convergence::{ConvergenceOptions, ConvergenceReport}, drift::{ConstantDrift, Drift}, @@ -254,7 +254,7 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History TimeSlice { pub(crate) fn new_backward_info>(&mut self, agents: &CompetitorStore) { 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); } @@ -761,8 +761,26 @@ impl TimeSlice { } } +/// 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(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)]