use chrono::{DateTime, Duration, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OutcomeMemoryRelation {
Validated,
Used,
Contradicted,
Superseded,
Rejected,
}
impl OutcomeMemoryRelation {
#[must_use]
pub const fn advances_validation(self) -> bool {
matches!(self, Self::Validated)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CrossSessionSalience {
pub cross_session_use_count: u32,
pub first_used_at: Option<DateTime<Utc>>,
pub last_cross_session_use_at: Option<DateTime<Utc>>,
pub last_validation_at: Option<DateTime<Utc>>,
pub validation_epoch: u32,
pub blessed_until: Option<DateTime<Utc>>,
}
impl CrossSessionSalience {
#[must_use]
pub fn penalty_window_applies(&self, now: DateTime<Utc>, window: Duration) -> bool {
if self.blessed_until.is_some_and(|until| now <= until) {
return false;
}
let Some(last_cross_session_use_at) = self.last_cross_session_use_at else {
return false;
};
self.last_validation_at
.is_none_or(|validated_at| validated_at < last_cross_session_use_at - window)
}
#[must_use]
pub fn cross_session_unvalidated_penalty(
&self,
now: DateTime<Utc>,
threshold: u32,
window: Duration,
penalty: f64,
) -> f64 {
if self.cross_session_use_count < threshold || !self.penalty_window_applies(now, window) {
return 0.0;
}
let excess = self.cross_session_use_count - threshold + 1;
penalty * f64::from(excess).ln()
}
#[must_use]
pub fn should_auto_quarantine(
&self,
now: DateTime<Utc>,
quarantine_threshold: u32,
window: Duration,
) -> bool {
self.cross_session_use_count >= quarantine_threshold
&& self.penalty_window_applies(now, window)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn salience(
cross_session_use_count: u32,
last_cross_session_use_at: Option<DateTime<Utc>>,
last_validation_at: Option<DateTime<Utc>>,
blessed_until: Option<DateTime<Utc>>,
) -> CrossSessionSalience {
CrossSessionSalience {
cross_session_use_count,
first_used_at: None,
last_cross_session_use_at,
last_validation_at,
validation_epoch: 0,
blessed_until,
}
}
#[test]
fn only_validated_outcome_advances_validation() {
assert!(OutcomeMemoryRelation::Validated.advances_validation());
assert!(!OutcomeMemoryRelation::Used.advances_validation());
assert!(!OutcomeMemoryRelation::Contradicted.advances_validation());
assert!(!OutcomeMemoryRelation::Superseded.advances_validation());
assert!(!OutcomeMemoryRelation::Rejected.advances_validation());
}
#[test]
fn stale_validation_triggers_penalty_window() {
let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
let last_use = Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap();
let old_validation = Utc.with_ymd_and_hms(2026, 4, 1, 0, 0, 0).unwrap();
let state = salience(6, Some(last_use), Some(old_validation), None);
assert!(state.penalty_window_applies(now, Duration::days(14)));
assert!(state.cross_session_unvalidated_penalty(now, 5, Duration::days(14), 0.3) > 0.0);
}
#[test]
fn fresh_validation_suppresses_penalty_window() {
let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
let last_use = Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap();
let fresh_validation = Utc.with_ymd_and_hms(2026, 5, 3, 0, 0, 0).unwrap();
let state = salience(20, Some(last_use), Some(fresh_validation), None);
assert!(!state.penalty_window_applies(now, Duration::days(14)));
assert!(!state.should_auto_quarantine(now, 20, Duration::days(14)));
}
#[test]
fn active_bless_suppresses_auto_quarantine_without_resetting_fields() {
let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
let last_use = Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap();
let blessed_until = Utc.with_ymd_and_hms(2026, 5, 6, 0, 0, 0).unwrap();
let state = salience(20, Some(last_use), None, Some(blessed_until));
assert!(!state.should_auto_quarantine(now, 20, Duration::days(14)));
assert_eq!(state.cross_session_use_count, 20);
}
}