Skip to main content

zeph_memory/
optical_forgetting.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `ScrapMem` optical forgetting — progressive content-fidelity decay (issue #3713).
5//!
6//! Transitions old messages through resolution levels by compressing their content via LLM:
7//!
8//! 1. **Full** — original message content, unchanged.
9//! 2. **Compressed** — LLM-generated summary preserving key facts (stored in `compressed_content`).
10//! 3. **`SummaryOnly`** — one-line distilled fact (replaces original content, most compact).
11//!
12//! The sweep is orthogonal to `SleepGate` (which decays importance scores):
13//! - `SleepGate` prunes by importance score below a floor.
14//! - Optical forgetting compresses by age (turns since creation).
15//!
16//! Both can run concurrently; optical forgetting skips messages below the `SleepGate` prune
17//! threshold to avoid compressing content that will be pruned shortly anyway.
18//!
19//! # Invariants
20//!
21//! - Messages below the `SleepGate` `forgetting_floor` are skipped.
22//! - The `episodic_events` table (EM-Graph) references messages by FK; events survive
23//!   optical forgetting because messages are never deleted — only their content is replaced.
24//! - `focus_pinned` is a runtime-only `MessageMetadata` field and is not stored in the
25//!   `messages` table, so it cannot be filtered at the SQL level. The agent loop is
26//!   responsible for not triggering optical forgetting on pinned sessions.
27
28use std::sync::Arc;
29use std::time::Duration;
30
31use tokio_util::sync::CancellationToken;
32use zeph_db::sql;
33use zeph_llm::any::AnyProvider;
34use zeph_llm::provider::{LlmProvider as _, Message, MessageMetadata, Role};
35
36pub use zeph_config::memory::OpticalForgettingConfig;
37
38use crate::error::MemoryError;
39use crate::store::SqliteStore;
40
41// ── Content fidelity tier ──────────────────────────────────────────────────────
42
43/// Content-fidelity level for optical forgetting.
44///
45/// Distinct from [`crate::compression::CompressionLevel`], which classifies memory *type*
46/// (episodic vs. declarative abstraction). `ContentFidelity` classifies memory *fidelity*:
47/// how much of the original content is preserved. A message can be both
48/// `CompressionLevel::Episodic` and `ContentFidelity::Compressed`.
49///
50/// Also distinct from [`zeph_common::fidelity::ContextFidelity`] introduced by
51/// Context-Adaptive Memory (CAM): `ContentFidelity` tracks the long-term memory store
52/// preservation level (optical forgetting in `zeph-memory`); `ContextFidelity` tracks how
53/// much of a message is shown in the active context window during LLM inference.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55#[non_exhaustive]
56pub enum ContentFidelity {
57    /// Original full-fidelity content.
58    Full,
59    /// LLM-compressed summary preserving key facts.
60    Compressed,
61    /// One-line distilled fact. Terminal state.
62    SummaryOnly,
63}
64
65impl ContentFidelity {
66    /// Canonical string stored in the `content_fidelity` column.
67    #[must_use]
68    pub fn as_str(self) -> &'static str {
69        match self {
70            Self::Full => "Full",
71            Self::Compressed => "Compressed",
72            Self::SummaryOnly => "SummaryOnly",
73        }
74    }
75}
76
77impl std::fmt::Display for ContentFidelity {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.write_str(self.as_str())
80    }
81}
82
83impl std::str::FromStr for ContentFidelity {
84    type Err = String;
85    fn from_str(s: &str) -> Result<Self, Self::Err> {
86        match s {
87            "Full" => Ok(Self::Full),
88            "Compressed" => Ok(Self::Compressed),
89            "SummaryOnly" => Ok(Self::SummaryOnly),
90            other => Err(format!("unknown content_fidelity: {other}")),
91        }
92    }
93}
94
95// ── Result ────────────────────────────────────────────────────────────────────
96
97/// Outcome of a single optical forgetting sweep.
98#[derive(Debug, Default)]
99pub struct OpticalForgettingResult {
100    /// Messages transitioned Full → Compressed.
101    pub compressed: u32,
102    /// Messages transitioned `Compressed` → `SummaryOnly`.
103    pub summarized: u32,
104    /// Messages skipped (pinned or below `SleepGate` floor).
105    pub skipped: u32,
106}
107
108// ── Background loop ───────────────────────────────────────────────────────────
109
110/// Start the background optical forgetting loop.
111///
112/// Periodically scans messages older than the configured thresholds and progressively
113/// compresses them. Database errors are logged but do not stop the loop.
114///
115/// The loop respects `cancel` for graceful shutdown.
116pub async fn start_optical_forgetting_loop(
117    store: Arc<SqliteStore>,
118    provider: AnyProvider,
119    config: OpticalForgettingConfig,
120    forgetting_floor: f32,
121    cancel: CancellationToken,
122) {
123    if !config.enabled {
124        tracing::debug!("optical forgetting disabled (optical_forgetting.enabled = false)");
125        return;
126    }
127
128    let provider = Arc::new(provider);
129    let mut ticker = tokio::time::interval(Duration::from_secs(config.sweep_interval_secs));
130    ticker.tick().await; // skip first immediate tick
131
132    loop {
133        tokio::select! {
134            () = cancel.cancelled() => {
135                tracing::debug!("optical forgetting loop shutting down");
136                return;
137            }
138            _ = ticker.tick() => {}
139        }
140
141        tracing::debug!("optical_forgetting: starting sweep");
142        let start = std::time::Instant::now();
143
144        match run_optical_forgetting_sweep(&store, &provider, &config, forgetting_floor).await {
145            Ok(r) => {
146                tracing::info!(
147                    compressed = r.compressed,
148                    summarized = r.summarized,
149                    skipped = r.skipped,
150                    elapsed_ms = start.elapsed().as_millis(),
151                    "optical_forgetting: sweep complete"
152                );
153            }
154            Err(e) => {
155                tracing::warn!(
156                    error = %e,
157                    elapsed_ms = start.elapsed().as_millis(),
158                    "optical_forgetting: sweep failed, will retry"
159                );
160            }
161        }
162    }
163}
164
165// ── Sweep implementation ──────────────────────────────────────────────────────
166
167/// Execute one full optical forgetting sweep.
168///
169/// Phase 1: compress Full messages older than `compress_after_turns`.
170/// Phase 2: summarize Compressed messages older than `summarize_after_turns`.
171///
172/// Skips messages with `importance_score` below `forgetting_floor`
173/// (they will be pruned by `SleepGate` soon anyway).
174///
175/// # Errors
176///
177/// Returns an error if any database operation fails.
178#[tracing::instrument(name = "memory.optical_forgetting", skip_all)]
179pub async fn run_optical_forgetting_sweep(
180    store: &SqliteStore,
181    provider: &Arc<AnyProvider>,
182    config: &OpticalForgettingConfig,
183    forgetting_floor: f32,
184) -> Result<OpticalForgettingResult, MemoryError> {
185    let mut result = OpticalForgettingResult::default();
186
187    // Phase 1: Full → Compressed
188    let full_candidates = fetch_full_candidates(store, config, forgetting_floor).await?;
189    for (msg_id, content) in full_candidates {
190        match compress_content(provider, &content).await {
191            Ok(compressed) => {
192                store_compressed(store, msg_id, &compressed).await?;
193                result.compressed += 1;
194                tracing::debug!(msg_id, "optical_forgetting: Full → Compressed");
195            }
196            Err(e) => {
197                tracing::warn!(error = %e, msg_id, "optical_forgetting: compression failed, skipping");
198                result.skipped += 1;
199            }
200        }
201    }
202
203    // Phase 2: Compressed → SummaryOnly
204    let compressed_candidates =
205        fetch_compressed_candidates(store, config, forgetting_floor).await?;
206    for (msg_id, compressed_content) in compressed_candidates {
207        match summarize_content(provider, &compressed_content).await {
208            Ok(summary) => {
209                store_summary_only(store, msg_id, &summary).await?;
210                result.summarized += 1;
211                tracing::debug!(msg_id, "optical_forgetting: Compressed → SummaryOnly");
212            }
213            Err(e) => {
214                tracing::warn!(error = %e, msg_id, "optical_forgetting: summarization failed, skipping");
215                result.skipped += 1;
216            }
217        }
218    }
219
220    Ok(result)
221}
222
223// ── Database helpers ──────────────────────────────────────────────────────────
224
225/// Fetch message IDs and content for Full messages eligible for compression.
226///
227/// Skips messages below `forgetting_floor`.
228async fn fetch_full_candidates(
229    store: &SqliteStore,
230    config: &OpticalForgettingConfig,
231    forgetting_floor: f32,
232) -> Result<Vec<(i64, String)>, MemoryError> {
233    // COALESCE handles empty table: MAX(id) returns NULL, NULL - N is NULL in SQLite,
234    // making the condition always false (no candidates). COALESCE(MAX(id), 0) returns 0,
235    // so 0 - N is negative and no row satisfies id <= negative (same safe result, explicit).
236    // Note: focus_pinned is not a DB column (it is a runtime MessageMetadata field only),
237    // so pinned messages are not excluded here — the caller should avoid passing pinned
238    // message IDs through optical forgetting, which is ensured by only sweeping full
239    // sessions at the agent level.
240    let rows = zeph_db::query_as::<_, (i64, String)>(sql!(
241        "SELECT id, content FROM messages
242         WHERE content_fidelity = 'Full'
243           AND deleted_at IS NULL
244           AND (importance_score IS NULL OR importance_score >= ?)
245           AND id <= (SELECT COALESCE(MAX(id), 0) - ? FROM messages)
246         ORDER BY id ASC
247         LIMIT ?"
248    ))
249    .bind(forgetting_floor)
250    .bind(i64::from(config.compress_after_turns))
251    .bind(i64::try_from(config.sweep_batch_size).unwrap_or(i64::MAX))
252    .fetch_all(store.pool())
253    .await?;
254
255    Ok(rows)
256}
257
258/// Fetch message IDs and compressed content for `Compressed` messages eligible for `SummaryOnly`.
259async fn fetch_compressed_candidates(
260    store: &SqliteStore,
261    config: &OpticalForgettingConfig,
262    forgetting_floor: f32,
263) -> Result<Vec<(i64, String)>, MemoryError> {
264    let rows = zeph_db::query_as::<_, (i64, Option<String>)>(sql!(
265        "SELECT id, compressed_content FROM messages
266         WHERE content_fidelity = 'Compressed'
267           AND deleted_at IS NULL
268           AND (importance_score IS NULL OR importance_score >= ?)
269           AND id <= (SELECT COALESCE(MAX(id), 0) - ? FROM messages)
270         ORDER BY id ASC
271         LIMIT ?"
272    ))
273    .bind(forgetting_floor)
274    .bind(i64::from(config.summarize_after_turns))
275    .bind(i64::try_from(config.sweep_batch_size).unwrap_or(i64::MAX))
276    .fetch_all(store.pool())
277    .await?;
278
279    Ok(rows
280        .into_iter()
281        .filter_map(|(id, content)| content.map(|c| (id, c)))
282        .collect())
283}
284
285/// Update a message to Compressed state, storing the LLM summary in `compressed_content`.
286async fn store_compressed(
287    store: &SqliteStore,
288    msg_id: i64,
289    compressed: &str,
290) -> Result<(), MemoryError> {
291    zeph_db::query(sql!(
292        "UPDATE messages
293         SET content_fidelity = 'Compressed', compressed_content = ?
294         WHERE id = ?"
295    ))
296    .bind(compressed)
297    .bind(msg_id)
298    .execute(store.pool())
299    .await?;
300    Ok(())
301}
302
303/// Update a message to `SummaryOnly` state, replacing content with the one-line summary.
304async fn store_summary_only(
305    store: &SqliteStore,
306    msg_id: i64,
307    summary: &str,
308) -> Result<(), MemoryError> {
309    zeph_db::query(sql!(
310        "UPDATE messages
311         SET content_fidelity = 'SummaryOnly', content = ?, compressed_content = NULL
312         WHERE id = ?"
313    ))
314    .bind(summary)
315    .bind(msg_id)
316    .execute(store.pool())
317    .await?;
318    Ok(())
319}
320
321// ── LLM compression helpers ───────────────────────────────────────────────────
322
323/// Ask the LLM to produce a compressed summary of `content`.
324#[tracing::instrument(name = "memory.optical_forgetting.compress", skip_all, err)]
325async fn compress_content(
326    provider: &Arc<AnyProvider>,
327    content: &str,
328) -> Result<String, MemoryError> {
329    let cleaned = zeph_common::sanitize::strip_control_chars_preserve_whitespace(content);
330    let snippet = cleaned.chars().take(2000).collect::<String>();
331    let messages = vec![
332        Message {
333            role: Role::System,
334            content: "You compress conversation messages into concise summaries that preserve \
335                      all key facts, decisions, and action items. Output only the summary text, \
336                      no preamble."
337                .to_owned(),
338            parts: vec![],
339            metadata: MessageMetadata::default(),
340        },
341        Message {
342            role: Role::User,
343            content: format!("Compress this message:\n\n{snippet}"),
344            parts: vec![],
345            metadata: MessageMetadata::default(),
346        },
347    ];
348
349    let raw = tokio::time::timeout(Duration::from_secs(15), provider.chat(&messages))
350        .await
351        .map_err(|_| MemoryError::Timeout("optical_forgetting: compress timed out".into()))?
352        .map_err(MemoryError::Llm)?;
353
354    Ok(raw.trim().to_owned())
355}
356
357/// Ask the LLM to distill `content` into a single-line summary.
358#[tracing::instrument(name = "memory.optical_forgetting.summarize", skip_all, err)]
359async fn summarize_content(
360    provider: &Arc<AnyProvider>,
361    content: &str,
362) -> Result<String, MemoryError> {
363    let cleaned = zeph_common::sanitize::strip_control_chars_preserve_whitespace(content);
364    let snippet = cleaned.chars().take(1000).collect::<String>();
365    let messages = vec![
366        Message {
367            role: Role::System,
368            content: "You distill summaries into single sentences that capture the essential \
369                      fact or outcome. Output only the one-sentence summary, no preamble."
370                .to_owned(),
371            parts: vec![],
372            metadata: MessageMetadata::default(),
373        },
374        Message {
375            role: Role::User,
376            content: format!("Distill into one sentence:\n\n{snippet}"),
377            parts: vec![],
378            metadata: MessageMetadata::default(),
379        },
380    ];
381
382    let raw = tokio::time::timeout(Duration::from_secs(10), provider.chat(&messages))
383        .await
384        .map_err(|_| MemoryError::Timeout("optical_forgetting: summarize timed out".into()))?
385        .map_err(MemoryError::Llm)?;
386
387    Ok(raw.trim().to_owned())
388}
389
390// ── Tests ─────────────────────────────────────────────────────────────────────
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use zeph_config::providers::ProviderName;
396
397    #[test]
398    fn content_fidelity_round_trip() {
399        for fidelity in [
400            ContentFidelity::Full,
401            ContentFidelity::Compressed,
402            ContentFidelity::SummaryOnly,
403        ] {
404            let s = fidelity.as_str();
405            let parsed: ContentFidelity = s.parse().expect("should parse");
406            assert_eq!(parsed, fidelity);
407            assert_eq!(format!("{fidelity}"), s);
408        }
409    }
410
411    #[test]
412    fn content_fidelity_unknown_string_errors() {
413        assert!("unknown".parse::<ContentFidelity>().is_err());
414    }
415
416    #[test]
417    fn optical_forgetting_config_defaults() {
418        let cfg = OpticalForgettingConfig::default();
419        assert!(!cfg.enabled);
420        assert_eq!(cfg.compress_after_turns, 100);
421        assert_eq!(cfg.summarize_after_turns, 500);
422        assert_eq!(cfg.sweep_interval_secs, 3600);
423        assert_eq!(cfg.sweep_batch_size, 50);
424    }
425
426    #[test]
427    fn optical_forgetting_result_default() {
428        let r = OpticalForgettingResult::default();
429        assert_eq!(r.compressed, 0);
430        assert_eq!(r.summarized, 0);
431        assert_eq!(r.skipped, 0);
432    }
433
434    /// Verify that `run_optical_forgetting_sweep` skips all messages when
435    /// `compress_after_turns` is larger than the message count (nothing is old enough).
436    #[tokio::test]
437    async fn sweep_skips_when_no_candidates_old_enough() {
438        use std::sync::Arc;
439
440        use zeph_llm::any::AnyProvider;
441        use zeph_llm::mock::MockProvider;
442
443        use crate::store::SqliteStore;
444
445        let store = Arc::new(
446            SqliteStore::new(":memory:")
447                .await
448                .expect("SqliteStore::new"),
449        );
450        let provider = Arc::new(AnyProvider::Mock(MockProvider::default()));
451
452        let cid = store.create_conversation().await.expect("conversation");
453        store
454            .save_message(cid, "user", "hello")
455            .await
456            .expect("save_message");
457
458        let config = OpticalForgettingConfig {
459            enabled: true,
460            compress_after_turns: 100, // message is too recent
461            summarize_after_turns: 500,
462            sweep_interval_secs: 3600,
463            sweep_batch_size: 50,
464            compress_provider: ProviderName::default(),
465        };
466        let result = run_optical_forgetting_sweep(&store, &provider, &config, 0.0)
467            .await
468            .expect("sweep");
469
470        assert_eq!(
471            result.compressed, 0,
472            "no message should be compressed when not old enough"
473        );
474        assert_eq!(result.summarized, 0);
475    }
476
477    /// Verify that `run_optical_forgetting_sweep` compresses a Full message that is
478    /// old enough (`compress_after_turns` = 0).
479    #[tokio::test]
480    async fn sweep_compresses_eligible_full_message() {
481        use std::sync::Arc;
482
483        use zeph_llm::any::AnyProvider;
484        use zeph_llm::mock::MockProvider;
485
486        use crate::store::SqliteStore;
487
488        let store = Arc::new(
489            SqliteStore::new(":memory:")
490                .await
491                .expect("SqliteStore::new"),
492        );
493        let mock = MockProvider::with_responses(vec!["compressed summary".to_owned()]);
494        let provider = Arc::new(AnyProvider::Mock(mock));
495
496        let cid = store.create_conversation().await.expect("conversation");
497        // Insert two messages so MAX(id) - 0 = MAX(id), meaning the first message qualifies.
498        store
499            .save_message(cid, "user", "first message")
500            .await
501            .expect("save_message 1");
502        store
503            .save_message(cid, "user", "second message")
504            .await
505            .expect("save_message 2");
506
507        let config = OpticalForgettingConfig {
508            enabled: true,
509            compress_after_turns: 0, // everything is eligible
510            summarize_after_turns: 500,
511            sweep_interval_secs: 3600,
512            sweep_batch_size: 50,
513            compress_provider: ProviderName::default(),
514        };
515        let result = run_optical_forgetting_sweep(&store, &provider, &config, 0.0)
516            .await
517            .expect("sweep");
518
519        // At least one message compressed (the mock returns one response).
520        assert!(
521            result.compressed >= 1,
522            "at least one message must be compressed"
523        );
524    }
525
526    /// Verify early return when `enabled = false`.
527    #[tokio::test]
528    async fn sweep_disabled_returns_empty_result() {
529        use std::sync::Arc;
530
531        use zeph_llm::any::AnyProvider;
532        use zeph_llm::mock::MockProvider;
533
534        use crate::store::SqliteStore;
535
536        let store = Arc::new(
537            SqliteStore::new(":memory:")
538                .await
539                .expect("SqliteStore::new"),
540        );
541        let provider = Arc::new(AnyProvider::Mock(MockProvider::default()));
542        let config = OpticalForgettingConfig {
543            enabled: false,
544            ..Default::default()
545        };
546        // With enabled=false, the loop won't call sweep at all. Test that sweep itself
547        // produces no side effects when the DB is empty.
548        let result = run_optical_forgetting_sweep(&store, &provider, &config, 0.0)
549            .await
550            .expect("sweep with disabled config");
551        assert_eq!(result.compressed, 0);
552        assert_eq!(result.summarized, 0);
553    }
554}