1use std::sync::Arc;
16
17use tokio::task::JoinHandle;
18use tokio::time::{Duration, interval};
19use tokio_util::sync::CancellationToken;
20
21use crate::error::MemoryError;
22use crate::sqlite::SqliteStore;
23use crate::types::MessageId;
24
25#[derive(Debug, Clone)]
29pub struct EvictionEntry {
30 pub id: MessageId,
31 pub created_at: String,
33 pub last_accessed: Option<String>,
35 pub access_count: u32,
37}
38
39pub trait EvictionPolicy: Send + Sync {
43 fn score(&self, entry: &EvictionEntry) -> f64;
48}
49
50#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
52#[serde(default)]
53pub struct EvictionConfig {
54 pub policy: String,
56 pub max_entries: usize,
58 pub sweep_interval_secs: u64,
60}
61
62impl Default for EvictionConfig {
63 fn default() -> Self {
64 Self {
65 policy: "ebbinghaus".to_owned(),
66 max_entries: 0,
67 sweep_interval_secs: 3600,
68 }
69 }
70}
71
72pub struct EbbinghausPolicy {
87 retention_strength: f64,
88}
89
90impl EbbinghausPolicy {
91 #[must_use]
95 pub fn new(retention_strength: f64) -> Self {
96 Self { retention_strength }
97 }
98}
99
100impl Default for EbbinghausPolicy {
101 fn default() -> Self {
102 Self::new(86_400.0) }
104}
105
106impl EvictionPolicy for EbbinghausPolicy {
107 fn score(&self, entry: &EvictionEntry) -> f64 {
108 let now_secs = unix_now_secs();
109
110 let reference_secs = entry
111 .last_accessed
112 .as_deref()
113 .and_then(parse_sqlite_timestamp_secs)
114 .unwrap_or_else(|| parse_sqlite_timestamp_secs(&entry.created_at).unwrap_or(now_secs));
115
116 #[allow(clippy::cast_precision_loss)]
118 let t = now_secs.saturating_sub(reference_secs) as f64;
119 let n = f64::from(entry.access_count);
120
121 let denominator = (self.retention_strength * (1.0_f64 + n).ln()).max(1.0);
123 (-t / denominator).exp()
124 }
125}
126
127fn unix_now_secs() -> u64 {
128 std::time::SystemTime::now()
129 .duration_since(std::time::UNIX_EPOCH)
130 .map(|d| d.as_secs())
131 .unwrap_or(0)
132}
133
134fn parse_sqlite_timestamp_secs(s: &str) -> Option<u64> {
138 let s = s.trim();
140 if s.len() < 19 {
141 return None;
142 }
143 let year: u64 = s[0..4].parse().ok()?;
144 let month: u64 = s[5..7].parse().ok()?;
145 let day: u64 = s[8..10].parse().ok()?;
146 let hour: u64 = s[11..13].parse().ok()?;
147 let min: u64 = s[14..16].parse().ok()?;
148 let sec: u64 = s[17..19].parse().ok()?;
149
150 let is_leap = |y: u64| (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
153 let days_in_month = |y: u64, m: u64| -> u64 {
154 match m {
155 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
156 4 | 6 | 9 | 11 => 30,
157 2 => {
158 if is_leap(y) {
159 29
160 } else {
161 28
162 }
163 }
164 _ => 0,
165 }
166 };
167
168 let mut days: u64 = 0;
169 for y in 1970..year {
170 days += if is_leap(y) { 366 } else { 365 };
171 }
172 for m in 1..month {
173 days += days_in_month(year, m);
174 }
175 days += day.saturating_sub(1);
176
177 Some(days * 86400 + hour * 3600 + min * 60 + sec)
178}
179
180pub fn start_eviction_loop(
197 store: Arc<SqliteStore>,
198 config: &EvictionConfig,
199 policy: Arc<dyn EvictionPolicy + 'static>,
200 cancel: CancellationToken,
201) -> JoinHandle<()> {
202 let config = config.clone();
203 tokio::spawn(async move {
204 if config.max_entries == 0 {
205 tracing::debug!("eviction disabled (max_entries = 0)");
206 return;
207 }
208
209 let mut ticker = interval(Duration::from_secs(config.sweep_interval_secs));
210 ticker.tick().await;
212
213 loop {
214 tokio::select! {
215 () = cancel.cancelled() => {
216 tracing::debug!("eviction loop shutting down");
217 return;
218 }
219 _ = ticker.tick() => {}
220 }
221
222 tracing::debug!(max_entries = config.max_entries, "running eviction sweep");
223
224 match run_eviction_phase1(&store, &*policy, config.max_entries).await {
226 Ok(deleted) => {
227 if deleted > 0 {
228 tracing::info!(deleted, "eviction phase 1: soft-deleted entries");
229 }
230 }
231 Err(e) => {
232 tracing::warn!(error = %e, "eviction phase 1 failed, will retry next sweep");
233 }
234 }
235
236 match run_eviction_phase2(&store).await {
239 Ok(cleaned) => {
240 if cleaned > 0 {
241 tracing::info!(cleaned, "eviction phase 2: removed Qdrant vectors");
242 }
243 }
244 Err(e) => {
245 tracing::warn!(error = %e, "eviction phase 2 failed, will retry next sweep");
246 }
247 }
248 }
249 })
250}
251
252async fn run_eviction_phase1(
253 store: &SqliteStore,
254 policy: &dyn EvictionPolicy,
255 max_entries: usize,
256) -> Result<usize, MemoryError> {
257 let candidates = store.get_eviction_candidates().await?;
258 let total = candidates.len();
259
260 if total <= max_entries {
261 return Ok(0);
262 }
263
264 let excess = total - max_entries;
265 let mut scored: Vec<(f64, MessageId)> = candidates
266 .into_iter()
267 .map(|e| (policy.score(&e), e.id))
268 .collect();
269
270 scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
272
273 let ids_to_delete: Vec<MessageId> = scored.into_iter().take(excess).map(|(_, id)| id).collect();
274 store.soft_delete_messages(&ids_to_delete).await?;
275
276 Ok(ids_to_delete.len())
277}
278
279async fn run_eviction_phase2(store: &SqliteStore) -> Result<usize, MemoryError> {
280 let ids = store.get_soft_deleted_message_ids().await?;
282 if ids.is_empty() {
283 return Ok(0);
284 }
285
286 tracing::warn!(
291 count = ids.len(),
292 "eviction phase 2: Qdrant vector removal not yet wired — marking cleaned without actual deletion (MVP)"
293 );
294
295 store.mark_qdrant_cleaned(&ids).await?;
298 Ok(ids.len())
299}
300
301#[cfg(test)]
304mod tests {
305 use super::*;
306
307 fn ts_ago(seconds_ago: u64) -> String {
311 let ts = unix_now_secs().saturating_sub(seconds_ago);
312 let sec = ts % 60;
314 let min = (ts / 60) % 60;
315 let hour = (ts / 3600) % 24;
316 let mut total_days = ts / 86400;
317 let is_leap =
318 |y: u64| (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
319 let mut year = 1970u64;
320 loop {
321 let days_in_year = if is_leap(year) { 366 } else { 365 };
322 if total_days < days_in_year {
323 break;
324 }
325 total_days -= days_in_year;
326 year += 1;
327 }
328 let month_days = [
329 0u64,
330 31,
331 28 + u64::from(is_leap(year)),
332 31,
333 30,
334 31,
335 30,
336 31,
337 31,
338 30,
339 31,
340 30,
341 31,
342 ];
343 let mut month = 1u64;
344 while month <= 12 && total_days >= month_days[month as usize] {
345 total_days -= month_days[month as usize];
346 month += 1;
347 }
348 let day = total_days + 1;
349 format!("{year:04}-{month:02}-{day:02} {hour:02}:{min:02}:{sec:02}")
350 }
351
352 fn make_entry(access_count: u32, seconds_ago: u64) -> EvictionEntry {
353 let ts = ts_ago(seconds_ago);
354 EvictionEntry {
355 id: MessageId(1),
356 created_at: ts.clone(),
357 last_accessed: Some(ts),
358 access_count,
359 }
360 }
361
362 #[test]
363 fn ebbinghaus_recent_high_access_scores_near_one() {
364 let policy = EbbinghausPolicy::default();
365 let entry = make_entry(10, 1);
367 let score = policy.score(&entry);
368 assert!(
370 score > 0.99,
371 "score should be near 1.0 for recently accessed entry, got {score}"
372 );
373 }
374
375 #[test]
376 fn ebbinghaus_old_zero_access_scores_lower() {
377 let policy = EbbinghausPolicy::default();
378 let old = make_entry(0, 7 * 24 * 3600); let recent = make_entry(0, 60); assert!(
381 policy.score(&old) < policy.score(&recent),
382 "old entry must score lower than recent"
383 );
384 }
385
386 #[test]
387 fn ebbinghaus_high_access_decays_slower() {
388 let policy = EbbinghausPolicy::default();
389 let low = make_entry(1, 3600); let high = make_entry(20, 3600); assert!(
392 policy.score(&high) > policy.score(&low),
393 "high access count should yield higher score"
394 );
395 }
396
397 #[test]
398 fn ebbinghaus_never_accessed_uses_created_at_as_reference() {
399 let policy = EbbinghausPolicy::default();
400 let old_with_no_last_accessed = EvictionEntry {
403 id: MessageId(2),
404 created_at: ts_ago(7 * 24 * 3600),
405 last_accessed: None,
406 access_count: 0,
407 };
408 let old_with_same_last_accessed = make_entry(0, 7 * 24 * 3600);
409 let score_no_access = policy.score(&old_with_no_last_accessed);
410 let score_same = policy.score(&old_with_same_last_accessed);
411 let diff = (score_no_access - score_same).abs();
413 assert!(diff < 1e-6, "scores should match; diff = {diff}");
414 }
415
416 #[test]
417 fn eviction_config_default_is_disabled() {
418 let config = EvictionConfig::default();
419 assert_eq!(
420 config.max_entries, 0,
421 "eviction must be disabled by default"
422 );
423 }
424
425 #[test]
426 fn parse_sqlite_timestamp_known_value() {
427 let ts = parse_sqlite_timestamp_secs("2024-01-01 00:00:00").unwrap();
429 assert_eq!(
432 ts, 1_704_067_200,
433 "2024-01-01 must parse to known timestamp"
434 );
435 }
436}