1use chrono::{DateTime, Duration, Utc};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
9#[serde(rename_all = "snake_case")]
10pub enum OutcomeMemoryRelation {
11 Validated,
13 Used,
15 Contradicted,
17 Superseded,
19 Rejected,
21}
22
23impl OutcomeMemoryRelation {
24 #[must_use]
26 pub const fn advances_validation(self) -> bool {
27 matches!(self, Self::Validated)
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33pub struct CrossSessionSalience {
34 pub cross_session_use_count: u32,
36 pub first_used_at: Option<DateTime<Utc>>,
38 pub last_cross_session_use_at: Option<DateTime<Utc>>,
40 pub last_validation_at: Option<DateTime<Utc>>,
42 pub validation_epoch: u32,
44 pub blessed_until: Option<DateTime<Utc>>,
46}
47
48impl CrossSessionSalience {
49 #[must_use]
51 pub fn penalty_window_applies(&self, now: DateTime<Utc>, window: Duration) -> bool {
52 if self.blessed_until.is_some_and(|until| now <= until) {
53 return false;
54 }
55
56 let Some(last_cross_session_use_at) = self.last_cross_session_use_at else {
57 return false;
58 };
59
60 self.last_validation_at
61 .is_none_or(|validated_at| validated_at < last_cross_session_use_at - window)
62 }
63
64 #[must_use]
66 pub fn cross_session_unvalidated_penalty(
67 &self,
68 now: DateTime<Utc>,
69 threshold: u32,
70 window: Duration,
71 penalty: f64,
72 ) -> f64 {
73 if self.cross_session_use_count < threshold || !self.penalty_window_applies(now, window) {
74 return 0.0;
75 }
76
77 let excess = self.cross_session_use_count - threshold + 1;
78 penalty * f64::from(excess).ln()
79 }
80
81 #[must_use]
83 pub fn should_auto_quarantine(
84 &self,
85 now: DateTime<Utc>,
86 quarantine_threshold: u32,
87 window: Duration,
88 ) -> bool {
89 self.cross_session_use_count >= quarantine_threshold
90 && self.penalty_window_applies(now, window)
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use chrono::TimeZone;
98
99 fn salience(
100 cross_session_use_count: u32,
101 last_cross_session_use_at: Option<DateTime<Utc>>,
102 last_validation_at: Option<DateTime<Utc>>,
103 blessed_until: Option<DateTime<Utc>>,
104 ) -> CrossSessionSalience {
105 CrossSessionSalience {
106 cross_session_use_count,
107 first_used_at: None,
108 last_cross_session_use_at,
109 last_validation_at,
110 validation_epoch: 0,
111 blessed_until,
112 }
113 }
114
115 #[test]
116 fn only_validated_outcome_advances_validation() {
117 assert!(OutcomeMemoryRelation::Validated.advances_validation());
118 assert!(!OutcomeMemoryRelation::Used.advances_validation());
119 assert!(!OutcomeMemoryRelation::Contradicted.advances_validation());
120 assert!(!OutcomeMemoryRelation::Superseded.advances_validation());
121 assert!(!OutcomeMemoryRelation::Rejected.advances_validation());
122 }
123
124 #[test]
125 fn stale_validation_triggers_penalty_window() {
126 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
127 let last_use = Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap();
128 let old_validation = Utc.with_ymd_and_hms(2026, 4, 1, 0, 0, 0).unwrap();
129 let state = salience(6, Some(last_use), Some(old_validation), None);
130
131 assert!(state.penalty_window_applies(now, Duration::days(14)));
132 assert!(state.cross_session_unvalidated_penalty(now, 5, Duration::days(14), 0.3) > 0.0);
133 }
134
135 #[test]
136 fn fresh_validation_suppresses_penalty_window() {
137 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
138 let last_use = Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap();
139 let fresh_validation = Utc.with_ymd_and_hms(2026, 5, 3, 0, 0, 0).unwrap();
140 let state = salience(20, Some(last_use), Some(fresh_validation), None);
141
142 assert!(!state.penalty_window_applies(now, Duration::days(14)));
143 assert!(!state.should_auto_quarantine(now, 20, Duration::days(14)));
144 }
145
146 #[test]
147 fn active_bless_suppresses_auto_quarantine_without_resetting_fields() {
148 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
149 let last_use = Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap();
150 let blessed_until = Utc.with_ymd_and_hms(2026, 5, 6, 0, 0, 0).unwrap();
151 let state = salience(20, Some(last_use), None, Some(blessed_until));
152
153 assert!(!state.should_auto_quarantine(now, 20, Duration::days(14)));
154 assert_eq!(state.cross_session_use_count, 20);
155 }
156}