Skip to main content

context_forge/
engine.rs

1//! Core business logic: assembly, scoring, and snapshot management.
2
3use std::sync::Arc;
4use std::time::{SystemTime, UNIX_EPOCH};
5use uuid::Uuid;
6
7use crate::config::{Config, DEFAULT_RECENCY_HALF_LIFE_SECS};
8use crate::entry::ContextEntry;
9use crate::error::Error;
10use crate::lexicon::LexiconScorer;
11#[cfg(feature = "semantic")]
12use crate::semantic::Embedder;
13use crate::traits::{ContextStorage, Result, Searcher};
14
15/// Default candidate limit when fetching search results for assembly.
16const DEFAULT_SEARCH_LIMIT: usize = 50;
17
18/// Well-defined query constant that means "match all entries" in the Searcher trait.
19///
20/// FTS5 interprets `*` as a prefix wildcard that matches every token.
21/// Searcher implementations MUST treat this value as a match-all query.
22pub const MATCH_ALL_QUERY: &str = "*";
23
24/// Options for saving a snapshot entry.
25#[derive(Debug, Default, Clone)]
26pub struct SaveOptions {
27    /// Optional caller session identifier.
28    pub session_id: Option<String>,
29    /// Namespace partition for the new entry. `None` = global scope.
30    pub scope: Option<String>,
31    /// Arbitrary caller metadata, stored as JSON.
32    ///
33    /// Unlike `content`, metadata is persisted **verbatim** by
34    /// [`crate::ContextForge::save`] — it is not passed through
35    /// [`crate::scrub_secrets`]. Callers MUST NOT place untrusted or
36    /// secret-bearing text in this field.
37    pub metadata: Option<serde_json::Value>,
38}
39
40/// Estimate token count using whitespace heuristic (1 token ≈ 4 chars).
41#[must_use]
42pub fn estimate_tokens(text: &str) -> usize {
43    text.len().div_ceil(4)
44}
45
46/// Exponential recency decay.
47///
48/// Returns a multiplier in `(0.0, 1.0]` where 1.0 means "just created" and
49/// the value halves every `half_life` seconds.
50fn recency_decay(age_seconds: f64, half_life: f64) -> f64 {
51    0.5_f64.powf(age_seconds / half_life)
52}
53
54/// The core context engine that coordinates storage, search, and assembly.
55pub struct ContextEngine {
56    storage: Box<dyn ContextStorage>,
57    searcher: Box<dyn Searcher>,
58    config: Config,
59    scorer: Option<Arc<dyn LexiconScorer>>,
60    #[cfg(feature = "semantic")]
61    embedder: Option<Arc<dyn Embedder>>,
62}
63
64impl ContextEngine {
65    /// Create a new engine with the given storage backend, searcher, and config.
66    ///
67    /// If `config.recency_half_life_secs` is not positive and finite, it is
68    /// clamped to [`DEFAULT_RECENCY_HALF_LIFE_SECS`] to prevent NaN/inf in
69    /// recency decay scoring.
70    #[must_use]
71    pub fn new(
72        storage: Box<dyn ContextStorage>,
73        searcher: Box<dyn Searcher>,
74        mut config: Config,
75    ) -> Self {
76        if !config.recency_half_life_secs.is_finite() || config.recency_half_life_secs <= 0.0 {
77            config.recency_half_life_secs = DEFAULT_RECENCY_HALF_LIFE_SECS;
78        }
79        if !config.lexicon_boost_clamp.is_finite() || config.lexicon_boost_clamp < 0.0 {
80            config.lexicon_boost_clamp = crate::config::DEFAULT_LEXICON_BOOST_CLAMP;
81        }
82
83        Self {
84            storage,
85            searcher,
86            config,
87            scorer: None,
88            #[cfg(feature = "semantic")]
89            embedder: None,
90        }
91    }
92
93    /// Attach a [`LexiconScorer`] to this engine.
94    ///
95    /// The scorer runs inside [`Self::assemble`] after BM25 + recency decay
96    /// and before the token-budget cut. Calling this again replaces the previous
97    /// scorer. Prefer constructing via [`crate::ContextForgeBuilder`], which
98    /// automatically composes [`crate::DefaultEnglishScorer`] with any
99    /// caller-provided persona scorer.
100    #[must_use]
101    pub fn with_scorer(mut self, scorer: Arc<dyn LexiconScorer>) -> Self {
102        self.scorer = Some(scorer);
103        self
104    }
105
106    /// Attach an [`crate::semantic::Embedder`] for semantic search.
107    ///
108    /// When set, [`Self::save_snapshot`] generates and stores an embedding for
109    /// each new entry, and [`Self::assemble`] blends BM25 and semantic
110    /// candidates via Reciprocal Rank Fusion (RRF, k=60). Accepts any
111    /// [`Embedder`](crate::semantic::Embedder) implementation, so callers can
112    /// inject a shared instance (load the model once) or an alternate backend.
113    #[cfg(feature = "semantic")]
114    #[must_use]
115    pub fn with_embedder(mut self, embedder: Arc<dyn Embedder>) -> Self {
116        self.embedder = Some(embedder);
117        self
118    }
119
120    /// Assemble context entries that fit within `token_budget`.
121    ///
122    /// 1. Searches for candidates matching `query`, restricted to `scope` if given.
123    /// 2. Applies recency weighting to each candidate's score.
124    /// 3. Sorts by weighted score descending.
125    /// 4. Packs entries greedily until the budget is exhausted.
126    ///
127    /// `scope = None` searches every entry regardless of scope (global recall).
128    /// `scope = Some(s)` restricts the search to entries whose `scope` equals `s`.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the underlying search fails.
133    pub async fn assemble(
134        &self,
135        query: &str,
136        scope: Option<&str>,
137        token_budget: usize,
138    ) -> Result<Vec<ContextEntry>> {
139        let limit = DEFAULT_SEARCH_LIMIT;
140
141        tracing::debug!(query = %query, ?scope, token_budget, "assemble: bm25 search");
142        let bm25_candidates = self.searcher.search(query, scope, limit).await?;
143        tracing::debug!(count = %bm25_candidates.len(), "assemble: bm25 complete");
144
145        // Semantic search runs when the feature is enabled and an embedder is
146        // configured; otherwise returns empty (no-op default on the Searcher trait).
147        let semantic_candidates = self.run_semantic_search(query, scope, limit).await;
148        tracing::debug!(count = %semantic_candidates.len(), "assemble: semantic complete");
149
150        if bm25_candidates.is_empty() && semantic_candidates.is_empty() {
151            tracing::debug!("assemble: both searches empty");
152            return Ok(Vec::new());
153        }
154
155        // Reciprocal Rank Fusion (RRF, k=60) over the full union of both
156        // candidate sets. Entries absent from one list get worst_rank = limit+1.
157        //
158        // RRF score = 1/(k + bm25_rank) + 1/(k + semantic_rank)
159        // Then multiply by recency decay and (1 + lexicon_boost).
160        let worst_rank = limit + 1;
161        let k = 60.0_f64;
162        let now = current_timestamp();
163        let half_life = self.config.recency_half_life_secs;
164
165        // Build entry map: semantic first so BM25 entries win on overlap.
166        let mut entry_map: std::collections::HashMap<String, ContextEntry> =
167            std::collections::HashMap::new();
168        for se in &semantic_candidates {
169            entry_map
170                .entry(se.entry.id.clone())
171                .or_insert_with(|| se.entry.clone());
172        }
173        for se in &bm25_candidates {
174            entry_map.insert(se.entry.id.clone(), se.entry.clone());
175        }
176
177        let bm25_rank: std::collections::HashMap<&str, usize> = bm25_candidates
178            .iter()
179            .enumerate()
180            .map(|(i, se)| (se.entry.id.as_str(), i + 1))
181            .collect();
182        let semantic_rank: std::collections::HashMap<&str, usize> = semantic_candidates
183            .iter()
184            .enumerate()
185            .map(|(i, se)| (se.entry.id.as_str(), i + 1))
186            .collect();
187
188        let mut weighted: Vec<(f64, ContextEntry)> = entry_map
189            .into_iter()
190            .map(|(id, entry)| {
191                let br = *bm25_rank.get(id.as_str()).unwrap_or(&worst_rank);
192                let sr = *semantic_rank.get(id.as_str()).unwrap_or(&worst_rank);
193                #[allow(
194                    clippy::cast_precision_loss,
195                    reason = "ranks are small integers (max ~51); lossless"
196                )]
197                let rrf = 1.0 / (k + br as f64) + 1.0 / (k + sr as f64);
198
199                #[allow(
200                    clippy::cast_precision_loss,
201                    reason = "Unix timestamps fit losslessly in f64 for millions of years"
202                )]
203                let age = (now - entry.timestamp).max(0) as f64;
204                let decay = recency_decay(age, half_life);
205                let boost = self
206                    .scorer
207                    .as_ref()
208                    .map_or(0.0_f32, |s| s.score(&entry, query));
209                let c = self.config.lexicon_boost_clamp;
210                let final_score = rrf * decay * (1.0 + f64::from(boost).clamp(-c, c));
211                (final_score, entry)
212            })
213            .collect();
214
215        weighted.sort_by(|a, b| b.0.total_cmp(&a.0));
216        tracing::debug!(fused = %weighted.len(), "assemble: rrf fusion complete");
217
218        // Greedy bin-packing by token budget — skip oversized entries, don't abort.
219        let mut result = Vec::new();
220        let mut tokens_used: usize = 0;
221        for (_score, entry) in weighted {
222            let entry_tokens = entry
223                .token_count
224                .unwrap_or_else(|| estimate_tokens(&entry.content));
225            if tokens_used.saturating_add(entry_tokens) > token_budget {
226                continue;
227            }
228            tokens_used += entry_tokens;
229            result.push(entry);
230        }
231
232        tracing::debug!(entries = %result.len(), tokens_used, "assemble: complete");
233        Ok(result)
234    }
235
236    /// Run semantic search if an embedder is available; otherwise return empty.
237    #[allow(
238        clippy::unused_async,
239        reason = "async only active under semantic feature"
240    )]
241    async fn run_semantic_search(
242        &self,
243        query: &str,
244        scope: Option<&str>,
245        limit: usize,
246    ) -> Vec<crate::entry::ScoredEntry> {
247        #[cfg(feature = "semantic")]
248        if let Some(ref embedder) = self.embedder {
249            let emb = embedder.clone();
250            let query_owned = query.to_owned();
251            match tokio::task::spawn_blocking(move || emb.embed(&query_owned)).await {
252                Ok(Ok(embedding)) => {
253                    tracing::debug!(dims = %embedding.len(), "query embedding ready");
254                    match self
255                        .searcher
256                        .search_semantic(&embedding, scope, limit)
257                        .await
258                    {
259                        Ok(results) => return results,
260                        Err(e) => tracing::warn!(error = %e, "semantic search failed"),
261                    }
262                }
263                Ok(Err(e)) => tracing::warn!(error = %e, "query embedding failed"),
264                Err(e) => tracing::warn!(error = %e, "embed task panicked"),
265            }
266        }
267        // Suppress unused-parameter lint when semantic feature is disabled.
268        #[cfg(not(feature = "semantic"))]
269        let _ = (query, scope, limit);
270        Vec::new()
271    }
272
273    /// Save a new snapshot entry. Capacity enforcement (LRU eviction) is
274    /// handled atomically by the storage layer inside an immediate
275    /// transaction, so no in-process locking is required here.
276    ///
277    /// Returns the generated entry ID.
278    ///
279    /// # Security
280    ///
281    /// This is a low-level write path that does **not** scrub secrets from
282    /// `content`. [`ContextForge::save`](crate::ContextForge::save) is the
283    /// only entry point that applies [`scrub_secrets`](crate::scrub_secrets)
284    /// before persistence; callers writing through the engine directly are
285    /// responsible for scrubbing first.
286    ///
287    /// # Errors
288    ///
289    /// Returns [`Error::InvalidEntry`] if `content` is empty, or propagates
290    /// any error from the underlying storage write.
291    pub async fn save_snapshot(
292        &self,
293        content: &str,
294        kind: &str,
295        options: &SaveOptions,
296    ) -> Result<String> {
297        if content.is_empty() {
298            return Err(Error::InvalidEntry("content must not be empty".into()));
299        }
300
301        let timestamp = current_timestamp();
302        let id = Uuid::now_v7().to_string();
303        let token_count = estimate_tokens(content);
304
305        let entry = ContextEntry {
306            id: id.clone(),
307            content: content.to_owned(),
308            timestamp,
309            kind: kind.to_owned(),
310            scope: options.scope.clone(),
311            session_id: options.session_id.clone(),
312            token_count: Some(token_count),
313            metadata: options.metadata.clone(),
314        };
315
316        self.storage.save(&entry).await?;
317        tracing::trace!(id = %id, kind = %kind, tokens = %token_count, "entry saved");
318
319        // Non-fatal: if embedding fails, the entry is stored and BM25-searchable.
320        self.embed_and_store(&id, content).await;
321
322        Ok(id)
323    }
324
325    /// Save multiple snapshot entries with a **single** search-index commit for
326    /// the whole batch (see [`crate::traits::ContextStorage::save_batch`]),
327    /// instead of one commit per entry. Embeddings are still generated per entry
328    /// (non-fatal). Returns the generated ids in the same order as `items`.
329    ///
330    /// Each item is `(content, kind, options)`. Content must already be scrubbed
331    /// by the caller, as with [`Self::save_snapshot`].
332    pub(crate) async fn save_snapshot_batch(
333        &self,
334        items: &[(String, String, SaveOptions)],
335    ) -> Result<Vec<String>> {
336        if items.iter().any(|(content, _, _)| content.is_empty()) {
337            return Err(Error::InvalidEntry("content must not be empty".into()));
338        }
339
340        let entries: Vec<ContextEntry> = items
341            .iter()
342            .map(|(content, kind, options)| ContextEntry {
343                id: Uuid::now_v7().to_string(),
344                content: content.clone(),
345                timestamp: current_timestamp(),
346                kind: kind.clone(),
347                scope: options.scope.clone(),
348                session_id: options.session_id.clone(),
349                token_count: Some(estimate_tokens(content)),
350                metadata: options.metadata.clone(),
351            })
352            .collect();
353
354        self.storage.save_batch(&entries).await?;
355        tracing::trace!(count = %entries.len(), "entry batch saved");
356
357        // Embeddings are independent of the batched index commit (a separate
358        // turso UPDATE per entry); generate them after the batch is saved.
359        for entry in &entries {
360            self.embed_and_store(&entry.id, &entry.content).await;
361        }
362
363        Ok(entries.into_iter().map(|e| e.id).collect())
364    }
365
366    /// Generate and persist an embedding for an already-saved entry.
367    ///
368    /// Errors are logged and swallowed — the entry is always stored even when
369    /// embedding fails.
370    #[allow(
371        clippy::unused_async,
372        reason = "async only active under semantic feature"
373    )]
374    async fn embed_and_store(&self, id: &str, content: &str) {
375        #[cfg(feature = "semantic")]
376        if let Some(ref embedder) = self.embedder {
377            tracing::debug!(id = %id, "generating embedding");
378            let emb = embedder.clone();
379            let text = content.to_owned();
380            let id_owned = id.to_owned();
381            match tokio::task::spawn_blocking(move || emb.embed(&text)).await {
382                Ok(Ok(embedding)) => {
383                    if let Err(e) = self.storage.save_embedding(&id_owned, &embedding).await {
384                        tracing::warn!(
385                            id = %id_owned,
386                            error = %e,
387                            "save_embedding failed; entry stored without semantic index"
388                        );
389                    } else {
390                        tracing::trace!(id = %id_owned, dims = %embedding.len(), "embedding stored");
391                    }
392                }
393                Ok(Err(e)) => tracing::warn!(
394                    id = %id,
395                    error = %e,
396                    "embed failed; entry stored without semantic index"
397                ),
398                Err(e) => tracing::warn!(
399                    id = %id,
400                    error = %e,
401                    "embed task panicked; entry stored without semantic index"
402                ),
403            }
404        }
405        #[cfg(not(feature = "semantic"))]
406        let _ = (id, content);
407    }
408
409    /// Return a reference to the underlying storage backend.
410    ///
411    /// This is exposed for callers that need direct access to storage
412    /// operations (delete, clear, count) not covered by [`Self::assemble`]
413    /// or [`Self::save_snapshot`].
414    #[must_use]
415    pub fn storage(&self) -> &dyn ContextStorage {
416        self.storage.as_ref()
417    }
418
419    /// Backfill embeddings for all entries that do not yet have one.
420    ///
421    /// Entries are fetched and embedded in batches of `batch_size` (capped to
422    /// at least 1). The `progress` callback is called after each batch with
423    /// `(done_so_far, total)`.
424    ///
425    /// Returns the number of entries that were successfully embedded.
426    ///
427    /// # Errors
428    ///
429    /// Returns an error if fetching unembedded entries fails or if the
430    /// embedding task panics.
431    #[cfg(feature = "semantic")]
432    pub async fn backfill_embeddings(
433        &self,
434        batch_size: usize,
435        progress: impl Fn(usize, usize),
436    ) -> Result<usize> {
437        let embedder = if let Some(e) = &self.embedder {
438            e.clone()
439        } else {
440            tracing::debug!("backfill_embeddings: no embedder configured");
441            return Ok(0);
442        };
443
444        let all = self.storage.get_unembedded(usize::MAX).await?;
445        let total = all.len();
446        if total == 0 {
447            tracing::debug!("backfill_embeddings: all entries already embedded");
448            return Ok(0);
449        }
450
451        tracing::debug!(total = %total, batch_size = %batch_size, "backfill_embeddings: starting");
452        let batch = batch_size.max(1);
453        let mut n_embedded = 0usize;
454
455        for chunk in all.chunks(batch) {
456            let texts: Vec<String> = chunk.iter().map(|e| e.content.clone()).collect();
457            let emb = embedder.clone();
458            let embeddings = tokio::task::spawn_blocking(move || {
459                let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
460                emb.embed_batch(&refs)
461            })
462            .await
463            .map_err(|e| Error::Migration(format!("backfill embed task panicked: {e}")))?
464            .map_err(|e| Error::Migration(format!("backfill embed failed: {e}")))?;
465
466            for (entry, embedding) in chunk.iter().zip(embeddings.iter()) {
467                if let Err(e) = self.storage.save_embedding(&entry.id, embedding).await {
468                    tracing::warn!(id = %entry.id, error = %e, "backfill: save_embedding failed");
469                } else {
470                    n_embedded += 1;
471                }
472            }
473
474            progress(n_embedded, total);
475            tracing::debug!(done = %n_embedded, total = %total, "backfill_embeddings: batch done");
476        }
477
478        Ok(n_embedded)
479    }
480}
481
482/// Current Unix timestamp in seconds.
483///
484/// Returns `0` if the system clock reports a time before the Unix epoch,
485/// which should not happen on any supported platform.
486fn current_timestamp() -> i64 {
487    let secs = SystemTime::now()
488        .duration_since(UNIX_EPOCH)
489        .map_or(0, |d| d.as_secs());
490    i64::try_from(secs).unwrap_or(i64::MAX)
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use crate::config::{EvictionPolicy, DEFAULT_RECENCY_HALF_LIFE_SECS};
497    use crate::entry::ScoredEntry;
498    use async_trait::async_trait;
499    use std::path::PathBuf;
500    use std::sync::Mutex;
501
502    // -----------------------------------------------------------------------
503    // Mock implementations
504    // -----------------------------------------------------------------------
505
506    struct MockStorage {
507        entries: Mutex<Vec<ContextEntry>>,
508    }
509
510    impl MockStorage {
511        fn new() -> Self {
512            Self {
513                entries: Mutex::new(Vec::new()),
514            }
515        }
516    }
517
518    #[async_trait]
519    impl ContextStorage for MockStorage {
520        async fn save(&self, entry: &ContextEntry) -> Result<()> {
521            self.entries.lock().unwrap().push(entry.clone());
522            Ok(())
523        }
524
525        async fn get_top_k(&self, k: usize) -> Result<Vec<ContextEntry>> {
526            let guard = self.entries.lock().unwrap();
527            let mut sorted = guard.clone();
528            sorted.sort_by_key(|e| std::cmp::Reverse(e.timestamp));
529            sorted.truncate(k);
530            Ok(sorted)
531        }
532
533        async fn get_all(&self) -> Result<Vec<ContextEntry>> {
534            Ok(self.entries.lock().unwrap().clone())
535        }
536
537        async fn delete(&self, id: &str) -> Result<bool> {
538            let mut guard = self.entries.lock().unwrap();
539            let before = guard.len();
540            guard.retain(|e| e.id != id);
541            Ok(guard.len() < before)
542        }
543
544        async fn clear(&self) -> Result<usize> {
545            let mut guard = self.entries.lock().unwrap();
546            let n = guard.len();
547            guard.clear();
548            Ok(n)
549        }
550
551        async fn clear_scope(&self, scope: &str) -> Result<usize> {
552            let mut guard = self.entries.lock().unwrap();
553            let before = guard.len();
554            guard.retain(|e| e.scope.as_deref() != Some(scope));
555            Ok(before - guard.len())
556        }
557
558        async fn count(&self) -> Result<usize> {
559            Ok(self.entries.lock().unwrap().len())
560        }
561    }
562
563    struct MockSearcher {
564        results: Mutex<Vec<ScoredEntry>>,
565    }
566
567    impl MockSearcher {
568        fn new(results: Vec<ScoredEntry>) -> Self {
569            Self {
570                results: Mutex::new(results),
571            }
572        }
573
574        fn empty() -> Self {
575            Self::new(Vec::new())
576        }
577    }
578
579    #[async_trait]
580    impl Searcher for MockSearcher {
581        async fn search(
582            &self,
583            _query: &str,
584            _scope: Option<&str>,
585            limit: usize,
586        ) -> Result<Vec<ScoredEntry>> {
587            let guard = self.results.lock().unwrap();
588            Ok(guard.iter().take(limit).cloned().collect())
589        }
590    }
591
592    fn default_config(max_entries: usize) -> Config {
593        Config {
594            max_entries,
595            token_budget: 8192,
596            db_path: PathBuf::from(":memory:"),
597            eviction_policy: EvictionPolicy::Lru,
598            recency_half_life_secs: DEFAULT_RECENCY_HALF_LIFE_SECS,
599            ..Config::default()
600        }
601    }
602
603    fn make_entry(id: &str, content: &str, timestamp: i64) -> ContextEntry {
604        ContextEntry {
605            id: id.into(),
606            content: content.into(),
607            timestamp,
608            kind: crate::entry::kind::MANUAL.to_owned(),
609            scope: None,
610            session_id: None,
611            token_count: Some(estimate_tokens(content)),
612            metadata: None,
613        }
614    }
615
616    fn make_scored(id: &str, content: &str, timestamp: i64, score: f64) -> ScoredEntry {
617        ScoredEntry {
618            entry: make_entry(id, content, timestamp),
619            score,
620        }
621    }
622
623    // -----------------------------------------------------------------------
624    // Tests
625    // -----------------------------------------------------------------------
626
627    #[test]
628    fn test_estimate_tokens() {
629        assert_eq!(estimate_tokens(""), 0);
630        assert_eq!(estimate_tokens("a"), 1); // (1+3)/4 = 1
631        assert_eq!(estimate_tokens("ab"), 1); // (2+3)/4 = 1
632        assert_eq!(estimate_tokens("abc"), 1); // (3+3)/4 = 1 (integer)
633        assert_eq!(estimate_tokens("abcd"), 1); // (4+3)/4 = 1
634        assert_eq!(estimate_tokens("abcde"), 2); // (5+3)/4 = 2
635        assert_eq!(estimate_tokens("abcdefgh"), 2); // (8+3)/4 = 2 (integer)
636        assert_eq!(estimate_tokens("hello world, this is a test"), 7);
637    }
638
639    #[test]
640    fn test_engine_send_sync() {
641        fn assert_send_sync<T: Send + Sync>() {}
642        assert_send_sync::<ContextEngine>();
643    }
644
645    #[tokio::test]
646    async fn test_assemble_fits_budget() {
647        let now = current_timestamp();
648        let results = vec![
649            make_scored("a", "short", now, 1.0),
650            make_scored("b", "medium length text here", now, 0.9),
651            make_scored("c", "another entry with more content inside", now, 0.8),
652        ];
653
654        let engine = ContextEngine::new(
655            Box::new(MockStorage::new()),
656            Box::new(MockSearcher::new(results)),
657            default_config(100),
658        );
659
660        // Budget of 2 tokens: "short" = 2 tokens fits exactly.
661        let assembled = engine.assemble("test", None, 2).await.unwrap();
662        assert_eq!(assembled.len(), 1);
663        assert_eq!(assembled[0].id, "a");
664
665        // Budget large enough for all.
666        let assembled = engine.assemble("test", None, 1000).await.unwrap();
667        assert_eq!(assembled.len(), 3);
668    }
669
670    #[tokio::test]
671    async fn test_assemble_skips_oversized_entries() {
672        // B1 regression: oversized top-ranked entry must not abort the loop.
673        let now = current_timestamp();
674        let big_content = "x".repeat(4000); // 1000 tokens
675        let results = vec![
676            make_scored("big", &big_content, now, 1.0),
677            make_scored("small", "fits", now, 0.9),
678        ];
679
680        let engine = ContextEngine::new(
681            Box::new(MockStorage::new()),
682            Box::new(MockSearcher::new(results)),
683            default_config(100),
684        );
685
686        // Budget = 5: "big" (1000 tokens) is skipped, "fits" (1 token) is returned.
687        let assembled = engine.assemble("test", None, 5).await.unwrap();
688        assert_eq!(assembled.len(), 1);
689        assert_eq!(assembled[0].id, "small");
690    }
691
692    #[tokio::test]
693    async fn test_assemble_empty_results() {
694        let engine = ContextEngine::new(
695            Box::new(MockStorage::new()),
696            Box::new(MockSearcher::empty()),
697            default_config(100),
698        );
699        let assembled = engine.assemble("anything", None, 1000).await.unwrap();
700        assert!(assembled.is_empty());
701    }
702
703    #[tokio::test]
704    async fn test_recency_weighting() {
705        let now = current_timestamp();
706        // Two entries with equal BM25 scores but different timestamps.
707        let results = vec![
708            make_scored("old", "old entry", now - 86_400, 1.0), // 24h old
709            make_scored("new", "new entry", now, 1.0),          // just now
710        ];
711
712        let engine = ContextEngine::new(
713            Box::new(MockStorage::new()),
714            Box::new(MockSearcher::new(results)),
715            default_config(100),
716        );
717
718        let assembled = engine.assemble("test", None, 1000).await.unwrap();
719        assert_eq!(assembled.len(), 2);
720        // Newer entry should rank first due to higher recency score.
721        assert_eq!(assembled[0].id, "new");
722        assert_eq!(assembled[1].id, "old");
723    }
724
725    #[tokio::test]
726    async fn test_save_snapshot_creates_entry() {
727        let engine = ContextEngine::new(
728            Box::new(MockStorage::new()),
729            Box::new(MockSearcher::empty()),
730            default_config(100),
731        );
732
733        let id = engine
734            .save_snapshot(
735                "hello world",
736                crate::entry::kind::MANUAL,
737                &SaveOptions::default(),
738            )
739            .await
740            .unwrap();
741        assert!(!id.is_empty());
742
743        // Verify the entry exists in storage.
744        let all = engine.storage.get_all().await.unwrap();
745        assert_eq!(all.len(), 1);
746        assert_eq!(all[0].id, id);
747        assert_eq!(all[0].content, "hello world");
748        assert_eq!(all[0].token_count, Some(estimate_tokens("hello world")));
749        assert_eq!(all[0].kind, crate::entry::kind::MANUAL);
750    }
751
752    #[tokio::test]
753    async fn test_save_snapshot_populates_scope_and_metadata() {
754        let engine = ContextEngine::new(
755            Box::new(MockStorage::new()),
756            Box::new(MockSearcher::empty()),
757            default_config(100),
758        );
759
760        let metadata = serde_json::json!({"source": "test"});
761        let options = SaveOptions {
762            session_id: Some("sess-1".to_owned()),
763            scope: Some("project:homelab-rs".to_owned()),
764            metadata: Some(metadata.clone()),
765        };
766
767        let id = engine
768            .save_snapshot("hello scoped", crate::entry::kind::SNAPSHOT, &options)
769            .await
770            .unwrap();
771
772        let all = engine.storage.get_all().await.unwrap();
773        assert_eq!(all.len(), 1);
774        assert_eq!(all[0].id, id);
775        assert_eq!(all[0].kind, crate::entry::kind::SNAPSHOT);
776        assert_eq!(all[0].scope.as_deref(), Some("project:homelab-rs"));
777        assert_eq!(all[0].session_id.as_deref(), Some("sess-1"));
778        assert_eq!(all[0].metadata, Some(metadata));
779    }
780
781    #[test]
782    fn test_recency_decay_values() {
783        let half_life = crate::config::DEFAULT_RECENCY_HALF_LIFE_HOURS * 3600.0;
784
785        // At age 0, decay should be 1.0.
786        let decay_0 = recency_decay(0.0, half_life);
787        assert!((decay_0 - 1.0).abs() < f64::EPSILON);
788
789        // At age = half_life, decay should be 0.5.
790        let decay_half = recency_decay(half_life, half_life);
791        assert!((decay_half - 0.5).abs() < 1e-10);
792
793        // At age = 2 * half_life, decay should be 0.25.
794        let decay_double = recency_decay(2.0 * half_life, half_life);
795        assert!((decay_double - 0.25).abs() < 1e-10);
796    }
797
798    #[tokio::test]
799    async fn test_save_snapshot_ids_are_unique() {
800        let engine = ContextEngine::new(
801            Box::new(MockStorage::new()),
802            Box::new(MockSearcher::empty()),
803            default_config(100),
804        );
805
806        let id1 = engine
807            .save_snapshot(
808                "identical content",
809                crate::entry::kind::SNAPSHOT,
810                &SaveOptions::default(),
811            )
812            .await
813            .unwrap();
814        let id2 = engine
815            .save_snapshot(
816                "identical content",
817                crate::entry::kind::SNAPSHOT,
818                &SaveOptions::default(),
819            )
820            .await
821            .unwrap();
822        assert_ne!(
823            id1, id2,
824            "Two saves of identical content must produce distinct IDs"
825        );
826    }
827
828    #[tokio::test]
829    async fn test_save_empty_content_rejected() {
830        let engine = ContextEngine::new(
831            Box::new(MockStorage::new()),
832            Box::new(MockSearcher::empty()),
833            default_config(100),
834        );
835
836        let result = engine
837            .save_snapshot("", crate::entry::kind::MANUAL, &SaveOptions::default())
838            .await;
839        assert!(result.is_err());
840        let err = result.unwrap_err();
841        assert!(err.to_string().contains("content must not be empty"));
842    }
843
844    #[test]
845    fn test_invalid_half_life_clamped_to_default() {
846        let invalid_values: Vec<f64> = vec![
847            0.0,
848            -1.0,
849            -100.0,
850            f64::NAN,
851            f64::INFINITY,
852            f64::NEG_INFINITY,
853        ];
854
855        for value in invalid_values {
856            let mut config = default_config(100);
857            config.recency_half_life_secs = value;
858
859            let engine = ContextEngine::new(
860                Box::new(MockStorage::new()),
861                Box::new(MockSearcher::empty()),
862                config,
863            );
864
865            assert!(
866                (engine.config.recency_half_life_secs - DEFAULT_RECENCY_HALF_LIFE_SECS).abs()
867                    < f64::EPSILON,
868                "half_life {value} should have been clamped to default",
869            );
870        }
871    }
872
873    #[tokio::test]
874    async fn lexicon_scorer_boosts_important_entry_over_neutral() {
875        use crate::lexicon::DefaultEnglishScorer;
876
877        let now = current_timestamp();
878        // Equal BM25 scores, equal timestamps — only differentiator is lexicon signal.
879        let results = vec![
880            make_scored("neutral", "something unrelated here", now, 1.0),
881            make_scored("important", "confirmed, that is correct", now, 1.0),
882        ];
883
884        let scorer: Arc<dyn LexiconScorer> = Arc::new(DefaultEnglishScorer::default());
885        let engine = ContextEngine::new(
886            Box::new(MockStorage::new()),
887            Box::new(MockSearcher::new(results)),
888            default_config(100),
889        )
890        .with_scorer(scorer);
891
892        let assembled = engine.assemble("test", None, 1000).await.unwrap();
893        assert_eq!(assembled.len(), 2);
894        assert_eq!(
895            assembled[0].id, "important",
896            "lexicon-boosted entry should rank first"
897        );
898    }
899
900    #[test]
901    fn test_valid_half_life_preserved() {
902        let mut config = default_config(100);
903        config.recency_half_life_secs = 3600.0; // 1 hour — valid
904
905        let engine = ContextEngine::new(
906            Box::new(MockStorage::new()),
907            Box::new(MockSearcher::empty()),
908            config,
909        );
910
911        assert!((engine.config.recency_half_life_secs - 3600.0).abs() < f64::EPSILON);
912    }
913}