Skip to main content

vex_temporal/
compression.rs

1//! Temporal compression strategies
2
3use chrono::{DateTime, Duration, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use vex_llm::{EmbeddingProvider, LlmError, LlmProvider};
7use vex_persist::VectorStoreBackend;
8
9/// Strategy for decaying old context
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum DecayStrategy {
12    /// Linear decay: importance decreases linearly with age
13    Linear,
14    /// Exponential decay: importance drops rapidly then stabilizes
15    Exponential,
16    /// Step decay: full importance until threshold, then compressed
17    Step,
18    /// None: no decay, manual control only
19    None,
20}
21
22impl DecayStrategy {
23    /// Calculate decay factor for given age.
24    /// Returns 1.0 for fresh, approaching 0.0 for fully decayed.
25    /// `exp_rate` is the exponential decay rate constant (only used for `Exponential` strategy).
26    pub fn calculate(&self, age: Duration, max_age: Duration, exp_rate: f64) -> f64 {
27        if max_age.num_seconds() == 0 {
28            return 1.0;
29        }
30
31        let ratio = age.num_seconds() as f64 / max_age.num_seconds() as f64;
32        let ratio = ratio.clamp(0.0, 1.0);
33
34        match self {
35            Self::Linear => 1.0 - ratio,
36            Self::Exponential => (-exp_rate * ratio).exp(),
37            Self::Step => {
38                if ratio < 0.5 {
39                    1.0
40                } else {
41                    0.3
42                }
43            }
44            Self::None => 1.0,
45        }
46    }
47}
48
49/// Compressor for temporal context
50#[derive(Debug, Clone)]
51pub struct TemporalCompressor {
52    /// Decay strategy
53    pub strategy: DecayStrategy,
54    /// Maximum age before full decay
55    pub max_age: Duration,
56    /// Minimum importance threshold
57    pub min_importance: f64,
58    /// Exponential decay rate constant.
59    /// At ratio=1.0, importance = e^(-rate) (e.g., rate=3.0 gives ~5% importance at max age).
60    pub exponential_decay_rate: f64,
61}
62
63impl Default for TemporalCompressor {
64    fn default() -> Self {
65        Self {
66            strategy: DecayStrategy::Exponential,
67            max_age: Duration::hours(24),
68            min_importance: 0.1,
69            exponential_decay_rate: 3.0,
70        }
71    }
72}
73
74impl TemporalCompressor {
75    /// Create a new compressor with given strategy
76    pub fn new(strategy: DecayStrategy, max_age: Duration) -> Self {
77        Self {
78            strategy,
79            max_age,
80            min_importance: 0.1,
81            exponential_decay_rate: 3.0,
82        }
83    }
84
85    /// Calculate current importance of content with given timestamp
86    pub fn importance(&self, created_at: DateTime<Utc>, base_importance: f64) -> f64 {
87        let age = Utc::now() - created_at;
88        let decay = self
89            .strategy
90            .calculate(age, self.max_age, self.exponential_decay_rate);
91        (base_importance * decay).max(self.min_importance)
92    }
93
94    /// Check if content should be evicted
95    pub fn should_evict(&self, created_at: DateTime<Utc>) -> bool {
96        let age = Utc::now() - created_at;
97        age > self.max_age
98    }
99
100    /// Calculate compression ratio based on age
101    pub fn compression_ratio(&self, created_at: DateTime<Utc>) -> f64 {
102        let age = Utc::now() - created_at;
103        let ratio = age.num_seconds() as f64 / self.max_age.num_seconds() as f64;
104        ratio.clamp(0.0, 0.9) // Max 90% compression
105    }
106
107    /// Summarize content based on compression ratio (sync fallback - just truncates)
108    pub fn compress(&self, content: &str, ratio: f64) -> String {
109        if ratio <= 0.0 {
110            return content.to_string();
111        }
112
113        let target_len = ((1.0 - ratio) * content.len() as f64) as usize;
114        let target_len = target_len.max(20);
115
116        if target_len >= content.len() {
117            content.to_string()
118        } else {
119            format!("{}...[compressed]", &content[..target_len])
120        }
121    }
122
123    /// Summarize content using an LLM and store it in semantic memory
124    ///
125    /// # Arguments
126    /// * `content` - The text to compress
127    /// * `ratio` - Compression ratio
128    /// * `llm` - LLM and Embedding provider
129    /// * `vector_store` - Optional persistent vector store for RAG fallback
130    /// * `tenant_id` - Tenant ID for vector storage
131    pub async fn compress_with_llm<L: LlmProvider + EmbeddingProvider>(
132        &self,
133        content: &str,
134        ratio: f64,
135        llm: &L,
136        vector_store: Option<&dyn VectorStoreBackend>,
137        tenant_id: Option<&str>,
138    ) -> Result<String, LlmError> {
139        // If no compression needed, return as-is
140        if ratio <= 0.0 || content.len() < 50 {
141            return Ok(content.to_string());
142        }
143
144        // Calculate target length
145        let word_count = content.split_whitespace().count();
146        let target_words = ((1.0 - ratio) * word_count as f64).max(10.0) as usize;
147
148        // Build summarization prompt
149        let prompt = format!(
150            "Summarize the following text in approximately {} words. \
151             Preserve the most important facts, decisions, and context. \
152             Be concise but maintain accuracy.\n\n\
153             TEXT TO SUMMARIZE:\n{}\n\n\
154             SUMMARY:",
155            target_words, content
156        );
157
158        let summary = llm.ask(&prompt).await?;
159
160        // Semantic Memory Integration: Embed and store the summary
161        if let (Some(vs), Some(tid)) = (vector_store, tenant_id) {
162            match llm.embed(&summary).await {
163                Ok(vector) => {
164                    let mut metadata = HashMap::new();
165                    metadata.insert("type".to_string(), "temporal_summary".to_string());
166                    metadata.insert("original_len".to_string(), content.len().to_string());
167                    metadata.insert("timestamp".to_string(), Utc::now().to_rfc3339());
168
169                    let id = format!("summary_{}", uuid::Uuid::new_v4());
170                    if let Err(e) = vs.add(id, tid.to_string(), vector, metadata).await {
171                        tracing::warn!("Failed to store summary embedding: {}", e);
172                    }
173                }
174                Err(e) => tracing::warn!("Failed to generate summary embedding: {}", e),
175            }
176        }
177
178        Ok(summary.trim().to_string())
179    }
180}