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