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:
+20
-2
@@ -581,7 +581,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
|
||||
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
||||
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<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 {
|
||||
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)]
|
||||
|
||||
Reference in New Issue
Block a user