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