jiandu_memory/memory_store/
freshness.rs1use chrono::Utc;
2
3use super::TemporalGranularity;
4use super::parse_rfc3339;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum FreshnessKind {
8 Index,
9 RecalledMemory,
10 StateLikeClaim,
11}
12
13pub const DAY_GRANULARITY_STALE_AFTER_DAYS: i64 = 7;
20
21pub const WEEK_GRANULARITY_STALE_AFTER_DAYS: i64 = 30;
26
27pub fn granularity_expired(granularity: Option<TemporalGranularity>, updated_at: &str) -> bool {
38 let Some(age_days) = memory_age_days(updated_at) else {
39 return false;
40 };
41 match granularity {
42 Some(TemporalGranularity::Day) => age_days > DAY_GRANULARITY_STALE_AFTER_DAYS,
43 Some(TemporalGranularity::Week) => age_days > WEEK_GRANULARITY_STALE_AFTER_DAYS,
44 _ => false,
45 }
46}
47
48pub fn memory_age_days(updated_at: &str) -> Option<i64> {
49 let updated_at = parse_rfc3339(updated_at)?;
50 let now = Utc::now();
51 let clamped = if updated_at > now { now } else { updated_at };
52 Some((now - clamped).num_days())
53}
54
55pub fn memory_age_label(updated_at: &str) -> Option<String> {
56 let days = memory_age_days(updated_at)?;
57 Some(match days {
58 0 => "today".to_string(),
59 1 => "1 day old".to_string(),
60 _ => format!("{days} days old"),
61 })
62}
63
64pub fn memory_freshness_text(updated_at: &str, kind: FreshnessKind) -> Option<String> {
65 let days = memory_age_days(updated_at)?;
66 if days <= 1 {
67 return None;
68 }
69
70 let age_label = memory_age_label(updated_at)?;
71 let text = match kind {
72 FreshnessKind::Index => {
73 if days <= 7 {
74 format!(
75 "Historical memory index entry ({age_label}); verify against current tools/files before treating it as live project state."
76 )
77 } else {
78 format!(
79 "Older memory index entry ({age_label}); verify against current tools/files before treating it as current project truth."
80 )
81 }
82 }
83 FreshnessKind::RecalledMemory => {
84 if days <= 7 {
85 format!(
86 "Historical memory ({age_label}); verify against current task context before treating it as still current."
87 )
88 } else {
89 format!(
90 "Older historical memory ({age_label}); verify against current tools/files before relying on it."
91 )
92 }
93 }
94 FreshnessKind::StateLikeClaim => {
95 if days <= 7 {
96 format!(
97 "Historical state-like memory ({age_label}); verify against current code/config before asserting it as fact."
98 )
99 } else {
100 format!(
101 "Older state-like memory ({age_label}); verify against current code/config before asserting it as fact."
102 )
103 }
104 }
105 };
106
107 Some(text)
108}
109
110pub fn render_memory_freshness_note(updated_at: &str, kind: FreshnessKind) -> Option<String> {
111 memory_freshness_text(updated_at, kind).map(|text| format!("Note: {text}"))
112}
113
114#[cfg(test)]
115mod tests {
116 use chrono::{Duration, Utc};
117
118 use super::*;
119
120 #[test]
121 fn same_day_timestamp_has_no_warning() {
122 let timestamp = Utc::now().to_rfc3339();
123 assert_eq!(memory_age_days(×tamp), Some(0));
124 assert_eq!(memory_age_label(×tamp).as_deref(), Some("today"));
125 assert!(memory_freshness_text(×tamp, FreshnessKind::Index).is_none());
126 }
127
128 #[test]
129 fn multi_day_timestamp_gets_warning_and_age_label() {
130 let timestamp = (Utc::now() - Duration::days(3)).to_rfc3339();
131 assert_eq!(memory_age_days(×tamp), Some(3));
132 assert_eq!(memory_age_label(×tamp).as_deref(), Some("3 days old"));
133 let text = memory_freshness_text(×tamp, FreshnessKind::RecalledMemory)
134 .expect("warning expected");
135 assert!(text.contains("3 days old"));
136 assert!(text.contains("Historical memory"));
137 }
138
139 #[test]
140 fn invalid_timestamp_degrades_safely() {
141 assert!(memory_age_days("not-a-timestamp").is_none());
142 assert!(memory_age_label("not-a-timestamp").is_none());
143 assert!(memory_freshness_text("not-a-timestamp", FreshnessKind::Index).is_none());
144 assert!(render_memory_freshness_note("not-a-timestamp", FreshnessKind::Index).is_none());
145 }
146
147 #[test]
148 fn future_timestamp_is_clamped_to_zero_age() {
149 let timestamp = (Utc::now() + Duration::days(5)).to_rfc3339();
150 assert_eq!(memory_age_days(×tamp), Some(0));
151 assert_eq!(memory_age_label(×tamp).as_deref(), Some("today"));
152 assert!(memory_freshness_text(×tamp, FreshnessKind::Index).is_none());
153 }
154
155 #[test]
156 fn day_granularity_expires_after_its_window_not_before() {
157 let just_under =
158 (Utc::now() - Duration::days(DAY_GRANULARITY_STALE_AFTER_DAYS)).to_rfc3339();
159 let just_over =
160 (Utc::now() - Duration::days(DAY_GRANULARITY_STALE_AFTER_DAYS + 1)).to_rfc3339();
161 assert!(!granularity_expired(
162 Some(TemporalGranularity::Day),
163 &just_under
164 ));
165 assert!(granularity_expired(
166 Some(TemporalGranularity::Day),
167 &just_over
168 ));
169 }
170
171 #[test]
172 fn week_granularity_uses_a_longer_window_than_day() {
173 let mid_window =
174 (Utc::now() - Duration::days(DAY_GRANULARITY_STALE_AFTER_DAYS + 1)).to_rfc3339();
175 assert!(!granularity_expired(
177 Some(TemporalGranularity::Week),
178 &mid_window
179 ));
180 let past_week_window =
181 (Utc::now() - Duration::days(WEEK_GRANULARITY_STALE_AFTER_DAYS + 1)).to_rfc3339();
182 assert!(granularity_expired(
183 Some(TemporalGranularity::Week),
184 &past_week_window
185 ));
186 }
187
188 #[test]
189 fn coarse_and_unset_granularity_never_expire() {
190 let ancient = (Utc::now() - Duration::days(10_000)).to_rfc3339();
191 assert!(!granularity_expired(None, &ancient));
192 assert!(!granularity_expired(
193 Some(TemporalGranularity::Month),
194 &ancient
195 ));
196 assert!(!granularity_expired(
197 Some(TemporalGranularity::Quarter),
198 &ancient
199 ));
200 assert!(!granularity_expired(
201 Some(TemporalGranularity::Year),
202 &ancient
203 ));
204 }
205
206 #[test]
207 fn invalid_timestamp_never_expires() {
208 assert!(!granularity_expired(
209 Some(TemporalGranularity::Day),
210 "not-a-timestamp"
211 ));
212 }
213
214 #[test]
215 fn state_like_claim_uses_stronger_wording_than_index() {
216 let timestamp = (Utc::now() - Duration::days(10)).to_rfc3339();
217 let index_text = memory_freshness_text(×tamp, FreshnessKind::Index)
218 .expect("index warning expected");
219 let state_text = memory_freshness_text(×tamp, FreshnessKind::StateLikeClaim)
220 .expect("state-like warning expected");
221 assert!(index_text.contains("current project truth"));
222 assert!(state_text.contains("current code/config"));
223 assert_ne!(index_text, state_text);
224 }
225}