subcog/hooks/
search_context.rs

1//! Search context builder for adaptive memory injection.
2//!
3//! Builds memory context based on detected search intent for proactive surfacing.
4
5use crate::Result;
6use crate::config::{NamespaceWeightsConfig, SearchIntentConfig};
7use crate::hooks::search_intent::{SearchIntent, SearchIntentType};
8use crate::models::{Namespace, SearchFilter, SearchMode};
9use crate::services::{ContextBuilderService, RecallService};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Configuration for adaptive memory context injection.
14#[derive(Debug, Clone)]
15pub struct AdaptiveContextConfig {
16    /// Base number of memories to retrieve.
17    pub base_count: usize,
18    /// Maximum number of memories to retrieve (high confidence).
19    pub max_count: usize,
20    /// Maximum tokens for injected memory content.
21    pub max_tokens: usize,
22    /// Maximum length for content preview.
23    pub preview_length: usize,
24    /// Minimum confidence threshold for injection.
25    pub min_confidence: f32,
26    /// Namespace weights configuration.
27    pub weights: NamespaceWeightsConfig,
28}
29
30/// Tokens per character approximation (consistent with `ContextBuilderService`).
31const TOKENS_PER_CHAR: usize = 4;
32
33impl Default for AdaptiveContextConfig {
34    fn default() -> Self {
35        Self {
36            base_count: 5,
37            max_count: 15,
38            max_tokens: 4000,
39            preview_length: 200,
40            min_confidence: 0.5,
41            weights: NamespaceWeightsConfig::with_defaults(),
42        }
43    }
44}
45
46impl AdaptiveContextConfig {
47    /// Creates a new configuration with defaults.
48    #[must_use]
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Sets the base memory count.
54    #[must_use]
55    pub const fn with_base_count(mut self, count: usize) -> Self {
56        self.base_count = count;
57        self
58    }
59
60    /// Sets the maximum memory count.
61    #[must_use]
62    pub const fn with_max_count(mut self, count: usize) -> Self {
63        self.max_count = count;
64        self
65    }
66
67    /// Sets the maximum token budget.
68    #[must_use]
69    pub const fn with_max_tokens(mut self, tokens: usize) -> Self {
70        self.max_tokens = tokens;
71        self
72    }
73
74    /// Sets the preview length for memory content.
75    #[must_use]
76    pub const fn with_preview_length(mut self, length: usize) -> Self {
77        self.preview_length = length;
78        self
79    }
80
81    /// Sets the minimum confidence threshold.
82    #[must_use]
83    pub const fn with_min_confidence(mut self, confidence: f32) -> Self {
84        self.min_confidence = confidence;
85        self
86    }
87
88    /// Builds context configuration from search intent settings.
89    #[must_use]
90    pub fn from_search_intent_config(config: &SearchIntentConfig) -> Self {
91        Self {
92            base_count: config.base_count,
93            max_count: config.max_count,
94            max_tokens: config.max_tokens,
95            preview_length: 200,
96            min_confidence: config.min_confidence,
97            weights: config.weights.clone(),
98        }
99    }
100
101    /// Sets custom namespace weights.
102    #[must_use]
103    pub fn with_weights(mut self, weights: NamespaceWeightsConfig) -> Self {
104        self.weights = weights;
105        self
106    }
107
108    /// Calculates the number of memories to retrieve based on confidence.
109    #[must_use]
110    pub const fn memories_for_confidence(&self, confidence: f32) -> usize {
111        if confidence >= 0.8 {
112            self.max_count
113        } else if confidence >= 0.5 {
114            self.base_count + 5
115        } else {
116            self.base_count
117        }
118    }
119}
120
121/// Namespace weight multipliers for intent-based search.
122#[derive(Debug, Clone, Default)]
123pub struct NamespaceWeights {
124    weights: HashMap<Namespace, f32>,
125}
126
127impl NamespaceWeights {
128    /// Creates empty namespace weights (all 1.0).
129    #[must_use]
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    /// Creates namespace weights for a specific intent type using hard-coded defaults.
135    #[must_use]
136    pub fn for_intent(intent_type: SearchIntentType) -> Self {
137        Self::for_intent_with_config(intent_type, &NamespaceWeightsConfig::default())
138    }
139
140    /// Creates namespace weights for a specific intent type using config overrides.
141    ///
142    /// Config weights take precedence over hard-coded defaults. If a namespace
143    /// is not specified in config, the hard-coded default is used.
144    #[must_use]
145    pub fn for_intent_with_config(
146        intent_type: SearchIntentType,
147        config: &NamespaceWeightsConfig,
148    ) -> Self {
149        // Pre-allocate for max namespace count (14 namespaces)
150        let mut weights = HashMap::with_capacity(14);
151
152        // Get the intent name for config lookup
153        let intent_name = match intent_type {
154            SearchIntentType::HowTo => "howto",
155            SearchIntentType::Troubleshoot => "troubleshoot",
156            SearchIntentType::Location => "location",
157            SearchIntentType::Explanation => "explanation",
158            SearchIntentType::Comparison => "comparison",
159            SearchIntentType::General => "general",
160        };
161
162        // Apply hard-coded defaults first
163        let defaults = Self::get_defaults(intent_type);
164        for (ns, weight) in defaults {
165            weights.insert(ns, weight);
166        }
167
168        // Apply config overrides
169        for (ns_str, weight) in config.get_intent_weights(intent_name) {
170            if let Ok(ns) = ns_str.parse::<Namespace>() {
171                weights.insert(ns, weight);
172            }
173        }
174
175        Self { weights }
176    }
177
178    /// Gets the hard-coded default weights for an intent type.
179    fn get_defaults(intent_type: SearchIntentType) -> Vec<(Namespace, f32)> {
180        match intent_type {
181            SearchIntentType::HowTo => {
182                vec![
183                    (Namespace::Patterns, 1.5),
184                    (Namespace::Learnings, 1.3),
185                    (Namespace::Decisions, 1.0),
186                ]
187            },
188            SearchIntentType::Troubleshoot => {
189                vec![
190                    (Namespace::Blockers, 1.5),
191                    (Namespace::Learnings, 1.3),
192                    (Namespace::Decisions, 1.0),
193                ]
194            },
195            SearchIntentType::Location | SearchIntentType::Explanation => {
196                vec![
197                    (Namespace::Decisions, 1.5),
198                    (Namespace::Context, 1.3),
199                    (Namespace::Patterns, 1.0),
200                ]
201            },
202            SearchIntentType::Comparison => {
203                vec![
204                    (Namespace::Decisions, 1.5),
205                    (Namespace::Patterns, 1.3),
206                    (Namespace::Learnings, 1.0),
207                ]
208            },
209            SearchIntentType::General => {
210                vec![
211                    (Namespace::Decisions, 1.2),
212                    (Namespace::Patterns, 1.2),
213                    (Namespace::Learnings, 1.0),
214                ]
215            },
216        }
217    }
218
219    /// Gets the weight for a namespace (defaults to 1.0).
220    #[must_use]
221    pub fn get(&self, namespace: &Namespace) -> f32 {
222        self.weights.get(namespace).copied().unwrap_or(1.0)
223    }
224
225    /// Applies weights to a score based on namespace.
226    #[must_use]
227    pub fn apply(&self, namespace: &Namespace, score: f32) -> f32 {
228        score * self.get(namespace)
229    }
230}
231
232/// An injected memory in the context response.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct InjectedMemory {
235    /// Memory ID (URN format).
236    pub id: String,
237    /// Memory namespace.
238    pub namespace: String,
239    /// Truncated content preview.
240    pub content_preview: String,
241    /// Relevance score.
242    pub score: f32,
243    /// Optional tags.
244    #[serde(skip_serializing_if = "Vec::is_empty")]
245    pub tags: Vec<String>,
246}
247
248/// Memory context for hook response.
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
250pub struct MemoryContext {
251    /// Whether search intent was detected.
252    pub search_intent_detected: bool,
253    /// The detected intent type (if any).
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub intent_type: Option<String>,
256    /// Extracted topics from the prompt.
257    #[serde(skip_serializing_if = "Vec::is_empty")]
258    pub topics: Vec<String>,
259    /// Injected memories.
260    #[serde(skip_serializing_if = "Vec::is_empty")]
261    pub injected_memories: Vec<InjectedMemory>,
262    /// Suggested resource URIs.
263    #[serde(skip_serializing_if = "Vec::is_empty")]
264    pub suggested_resources: Vec<String>,
265    /// Optional reminder text for the assistant.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub reminder: Option<String>,
268}
269
270impl MemoryContext {
271    /// Creates an empty memory context (no intent detected).
272    #[must_use]
273    pub fn empty() -> Self {
274        Self::default()
275    }
276
277    /// Creates a memory context from a search intent.
278    #[must_use]
279    pub fn from_intent(intent: &SearchIntent) -> Self {
280        Self {
281            search_intent_detected: true,
282            intent_type: Some(intent.intent_type.as_str().to_string()),
283            topics: intent.topics.clone(),
284            injected_memories: Vec::new(),
285            suggested_resources: Vec::new(),
286            reminder: None,
287        }
288    }
289
290    /// Adds injected memories to the context.
291    #[must_use]
292    pub fn with_memories(mut self, memories: Vec<InjectedMemory>) -> Self {
293        self.injected_memories = memories;
294        self
295    }
296
297    /// Adds suggested resources to the context.
298    #[must_use]
299    pub fn with_resources(mut self, resources: Vec<String>) -> Self {
300        self.suggested_resources = resources;
301        self
302    }
303
304    /// Adds a reminder to the context.
305    #[must_use]
306    pub fn with_reminder(mut self, reminder: impl Into<String>) -> Self {
307        self.reminder = Some(reminder.into());
308        self
309    }
310}
311
312/// Builder for search context with adaptive memory injection.
313pub struct SearchContextBuilder<'a> {
314    config: AdaptiveContextConfig,
315    recall_service: Option<&'a RecallService>,
316}
317
318impl Default for SearchContextBuilder<'_> {
319    fn default() -> Self {
320        Self::new()
321    }
322}
323
324impl<'a> SearchContextBuilder<'a> {
325    /// Creates a new search context builder with default configuration.
326    #[must_use]
327    pub fn new() -> Self {
328        Self {
329            config: AdaptiveContextConfig::default(),
330            recall_service: None,
331        }
332    }
333
334    /// Sets the configuration.
335    #[must_use]
336    pub fn with_config(mut self, config: AdaptiveContextConfig) -> Self {
337        self.config = config;
338        self
339    }
340
341    /// Sets the recall service for memory retrieval.
342    #[must_use]
343    pub const fn with_recall_service(mut self, service: &'a RecallService) -> Self {
344        self.recall_service = Some(service);
345        self
346    }
347
348    /// Builds the memory context for a search intent.
349    ///
350    /// # Errors
351    ///
352    /// Returns an error if memory retrieval fails.
353    pub fn build_context(&self, intent: &SearchIntent) -> Result<MemoryContext> {
354        // Check confidence threshold
355        if intent.confidence < self.config.min_confidence {
356            return Ok(MemoryContext::empty());
357        }
358
359        let mut context = MemoryContext::from_intent(intent);
360
361        // Build suggested resources from topics
362        let resources = self.build_suggested_resources(intent);
363        context = context.with_resources(resources);
364
365        // Add reminder if confidence is high enough
366        if intent.confidence >= self.config.min_confidence {
367            context = context.with_reminder(build_reminder_text(intent));
368        }
369
370        // Retrieve memories if recall service is available
371        if let Some(recall) = self.recall_service {
372            let memories = self.retrieve_memories(recall, intent)?;
373            context = context.with_memories(memories);
374        }
375
376        Ok(context)
377    }
378
379    /// Retrieves memories based on intent.
380    fn retrieve_memories(
381        &self,
382        recall: &RecallService,
383        intent: &SearchIntent,
384    ) -> Result<Vec<InjectedMemory>> {
385        let limit = self.config.memories_for_confidence(intent.confidence);
386        let weights =
387            NamespaceWeights::for_intent_with_config(intent.intent_type, &self.config.weights);
388
389        // Build query from topics and keywords
390        let query = build_search_query(intent);
391        if query.is_empty() {
392            return Ok(Vec::new());
393        }
394
395        // Search with double limit to allow for reranking
396        let filter = SearchFilter::new();
397        let result = recall.search(&query, SearchMode::Hybrid, &filter, limit * 2)?;
398
399        // Apply namespace weights and rerank
400        let mut weighted_memories: Vec<_> = result
401            .memories
402            .into_iter()
403            .map(|hit| {
404                let weighted_score = weights.apply(&hit.memory.namespace, hit.score);
405                (hit, weighted_score)
406            })
407            .collect();
408
409        // Sort by weighted score
410        weighted_memories
411            .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
412
413        // Take top results and convert to InjectedMemory with token budget enforcement.
414        let mut injected = Vec::new();
415        let mut remaining_tokens = self.config.max_tokens;
416
417        for (hit, score) in weighted_memories.into_iter().take(limit) {
418            let Some((content_preview, tokens_used)) = build_preview(
419                &hit.memory.content,
420                remaining_tokens,
421                self.config.preview_length,
422            ) else {
423                break;
424            };
425
426            remaining_tokens = remaining_tokens.saturating_sub(tokens_used);
427
428            injected.push(InjectedMemory {
429                id: format!("subcog://memories/{}", hit.memory.id.as_str()),
430                namespace: hit.memory.namespace.as_str().to_string(),
431                content_preview,
432                score,
433                tags: hit.memory.tags.clone(),
434            });
435        }
436
437        Ok(injected)
438    }
439
440    /// Builds suggested resource URIs from intent.
441    fn build_suggested_resources(&self, intent: &SearchIntent) -> Vec<String> {
442        let mut resources = Vec::with_capacity(4);
443
444        // Add topic-based resources
445        for topic in intent.topics.iter().take(3) {
446            resources.push(format!("subcog://topics/{topic}"));
447        }
448
449        // Add topics list
450        if !intent.topics.is_empty() {
451            resources.push("subcog://topics".to_string());
452        }
453
454        resources
455    }
456}
457
458/// Builds a search query from intent topics and keywords.
459fn build_search_query(intent: &SearchIntent) -> String {
460    let mut parts = Vec::new();
461
462    // Add topics
463    parts.extend(intent.topics.iter().cloned());
464
465    // Add keywords (cleaned up)
466    for keyword in &intent.keywords {
467        let cleaned = keyword
468            .trim()
469            .to_lowercase()
470            .replace(|c: char| !c.is_alphanumeric() && c != ' ', "");
471        if !cleaned.is_empty() && !parts.contains(&cleaned) {
472            parts.push(cleaned);
473        }
474    }
475
476    parts.join(" ")
477}
478
479/// Builds reminder text for the assistant.
480fn build_reminder_text(intent: &SearchIntent) -> String {
481    let intent_desc = match intent.intent_type {
482        SearchIntentType::HowTo => "implementation guidance",
483        SearchIntentType::Location => "code location",
484        SearchIntentType::Explanation => "explanation",
485        SearchIntentType::Comparison => "comparison",
486        SearchIntentType::Troubleshoot => "troubleshooting help",
487        SearchIntentType::General => "information",
488    };
489
490    format!(
491        "User appears to be seeking {intent_desc}. \
492         Consider using `subcog_recall` to retrieve relevant memories \
493         or access the suggested resources for more context."
494    )
495}
496
497/// Truncates content for preview.
498fn truncate_content(content: &str, max_len: usize) -> String {
499    if content.len() <= max_len {
500        content.to_string()
501    } else {
502        let truncated = &content[..max_len.saturating_sub(3)];
503        // Try to break at a word boundary
504        truncated.rfind(' ').map_or_else(
505            || format!("{truncated}..."),
506            |last_space| format!("{}...", &truncated[..last_space]),
507        )
508    }
509}
510
511fn build_preview(
512    content: &str,
513    remaining_tokens: usize,
514    preview_len: usize,
515) -> Option<(String, usize)> {
516    if remaining_tokens == 0 {
517        return None;
518    }
519
520    let preview = truncate_content(content, preview_len);
521    let preview_tokens = ContextBuilderService::estimate_tokens(&preview);
522    if preview_tokens <= remaining_tokens {
523        return Some((preview, preview_tokens));
524    }
525
526    let max_chars = remaining_tokens.saturating_mul(TOKENS_PER_CHAR);
527    let truncated = truncate_content(content, max_chars);
528    let truncated_tokens = ContextBuilderService::estimate_tokens(&truncated);
529    let tokens_used = truncated_tokens.min(remaining_tokens);
530    if tokens_used == 0 {
531        return None;
532    }
533
534    Some((truncated, tokens_used))
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::hooks::search_intent::DetectionSource;
541    use crate::models::{Domain, Memory, MemoryId, MemoryStatus, Namespace};
542    use crate::services::RecallService;
543    use crate::storage::index::SqliteBackend;
544    use crate::storage::traits::IndexBackend;
545
546    fn create_test_intent() -> SearchIntent {
547        SearchIntent {
548            intent_type: SearchIntentType::HowTo,
549            confidence: 0.8,
550            keywords: vec!["how to".to_string(), "implement".to_string()],
551            topics: vec!["authentication".to_string(), "oauth".to_string()],
552            source: DetectionSource::Keyword,
553        }
554    }
555
556    // AdaptiveContextConfig tests
557
558    #[test]
559    fn test_config_defaults() {
560        let config = AdaptiveContextConfig::default();
561        assert_eq!(config.base_count, 5);
562        assert_eq!(config.max_count, 15);
563        assert_eq!(config.max_tokens, 4000);
564        assert!((config.min_confidence - 0.5).abs() < f32::EPSILON);
565    }
566
567    #[test]
568    fn test_config_builder() {
569        let config = AdaptiveContextConfig::new()
570            .with_base_count(10)
571            .with_max_count(20)
572            .with_max_tokens(8000)
573            .with_min_confidence(0.6);
574
575        assert_eq!(config.base_count, 10);
576        assert_eq!(config.max_count, 20);
577        assert_eq!(config.max_tokens, 8000);
578        assert!((config.min_confidence - 0.6).abs() < f32::EPSILON);
579    }
580
581    #[test]
582    fn test_memories_for_confidence() {
583        let config = AdaptiveContextConfig::default();
584
585        // High confidence -> max_count
586        assert_eq!(config.memories_for_confidence(0.9), 15);
587        assert_eq!(config.memories_for_confidence(0.8), 15);
588
589        // Medium confidence -> base_count + 5
590        assert_eq!(config.memories_for_confidence(0.7), 10);
591        assert_eq!(config.memories_for_confidence(0.5), 10);
592
593        // Low confidence -> base_count
594        assert_eq!(config.memories_for_confidence(0.4), 5);
595        assert_eq!(config.memories_for_confidence(0.1), 5);
596    }
597
598    // NamespaceWeights tests
599
600    #[test]
601    fn test_weights_for_howto() {
602        let weights = NamespaceWeights::for_intent(SearchIntentType::HowTo);
603
604        assert!((weights.get(&Namespace::Patterns) - 1.5).abs() < f32::EPSILON);
605        assert!((weights.get(&Namespace::Learnings) - 1.3).abs() < f32::EPSILON);
606        assert!((weights.get(&Namespace::Decisions) - 1.0).abs() < f32::EPSILON);
607        // Unknown namespace defaults to 1.0
608        assert!((weights.get(&Namespace::Apis) - 1.0).abs() < f32::EPSILON);
609    }
610
611    #[test]
612    fn test_weights_for_troubleshoot() {
613        let weights = NamespaceWeights::for_intent(SearchIntentType::Troubleshoot);
614
615        assert!((weights.get(&Namespace::Blockers) - 1.5).abs() < f32::EPSILON);
616        assert!((weights.get(&Namespace::Learnings) - 1.3).abs() < f32::EPSILON);
617    }
618
619    #[test]
620    fn test_weights_apply() {
621        let weights = NamespaceWeights::for_intent(SearchIntentType::HowTo);
622
623        let score = 0.5;
624        let weighted = weights.apply(&Namespace::Patterns, score);
625        assert!((weighted - 0.75).abs() < f32::EPSILON); // 0.5 * 1.5
626    }
627
628    // MemoryContext tests
629
630    #[test]
631    fn test_memory_context_empty() {
632        let ctx = MemoryContext::empty();
633        assert!(!ctx.search_intent_detected);
634        assert!(ctx.intent_type.is_none());
635        assert!(ctx.topics.is_empty());
636        assert!(ctx.injected_memories.is_empty());
637    }
638
639    #[test]
640    fn test_memory_context_from_intent() {
641        let intent = create_test_intent();
642        let ctx = MemoryContext::from_intent(&intent);
643
644        assert!(ctx.search_intent_detected);
645        assert_eq!(ctx.intent_type, Some("howto".to_string()));
646        assert_eq!(ctx.topics, vec!["authentication", "oauth"]);
647    }
648
649    #[test]
650    fn test_memory_context_builder() {
651        let intent = create_test_intent();
652        let ctx = MemoryContext::from_intent(&intent)
653            .with_memories(vec![InjectedMemory {
654                id: "subcog://memories/test".to_string(),
655                namespace: "patterns".to_string(),
656                content_preview: "Test content".to_string(),
657                score: 0.9,
658                tags: vec![],
659            }])
660            .with_resources(vec!["subcog://topics/auth".to_string()])
661            .with_reminder("Test reminder");
662
663        assert_eq!(ctx.injected_memories.len(), 1);
664        assert_eq!(ctx.suggested_resources.len(), 1);
665        assert_eq!(ctx.reminder, Some("Test reminder".to_string()));
666    }
667
668    // SearchContextBuilder tests
669
670    #[test]
671    fn test_build_context_low_confidence() {
672        let builder = SearchContextBuilder::new();
673        let intent = SearchIntent {
674            confidence: 0.3,
675            ..create_test_intent()
676        };
677
678        let ctx = builder.build_context(&intent).unwrap();
679        assert!(!ctx.search_intent_detected);
680    }
681
682    #[test]
683    fn test_build_context_high_confidence() {
684        let builder = SearchContextBuilder::new();
685        let intent = create_test_intent();
686
687        let ctx = builder.build_context(&intent).unwrap();
688        assert!(ctx.search_intent_detected);
689        assert!(ctx.reminder.is_some());
690        assert!(!ctx.suggested_resources.is_empty());
691    }
692
693    #[test]
694    fn test_build_suggested_resources() {
695        let builder = SearchContextBuilder::new();
696        let intent = create_test_intent();
697
698        let resources = builder.build_suggested_resources(&intent);
699        assert!(resources.contains(&"subcog://topics/authentication".to_string()));
700        assert!(resources.contains(&"subcog://topics/oauth".to_string()));
701        assert!(resources.contains(&"subcog://topics".to_string()));
702    }
703
704    // Helper function tests
705
706    #[test]
707    fn test_build_search_query() {
708        let intent = create_test_intent();
709        let query = build_search_query(&intent);
710
711        assert!(query.contains("authentication"));
712        assert!(query.contains("oauth"));
713        assert!(query.contains("implement"));
714    }
715
716    #[test]
717    fn test_truncate_content_short() {
718        let content = "Short content";
719        let truncated = truncate_content(content, 50);
720        assert_eq!(truncated, content);
721    }
722
723    #[test]
724    fn test_truncate_content_long() {
725        let content =
726            "This is a much longer content that needs to be truncated for display purposes";
727        let truncated = truncate_content(content, 30);
728        assert!(truncated.ends_with("..."));
729        assert!(truncated.len() <= 30);
730    }
731
732    #[test]
733    fn test_build_reminder_text() {
734        let intent = create_test_intent();
735        let reminder = build_reminder_text(&intent);
736        assert!(reminder.contains("implementation guidance"));
737        assert!(reminder.contains("subcog_recall"));
738    }
739
740    // InjectedMemory tests
741
742    #[test]
743    fn test_injected_memory_serialization() {
744        let memory = InjectedMemory {
745            id: "subcog://memories/test-123".to_string(),
746            namespace: "decisions".to_string(),
747            content_preview: "Use PostgreSQL for storage".to_string(),
748            score: 0.85,
749            tags: vec!["database".to_string()],
750        };
751
752        let json = serde_json::to_string(&memory).unwrap();
753        assert!(json.contains("test-123"));
754        assert!(json.contains("decisions"));
755        assert!(json.contains("PostgreSQL"));
756    }
757
758    fn create_test_memory(id: &str, content: &str, now: u64) -> Memory {
759        Memory {
760            id: MemoryId::new(id),
761            content: content.to_string(),
762            namespace: Namespace::Decisions,
763            domain: Domain::new(),
764            project_id: None,
765            branch: None,
766            file_path: None,
767            status: MemoryStatus::Active,
768            created_at: now,
769            updated_at: now,
770            tombstoned_at: None,
771            expires_at: None,
772            embedding: None,
773            tags: vec![],
774            #[cfg(feature = "group-scope")]
775            group_id: None,
776            source: None,
777            is_summary: false,
778            source_memory_ids: None,
779            consolidation_timestamp: None,
780        }
781    }
782
783    #[test]
784    fn test_injected_memories_respect_token_budget() {
785        let index = SqliteBackend::in_memory().unwrap();
786        let now = 1_700_000_000;
787        let content = "authentication ".repeat(40);
788
789        let memory1 = create_test_memory("mem1", &content, now);
790        let memory2 = create_test_memory("mem2", &content, now);
791        index.index(&memory1).unwrap();
792        index.index(&memory2).unwrap();
793
794        let recall = RecallService::with_index(index);
795        let config = AdaptiveContextConfig::new()
796            .with_base_count(2)
797            .with_max_count(2)
798            .with_max_tokens(20)
799            .with_min_confidence(0.0);
800        let builder = SearchContextBuilder::new()
801            .with_config(config)
802            .with_recall_service(&recall);
803
804        let intent = SearchIntent {
805            intent_type: SearchIntentType::General,
806            confidence: 0.9,
807            keywords: vec!["authentication".to_string()],
808            topics: vec!["authentication".to_string()],
809            source: DetectionSource::Keyword,
810        };
811
812        let ctx = builder.build_context(&intent).unwrap();
813        let total_tokens: usize = ctx
814            .injected_memories
815            .iter()
816            .map(|memory| ContextBuilderService::estimate_tokens(&memory.content_preview))
817            .sum();
818
819        assert!(total_tokens <= 20);
820    }
821}