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 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        // Apply recency weighting using the configured half-life.
107        let half_life = self.config.recency_half_life_secs;
108        let mut weighted: Vec<(f64, ContextEntry)> = candidates
109            .into_iter()
110            .map(|se| {
111                // Unix timestamps are well within f64's 52-bit mantissa for
112                // any realistic date range, so precision loss is not a concern.
113                #[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        // Sort descending by weighted score (total_cmp handles NaN consistently).
125        weighted.sort_by(|a, b| b.0.total_cmp(&a.0));
126
127        // Greedy bin-packing by token budget — skip oversized entries, don't abort.
128        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    /// Save a new snapshot entry. Capacity enforcement (LRU eviction) is
145    /// handled atomically by the storage layer inside an immediate
146    /// transaction, so no in-process locking is required here.
147    ///
148    /// Returns the generated entry ID.
149    ///
150    /// # Security
151    ///
152    /// This is a low-level write path that does **not** scrub secrets from
153    /// `content`. [`ContextForge::save`](crate::ContextForge::save) is the
154    /// only entry point that applies [`scrub_secrets`](crate::scrub_secrets)
155    /// before persistence; callers writing through the engine directly are
156    /// responsible for scrubbing first.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`Error::InvalidEntry`] if `content` is empty, or propagates
161    /// any error from the underlying storage write.
162    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    /// Return a reference to the underlying storage backend.
193    ///
194    /// This is exposed for callers that need direct access to storage
195    /// operations (delete, clear, count) not covered by [`Self::assemble`]
196    /// or [`Self::save_snapshot`].
197    #[must_use]
198    pub fn storage(&self) -> &dyn ContextStorage {
199        self.storage.as_ref()
200    }
201}
202
203/// Current Unix timestamp in seconds.
204///
205/// Returns `0` if the system clock reports a time before the Unix epoch,
206/// which should not happen on any supported platform.
207fn 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    // -----------------------------------------------------------------------
223    // Mock implementations
224    // -----------------------------------------------------------------------
225
226    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    // -----------------------------------------------------------------------
342    // Tests
343    // -----------------------------------------------------------------------
344
345    #[test]
346    fn test_estimate_tokens() {
347        assert_eq!(estimate_tokens(""), 0);
348        assert_eq!(estimate_tokens("a"), 1); // (1+3)/4 = 1
349        assert_eq!(estimate_tokens("ab"), 1); // (2+3)/4 = 1
350        assert_eq!(estimate_tokens("abc"), 1); // (3+3)/4 = 1 (integer)
351        assert_eq!(estimate_tokens("abcd"), 1); // (4+3)/4 = 1
352        assert_eq!(estimate_tokens("abcde"), 2); // (5+3)/4 = 2
353        assert_eq!(estimate_tokens("abcdefgh"), 2); // (8+3)/4 = 2 (integer)
354        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        // Budget of 2 tokens: "short" = 2 tokens fits exactly.
379        let assembled = engine.assemble("test", None, 2).unwrap();
380        assert_eq!(assembled.len(), 1);
381        assert_eq!(assembled[0].id, "a");
382
383        // Budget large enough for all.
384        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        // B1 regression: oversized top-ranked entry must not abort the loop.
391        let now = current_timestamp();
392        let big_content = "x".repeat(4000); // 1000 tokens
393        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        // Budget = 5: "big" (1000 tokens) is skipped, "fits" (1 token) is returned.
405        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        // Two entries with equal BM25 scores but different timestamps.
425        let results = vec![
426            make_scored("old", "old entry", now - 86_400, 1.0), // 24h old
427            make_scored("new", "new entry", now, 1.0),          // just now
428        ];
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        // Newer entry should rank first due to higher recency score.
439        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        // Verify the entry exists in storage.
461        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        // At age 0, decay should be 1.0.
502        let decay_0 = recency_decay(0.0, half_life);
503        assert!((decay_0 - 1.0).abs() < f64::EPSILON);
504
505        // At age = half_life, decay should be 0.5.
506        let decay_half = recency_decay(half_life, half_life);
507        assert!((decay_half - 0.5).abs() < 1e-10);
508
509        // At age = 2 * half_life, decay should be 0.25.
510        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; // 1 hour — valid
589
590        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}