1use 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
15const DEFAULT_SEARCH_LIMIT: usize = 50;
17
18pub const MATCH_ALL_QUERY: &str = "*";
23
24#[derive(Debug, Default, Clone)]
26pub struct SaveOptions {
27 pub session_id: Option<String>,
29 pub scope: Option<String>,
31 pub metadata: Option<serde_json::Value>,
38}
39
40#[must_use]
42pub fn estimate_tokens(text: &str) -> usize {
43 text.len().div_ceil(4)
44}
45
46fn recency_decay(age_seconds: f64, half_life: f64) -> f64 {
51 0.5_f64.powf(age_seconds / half_life)
52}
53
54pub 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 #[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 #[must_use]
101 pub fn with_scorer(mut self, scorer: Arc<dyn LexiconScorer>) -> Self {
102 self.scorer = Some(scorer);
103 self
104 }
105
106 #[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 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 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 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 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 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 #[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 #[cfg(not(feature = "semantic"))]
269 let _ = (query, scope, limit);
270 Vec::new()
271 }
272
273 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 self.embed_and_store(&id, content).await;
321
322 Ok(id)
323 }
324
325 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 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 #[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 #[must_use]
415 pub fn storage(&self) -> &dyn ContextStorage {
416 self.storage.as_ref()
417 }
418
419 #[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
482fn 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 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 #[test]
628 fn test_estimate_tokens() {
629 assert_eq!(estimate_tokens(""), 0);
630 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);
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 let assembled = engine.assemble("test", None, 2).await.unwrap();
662 assert_eq!(assembled.len(), 1);
663 assert_eq!(assembled[0].id, "a");
664
665 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 let now = current_timestamp();
674 let big_content = "x".repeat(4000); 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 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 let results = vec![
708 make_scored("old", "old entry", now - 86_400, 1.0), make_scored("new", "new entry", now, 1.0), ];
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 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 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 let decay_0 = recency_decay(0.0, half_life);
787 assert!((decay_0 - 1.0).abs() < f64::EPSILON);
788
789 let decay_half = recency_decay(half_life, half_life);
791 assert!((decay_half - 0.5).abs() < 1e-10);
792
793 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 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; 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}