Skip to main content

jiandu_memory/memory_store/
freshness.rs

1use 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
13/// Age (days, based on `updated_at`) after which a `day`-granularity memory is
14/// conservatively marked Stale by the freshness gardener (issue #61 phase 2).
15/// Day-level memories are meant to capture today's working context, so roughly a
16/// week without a touch is a reasonable, conservative staleness signal — long
17/// enough to survive a normal weekend gap, short enough that genuinely stale
18/// day-level context doesn't linger in recall for weeks.
19pub const DAY_GRANULARITY_STALE_AFTER_DAYS: i64 = 7;
20
21/// Age (days, based on `updated_at`) after which a `week`-granularity memory is
22/// conservatively marked Stale. Sprint-level content is expected to stay relevant
23/// roughly a month, so this is looser than the day threshold — weekly memories
24/// shouldn't be churned as aggressively as daily ones.
25pub const WEEK_GRANULARITY_STALE_AFTER_DAYS: i64 = 30;
26
27/// Whether a memory with the given (optional) granularity has crossed its
28/// conservative auto-staleness window, based on `updated_at` age. Only `day` and
29/// `week` — the two granularities the issue's Prefix Cache Friendly constraint
30/// calls "high churn" (see [`TemporalGranularity::is_high_churn`]) — ever expire
31/// here; `None` and month/quarter/year never do, since those are meant to be
32/// long-lived by design and the freshness gardener must stay conservative (no
33/// silent reclassification of memories that were never tagged with a temporal
34/// dimension). Callers are expected to additionally gate on
35/// `frontmatter.status == Active` before acting — this is purely the time-based
36/// predicate.
37pub 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(&timestamp), Some(0));
124        assert_eq!(memory_age_label(&timestamp).as_deref(), Some("today"));
125        assert!(memory_freshness_text(&timestamp, 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(&timestamp), Some(3));
132        assert_eq!(memory_age_label(&timestamp).as_deref(), Some("3 days old"));
133        let text = memory_freshness_text(&timestamp, 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(&timestamp), Some(0));
151        assert_eq!(memory_age_label(&timestamp).as_deref(), Some("today"));
152        assert!(memory_freshness_text(&timestamp, 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        // Past the day threshold but still inside the week threshold.
176        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(&timestamp, FreshnessKind::Index)
218            .expect("index warning expected");
219        let state_text = memory_freshness_text(&timestamp, 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}