Skip to main content

zeph_memory/
eviction.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Memory eviction subsystem.
5//!
6//! Provides a trait-based eviction policy framework with an Ebbinghaus
7//! forgetting curve implementation. The background sweep loop runs
8//! periodically, scoring entries and soft-deleting the lowest-scoring ones
9//! from `SQLite` before removing their `Qdrant` vectors in a second phase.
10//!
11//! Two-phase design ensures crash safety: soft-deleted `SQLite` rows are
12//! invisible to the application immediately, and `Qdrant` cleanup is retried
13//! on the next sweep if the agent crashes between phases.
14
15use std::sync::Arc;
16
17use tokio::time::{Duration, interval};
18use tokio_util::sync::CancellationToken;
19
20use crate::embedding_store::EmbeddingStore;
21use crate::error::MemoryError;
22use crate::sqlite_time::parse_sqlite_datetime_to_unix;
23use crate::store::SqliteStore;
24use crate::types::MessageId;
25
26// ── Public types ──────────────────────────────────────────────────────────────
27
28/// Metadata for a single memory entry evaluated by [`EvictionPolicy::score`].
29#[derive(Debug, Clone)]
30pub struct EvictionEntry {
31    /// `SQLite` row ID of the message.
32    pub id: MessageId,
33    /// ISO 8601 creation timestamp (TEXT column from `SQLite`, UTC).
34    pub created_at: String,
35    /// ISO 8601 last-accessed timestamp, or `None` if never accessed after creation.
36    pub last_accessed: Option<String>,
37    /// Number of times this message has been retrieved via recall.
38    pub access_count: u32,
39}
40
41/// Trait for eviction scoring strategies.
42///
43/// Implementations must be `Send + Sync` so they can be shared across threads.
44pub trait EvictionPolicy: Send + Sync {
45    /// Compute a retention score for the given entry.
46    ///
47    /// Higher scores mean the entry is more likely to be retained.
48    /// Lower scores mean the entry is a candidate for eviction.
49    fn score(&self, entry: &EvictionEntry) -> f64;
50}
51
52use zeph_config::memory::EvictionConfig;
53
54// ── Ebbinghaus policy ─────────────────────────────────────────────────────────
55
56/// Ebbinghaus forgetting curve eviction policy.
57///
58/// Score formula:
59///   `score = exp(-t / (S * ln(1 + n)))`
60///
61/// Where:
62/// - `t` = seconds since `last_accessed` (or `created_at` if never accessed)
63/// - `S` = `retention_strength` (higher = slower decay)
64/// - `n` = `access_count`
65///
66/// Entries with a high access count or recent access get higher scores
67/// and are less likely to be evicted.
68pub struct EbbinghausPolicy {
69    retention_strength: f64,
70}
71
72impl EbbinghausPolicy {
73    /// Create a new policy with the given retention strength.
74    ///
75    /// A good default is `86400.0` (one day in seconds).
76    #[must_use]
77    pub fn new(retention_strength: f64) -> Self {
78        Self { retention_strength }
79    }
80}
81
82impl Default for EbbinghausPolicy {
83    fn default() -> Self {
84        Self::new(86_400.0) // 1 day
85    }
86}
87
88impl EvictionPolicy for EbbinghausPolicy {
89    fn score(&self, entry: &EvictionEntry) -> f64 {
90        let now_secs = unix_now_secs();
91
92        let reference_secs = entry
93            .last_accessed
94            .as_deref()
95            .and_then(parse_sqlite_timestamp_secs)
96            .unwrap_or_else(|| parse_sqlite_timestamp_secs(&entry.created_at).unwrap_or(now_secs));
97
98        // Clamp t >= 0 to handle clock skew or future timestamps.
99        #[allow(clippy::cast_precision_loss)]
100        let t = now_secs.saturating_sub(reference_secs) as f64;
101        let n = f64::from(entry.access_count);
102
103        // ln(1 + 0) = 0 which would divide by zero — use 1.0 as minimum denominator.
104        let denominator = (self.retention_strength * (1.0_f64 + n).ln()).max(1.0);
105        (-t / denominator).exp()
106    }
107}
108
109fn unix_now_secs() -> u64 {
110    std::time::SystemTime::now()
111        .duration_since(std::time::UNIX_EPOCH)
112        .map_or(0, |d| d.as_secs())
113}
114
115/// Parse a `SQLite` TEXT timestamp ("YYYY-MM-DD HH:MM:SS") into Unix seconds.
116///
117/// Thin `u64` wrapper around [`parse_sqlite_datetime_to_unix`] — eviction timestamps are
118/// always post-epoch, so the conversion only fails on a (impossible in practice) negative
119/// parse result.
120///
121/// # Behavior note (pre-1970 / negative timestamps)
122///
123/// A pre-1970 (or otherwise negative-computing) input now returns `None` here, whereas the
124/// old hand-rolled `u64`-only parser this replaced silently returned `Some(0)` for such
125/// input (its `for y in 1970..year` day-accumulation loop is simply empty when `year <
126/// 1970`, contributing zero days with no error). That old behavior treated a corrupt
127/// timestamp as "epoch" — maximally old, strongly favoring eviction. This function instead
128/// treats it as unparseable, so [`EbbinghausPolicy::score`]'s `last_accessed → created_at →
129/// now_secs` fallback chain falls through to the next signal (or `now_secs`, i.e. "brand
130/// new" — the *opposite* eviction bias) instead. `last_accessed`/`created_at` are always
131/// application-generated `datetime('now')` strings, so a real pre-1970 value should never
132/// occur outside data corruption — this divergence is believed unreachable in practice, but
133/// is intentional and pinned by a test rather than accidental.
134fn parse_sqlite_timestamp_secs(s: &str) -> Option<u64> {
135    u64::try_from(parse_sqlite_datetime_to_unix(s)?).ok()
136}
137
138// ── Sweep loop ────────────────────────────────────────────────────────────────
139
140/// Start the background eviction loop.
141///
142/// The loop runs every `config.sweep_interval_secs` seconds. Each iteration:
143/// 1. Queries `SQLite` for all non-deleted entries and their eviction metadata.
144/// 2. Scores each entry using `policy`.
145/// 3. If the count exceeds `config.max_entries`, soft-deletes the excess lowest-scoring rows.
146/// 4. Queries for all soft-deleted rows, deletes their Qdrant vectors via `embedding` (when
147///    `Some`), then marks the rows clean. If deletion fails, clean-up is retried next sweep.
148///
149/// If `config.max_entries == 0`, the loop exits immediately without doing anything.
150///
151/// Pass `embedding: None` when Qdrant is disabled — the loop will still clean `SQLite`
152/// bookkeeping without attempting any vector deletion.
153///
154/// # Errors (non-fatal)
155///
156/// Database and Qdrant errors are logged but do not stop the loop.
157#[tracing::instrument(name = "memory.eviction.start_loop", skip_all)]
158pub async fn start_eviction_loop(
159    store: Arc<SqliteStore>,
160    embedding: Option<Arc<EmbeddingStore>>,
161    config: EvictionConfig,
162    policy: Arc<dyn EvictionPolicy + 'static>,
163    cancel: CancellationToken,
164) {
165    if config.max_entries == 0 {
166        tracing::debug!("eviction disabled (max_entries = 0)");
167        return;
168    }
169
170    let mut ticker = interval(Duration::from_secs(config.sweep_interval_secs));
171    // Skip the first immediate tick so the loop doesn't run at startup.
172    ticker.tick().await;
173
174    loop {
175        tokio::select! {
176            () = cancel.cancelled() => {
177                tracing::debug!("eviction loop shutting down");
178                return;
179            }
180            _ = ticker.tick() => {}
181        }
182
183        tracing::debug!(max_entries = config.max_entries, "running eviction sweep");
184
185        // Phase 1: score and soft-delete excess entries.
186        match run_eviction_phase1(&store, &*policy, config.max_entries).await {
187            Ok(deleted) => {
188                if deleted > 0 {
189                    tracing::info!(deleted, "eviction phase 1: soft-deleted entries");
190                }
191            }
192            Err(e) => {
193                tracing::warn!(error = %e, "eviction phase 1 failed, will retry next sweep");
194            }
195        }
196
197        // Phase 2: delete Qdrant vectors for soft-deleted entries, then mark them clean.
198        // On startup or after a crash this also cleans up any orphaned vectors.
199        match run_eviction_phase2(&store, embedding.as_deref()).await {
200            Ok(cleaned) => {
201                if cleaned > 0 {
202                    tracing::info!(cleaned, "eviction phase 2: removed Qdrant vectors");
203                }
204            }
205            Err(e) => {
206                tracing::warn!(error = %e, "eviction phase 2 failed, will retry next sweep");
207            }
208        }
209    }
210}
211
212#[cfg_attr(
213    feature = "profiling",
214    tracing::instrument(name = "memory.eviction_phase1", skip_all)
215)]
216async fn run_eviction_phase1(
217    store: &SqliteStore,
218    policy: &dyn EvictionPolicy,
219    max_entries: usize,
220) -> Result<usize, MemoryError> {
221    let candidates = store.get_eviction_candidates().await?;
222    let total = candidates.len();
223
224    if total <= max_entries {
225        return Ok(0);
226    }
227
228    let excess = total - max_entries;
229    let mut scored: Vec<(f64, MessageId)> = candidates
230        .into_iter()
231        .map(|e| (policy.score(&e), e.id))
232        .collect();
233
234    // Sort ascending by score — lowest scores (most forgettable) first.
235    scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
236
237    let candidates_to_delete: Vec<MessageId> =
238        scored.into_iter().take(excess).map(|(_, id)| id).collect();
239    let candidate_count = candidates_to_delete.len();
240    // MM-F4: always protect messages referenced by summaries (data-integrity invariant, #3341).
241    // `ids_to_delete` is the post-filter set; it may be smaller than `candidate_count` when
242    // summary-anchored messages are present. The return value reflects **actually soft-deleted**
243    // messages, not the original candidate count.
244    let ids_to_delete = store
245        .filter_out_preserved_episode_ids(&candidates_to_delete)
246        .await?;
247    let preserved = candidate_count - ids_to_delete.len();
248    if preserved > 0 {
249        tracing::debug!(
250            preserved,
251            deleted = ids_to_delete.len(),
252            "eviction phase 1: {preserved} candidate(s) preserved by summary anchor"
253        );
254    }
255    store.soft_delete_messages(&ids_to_delete).await?;
256
257    // Returns the number of messages actually soft-deleted (post-filter), not the candidate count.
258    Ok(ids_to_delete.len())
259}
260
261#[cfg_attr(
262    feature = "profiling",
263    tracing::instrument(name = "memory.eviction_phase2", skip_all)
264)]
265async fn run_eviction_phase2(
266    store: &SqliteStore,
267    embedding: Option<&EmbeddingStore>,
268) -> Result<usize, MemoryError> {
269    // Find all soft-deleted entries that haven't been cleaned from Qdrant yet.
270    let ids = store.get_soft_deleted_message_ids().await?;
271    if ids.is_empty() {
272        return Ok(0);
273    }
274
275    if let Some(emb) = embedding {
276        // Delete vectors before marking clean — crash-safe: if this fails, the next
277        // sweep retries (ids are still soft-deleted and not yet marked clean).
278        emb.delete_by_message_ids(&ids).await?;
279    } else {
280        tracing::debug!(
281            count = ids.len(),
282            "eviction phase 2: Qdrant disabled, cleaning SQLite bookkeeping only"
283        );
284    }
285
286    store.mark_qdrant_cleaned(&ids).await?;
287    Ok(ids.len())
288}
289
290// ── Tests ─────────────────────────────────────────────────────────────────────
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    /// Build a timestamp string for a time N seconds ago from now.
297    ///
298    /// Returns a string parseable by `parse_sqlite_timestamp_secs`.
299    fn ts_ago(seconds_ago: u64) -> String {
300        let ts = unix_now_secs().saturating_sub(seconds_ago);
301        // Convert back to "YYYY-MM-DD HH:MM:SS" using the same logic as parse_sqlite_timestamp_secs
302        let sec = ts % 60;
303        let min = (ts / 60) % 60;
304        let hour = (ts / 3600) % 24;
305        let mut total_days = ts / 86400;
306        let is_leap =
307            |y: u64| (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
308        let mut year = 1970u64;
309        loop {
310            let days_in_year = if is_leap(year) { 366 } else { 365 };
311            if total_days < days_in_year {
312                break;
313            }
314            total_days -= days_in_year;
315            year += 1;
316        }
317        let month_days = [
318            0u64,
319            31,
320            28 + u64::from(is_leap(year)),
321            31,
322            30,
323            31,
324            30,
325            31,
326            31,
327            30,
328            31,
329            30,
330            31,
331        ];
332        let mut month = 1u64;
333        while month <= 12 {
334            let month_idx = usize::try_from(month).unwrap_or_else(|_| unreachable!());
335            if total_days < month_days[month_idx] {
336                break;
337            }
338            total_days -= month_days[month_idx];
339            month += 1;
340        }
341        let day = total_days + 1;
342        format!("{year:04}-{month:02}-{day:02} {hour:02}:{min:02}:{sec:02}")
343    }
344
345    fn make_entry(access_count: u32, seconds_ago: u64) -> EvictionEntry {
346        let ts = ts_ago(seconds_ago);
347        EvictionEntry {
348            id: MessageId(1),
349            created_at: ts.clone(),
350            last_accessed: Some(ts),
351            access_count,
352        }
353    }
354
355    #[test]
356    fn ebbinghaus_recent_high_access_scores_near_one() {
357        let policy = EbbinghausPolicy::default();
358        // Use 1 second ago to ensure t is close to 0
359        let entry = make_entry(10, 1);
360        let score = policy.score(&entry);
361        // t = 1, n = 10, denominator = 86400 * ln(11) ≈ 207_946; exp(-1/207_946) ≈ 1.0
362        assert!(
363            score > 0.99,
364            "score should be near 1.0 for recently accessed entry, got {score}"
365        );
366    }
367
368    #[test]
369    fn ebbinghaus_old_zero_access_scores_lower() {
370        let policy = EbbinghausPolicy::default();
371        let old = make_entry(0, 7 * 24 * 3600); // 7 days ago, never accessed
372        let recent = make_entry(0, 60); // 1 minute ago
373        assert!(
374            policy.score(&old) < policy.score(&recent),
375            "old entry must score lower than recent"
376        );
377    }
378
379    #[test]
380    fn ebbinghaus_high_access_decays_slower() {
381        let policy = EbbinghausPolicy::default();
382        let low = make_entry(1, 3600); // accessed 1 hour ago, 1 time
383        let high = make_entry(20, 3600); // accessed 1 hour ago, 20 times
384        assert!(
385            policy.score(&high) > policy.score(&low),
386            "high access count should yield higher score"
387        );
388    }
389
390    #[test]
391    fn ebbinghaus_never_accessed_uses_created_at_as_reference() {
392        let policy = EbbinghausPolicy::default();
393        // An old entry (7 days ago) with last_accessed = None.
394        // Score should be the same as make_entry(0, 7 days) because both use created_at.
395        let old_with_no_last_accessed = EvictionEntry {
396            id: MessageId(2),
397            created_at: ts_ago(7 * 24 * 3600),
398            last_accessed: None,
399            access_count: 0,
400        };
401        let old_with_same_last_accessed = make_entry(0, 7 * 24 * 3600);
402        let score_no_access = policy.score(&old_with_no_last_accessed);
403        let score_same = policy.score(&old_with_same_last_accessed);
404        // Both reference the same time; scores should be approximately equal
405        let diff = (score_no_access - score_same).abs();
406        assert!(diff < 1e-6, "scores should match; diff = {diff}");
407    }
408
409    #[test]
410    fn eviction_config_default_is_disabled() {
411        let config = EvictionConfig::default();
412        assert_eq!(
413            config.max_entries, 0,
414            "eviction must be disabled by default"
415        );
416    }
417
418    // ── MM-F4: eviction preserves summary-anchored messages ───────────────────
419
420    #[tokio::test]
421    async fn test_eviction_preserves_summary_anchored_messages() {
422        use crate::store::SqliteStore;
423
424        let store = SqliteStore::new(":memory:").await.unwrap();
425        let cid = store.create_conversation().await.unwrap();
426
427        // Insert 6 messages (max_entries=3 → 3 excess candidates).
428        let ids: Vec<_> = (0..6)
429            .map(|_| async { store.save_message(cid, "user", "msg").await.unwrap() })
430            .collect();
431        let mut msg_ids = Vec::new();
432        for f in ids {
433            msg_ids.push(f.await);
434        }
435
436        // Anchor messages 1–3 inside a summary range.
437        store
438            .save_summary(cid, "summary", Some(msg_ids[0]), Some(msg_ids[2]), 30)
439            .await
440            .unwrap();
441
442        let policy = EbbinghausPolicy::default();
443        // Trigger eviction: 6 messages, max_entries=3.
444        let deleted = run_eviction_phase1(&store, &policy, 3).await.unwrap();
445
446        // At most 3 were eligible for deletion; summary-anchored (0–2) must survive.
447        // Only messages 3–5 (outside summary range) may be deleted.
448        assert!(
449            deleted <= 3,
450            "at most 3 messages can be deleted, got {deleted}"
451        );
452
453        for &anchored in &msg_ids[0..=2] {
454            let is_deleted: Option<String> =
455                sqlx::query_scalar("SELECT deleted_at FROM messages WHERE id = ?")
456                    .bind(anchored)
457                    .fetch_one(store.pool())
458                    .await
459                    .unwrap();
460            assert!(
461                is_deleted.is_none(),
462                "summary-anchored message {anchored:?} must not be soft-deleted"
463            );
464        }
465    }
466
467    #[test]
468    fn parse_sqlite_timestamp_known_value() {
469        // 2024-01-01 00:00:00 UTC
470        let ts = parse_sqlite_timestamp_secs("2024-01-01 00:00:00").unwrap();
471        // Days from 1970 to 2024: 54 years, roughly
472        // Reference: 2024-01-01 00:00:00 UTC = 1704067200
473        assert_eq!(
474            ts, 1_704_067_200,
475            "2024-01-01 must parse to known timestamp"
476        );
477    }
478
479    #[test]
480    fn parse_sqlite_timestamp_pre_1970_returns_none() {
481        // Pinned behavior (post-refactor): a pre-epoch date now returns `None` rather than
482        // the old hand-rolled parser's silent `Some(0)` fallback — see the doc comment on
483        // `parse_sqlite_timestamp_secs` for why this divergence is intentional.
484        assert_eq!(parse_sqlite_timestamp_secs("1969-12-31 23:59:59"), None);
485        assert_eq!(parse_sqlite_timestamp_secs("1900-01-01 00:00:00"), None);
486    }
487
488    // ── Phase-2 Qdrant cleanup tests ─────────────────────────────────────────
489
490    /// Build a test `EmbeddingStore` backed by an in-memory vector store and a
491    /// fresh `SQLite` database. Returns both so the caller can manipulate `SQLite` directly.
492    async fn setup_embedding_store() -> (EmbeddingStore, crate::store::SqliteStore) {
493        let sqlite = crate::store::SqliteStore::new(":memory:").await.unwrap();
494        let pool = sqlite.pool().clone();
495        let mem_store = Box::new(crate::in_memory_store::InMemoryVectorStore::new());
496        let emb = EmbeddingStore::with_store(mem_store, pool);
497        emb.ensure_collection(4).await.unwrap();
498        (emb, sqlite)
499    }
500
501    /// Seed a message, store a vector, and soft-delete the message. Returns `(MessageId, point_id)`.
502    async fn seed_soft_deleted(
503        store: &crate::store::SqliteStore,
504        emb: &EmbeddingStore,
505    ) -> (MessageId, String) {
506        let cid = store.create_conversation().await.unwrap();
507        let msg_id = store.save_message(cid, "user", "hello").await.unwrap();
508
509        let point_id = emb
510            .store(
511                msg_id,
512                cid,
513                "user",
514                vec![1.0, 0.0, 0.0, 0.0],
515                crate::embedding_store::MessageKind::Regular,
516                "test",
517                0,
518            )
519            .await
520            .unwrap();
521
522        // Soft-delete the message so it appears in `get_soft_deleted_message_ids`.
523        store.soft_delete_messages(&[msg_id]).await.unwrap();
524
525        (msg_id, point_id)
526    }
527
528    /// Phase 2 with an embedding store: must delete vectors before marking clean.
529    #[tokio::test]
530    async fn eviction_phase2_calls_delete_before_mark_clean() {
531        let (emb, store) = setup_embedding_store().await;
532        let (msg_id, _point_id) = seed_soft_deleted(&store, &emb).await;
533
534        // Phase 2 should succeed, delete the vector, and mark the row clean.
535        let cleaned = run_eviction_phase2(&store, Some(&emb)).await.unwrap();
536        assert_eq!(cleaned, 1, "one message should be cleaned");
537
538        // After phase 2 the message must no longer appear in the pending cleanup list.
539        let remaining = store.get_soft_deleted_message_ids().await.unwrap();
540        assert!(
541            !remaining.contains(&msg_id),
542            "message must not remain in soft-deleted list after phase 2"
543        );
544    }
545
546    /// Phase 2 without an embedding store: `SQLite` bookkeeping still runs; no Qdrant call.
547    #[tokio::test]
548    async fn eviction_phase2_skips_delete_when_no_embedding_store() {
549        let (_, store) = setup_embedding_store().await;
550        let cid = store.create_conversation().await.unwrap();
551        let msg_id = store.save_message(cid, "user", "hello").await.unwrap();
552        store.soft_delete_messages(&[msg_id]).await.unwrap();
553
554        // No embedding store — must still mark rows clean.
555        let cleaned = run_eviction_phase2(&store, None).await.unwrap();
556        assert_eq!(
557            cleaned, 1,
558            "row must be cleaned even without embedding store"
559        );
560
561        let remaining = store.get_soft_deleted_message_ids().await.unwrap();
562        assert!(
563            !remaining.contains(&msg_id),
564            "message must not remain in soft-deleted list"
565        );
566    }
567
568    /// Phase 2 returns `Ok(0)` when there are no soft-deleted messages.
569    #[tokio::test]
570    async fn eviction_phase2_empty_returns_zero() {
571        let (emb, store) = setup_embedding_store().await;
572        let cleaned = run_eviction_phase2(&store, Some(&emb)).await.unwrap();
573        assert_eq!(cleaned, 0, "no soft-deleted messages → 0 cleaned");
574    }
575}