Skip to main content

context_forge/
engine.rs

1//! Core business logic: assembly, scoring, and snapshot management.
2
3use 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
11/// Default candidate limit when fetching search results for assembly.
12const DEFAULT_SEARCH_LIMIT: usize = 50;
13
14/// Well-defined query constant that means "match all entries" in the Searcher trait.
15///
16/// FTS5 interprets `*` as a prefix wildcard that matches every token.
17/// Searcher implementations MUST treat this value as a match-all query.
18pub const MATCH_ALL_QUERY: &str = "*";
19
20/// Options for saving a snapshot entry.
21#[derive(Debug, Default, Clone)]
22pub struct SaveOptions {
23    /// Optional caller session identifier.
24    pub session_id: Option<String>,
25    /// Namespace partition for the new entry. `None` = global scope.
26    pub scope: Option<String>,
27    /// Arbitrary caller metadata, stored as JSON.
28    ///
29    /// Unlike `content`, metadata is persisted **verbatim** by
30    /// [`crate::ContextForge::save`] — it is not passed through
31    /// [`crate::scrub_secrets`]. Callers MUST NOT place untrusted or
32    /// secret-bearing text in this field.
33    pub metadata: Option<serde_json::Value>,
34}
35
36/// Estimate token count using whitespace heuristic (1 token ≈ 4 chars).
37#[must_use]
38pub fn estimate_tokens(text: &str) -> usize {
39    text.len().div_ceil(4)
40}
41
42/// Exponential recency decay.
43///
44/// Returns a multiplier in `(0.0, 1.0]` where 1.0 means "just created" and
45/// the value halves every `half_life` seconds.
46fn recency_decay(age_seconds: f64, half_life: f64) -> f64 {
47    0.5_f64.powf(age_seconds / half_life)
48}
49
50/// The core context engine that coordinates storage, search, and assembly.
51pub struct ContextEngine {
52    storage: Box<dyn ContextStorage>,
53    searcher: Box<dyn Searcher>,
54    config: Config,
55}
56
57impl ContextEngine {
58    /// Create a new engine with the given storage backend, searcher, and config.
59    ///
60    /// If `config.recency_half_life_secs` is not positive and finite, it is
61    /// clamped to [`DEFAULT_RECENCY_HALF_LIFE_SECS`] to prevent NaN/inf in
62    /// recency decay scoring.
63    #[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    /// Assemble context entries that fit within `token_budget`.
81    ///
82    /// 1. Searches for candidates matching `query`, restricted to `scope` if given.
83    /// 2. Applies recency weighting to each candidate's score.
84    /// 3. Sorts by weighted score descending.
85    /// 4. Packs entries greedily until the budget is exhausted.
86    ///
87    /// `scope = None` searches every entry regardless of scope (global recall).
88    /// `scope = Some(s)` restricts the search to entries whose `scope` equals `s`.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the underlying search fails.
93    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        // Apply recency weighting using the configured half-life.
110        let half_life = self.config.recency_half_life_secs;
111        let mut weighted: Vec<(f64, ContextEntry)> = candidates
112            .into_iter()
113            .map(|se| {
114                // Unix timestamps are well within f64's 52-bit mantissa for
115                // any realistic date range, so precision loss is not a concern.
116                #[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        // Sort descending by weighted score (total_cmp handles NaN consistently).
128        weighted.sort_by(|a, b| b.0.total_cmp(&a.0));
129
130        // Greedy bin-packing by token budget — skip oversized entries, don't abort.
131        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    /// Save a new snapshot entry. Capacity enforcement (LRU eviction) is
148    /// handled atomically by the storage layer inside an immediate
149    /// transaction, so no in-process locking is required here.
150    ///
151    /// Returns the generated entry ID.
152    ///
153    /// # Security
154    ///
155    /// This is a low-level write path that does **not** scrub secrets from
156    /// `content`. [`ContextForge::save`](crate::ContextForge::save) is the
157    /// only entry point that applies [`scrub_secrets`](crate::scrub_secrets)
158    /// before persistence; callers writing through the engine directly are
159    /// responsible for scrubbing first.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`Error::InvalidEntry`] if `content` is empty, or propagates
164    /// any error from the underlying storage write.
165    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    /// Return a reference to the underlying storage backend.
196    ///
197    /// This is exposed for callers that need direct access to storage
198    /// operations (delete, clear, count) not covered by [`Self::assemble`]
199    /// or [`Self::save_snapshot`].
200    #[must_use]
201    pub fn storage(&self) -> &dyn ContextStorage {
202        self.storage.as_ref()
203    }
204}
205
206/// Current Unix timestamp in seconds.
207///
208/// Returns `0` if the system clock reports a time before the Unix epoch,
209/// which should not happen on any supported platform.
210fn 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    // -----------------------------------------------------------------------
227    // Mock implementations
228    // -----------------------------------------------------------------------
229
230    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    // -----------------------------------------------------------------------
348    // Tests
349    // -----------------------------------------------------------------------
350
351    #[test]
352    fn test_estimate_tokens() {
353        assert_eq!(estimate_tokens(""), 0);
354        assert_eq!(estimate_tokens("a"), 1); // (1+3)/4 = 1
355        assert_eq!(estimate_tokens("ab"), 1); // (2+3)/4 = 1
356        assert_eq!(estimate_tokens("abc"), 1); // (3+3)/4 = 1 (integer)
357        assert_eq!(estimate_tokens("abcd"), 1); // (4+3)/4 = 1
358        assert_eq!(estimate_tokens("abcde"), 2); // (5+3)/4 = 2
359        assert_eq!(estimate_tokens("abcdefgh"), 2); // (8+3)/4 = 2 (integer)
360        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        // Budget of 2 tokens: "short" = 2 tokens fits exactly.
385        let assembled = engine.assemble("test", None, 2).await.unwrap();
386        assert_eq!(assembled.len(), 1);
387        assert_eq!(assembled[0].id, "a");
388
389        // Budget large enough for all.
390        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        // B1 regression: oversized top-ranked entry must not abort the loop.
397        let now = current_timestamp();
398        let big_content = "x".repeat(4000); // 1000 tokens
399        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        // Budget = 5: "big" (1000 tokens) is skipped, "fits" (1 token) is returned.
411        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        // Two entries with equal BM25 scores but different timestamps.
431        let results = vec![
432            make_scored("old", "old entry", now - 86_400, 1.0), // 24h old
433            make_scored("new", "new entry", now, 1.0),          // just now
434        ];
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        // Newer entry should rank first due to higher recency score.
445        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        // Verify the entry exists in storage.
468        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        // At age 0, decay should be 1.0.
510        let decay_0 = recency_decay(0.0, half_life);
511        assert!((decay_0 - 1.0).abs() < f64::EPSILON);
512
513        // At age = half_life, decay should be 0.5.
514        let decay_half = recency_decay(half_life, half_life);
515        assert!((decay_half - 0.5).abs() < 1e-10);
516
517        // At age = 2 * half_life, decay should be 0.25.
518        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; // 1 hour — valid
601
602        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}