1use std::time::{SystemTime, UNIX_EPOCH};
4use uuid::Uuid;
5
6use crate::config::{Config, DEFAULT_RECENCY_HALF_LIFE_SECS};
7use crate::entry::ContextEntry;
8use crate::error::Error;
9use crate::traits::{ContextStorage, Result, Searcher};
10
11const DEFAULT_SEARCH_LIMIT: usize = 50;
13
14pub const MATCH_ALL_QUERY: &str = "*";
19
20#[derive(Debug, Default, Clone)]
22pub struct SaveOptions {
23 pub session_id: Option<String>,
25 pub scope: Option<String>,
27 pub metadata: Option<serde_json::Value>,
34}
35
36#[must_use]
38pub fn estimate_tokens(text: &str) -> usize {
39 text.len().div_ceil(4)
40}
41
42fn recency_decay(age_seconds: f64, half_life: f64) -> f64 {
47 0.5_f64.powf(age_seconds / half_life)
48}
49
50pub struct ContextEngine {
52 storage: Box<dyn ContextStorage>,
53 searcher: Box<dyn Searcher>,
54 config: Config,
55}
56
57impl ContextEngine {
58 #[must_use]
64 pub fn new(
65 storage: Box<dyn ContextStorage>,
66 searcher: Box<dyn Searcher>,
67 mut config: Config,
68 ) -> Self {
69 if !config.recency_half_life_secs.is_finite() || config.recency_half_life_secs <= 0.0 {
70 config.recency_half_life_secs = DEFAULT_RECENCY_HALF_LIFE_SECS;
71 }
72
73 Self {
74 storage,
75 searcher,
76 config,
77 }
78 }
79
80 pub async fn assemble(
94 &self,
95 query: &str,
96 scope: Option<&str>,
97 token_budget: usize,
98 ) -> Result<Vec<ContextEntry>> {
99 let candidates = self
100 .searcher
101 .search(query, scope, DEFAULT_SEARCH_LIMIT)
102 .await?;
103 if candidates.is_empty() {
104 return Ok(Vec::new());
105 }
106
107 let now = current_timestamp();
108
109 let half_life = self.config.recency_half_life_secs;
111 let mut weighted: Vec<(f64, ContextEntry)> = candidates
112 .into_iter()
113 .map(|se| {
114 #[allow(
117 clippy::cast_precision_loss,
118 reason = "Unix timestamps fit losslessly in f64 for millions of years"
119 )]
120 let age = (now - se.entry.timestamp).max(0) as f64;
121 let decay = recency_decay(age, half_life);
122 let weighted_score = se.score * decay;
123 (weighted_score, se.entry)
124 })
125 .collect();
126
127 weighted.sort_by(|a, b| b.0.total_cmp(&a.0));
129
130 let mut result = Vec::new();
132 let mut tokens_used: usize = 0;
133 for (_score, entry) in weighted {
134 let entry_tokens = entry
135 .token_count
136 .unwrap_or_else(|| estimate_tokens(&entry.content));
137 if tokens_used.saturating_add(entry_tokens) > token_budget {
138 continue;
139 }
140 tokens_used += entry_tokens;
141 result.push(entry);
142 }
143
144 Ok(result)
145 }
146
147 pub async fn save_snapshot(
166 &self,
167 content: &str,
168 kind: &str,
169 options: &SaveOptions,
170 ) -> Result<String> {
171 if content.is_empty() {
172 return Err(Error::InvalidEntry("content must not be empty".into()));
173 }
174
175 let timestamp = current_timestamp();
176 let id = Uuid::now_v7().to_string();
177 let token_count = estimate_tokens(content);
178
179 let entry = ContextEntry {
180 id: id.clone(),
181 content: content.to_owned(),
182 timestamp,
183 kind: kind.to_owned(),
184 scope: options.scope.clone(),
185 session_id: options.session_id.clone(),
186 token_count: Some(token_count),
187 metadata: options.metadata.clone(),
188 };
189
190 self.storage.save(&entry).await?;
191
192 Ok(id)
193 }
194
195 #[must_use]
201 pub fn storage(&self) -> &dyn ContextStorage {
202 self.storage.as_ref()
203 }
204}
205
206fn current_timestamp() -> i64 {
211 let secs = SystemTime::now()
212 .duration_since(UNIX_EPOCH)
213 .map_or(0, |d| d.as_secs());
214 i64::try_from(secs).unwrap_or(i64::MAX)
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::config::{EvictionPolicy, DEFAULT_RECENCY_HALF_LIFE_SECS};
221 use crate::entry::ScoredEntry;
222 use async_trait::async_trait;
223 use std::path::PathBuf;
224 use std::sync::Mutex;
225
226 struct MockStorage {
231 entries: Mutex<Vec<ContextEntry>>,
232 }
233
234 impl MockStorage {
235 fn new() -> Self {
236 Self {
237 entries: Mutex::new(Vec::new()),
238 }
239 }
240 }
241
242 #[async_trait]
243 impl ContextStorage for MockStorage {
244 async fn save(&self, entry: &ContextEntry) -> Result<()> {
245 self.entries.lock().unwrap().push(entry.clone());
246 Ok(())
247 }
248
249 async fn get_top_k(&self, k: usize) -> Result<Vec<ContextEntry>> {
250 let guard = self.entries.lock().unwrap();
251 let mut sorted = guard.clone();
252 sorted.sort_by_key(|e| std::cmp::Reverse(e.timestamp));
253 sorted.truncate(k);
254 Ok(sorted)
255 }
256
257 async fn get_all(&self) -> Result<Vec<ContextEntry>> {
258 Ok(self.entries.lock().unwrap().clone())
259 }
260
261 async fn delete(&self, id: &str) -> Result<bool> {
262 let mut guard = self.entries.lock().unwrap();
263 let before = guard.len();
264 guard.retain(|e| e.id != id);
265 Ok(guard.len() < before)
266 }
267
268 async fn clear(&self) -> Result<usize> {
269 let mut guard = self.entries.lock().unwrap();
270 let n = guard.len();
271 guard.clear();
272 Ok(n)
273 }
274
275 async fn clear_scope(&self, scope: &str) -> Result<usize> {
276 let mut guard = self.entries.lock().unwrap();
277 let before = guard.len();
278 guard.retain(|e| e.scope.as_deref() != Some(scope));
279 Ok(before - guard.len())
280 }
281
282 async fn count(&self) -> Result<usize> {
283 Ok(self.entries.lock().unwrap().len())
284 }
285 }
286
287 struct MockSearcher {
288 results: Mutex<Vec<ScoredEntry>>,
289 }
290
291 impl MockSearcher {
292 fn new(results: Vec<ScoredEntry>) -> Self {
293 Self {
294 results: Mutex::new(results),
295 }
296 }
297
298 fn empty() -> Self {
299 Self::new(Vec::new())
300 }
301 }
302
303 #[async_trait]
304 impl Searcher for MockSearcher {
305 async fn search(
306 &self,
307 _query: &str,
308 _scope: Option<&str>,
309 limit: usize,
310 ) -> Result<Vec<ScoredEntry>> {
311 let guard = self.results.lock().unwrap();
312 Ok(guard.iter().take(limit).cloned().collect())
313 }
314 }
315
316 fn default_config(max_entries: usize) -> Config {
317 Config {
318 max_entries,
319 token_budget: 8192,
320 db_path: PathBuf::from(":memory:"),
321 eviction_policy: EvictionPolicy::Lru,
322 recency_half_life_secs: DEFAULT_RECENCY_HALF_LIFE_SECS,
323 ..Config::default()
324 }
325 }
326
327 fn make_entry(id: &str, content: &str, timestamp: i64) -> ContextEntry {
328 ContextEntry {
329 id: id.into(),
330 content: content.into(),
331 timestamp,
332 kind: crate::entry::kind::MANUAL.to_owned(),
333 scope: None,
334 session_id: None,
335 token_count: Some(estimate_tokens(content)),
336 metadata: None,
337 }
338 }
339
340 fn make_scored(id: &str, content: &str, timestamp: i64, score: f64) -> ScoredEntry {
341 ScoredEntry {
342 entry: make_entry(id, content, timestamp),
343 score,
344 }
345 }
346
347 #[test]
352 fn test_estimate_tokens() {
353 assert_eq!(estimate_tokens(""), 0);
354 assert_eq!(estimate_tokens("a"), 1); assert_eq!(estimate_tokens("ab"), 1); assert_eq!(estimate_tokens("abc"), 1); assert_eq!(estimate_tokens("abcd"), 1); assert_eq!(estimate_tokens("abcde"), 2); assert_eq!(estimate_tokens("abcdefgh"), 2); assert_eq!(estimate_tokens("hello world, this is a test"), 7);
361 }
362
363 #[test]
364 fn test_engine_send_sync() {
365 fn assert_send_sync<T: Send + Sync>() {}
366 assert_send_sync::<ContextEngine>();
367 }
368
369 #[tokio::test]
370 async fn test_assemble_fits_budget() {
371 let now = current_timestamp();
372 let results = vec![
373 make_scored("a", "short", now, 1.0),
374 make_scored("b", "medium length text here", now, 0.9),
375 make_scored("c", "another entry with more content inside", now, 0.8),
376 ];
377
378 let engine = ContextEngine::new(
379 Box::new(MockStorage::new()),
380 Box::new(MockSearcher::new(results)),
381 default_config(100),
382 );
383
384 let assembled = engine.assemble("test", None, 2).await.unwrap();
386 assert_eq!(assembled.len(), 1);
387 assert_eq!(assembled[0].id, "a");
388
389 let assembled = engine.assemble("test", None, 1000).await.unwrap();
391 assert_eq!(assembled.len(), 3);
392 }
393
394 #[tokio::test]
395 async fn test_assemble_skips_oversized_entries() {
396 let now = current_timestamp();
398 let big_content = "x".repeat(4000); let results = vec![
400 make_scored("big", &big_content, now, 1.0),
401 make_scored("small", "fits", now, 0.9),
402 ];
403
404 let engine = ContextEngine::new(
405 Box::new(MockStorage::new()),
406 Box::new(MockSearcher::new(results)),
407 default_config(100),
408 );
409
410 let assembled = engine.assemble("test", None, 5).await.unwrap();
412 assert_eq!(assembled.len(), 1);
413 assert_eq!(assembled[0].id, "small");
414 }
415
416 #[tokio::test]
417 async fn test_assemble_empty_results() {
418 let engine = ContextEngine::new(
419 Box::new(MockStorage::new()),
420 Box::new(MockSearcher::empty()),
421 default_config(100),
422 );
423 let assembled = engine.assemble("anything", None, 1000).await.unwrap();
424 assert!(assembled.is_empty());
425 }
426
427 #[tokio::test]
428 async fn test_recency_weighting() {
429 let now = current_timestamp();
430 let results = vec![
432 make_scored("old", "old entry", now - 86_400, 1.0), make_scored("new", "new entry", now, 1.0), ];
435
436 let engine = ContextEngine::new(
437 Box::new(MockStorage::new()),
438 Box::new(MockSearcher::new(results)),
439 default_config(100),
440 );
441
442 let assembled = engine.assemble("test", None, 1000).await.unwrap();
443 assert_eq!(assembled.len(), 2);
444 assert_eq!(assembled[0].id, "new");
446 assert_eq!(assembled[1].id, "old");
447 }
448
449 #[tokio::test]
450 async fn test_save_snapshot_creates_entry() {
451 let engine = ContextEngine::new(
452 Box::new(MockStorage::new()),
453 Box::new(MockSearcher::empty()),
454 default_config(100),
455 );
456
457 let id = engine
458 .save_snapshot(
459 "hello world",
460 crate::entry::kind::MANUAL,
461 &SaveOptions::default(),
462 )
463 .await
464 .unwrap();
465 assert!(!id.is_empty());
466
467 let all = engine.storage.get_all().await.unwrap();
469 assert_eq!(all.len(), 1);
470 assert_eq!(all[0].id, id);
471 assert_eq!(all[0].content, "hello world");
472 assert_eq!(all[0].token_count, Some(estimate_tokens("hello world")));
473 assert_eq!(all[0].kind, crate::entry::kind::MANUAL);
474 }
475
476 #[tokio::test]
477 async fn test_save_snapshot_populates_scope_and_metadata() {
478 let engine = ContextEngine::new(
479 Box::new(MockStorage::new()),
480 Box::new(MockSearcher::empty()),
481 default_config(100),
482 );
483
484 let metadata = serde_json::json!({"source": "test"});
485 let options = SaveOptions {
486 session_id: Some("sess-1".to_owned()),
487 scope: Some("project:homelab-rs".to_owned()),
488 metadata: Some(metadata.clone()),
489 };
490
491 let id = engine
492 .save_snapshot("hello scoped", crate::entry::kind::SNAPSHOT, &options)
493 .await
494 .unwrap();
495
496 let all = engine.storage.get_all().await.unwrap();
497 assert_eq!(all.len(), 1);
498 assert_eq!(all[0].id, id);
499 assert_eq!(all[0].kind, crate::entry::kind::SNAPSHOT);
500 assert_eq!(all[0].scope.as_deref(), Some("project:homelab-rs"));
501 assert_eq!(all[0].session_id.as_deref(), Some("sess-1"));
502 assert_eq!(all[0].metadata, Some(metadata));
503 }
504
505 #[test]
506 fn test_recency_decay_values() {
507 let half_life = crate::config::DEFAULT_RECENCY_HALF_LIFE_HOURS * 3600.0;
508
509 let decay_0 = recency_decay(0.0, half_life);
511 assert!((decay_0 - 1.0).abs() < f64::EPSILON);
512
513 let decay_half = recency_decay(half_life, half_life);
515 assert!((decay_half - 0.5).abs() < 1e-10);
516
517 let decay_double = recency_decay(2.0 * half_life, half_life);
519 assert!((decay_double - 0.25).abs() < 1e-10);
520 }
521
522 #[tokio::test]
523 async fn test_save_snapshot_ids_are_unique() {
524 let engine = ContextEngine::new(
525 Box::new(MockStorage::new()),
526 Box::new(MockSearcher::empty()),
527 default_config(100),
528 );
529
530 let id1 = engine
531 .save_snapshot(
532 "identical content",
533 crate::entry::kind::SNAPSHOT,
534 &SaveOptions::default(),
535 )
536 .await
537 .unwrap();
538 let id2 = engine
539 .save_snapshot(
540 "identical content",
541 crate::entry::kind::SNAPSHOT,
542 &SaveOptions::default(),
543 )
544 .await
545 .unwrap();
546 assert_ne!(
547 id1, id2,
548 "Two saves of identical content must produce distinct IDs"
549 );
550 }
551
552 #[tokio::test]
553 async fn test_save_empty_content_rejected() {
554 let engine = ContextEngine::new(
555 Box::new(MockStorage::new()),
556 Box::new(MockSearcher::empty()),
557 default_config(100),
558 );
559
560 let result = engine
561 .save_snapshot("", crate::entry::kind::MANUAL, &SaveOptions::default())
562 .await;
563 assert!(result.is_err());
564 let err = result.unwrap_err();
565 assert!(err.to_string().contains("content must not be empty"));
566 }
567
568 #[test]
569 fn test_invalid_half_life_clamped_to_default() {
570 let invalid_values: Vec<f64> = vec![
571 0.0,
572 -1.0,
573 -100.0,
574 f64::NAN,
575 f64::INFINITY,
576 f64::NEG_INFINITY,
577 ];
578
579 for value in invalid_values {
580 let mut config = default_config(100);
581 config.recency_half_life_secs = value;
582
583 let engine = ContextEngine::new(
584 Box::new(MockStorage::new()),
585 Box::new(MockSearcher::empty()),
586 config,
587 );
588
589 assert!(
590 (engine.config.recency_half_life_secs - DEFAULT_RECENCY_HALF_LIFE_SECS).abs()
591 < f64::EPSILON,
592 "half_life {value} should have been clamped to default",
593 );
594 }
595 }
596
597 #[test]
598 fn test_valid_half_life_preserved() {
599 let mut config = default_config(100);
600 config.recency_half_life_secs = 3600.0; let engine = ContextEngine::new(
603 Box::new(MockStorage::new()),
604 Box::new(MockSearcher::empty()),
605 config,
606 );
607
608 assert!((engine.config.recency_half_life_secs - 3600.0).abs() < f64::EPSILON);
609 }
610}