Skip to main content

shodh_memory/
relevance.rs

1//! Proactive Memory Surfacing (SHO-29)
2//!
3//! Shifts from pull-based to push-based memory model. Instead of agents explicitly
4//! querying for memories, this module proactively surfaces relevant context based on
5//! current conversation/context.
6//!
7//! # Key Features
8//! - Entity matching triggers: Surface memories mentioning detected entities
9//! - Semantic similarity scoring: Find memories similar to current context
10//! - Configurable relevance thresholds
11//! - Sub-30ms latency requirement for real-time use
12//!
13//! # Performance Architecture
14//! - Pre-indexed entity lookups (O(1) hash lookup + O(k) retrieval)
15//! - LRU-cached context embeddings to avoid re-computation
16//! - Entity index for fast entity-to-memory lookups
17//! - Rank-based semantic similarity scoring
18//! - Parallel entity + semantic search
19//! - Early termination when threshold memories found
20
21use std::collections::{HashMap, HashSet};
22use std::sync::Arc;
23use std::time::Instant;
24
25use anyhow::Result;
26use chrono::{DateTime, Utc};
27use parking_lot::RwLock;
28use regex::Regex;
29use serde::{Deserialize, Serialize};
30use uuid::Uuid;
31
32use crate::embeddings::NeuralNer;
33use crate::graph_memory::GraphMemory;
34use crate::memory::feedback::FeedbackStore;
35use crate::memory::{Memory, MemorySystem};
36
37// =============================================================================
38// HELPER FUNCTIONS
39// =============================================================================
40
41/// Check if a word appears as a complete word in text (not as substring).
42/// Uses word boundary matching to avoid false positives like "MIT" matching "submit".
43fn contains_word(text: &str, word: &str) -> bool {
44    if word.is_empty() {
45        return false;
46    }
47    // Build regex pattern with word boundaries
48    // Escape special regex characters in the word
49    let escaped = regex::escape(word);
50    let pattern = format!(r"(?i)\b{}\b", escaped);
51    match Regex::new(&pattern) {
52        Ok(re) => re.is_match(text),
53        Err(_) => text.contains(word), // Fallback to substring if regex fails
54    }
55}
56
57// =============================================================================
58// LEARNED WEIGHT CONSTANTS
59// Default starting points, adapted via feedback
60// =============================================================================
61
62/// Default weight for semantic similarity in score fusion
63/// CTX-3: Reduced to prioritize proven-value signals over looks-good signals
64const DEFAULT_SEMANTIC_WEIGHT: f32 = 0.18;
65
66/// Default weight for entity matching in score fusion
67/// CTX-3: Reduced to prioritize proven-value signals
68const DEFAULT_ENTITY_WEIGHT: f32 = 0.17;
69
70/// Default weight for tag matching in score fusion
71const DEFAULT_TAG_WEIGHT: f32 = 0.05;
72
73/// Default weight for importance in score fusion
74const DEFAULT_IMPORTANCE_WEIGHT: f32 = 0.05;
75
76/// Default weight for feedback momentum EMA in score fusion (FBK-5, CTX-3)
77/// Significantly increased to prioritize proven-helpful memories
78/// High momentum = consistently useful in past interactions
79/// This is the PRIMARY signal for memory quality
80const DEFAULT_MOMENTUM_WEIGHT: f32 = 0.28;
81
82/// Default weight for access count in score fusion (CTX-3)
83/// Memories accessed more frequently have proven value
84const DEFAULT_ACCESS_COUNT_WEIGHT: f32 = 0.14;
85
86/// Default weight for graph Hebbian strength in score fusion (CTX-3)
87/// Memories with stronger entity relationship edges have proven associations
88const DEFAULT_GRAPH_STRENGTH_WEIGHT: f32 = 0.13;
89
90/// Learning rate for weight updates from feedback
91const WEIGHT_LEARNING_RATE: f32 = 0.05;
92
93/// Minimum weight (prevents any component from being zeroed out)
94const MIN_WEIGHT: f32 = 0.05;
95
96/// Sigmoid calibration steepness (higher = sharper cutoff)
97const SIGMOID_STEEPNESS: f32 = 10.0;
98
99/// Sigmoid calibration midpoint (scores below this are penalized)
100const SIGMOID_MIDPOINT: f32 = 0.5;
101
102/// Configuration for relevance triggers
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct RelevanceConfig {
105    /// Minimum semantic similarity score (0.0-1.0) to consider a memory relevant
106    #[serde(default = "default_semantic_threshold")]
107    pub semantic_threshold: f32,
108
109    /// Minimum entity match score (0.0-1.0) to trigger entity-based surfacing
110    #[serde(default = "default_entity_threshold")]
111    pub entity_threshold: f32,
112
113    /// Maximum number of memories to surface
114    #[serde(default = "default_max_results")]
115    pub max_results: usize,
116
117    /// Memory types to include (empty = all types)
118    #[serde(default)]
119    pub memory_types: Vec<String>,
120
121    /// Whether to include entity-based matching
122    #[serde(default = "default_true")]
123    pub enable_entity_matching: bool,
124
125    /// Whether to include semantic similarity matching
126    #[serde(default = "default_true")]
127    pub enable_semantic_matching: bool,
128
129    /// Minimum importance score for memories to be surfaced
130    #[serde(default = "default_min_importance")]
131    pub min_importance: f32,
132
133    /// Time window for recency boost (memories within this window get boosted)
134    #[serde(default = "default_recency_hours")]
135    pub recency_boost_hours: u64,
136
137    /// Recency boost multiplier (1.0 = no boost)
138    #[serde(default = "default_recency_multiplier")]
139    pub recency_boost_multiplier: f32,
140
141    /// Graph connectivity boost multiplier (FBK-7)
142    /// Applied to memories that have graph relationships with detected entities
143    #[serde(default = "default_graph_boost_multiplier")]
144    pub graph_boost_multiplier: f32,
145}
146
147fn default_graph_boost_multiplier() -> f32 {
148    1.15 // 15% boost for graph-connected memories
149}
150
151fn default_semantic_threshold() -> f32 {
152    0.45 // Lowered from 0.65 - composite relevance scores blend semantic, entity, recency
153}
154
155fn default_entity_threshold() -> f32 {
156    0.5
157}
158
159fn default_max_results() -> usize {
160    5
161}
162
163fn default_true() -> bool {
164    true
165}
166
167fn default_min_importance() -> f32 {
168    0.3
169}
170
171fn default_recency_hours() -> u64 {
172    24
173}
174
175fn default_recency_multiplier() -> f32 {
176    1.2
177}
178
179impl Default for RelevanceConfig {
180    fn default() -> Self {
181        Self {
182            semantic_threshold: default_semantic_threshold(),
183            entity_threshold: default_entity_threshold(),
184            max_results: default_max_results(),
185            memory_types: Vec::new(),
186            enable_entity_matching: true,
187            enable_semantic_matching: true,
188            min_importance: default_min_importance(),
189            recency_boost_hours: default_recency_hours(),
190            recency_boost_multiplier: default_recency_multiplier(),
191            graph_boost_multiplier: default_graph_boost_multiplier(),
192        }
193    }
194}
195
196/// A surfaced memory with relevance scoring
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct SurfacedMemory {
199    /// Memory ID
200    pub id: String,
201
202    /// Memory content
203    pub content: String,
204
205    /// Memory type (Decision, Learning, etc.)
206    pub memory_type: String,
207
208    /// Base importance score
209    pub importance: f32,
210
211    /// Relevance score for this context (0.0-1.0)
212    pub relevance_score: f32,
213
214    /// Why this memory was surfaced
215    pub relevance_reason: RelevanceReason,
216
217    /// Matched entities (if entity-based)
218    pub matched_entities: Vec<String>,
219
220    /// Semantic similarity score (if semantic-based)
221    pub semantic_similarity: Option<f32>,
222
223    /// When the memory was created
224    pub created_at: DateTime<Utc>,
225
226    /// Tags associated with the memory
227    pub tags: Vec<String>,
228}
229
230/// Reason why a memory was surfaced
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
232pub enum RelevanceReason {
233    /// Matched one or more entities in the context
234    EntityMatch,
235    /// High semantic similarity to current context
236    SemanticSimilarity,
237    /// Both entity match and semantic similarity
238    Combined,
239    /// Recent and important (fallback when no strong matches)
240    RecentImportant,
241}
242
243/// Request for proactive memory surfacing
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct RelevanceRequest {
246    /// User ID for memory lookup
247    pub user_id: String,
248
249    /// Current context text (conversation turn, user message, etc.)
250    pub context: String,
251
252    /// Optional pre-extracted entities (skip NER if provided)
253    #[serde(default)]
254    pub entities: Vec<String>,
255
256    /// Configuration overrides
257    #[serde(default)]
258    pub config: RelevanceConfig,
259}
260
261/// Response from proactive memory surfacing
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct RelevanceResponse {
264    /// Surfaced memories ordered by relevance
265    pub memories: Vec<SurfacedMemory>,
266
267    /// Entities detected in the context
268    pub detected_entities: Vec<DetectedEntity>,
269
270    /// Processing time in milliseconds
271    pub latency_ms: f64,
272
273    /// Whether latency target (<30ms) was met
274    pub latency_target_met: bool,
275
276    /// Debug info (only in debug builds)
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub debug: Option<RelevanceDebug>,
279}
280
281/// Detected entity with confidence
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct DetectedEntity {
284    /// Entity name
285    pub name: String,
286
287    /// Entity type (Person, Organization, etc.)
288    pub entity_type: String,
289
290    /// Detection confidence (0.0-1.0)
291    pub confidence: f32,
292}
293
294/// Debug information for relevance calculation
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct RelevanceDebug {
297    /// Time spent on NER
298    pub ner_ms: f64,
299
300    /// Time spent on entity matching
301    pub entity_match_ms: f64,
302
303    /// Time spent on semantic search
304    pub semantic_search_ms: f64,
305
306    /// Time spent on scoring and ranking
307    pub ranking_ms: f64,
308
309    /// Number of memories scanned
310    pub memories_scanned: usize,
311
312    /// Number of entity matches found
313    pub entity_matches: usize,
314
315    /// Number of semantic matches found
316    pub semantic_matches: usize,
317}
318
319/// Entity index entry: maps entity name to memory IDs containing that entity
320#[derive(Debug, Clone, Default)]
321struct EntityIndexEntry {
322    /// Memory IDs that mention this entity
323    memory_ids: HashSet<Uuid>,
324    /// Last time this entry was updated
325    #[allow(dead_code)]
326    last_updated: Option<DateTime<Utc>>,
327}
328
329/// Learned weights for score fusion, adapted via feedback
330///
331/// These weights determine how different scoring signals are combined:
332/// - semantic: Cosine similarity from vector search
333/// - entity: Entity name overlap between query and memory
334/// - tag: Tag overlap between query context and memory tags
335/// - importance: Memory's stored importance score
336/// - momentum: Feedback momentum EMA (FBK-5) - learned from past interactions
337/// - access_count: How often the memory has been retrieved (CTX-3)
338/// - graph_strength: Hebbian edge strength from knowledge graph (CTX-3)
339///
340/// Weights are normalized to sum to 1.0 and updated via gradient descent
341/// when user provides feedback on surfaced memories.
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct LearnedWeights {
344    /// Weight for semantic similarity score
345    pub semantic: f32,
346    /// Weight for entity matching score
347    pub entity: f32,
348    /// Weight for tag matching score
349    pub tag: f32,
350    /// Weight for importance score
351    pub importance: f32,
352    /// Weight for feedback momentum EMA (FBK-5)
353    /// Higher momentum = memory has been consistently helpful
354    /// Lower/negative momentum = memory has been consistently misleading
355    #[serde(default = "default_momentum_weight")]
356    pub momentum: f32,
357    /// Weight for access count (CTX-3)
358    /// Higher access count = memory has proven retrieval value
359    #[serde(default = "default_access_count_weight")]
360    pub access_count: f32,
361    /// Weight for graph Hebbian strength (CTX-3)
362    /// Higher strength = stronger entity relationships in knowledge graph
363    #[serde(default = "default_graph_strength_weight")]
364    pub graph_strength: f32,
365    /// Number of feedback updates applied
366    pub update_count: u32,
367    /// Last time weights were updated
368    pub last_updated: Option<DateTime<Utc>>,
369}
370
371fn default_momentum_weight() -> f32 {
372    DEFAULT_MOMENTUM_WEIGHT
373}
374
375fn default_access_count_weight() -> f32 {
376    DEFAULT_ACCESS_COUNT_WEIGHT
377}
378
379fn default_graph_strength_weight() -> f32 {
380    DEFAULT_GRAPH_STRENGTH_WEIGHT
381}
382
383impl Default for LearnedWeights {
384    fn default() -> Self {
385        Self {
386            semantic: DEFAULT_SEMANTIC_WEIGHT,
387            entity: DEFAULT_ENTITY_WEIGHT,
388            tag: DEFAULT_TAG_WEIGHT,
389            importance: DEFAULT_IMPORTANCE_WEIGHT,
390            momentum: DEFAULT_MOMENTUM_WEIGHT,
391            access_count: DEFAULT_ACCESS_COUNT_WEIGHT,
392            graph_strength: DEFAULT_GRAPH_STRENGTH_WEIGHT,
393            update_count: 0,
394            last_updated: None,
395        }
396    }
397}
398
399impl LearnedWeights {
400    /// Normalize weights to sum to 1.0
401    pub fn normalize(&mut self) {
402        let sum = self.semantic
403            + self.entity
404            + self.tag
405            + self.importance
406            + self.momentum
407            + self.access_count
408            + self.graph_strength;
409        if sum > 0.0 {
410            self.semantic /= sum;
411            self.entity /= sum;
412            self.tag /= sum;
413            self.importance /= sum;
414            self.momentum /= sum;
415            self.access_count /= sum;
416            self.graph_strength /= sum;
417        }
418    }
419
420    /// Apply feedback to update weights
421    ///
422    /// - helpful: Increase weights for components that contributed to this memory
423    /// - not_helpful: Decrease weights for components that led to this memory
424    ///
425    /// Uses gradient descent with learning rate WEIGHT_LEARNING_RATE.
426    /// Updates all 7 weight dimensions including momentum, access_count, and graph_strength.
427    pub fn apply_feedback(
428        &mut self,
429        semantic_contributed: bool,
430        entity_contributed: bool,
431        tag_contributed: bool,
432        helpful: bool,
433    ) {
434        let direction = if helpful { 1.0 } else { -1.0 };
435        let delta = WEIGHT_LEARNING_RATE * direction;
436
437        // Update weights based on which components contributed
438        if semantic_contributed {
439            self.semantic = (self.semantic + delta).max(MIN_WEIGHT);
440        }
441        if entity_contributed {
442            self.entity = (self.entity + delta).max(MIN_WEIGHT);
443        }
444        if tag_contributed {
445            self.tag = (self.tag + delta).max(MIN_WEIGHT);
446        }
447
448        // Importance is always a factor, so adjust inversely to others
449        if helpful && !semantic_contributed && !entity_contributed && !tag_contributed {
450            // Memory was helpful but no clear signal - boost importance
451            self.importance = (self.importance + delta).max(MIN_WEIGHT);
452        }
453
454        // Also adjust momentum, access_count, and graph_strength dimensions.
455        // These are implicit signals: helpful memories get a small boost across
456        // all auxiliary dimensions so they contribute to future fused scores.
457        let aux_delta = WEIGHT_LEARNING_RATE * direction * 0.5;
458        self.momentum = (self.momentum + aux_delta).max(MIN_WEIGHT);
459        self.access_count = (self.access_count + aux_delta).max(MIN_WEIGHT);
460        self.graph_strength = (self.graph_strength + aux_delta).max(MIN_WEIGHT);
461
462        self.normalize();
463        self.update_count += 1;
464        self.last_updated = Some(Utc::now());
465    }
466
467    /// Calculate fused score from component scores
468    pub fn fuse_scores(
469        &self,
470        semantic_score: f32,
471        entity_score: f32,
472        tag_score: f32,
473        importance_score: f32,
474    ) -> f32 {
475        // Use the full method with neutral defaults for backwards compatibility
476        // momentum = 0 (neutral), access_count = 0, graph_strength = 0.5 (neutral)
477        self.fuse_scores_full(
478            semantic_score,
479            entity_score,
480            tag_score,
481            importance_score,
482            0.0,
483            0,
484            0.5,
485        )
486    }
487
488    /// Calculate fused score from component scores including momentum EMA (FBK-5)
489    ///
490    /// # Arguments
491    /// - `semantic_score`: Cosine similarity from vector search (0.0 to 1.0)
492    /// - `entity_score`: Entity name overlap score (0.0 to 1.0)
493    /// - `tag_score`: Tag overlap score (0.0 to 1.0)
494    /// - `importance_score`: Memory's importance score (0.0 to 1.0)
495    /// - `momentum_ema`: Feedback momentum EMA (-1.0 to 1.0, 0.0 = neutral)
496    ///   - Positive: Memory has been consistently helpful
497    ///   - Negative: Memory has been consistently misleading
498    pub fn fuse_scores_with_momentum(
499        &self,
500        semantic_score: f32,
501        entity_score: f32,
502        tag_score: f32,
503        importance_score: f32,
504        momentum_ema: f32,
505    ) -> f32 {
506        // access_count = 0, graph_strength = 0.5 (neutral)
507        self.fuse_scores_full(
508            semantic_score,
509            entity_score,
510            tag_score,
511            importance_score,
512            momentum_ema,
513            0,
514            0.5,
515        )
516    }
517
518    /// Calculate fused score from all component scores (CTX-3)
519    ///
520    /// # Arguments
521    /// - `semantic_score`: Cosine similarity from vector search (0.0 to 1.0)
522    /// - `entity_score`: Entity name overlap score (0.0 to 1.0)
523    /// - `tag_score`: Tag overlap score (0.0 to 1.0)
524    /// - `importance_score`: Memory's importance score (0.0 to 1.0)
525    /// - `momentum_ema`: Feedback momentum EMA (-1.0 to 1.0, 0.0 = neutral)
526    /// - `access_count`: Number of times this memory has been retrieved
527    /// - `graph_strength`: Hebbian edge strength from knowledge graph (0.0 to 1.0)
528    pub fn fuse_scores_full(
529        &self,
530        semantic_score: f32,
531        entity_score: f32,
532        tag_score: f32,
533        importance_score: f32,
534        momentum_ema: f32,
535        access_count: u32,
536        graph_strength: f32,
537    ) -> f32 {
538        // Calibrate each score using sigmoid to normalize different scales
539        let calibrated_semantic = calibrate_score(semantic_score);
540        let calibrated_entity = calibrate_score(entity_score);
541        let calibrated_tag = calibrate_score(tag_score);
542        let calibrated_importance = calibrate_score(importance_score);
543
544        // Transform momentum from [-1, 1] to [0, 1] range for calibration
545        // EMA of -1.0 (always misleading) -> 0.0
546        // EMA of  0.0 (neutral)           -> 0.5
547        // EMA of  1.0 (always helpful)    -> 1.0
548        let normalized_momentum = (momentum_ema + 1.0) / 2.0;
549
550        // CTX-3: Apply momentum amplification for high-momentum memories
551        // Aggressive thresholds to strongly differentiate proven vs unproven memories
552        let amplified_momentum = if normalized_momentum > 0.65 {
553            // Positive momentum: 1.5x amplification (threshold lowered from 0.75)
554            (normalized_momentum * 1.5).min(1.0)
555        } else if normalized_momentum < 0.40 {
556            // Negative/neutral momentum: 0.3x penalty (threshold raised, penalty increased)
557            // This aggressively demotes memories that haven't proven helpful
558            (normalized_momentum * 0.3).max(0.0)
559        } else {
560            normalized_momentum
561        };
562        let calibrated_momentum = calibrate_score(amplified_momentum);
563
564        // CTX-3: Transform access count to 0-1 scale with diminishing returns
565        // 0 accesses -> 0.0, 1 -> 0.25, 2 -> 0.40, 5 -> 0.65, 16+ -> 1.0
566        let access_score = if access_count == 0 {
567            0.0
568        } else {
569            // log2(n+1) ensures access_count=1 produces a non-zero score (log2(2)=1.0)
570            // unlike log2(1)=0 which made first-access indistinguishable from never-accessed
571            let log_access = (access_count as f32 + 1.0).log2();
572            (log_access / 4.0).min(1.0)
573        };
574        let calibrated_access = calibrate_score(access_score);
575
576        // CTX-3: Graph strength already in 0-1 range, calibrate directly
577        let calibrated_graph = calibrate_score(graph_strength);
578
579        // Weighted sum
580        let result = self.semantic * calibrated_semantic
581            + self.entity * calibrated_entity
582            + self.tag * calibrated_tag
583            + self.importance * calibrated_importance
584            + self.momentum * calibrated_momentum
585            + self.access_count * calibrated_access
586            + self.graph_strength * calibrated_graph;
587
588        if result.is_finite() {
589            result
590        } else {
591            0.0
592        }
593    }
594}
595
596/// Calibrate a score using sigmoid function
597///
598/// Maps scores to a 0-1 range with smooth cutoff around SIGMOID_MIDPOINT.
599/// Scores near 1.0 stay near 1.0, scores near 0 are penalized more.
600fn calibrate_score(score: f32) -> f32 {
601    if !score.is_finite() {
602        return 0.0;
603    }
604    1.0 / (1.0 + (-SIGMOID_STEEPNESS * (score - SIGMOID_MIDPOINT)).exp())
605}
606
607/// Proactive relevance engine for memory surfacing
608pub struct RelevanceEngine {
609    /// Neural NER for entity extraction
610    ner: Arc<NeuralNer>,
611
612    /// Entity name index for O(1) lookup (entity_name_lower -> memory_ids)
613    /// Populated from GraphMemory on first use or via refresh_entity_index()
614    entity_index: Arc<RwLock<HashMap<String, EntityIndexEntry>>>,
615
616    /// Tracks when entity index was last fully refreshed
617    entity_index_timestamp: Arc<RwLock<Option<DateTime<Utc>>>>,
618
619    /// Learned weights for score fusion, adapted via feedback
620    learned_weights: Arc<RwLock<LearnedWeights>>,
621
622    /// Active A/B test ID (if any) for this engine
623    active_ab_test: Arc<RwLock<Option<String>>>,
624}
625
626impl RelevanceEngine {
627    /// Create a new relevance engine
628    pub fn new(ner: Arc<NeuralNer>) -> Self {
629        Self {
630            ner,
631            entity_index: Arc::new(RwLock::new(HashMap::new())),
632            entity_index_timestamp: Arc::new(RwLock::new(None)),
633            learned_weights: Arc::new(RwLock::new(LearnedWeights::default())),
634            active_ab_test: Arc::new(RwLock::new(None)),
635        }
636    }
637
638    /// Set active A/B test for this engine
639    ///
640    /// When set, the engine will use weights from the A/B test based on user assignment.
641    pub fn set_active_ab_test(&self, test_id: Option<String>) {
642        *self.active_ab_test.write() = test_id;
643    }
644
645    /// Get active A/B test ID
646    pub fn get_active_ab_test(&self) -> Option<String> {
647        self.active_ab_test.read().clone()
648    }
649
650    /// Get current learned weights
651    pub fn get_weights(&self) -> LearnedWeights {
652        self.learned_weights.read().clone()
653    }
654
655    /// Set learned weights (e.g., loaded from storage)
656    pub fn set_weights(&self, weights: LearnedWeights) {
657        *self.learned_weights.write() = weights;
658    }
659
660    /// Apply feedback to update learned weights
661    ///
662    /// Call this when user indicates a surfaced memory was helpful or not.
663    pub fn apply_feedback(
664        &self,
665        semantic_contributed: bool,
666        entity_contributed: bool,
667        tag_contributed: bool,
668        helpful: bool,
669    ) {
670        self.learned_weights.write().apply_feedback(
671            semantic_contributed,
672            entity_contributed,
673            tag_contributed,
674            helpful,
675        );
676    }
677
678    /// Calculate tag overlap score between context and memory tags
679    fn calculate_tag_score(&self, context: &str, tags: &[String]) -> f32 {
680        if tags.is_empty() {
681            return 0.0;
682        }
683
684        let context_lower = context.to_lowercase();
685        let mut matches = 0;
686
687        for tag in tags {
688            let tag_lower = tag.to_lowercase();
689            // Check if tag appears in context (or context words match tag)
690            if context_lower.contains(&tag_lower) {
691                matches += 1;
692            } else {
693                // Check if any context word starts with or equals the tag
694                for word in context_lower.split_whitespace() {
695                    if word.starts_with(&tag_lower) || tag_lower.starts_with(word) {
696                        matches += 1;
697                        break;
698                    }
699                }
700            }
701        }
702
703        matches as f32 / tags.len() as f32
704    }
705
706    /// Surface relevant memories for the given context
707    ///
708    /// This is the main entry point for proactive memory surfacing.
709    /// Target latency: <30ms
710    pub fn surface_relevant(
711        &self,
712        context: &str,
713        memory_system: &MemorySystem,
714        graph_memory: Option<&GraphMemory>,
715        config: &RelevanceConfig,
716        feedback_store: Option<&RwLock<FeedbackStore>>,
717    ) -> Result<RelevanceResponse> {
718        self.surface_relevant_inner(
719            context,
720            memory_system,
721            graph_memory,
722            config,
723            feedback_store,
724            None,
725        )
726    }
727
728    fn surface_relevant_inner(
729        &self,
730        context: &str,
731        memory_system: &MemorySystem,
732        graph_memory: Option<&GraphMemory>,
733        config: &RelevanceConfig,
734        feedback_store: Option<&RwLock<FeedbackStore>>,
735        weights_override: Option<LearnedWeights>,
736    ) -> Result<RelevanceResponse> {
737        let start = Instant::now();
738        let mut debug = RelevanceDebug {
739            ner_ms: 0.0,
740            entity_match_ms: 0.0,
741            semantic_search_ms: 0.0,
742            ranking_ms: 0.0,
743            memories_scanned: 0,
744            entity_matches: 0,
745            semantic_matches: 0,
746        };
747
748        // Phase 1: Entity extraction (if enabled)
749        let ner_start = Instant::now();
750        let detected_entities = if config.enable_entity_matching {
751            self.extract_entities(context)
752        } else {
753            Vec::new()
754        };
755        debug.ner_ms = ner_start.elapsed().as_secs_f64() * 1000.0;
756
757        // Phase 2: Parallel entity + semantic search
758        // Track individual component scores for learned weight fusion
759        // Structure: (Memory, semantic_score, entity_score, matched_entities)
760        let mut candidate_memories: HashMap<Uuid, (Memory, f32, f32, Vec<String>)> = HashMap::new();
761
762        // 2a: Entity-based matching
763        if config.enable_entity_matching && !detected_entities.is_empty() {
764            let entity_start = Instant::now();
765            let entity_matches =
766                self.match_by_entities(&detected_entities, memory_system, graph_memory, config)?;
767            debug.entity_match_ms = entity_start.elapsed().as_secs_f64() * 1000.0;
768            debug.entity_matches = entity_matches.len();
769
770            for (memory, score, matched) in entity_matches {
771                let id = memory.id.0;
772                // semantic=0, entity=score
773                candidate_memories.insert(id, (memory, 0.0, score, matched));
774            }
775        }
776
777        // 2b: Semantic similarity matching
778        if config.enable_semantic_matching {
779            let semantic_start = Instant::now();
780            let semantic_matches = self.match_by_semantic(context, memory_system, config)?;
781            debug.semantic_search_ms = semantic_start.elapsed().as_secs_f64() * 1000.0;
782            debug.semantic_matches = semantic_matches.len();
783
784            for (memory, score) in semantic_matches {
785                let id = memory.id.0;
786                if let Some((_, semantic_score, _entity_score, _matched)) =
787                    candidate_memories.get_mut(&id)
788                {
789                    // Already found via entity match - add semantic score
790                    *semantic_score = score;
791                } else {
792                    // New candidate from semantic search only
793                    candidate_memories.insert(id, (memory, score, 0.0, Vec::new()));
794                }
795            }
796        }
797
798        debug.memories_scanned = candidate_memories.len();
799
800        // Phase 3: Rank and select top results using learned weights
801        let ranking_start = Instant::now();
802        let weights = weights_override.unwrap_or_else(|| self.learned_weights.read().clone());
803
804        let mut results: Vec<SurfacedMemory> = candidate_memories
805            .into_iter()
806            .filter_map(
807                |(_, (memory, semantic_score, entity_score, matched_entities))| {
808                    // Apply minimum importance filter
809                    let importance = memory.importance();
810                    if importance < config.min_importance {
811                        return None;
812                    }
813
814                    // Apply memory type filter
815                    if !config.memory_types.is_empty() {
816                        let mem_type = format!("{:?}", memory.experience.experience_type);
817                        if !config
818                            .memory_types
819                            .iter()
820                            .any(|t| t.eq_ignore_ascii_case(&mem_type))
821                        {
822                            return None;
823                        }
824                    }
825
826                    // Calculate tag score
827                    let tag_score = self.calculate_tag_score(context, &memory.experience.tags);
828
829                    // CTX-3: Get access count for momentum scoring
830                    let access_count = memory.access_count();
831
832                    // CTX-3: Get graph Hebbian strength for this memory
833                    let graph_strength = graph_memory
834                        .and_then(|g| g.get_memory_hebbian_strength(&memory.id))
835                        .unwrap_or(0.5); // Neutral default if no graph or no edges
836
837                    // Look up momentum EMA from feedback store when available
838                    let momentum_ema = feedback_store
839                        .and_then(|fs| {
840                            let store = fs.read();
841                            store.get_momentum(&memory.id).map(|m| m.ema_with_decay())
842                        })
843                        .unwrap_or(0.0);
844
845                    // Fuse scores using learned weights with all signals including momentum
846                    let fused_score = weights.fuse_scores_full(
847                        semantic_score,
848                        entity_score,
849                        tag_score,
850                        importance,
851                        momentum_ema,
852                        access_count,
853                        graph_strength,
854                    );
855
856                    // Determine relevance reason
857                    let reason = if semantic_score > 0.0 && entity_score > 0.0 {
858                        RelevanceReason::Combined
859                    } else if entity_score > 0.0 {
860                        RelevanceReason::EntityMatch
861                    } else if semantic_score > 0.0 {
862                        RelevanceReason::SemanticSimilarity
863                    } else {
864                        RelevanceReason::RecentImportant
865                    };
866
867                    // Apply recency boost
868                    let recency_boosted = self.apply_recency_boost(
869                        fused_score,
870                        memory.created_at,
871                        config.recency_boost_hours,
872                        config.recency_boost_multiplier,
873                    );
874
875                    // FBK-7: Apply graph boost for memories found via entity matching
876                    let final_score = if entity_score > 0.0 {
877                        (recency_boosted * config.graph_boost_multiplier).min(1.0)
878                    } else {
879                        recency_boosted
880                    };
881
882                    Some(SurfacedMemory {
883                        id: memory.id.0.to_string(),
884                        content: memory.experience.content.clone(),
885                        memory_type: format!("{:?}", memory.experience.experience_type),
886                        importance,
887                        relevance_score: final_score,
888                        relevance_reason: reason.clone(),
889                        matched_entities,
890                        semantic_similarity: if semantic_score > 0.0 {
891                            Some(semantic_score)
892                        } else {
893                            None
894                        },
895                        created_at: memory.created_at,
896                        tags: memory.experience.tags.clone(),
897                    })
898                },
899            )
900            .collect();
901
902        // Sort by relevance score (descending)
903        results.sort_by(|a, b| b.relevance_score.total_cmp(&a.relevance_score));
904
905        // Quality-based filtering: only return memories above minimum relevance
906        // This prevents returning low-quality matches just to fill max_results
907        const MIN_RELEVANCE_SCORE: f32 = 0.25;
908        results.retain(|r| r.relevance_score >= MIN_RELEVANCE_SCORE);
909
910        // Cap at max_results (but may return fewer if quality threshold filters them)
911        results.truncate(config.max_results);
912
913        debug.ranking_ms = ranking_start.elapsed().as_secs_f64() * 1000.0;
914
915        let total_latency = start.elapsed().as_secs_f64() * 1000.0;
916        let latency_target_met = total_latency < 30.0;
917
918        Ok(RelevanceResponse {
919            memories: results,
920            detected_entities,
921            latency_ms: total_latency,
922            latency_target_met,
923            debug: if cfg!(debug_assertions) {
924                Some(debug)
925            } else {
926                None
927            },
928        })
929    }
930
931    /// Surface relevant memories with feedback momentum integration (FBK-5)
932    ///
933    /// This variant incorporates momentum EMA from past feedback into scoring.
934    /// Memories with positive momentum (consistently helpful) get boosted.
935    /// Memories with negative momentum (consistently misleading) get penalized.
936    ///
937    /// # Arguments
938    /// - `context`: Current context/query text
939    /// - `memory_system`: Memory storage
940    /// - `graph_memory`: Optional graph memory for entity lookup
941    /// - `config`: Relevance configuration
942    /// - `momentum_lookup`: Map of memory_id -> momentum EMA (-1.0 to 1.0)
943    pub fn surface_relevant_with_momentum(
944        &self,
945        context: &str,
946        memory_system: &MemorySystem,
947        graph_memory: Option<&GraphMemory>,
948        config: &RelevanceConfig,
949        momentum_lookup: &HashMap<Uuid, f32>,
950    ) -> Result<RelevanceResponse> {
951        let start = Instant::now();
952        let mut debug = RelevanceDebug {
953            ner_ms: 0.0,
954            entity_match_ms: 0.0,
955            semantic_search_ms: 0.0,
956            ranking_ms: 0.0,
957            memories_scanned: 0,
958            entity_matches: 0,
959            semantic_matches: 0,
960        };
961
962        // Phase 1: Entity extraction (if enabled)
963        let ner_start = Instant::now();
964        let detected_entities = if config.enable_entity_matching {
965            self.extract_entities(context)
966        } else {
967            Vec::new()
968        };
969        debug.ner_ms = ner_start.elapsed().as_secs_f64() * 1000.0;
970
971        // Phase 2: Parallel entity + semantic search
972        let mut candidate_memories: HashMap<Uuid, (Memory, f32, f32, Vec<String>)> = HashMap::new();
973
974        // 2a: Entity-based matching
975        if config.enable_entity_matching && !detected_entities.is_empty() {
976            let entity_start = Instant::now();
977            let entity_matches =
978                self.match_by_entities(&detected_entities, memory_system, graph_memory, config)?;
979            debug.entity_match_ms = entity_start.elapsed().as_secs_f64() * 1000.0;
980            debug.entity_matches = entity_matches.len();
981
982            for (memory, score, matched) in entity_matches {
983                let id = memory.id.0;
984                candidate_memories.insert(id, (memory, 0.0, score, matched));
985            }
986        }
987
988        // 2b: Semantic similarity matching
989        if config.enable_semantic_matching {
990            let semantic_start = Instant::now();
991            let semantic_matches = self.match_by_semantic(context, memory_system, config)?;
992            debug.semantic_search_ms = semantic_start.elapsed().as_secs_f64() * 1000.0;
993            debug.semantic_matches = semantic_matches.len();
994
995            for (memory, score) in semantic_matches {
996                let id = memory.id.0;
997                if let Some((_, semantic_score, _entity_score, _matched)) =
998                    candidate_memories.get_mut(&id)
999                {
1000                    *semantic_score = score;
1001                } else {
1002                    candidate_memories.insert(id, (memory, score, 0.0, Vec::new()));
1003                }
1004            }
1005        }
1006
1007        debug.memories_scanned = candidate_memories.len();
1008
1009        // Phase 3: Rank with momentum-aware scoring
1010        let ranking_start = Instant::now();
1011        let weights = self.learned_weights.read().clone();
1012
1013        let mut results: Vec<SurfacedMemory> = candidate_memories
1014            .into_iter()
1015            .filter_map(
1016                |(id, (memory, semantic_score, entity_score, matched_entities))| {
1017                    let importance = memory.importance();
1018                    if importance < config.min_importance {
1019                        return None;
1020                    }
1021
1022                    if !config.memory_types.is_empty() {
1023                        let mem_type = format!("{:?}", memory.experience.experience_type);
1024                        if !config
1025                            .memory_types
1026                            .iter()
1027                            .any(|t| t.eq_ignore_ascii_case(&mem_type))
1028                        {
1029                            return None;
1030                        }
1031                    }
1032
1033                    let tag_score = self.calculate_tag_score(context, &memory.experience.tags);
1034
1035                    // FBK-5: Look up momentum EMA for this memory
1036                    let momentum_ema = momentum_lookup.get(&id).copied().unwrap_or(0.0);
1037
1038                    // CTX-3: Get access count for scoring
1039                    let access_count = memory.access_count();
1040
1041                    // CTX-3: Get graph Hebbian strength for this memory
1042                    let graph_strength = graph_memory
1043                        .and_then(|g| g.get_memory_hebbian_strength(&memory.id))
1044                        .unwrap_or(0.5); // Neutral default if no graph or no edges
1045
1046                    // Use full score fusion with momentum, access count, and graph strength (CTX-3)
1047                    let fused_score = weights.fuse_scores_full(
1048                        semantic_score,
1049                        entity_score,
1050                        tag_score,
1051                        importance,
1052                        momentum_ema,
1053                        access_count,
1054                        graph_strength,
1055                    );
1056
1057                    let reason = if semantic_score > 0.0 && entity_score > 0.0 {
1058                        RelevanceReason::Combined
1059                    } else if entity_score > 0.0 {
1060                        RelevanceReason::EntityMatch
1061                    } else if semantic_score > 0.0 {
1062                        RelevanceReason::SemanticSimilarity
1063                    } else {
1064                        RelevanceReason::RecentImportant
1065                    };
1066
1067                    // Apply recency boost
1068                    let recency_boosted = self.apply_recency_boost(
1069                        fused_score,
1070                        memory.created_at,
1071                        config.recency_boost_hours,
1072                        config.recency_boost_multiplier,
1073                    );
1074
1075                    // FBK-7: Apply graph boost for memories found via entity matching
1076                    // Entity matching uses the knowledge graph, so entity_score > 0 indicates graph relevance
1077                    let final_score = if entity_score > 0.0 {
1078                        (recency_boosted * config.graph_boost_multiplier).min(1.0)
1079                    } else {
1080                        recency_boosted
1081                    };
1082
1083                    Some(SurfacedMemory {
1084                        id: memory.id.0.to_string(),
1085                        content: memory.experience.content.clone(),
1086                        memory_type: format!("{:?}", memory.experience.experience_type),
1087                        importance,
1088                        relevance_score: final_score,
1089                        relevance_reason: reason.clone(),
1090                        matched_entities,
1091                        semantic_similarity: if semantic_score > 0.0 {
1092                            Some(semantic_score)
1093                        } else {
1094                            None
1095                        },
1096                        created_at: memory.created_at,
1097                        tags: memory.experience.tags.clone(),
1098                    })
1099                },
1100            )
1101            .collect();
1102
1103        results.sort_by(|a, b| b.relevance_score.total_cmp(&a.relevance_score));
1104
1105        // Quality-based filtering: only return memories above minimum relevance
1106        const MIN_RELEVANCE_SCORE: f32 = 0.25;
1107        results.retain(|r| r.relevance_score >= MIN_RELEVANCE_SCORE);
1108
1109        // Cap at max_results (but may return fewer if quality threshold filters them)
1110        results.truncate(config.max_results);
1111
1112        debug.ranking_ms = ranking_start.elapsed().as_secs_f64() * 1000.0;
1113
1114        let total_latency = start.elapsed().as_secs_f64() * 1000.0;
1115        let latency_target_met = total_latency < 30.0;
1116
1117        Ok(RelevanceResponse {
1118            memories: results,
1119            detected_entities,
1120            latency_ms: total_latency,
1121            latency_target_met,
1122            debug: if cfg!(debug_assertions) {
1123                Some(debug)
1124            } else {
1125                None
1126            },
1127        })
1128    }
1129
1130    /// Surface relevant memories with A/B test integration
1131    ///
1132    /// When an A/B test manager is provided, this method:
1133    /// 1. Assigns the user to a variant (control or treatment)
1134    /// 2. Uses weights from the assigned variant
1135    /// 3. Records impressions for the test
1136    ///
1137    /// Returns the variant used along with the response for tracking.
1138    pub fn surface_relevant_with_ab_test(
1139        &self,
1140        context: &str,
1141        user_id: &str,
1142        memory_system: &MemorySystem,
1143        graph_memory: Option<&GraphMemory>,
1144        config: &RelevanceConfig,
1145        ab_manager: &crate::ab_testing::ABTestManager,
1146    ) -> Result<(RelevanceResponse, Option<crate::ab_testing::ABTestVariant>)> {
1147        let start = Instant::now();
1148
1149        // Check if we have an active A/B test
1150        let active_test = self.get_active_ab_test();
1151
1152        let (weights, variant) = if let Some(ref test_id) = active_test {
1153            // Get weights from A/B test based on user assignment
1154            match ab_manager.get_weights_for_user(test_id, user_id) {
1155                Ok(w) => {
1156                    let v = ab_manager.get_variant(test_id, user_id).ok();
1157                    (w, v)
1158                }
1159                Err(_) => {
1160                    // Fall back to learned weights if test error
1161                    (self.get_weights(), None)
1162                }
1163            }
1164        } else {
1165            // No active test, use learned weights
1166            (self.get_weights(), None)
1167        };
1168
1169        // Use per-request weights override (thread-safe: no global state mutation)
1170        let response = self.surface_relevant_inner(
1171            context,
1172            memory_system,
1173            graph_memory,
1174            config,
1175            None,
1176            Some(weights),
1177        )?;
1178
1179        // Record impression for A/B test if active
1180        if let (Some(ref test_id), Some(ref _v)) = (&active_test, &variant) {
1181            let latency_us = start.elapsed().as_micros() as u64;
1182            let avg_score = if response.memories.is_empty() {
1183                0.0
1184            } else {
1185                response
1186                    .memories
1187                    .iter()
1188                    .map(|m| m.relevance_score as f64)
1189                    .sum::<f64>()
1190                    / response.memories.len() as f64
1191            };
1192
1193            let _ = ab_manager.record_impression(test_id, user_id, avg_score, latency_us);
1194        }
1195
1196        Ok((response, variant))
1197    }
1198
1199    /// Record a click for A/B testing
1200    ///
1201    /// Call this when a user interacts with a surfaced memory.
1202    pub fn record_ab_click(
1203        &self,
1204        user_id: &str,
1205        memory_id: Uuid,
1206        ab_manager: &crate::ab_testing::ABTestManager,
1207    ) -> Result<()> {
1208        if let Some(test_id) = self.get_active_ab_test() {
1209            ab_manager
1210                .record_click(&test_id, user_id, memory_id)
1211                .map_err(|e| anyhow::anyhow!("Failed to record A/B click: {}", e))?;
1212        }
1213        Ok(())
1214    }
1215
1216    /// Record feedback for A/B testing
1217    ///
1218    /// Call this when a user provides explicit feedback on a surfaced memory.
1219    pub fn record_ab_feedback(
1220        &self,
1221        user_id: &str,
1222        positive: bool,
1223        ab_manager: &crate::ab_testing::ABTestManager,
1224    ) -> Result<()> {
1225        if let Some(test_id) = self.get_active_ab_test() {
1226            ab_manager
1227                .record_feedback(&test_id, user_id, positive)
1228                .map_err(|e| anyhow::anyhow!("Failed to record A/B feedback: {}", e))?;
1229        }
1230        Ok(())
1231    }
1232
1233    /// Extract entities from context using NER
1234    fn extract_entities(&self, context: &str) -> Vec<DetectedEntity> {
1235        match self.ner.extract(context) {
1236            Ok(entities) => entities
1237                .into_iter()
1238                .map(|e| DetectedEntity {
1239                    name: e.text,
1240                    entity_type: format!("{:?}", e.entity_type),
1241                    confidence: e.confidence,
1242                })
1243                .collect(),
1244            Err(_) => Vec::new(),
1245        }
1246    }
1247
1248    /// Match memories by entity overlap
1249    ///
1250    /// Optimized for <30ms latency with two strategies:
1251    /// 1. **Cache path (O(k))**: Use entity_index for direct entity->memory ID lookup
1252    /// 2. **Scan path (O(n))**: Fall back to content scanning if cache miss
1253    ///
1254    /// The cache path is ~10-100x faster for repeated entity lookups.
1255    fn match_by_entities(
1256        &self,
1257        entities: &[DetectedEntity],
1258        memory_system: &MemorySystem,
1259        graph_memory: Option<&GraphMemory>,
1260        config: &RelevanceConfig,
1261    ) -> Result<Vec<(Memory, f32, Vec<String>)>> {
1262        // Pre-compute lowercase entity names and weights for O(1) lookup
1263        let entity_lookup: Vec<(String, &DetectedEntity, f32)> = entities
1264            .iter()
1265            .map(|e| {
1266                let weight = self.entity_type_weight(&e.entity_type);
1267                (e.name.to_lowercase(), e, weight)
1268            })
1269            .collect();
1270
1271        let max_candidates = config.max_results * 3;
1272        let mut results: Vec<(Memory, f32, Vec<String>)> = Vec::with_capacity(max_candidates);
1273        let mut found_ids: HashSet<Uuid> = HashSet::new();
1274
1275        // =====================================================================
1276        // FAST PATH: Use cached entity index for O(1) lookups
1277        // =====================================================================
1278        {
1279            let index = self.entity_index.read();
1280            if !index.is_empty() {
1281                // Collect candidate memory IDs from index
1282                let mut candidate_ids: HashMap<Uuid, (f32, Vec<String>)> = HashMap::new();
1283
1284                for (name_lower, entity, weight) in &entity_lookup {
1285                    if let Some(entry) = index.get(name_lower) {
1286                        for &memory_id in &entry.memory_ids {
1287                            let score = entity.confidence * weight;
1288                            candidate_ids
1289                                .entry(memory_id)
1290                                .and_modify(|(existing_score, matched)| {
1291                                    *existing_score += score;
1292                                    if !matched.contains(&entity.name) {
1293                                        matched.push(entity.name.clone());
1294                                    }
1295                                })
1296                                .or_insert((score, vec![entity.name.clone()]));
1297                        }
1298                    }
1299                }
1300
1301                // Fetch memories for candidate IDs
1302                if !candidate_ids.is_empty() {
1303                    // Sort by score descending and take top candidates
1304                    let mut sorted_candidates: Vec<_> = candidate_ids.into_iter().collect();
1305                    sorted_candidates.sort_by(|a, b| b.1 .0.total_cmp(&a.1 .0));
1306
1307                    for (memory_id, (score, matched)) in
1308                        sorted_candidates.into_iter().take(max_candidates)
1309                    {
1310                        let normalized_score = (score / matched.len() as f32).min(1.0);
1311                        if normalized_score >= config.entity_threshold {
1312                            // Try to get the memory
1313                            let mem_id = crate::memory::MemoryId(memory_id);
1314                            if let Ok(memory) = memory_system.get_memory(&mem_id) {
1315                                found_ids.insert(memory_id);
1316                                results.push((memory, normalized_score, matched));
1317                            }
1318                        }
1319                    }
1320                }
1321
1322                // If cache hit was successful, return early
1323                if !results.is_empty() {
1324                    return Ok(results);
1325                }
1326            }
1327        }
1328
1329        // =====================================================================
1330        // SLOW PATH: Full memory scan (when cache empty or miss)
1331        // =====================================================================
1332        let all_memories = memory_system.get_all_memories()?;
1333
1334        if all_memories.is_empty() {
1335            return Ok(results);
1336        }
1337
1338        for shared_memory in &all_memories {
1339            // Early termination: stop when we have enough high-quality candidates
1340            if results.len() >= max_candidates {
1341                break;
1342            }
1343
1344            // Skip if already found via cache
1345            if found_ids.contains(&shared_memory.id.0) {
1346                continue;
1347            }
1348
1349            let content_lower = shared_memory.experience.content.to_lowercase();
1350            let mut matched: Vec<String> = Vec::new();
1351            let mut match_score = 0.0f32;
1352
1353            // Check direct text matches with word boundaries
1354            for (name_lower, entity, weight) in &entity_lookup {
1355                if contains_word(&content_lower, name_lower) {
1356                    matched.push(entity.name.clone());
1357                    match_score += entity.confidence * weight;
1358                }
1359            }
1360
1361            // Normalize score and filter
1362            if !matched.is_empty() {
1363                let normalized_score = (match_score / matched.len() as f32).min(1.0);
1364                if normalized_score >= config.entity_threshold {
1365                    results.push(((**shared_memory).clone(), normalized_score, matched));
1366                }
1367            }
1368        }
1369
1370        // Skip graph memory lookup if we already have enough results (fast path)
1371        // This avoids expensive graph traversal when text matching is sufficient
1372        if results.len() >= config.max_results * 2 || graph_memory.is_none() {
1373            return Ok(results);
1374        }
1375
1376        // Graph memory lookup with TRAVERSAL for additional entity relationships
1377        let mut graph_results = results;
1378        if let Some(graph) = graph_memory {
1379            // Build set of already-found memory IDs to avoid duplicates
1380            let found_ids: HashSet<Uuid> = graph_results.iter().map(|(m, _, _)| m.id.0).collect();
1381
1382            // Limit graph lookups to avoid latency spikes
1383            let max_graph_lookups = 5;
1384            for (idx, entity) in entities.iter().enumerate() {
1385                if idx >= max_graph_lookups {
1386                    break;
1387                }
1388
1389                if let Ok(Some(entity_node)) = graph.find_entity_by_name(&entity.name) {
1390                    // GRAPH TRAVERSAL: Traverse to connected entities (5 hops for deep multi-hop reasoning)
1391                    // Decay factor (0.86^hop) naturally down-weights distant connections
1392                    if let Ok(traversal) = graph.traverse_from_entity(&entity_node.uuid, 5) {
1393                        for traversed in &traversal.entities {
1394                            // Get episodes (memories) connected to this entity
1395                            if let Ok(episodes) =
1396                                graph.get_episodes_by_entity(&traversed.entity.uuid)
1397                            {
1398                                for episode in episodes.iter().take(10) {
1399                                    // Episode UUID = Memory ID (we store it this way in remember())
1400                                    let memory_id = crate::memory::MemoryId(episode.uuid);
1401
1402                                    // Skip if already found
1403                                    if found_ids.contains(&episode.uuid) {
1404                                        continue;
1405                                    }
1406
1407                                    // Score = confidence * salience * decay_factor
1408                                    // decay_factor accounts for hop distance (0.7^hop)
1409                                    let score = entity.confidence
1410                                        * traversed.entity.salience
1411                                        * traversed.decay_factor;
1412                                    if score >= config.entity_threshold {
1413                                        // Direct memory lookup by ID (fast!)
1414                                        if let Ok(memory) = memory_system.get_memory(&memory_id) {
1415                                            graph_results.push((
1416                                                memory,
1417                                                score,
1418                                                vec![
1419                                                    entity.name.clone(),
1420                                                    traversed.entity.name.clone(),
1421                                                ],
1422                                            ));
1423                                        }
1424                                    }
1425                                }
1426                            }
1427                        }
1428                    }
1429                }
1430            }
1431        }
1432
1433        Ok(graph_results)
1434    }
1435
1436    /// Get weight for entity type (used in scoring)
1437    fn entity_type_weight(&self, entity_type: &str) -> f32 {
1438        match entity_type.to_lowercase().as_str() {
1439            "person" => 1.0,
1440            "organization" => 0.9,
1441            "location" => 0.8,
1442            "technology" => 0.85,
1443            "product" => 0.9,
1444            "event" => 0.7,
1445            "date" => 0.5,
1446            _ => 0.6,
1447        }
1448    }
1449
1450    /// Match memories by semantic similarity
1451    fn match_by_semantic(
1452        &self,
1453        context: &str,
1454        memory_system: &MemorySystem,
1455        config: &RelevanceConfig,
1456    ) -> Result<Vec<(Memory, f32)>> {
1457        // Build query for semantic search using memory system's retrieve()
1458        let query = crate::memory::Query {
1459            query_text: Some(context.to_string()),
1460            max_results: config.max_results * 2, // Get more candidates for filtering
1461            importance_threshold: Some(config.min_importance),
1462            ..Default::default()
1463        };
1464
1465        let mut results: Vec<(Memory, f32)> = Vec::new();
1466
1467        // Use memory system's semantic recall (uses vector index)
1468        match memory_system.recall(&query) {
1469            Ok(shared_memories) => {
1470                // Use the unified 5-layer pipeline score when available.
1471                // Falls back to reciprocal rank scoring when pipeline score is absent.
1472                for (rank, shared_memory) in shared_memories.into_iter().enumerate() {
1473                    let memory = (*shared_memory).clone();
1474                    let score = shared_memory
1475                        .get_score()
1476                        .unwrap_or(1.0 / (rank as f32 + 1.0));
1477                    if score >= config.semantic_threshold {
1478                        results.push((memory, score));
1479                    }
1480                }
1481            }
1482            Err(_) => {
1483                // Fallback: simple keyword matching (less accurate but fast)
1484                let context_words: HashSet<&str> =
1485                    context.split_whitespace().filter(|w| w.len() > 3).collect();
1486
1487                let all_memories = memory_system.get_all_memories()?;
1488                for shared_memory in all_memories {
1489                    let content_words: HashSet<&str> = shared_memory
1490                        .experience
1491                        .content
1492                        .split_whitespace()
1493                        .filter(|w| w.len() > 3)
1494                        .collect();
1495
1496                    let overlap = context_words.intersection(&content_words).count();
1497                    if overlap > 0 {
1498                        let score = overlap as f32
1499                            / (context_words.len() + content_words.len()) as f32
1500                            * 2.0;
1501                        if score >= config.semantic_threshold {
1502                            let memory = (*shared_memory).clone();
1503                            results.push((memory, score.min(1.0)));
1504                        }
1505                    }
1506                }
1507            }
1508        }
1509
1510        Ok(results)
1511    }
1512
1513    /// Apply recency boost to relevance score
1514    fn apply_recency_boost(
1515        &self,
1516        base_score: f32,
1517        created_at: DateTime<Utc>,
1518        boost_hours: u64,
1519        multiplier: f32,
1520    ) -> f32 {
1521        if boost_hours == 0 {
1522            return base_score;
1523        }
1524
1525        let now = Utc::now();
1526        let age = now.signed_duration_since(created_at);
1527        let age_hours = age.num_hours() as u64;
1528
1529        if age_hours <= boost_hours {
1530            // Linear decay of boost based on age
1531            let decay = 1.0 - (age_hours as f32 / boost_hours as f32);
1532            let boost = 1.0 + (multiplier - 1.0) * decay;
1533            (base_score * boost).min(1.0)
1534        } else {
1535            base_score
1536        }
1537    }
1538
1539    // =========================================================================
1540    // Entity Index Caching Methods
1541    // =========================================================================
1542
1543    /// Refresh the entity index from GraphMemory
1544    ///
1545    /// This builds an O(1) lookup table from entity names to memory IDs.
1546    /// Call this periodically or when the graph changes significantly.
1547    ///
1548    /// Performance: O(E * M) where E = entities, M = avg memories per entity
1549    /// Typically completes in <10ms for 1000 entities.
1550    pub fn refresh_entity_index(&self, graph_memory: &GraphMemory) -> Result<()> {
1551        let mut index = self.entity_index.write();
1552        index.clear();
1553
1554        let now = Utc::now();
1555        let entities = graph_memory.get_all_entities()?;
1556
1557        for entity in entities {
1558            let episodes = graph_memory.get_episodes_by_entity(&entity.uuid)?;
1559            let episode_ids: HashSet<Uuid> = episodes.iter().map(|e| e.uuid).collect();
1560
1561            let name_lower = entity.name.to_lowercase();
1562            index.insert(
1563                name_lower,
1564                EntityIndexEntry {
1565                    memory_ids: episode_ids,
1566                    last_updated: Some(now),
1567                },
1568            );
1569        }
1570
1571        // Update timestamp
1572        *self.entity_index_timestamp.write() = Some(now);
1573
1574        Ok(())
1575    }
1576
1577    /// Get memory IDs for an entity name using the cached index
1578    ///
1579    /// Returns None if entity not in index, empty set if entity known but has no memories.
1580    /// Falls back to direct GraphMemory lookup if index is stale or missing.
1581    pub fn get_memories_for_entity(
1582        &self,
1583        entity_name: &str,
1584        graph_memory: Option<&GraphMemory>,
1585    ) -> Option<HashSet<Uuid>> {
1586        let name_lower = entity_name.to_lowercase();
1587
1588        // Try cache first
1589        {
1590            let index = self.entity_index.read();
1591            if let Some(entry) = index.get(&name_lower) {
1592                return Some(entry.memory_ids.clone());
1593            }
1594        }
1595
1596        // Cache miss - try direct lookup if GraphMemory available
1597        if let Some(graph) = graph_memory {
1598            if let Ok(Some(entity)) = graph.find_entity_by_name(&name_lower) {
1599                if let Ok(episodes) = graph.get_episodes_by_entity(&entity.uuid) {
1600                    let memory_ids: HashSet<Uuid> = episodes.iter().map(|e| e.uuid).collect();
1601
1602                    // Update cache with this entry
1603                    let mut index = self.entity_index.write();
1604                    index.insert(
1605                        name_lower,
1606                        EntityIndexEntry {
1607                            memory_ids: memory_ids.clone(),
1608                            last_updated: Some(Utc::now()),
1609                        },
1610                    );
1611
1612                    return Some(memory_ids);
1613                }
1614            }
1615        }
1616
1617        None
1618    }
1619
1620    /// Check if entity index needs refresh (older than max_age_hours)
1621    pub fn entity_index_needs_refresh(&self, max_age_hours: i64) -> bool {
1622        let timestamp = self.entity_index_timestamp.read();
1623        match *timestamp {
1624            None => true,
1625            Some(ts) => {
1626                let age = Utc::now().signed_duration_since(ts);
1627                age.num_hours() > max_age_hours
1628            }
1629        }
1630    }
1631
1632    /// Get entity index statistics
1633    pub fn entity_index_stats(&self) -> (usize, Option<DateTime<Utc>>) {
1634        let index = self.entity_index.read();
1635        let timestamp = *self.entity_index_timestamp.read();
1636        (index.len(), timestamp)
1637    }
1638
1639    /// Clear the entity index (useful for testing or memory pressure)
1640    pub fn clear_entity_index(&self) {
1641        self.entity_index.write().clear();
1642        *self.entity_index_timestamp.write() = None;
1643    }
1644}
1645
1646// =============================================================================
1647// WebSocket Protocol Types for /api/context/monitor
1648// =============================================================================
1649
1650/// WebSocket handshake message for context monitoring
1651#[derive(Debug, Clone, Serialize, Deserialize)]
1652pub struct ContextMonitorHandshake {
1653    /// User ID to monitor context for
1654    pub user_id: String,
1655
1656    /// Optional configuration override
1657    #[serde(default)]
1658    pub config: Option<RelevanceConfig>,
1659
1660    /// Debounce interval in milliseconds (default: 100ms)
1661    #[serde(default = "default_debounce_ms")]
1662    pub debounce_ms: u64,
1663}
1664
1665fn default_debounce_ms() -> u64 {
1666    100
1667}
1668
1669/// Context update message sent by client
1670#[derive(Debug, Clone, Serialize, Deserialize)]
1671pub struct ContextUpdate {
1672    /// The current context/conversation text
1673    pub context: String,
1674
1675    /// Optional entities pre-extracted by client
1676    #[serde(default)]
1677    pub entities: Vec<String>,
1678
1679    /// Optional configuration override for this update
1680    #[serde(default)]
1681    pub config: Option<RelevanceConfig>,
1682}
1683
1684/// Server response for context monitoring
1685#[derive(Debug, Clone, Serialize, Deserialize)]
1686#[serde(tag = "type")]
1687pub enum ContextMonitorResponse {
1688    /// Handshake acknowledgement
1689    #[serde(rename = "ack")]
1690    Ack { timestamp: DateTime<Utc> },
1691
1692    /// Relevant memories surfaced
1693    #[serde(rename = "relevant")]
1694    Relevant {
1695        memories: Vec<SurfacedMemory>,
1696        detected_entities: Vec<DetectedEntity>,
1697        latency_ms: f64,
1698        timestamp: DateTime<Utc>,
1699    },
1700
1701    /// No relevant memories found (optional, can be suppressed)
1702    #[serde(rename = "none")]
1703    None { timestamp: DateTime<Utc> },
1704
1705    /// Error occurred
1706    #[serde(rename = "error")]
1707    Error {
1708        code: String,
1709        message: String,
1710        fatal: bool,
1711        timestamp: DateTime<Utc>,
1712    },
1713}
1714
1715/// Context monitor for WebSocket-based proactive surfacing
1716pub struct ContextMonitor {
1717    /// Relevance engine for surfacing
1718    engine: Arc<RelevanceEngine>,
1719
1720    /// Default configuration
1721    default_config: RelevanceConfig,
1722
1723    /// Minimum time between surfacing checks (prevents spam)
1724    debounce_ms: u64,
1725}
1726
1727impl ContextMonitor {
1728    /// Create a new context monitor
1729    pub fn new(engine: Arc<RelevanceEngine>, debounce_ms: u64) -> Self {
1730        Self {
1731            engine,
1732            default_config: RelevanceConfig::default(),
1733            debounce_ms,
1734        }
1735    }
1736
1737    /// Get the debounce interval
1738    pub fn debounce_ms(&self) -> u64 {
1739        self.debounce_ms
1740    }
1741
1742    /// Set default configuration
1743    pub fn set_config(&mut self, config: RelevanceConfig) {
1744        self.default_config = config;
1745    }
1746
1747    /// Get the underlying engine
1748    pub fn engine(&self) -> &Arc<RelevanceEngine> {
1749        &self.engine
1750    }
1751
1752    /// Process a context update and return relevant memories (if any)
1753    pub fn process_context(
1754        &self,
1755        context: &str,
1756        memory_system: &MemorySystem,
1757        graph_memory: Option<&GraphMemory>,
1758        config: Option<&RelevanceConfig>,
1759    ) -> Result<Option<RelevanceResponse>> {
1760        let cfg = config.unwrap_or(&self.default_config);
1761
1762        // Skip very short contexts
1763        if context.len() < 10 {
1764            return Ok(None);
1765        }
1766
1767        let response =
1768            self.engine
1769                .surface_relevant(context, memory_system, graph_memory, cfg, None)?;
1770
1771        // Only return if we found relevant memories
1772        if response.memories.is_empty() {
1773            Ok(None)
1774        } else {
1775            Ok(Some(response))
1776        }
1777    }
1778}
1779
1780#[cfg(test)]
1781mod tests {
1782    use super::*;
1783
1784    #[test]
1785    fn test_relevance_config_defaults() {
1786        let config = RelevanceConfig::default();
1787        assert_eq!(config.semantic_threshold, 0.45);
1788        assert_eq!(config.entity_threshold, 0.5);
1789        assert_eq!(config.max_results, 5);
1790        assert!(config.enable_entity_matching);
1791        assert!(config.enable_semantic_matching);
1792    }
1793
1794    #[test]
1795    fn test_recency_boost() {
1796        let engine = RelevanceEngine::new(Arc::new(crate::embeddings::NeuralNer::new_fallback(
1797            crate::embeddings::NerConfig::default(),
1798        )));
1799
1800        // Recent memory should get boost
1801        let recent = Utc::now();
1802        let boosted = engine.apply_recency_boost(0.5, recent, 24, 1.2);
1803        assert!(boosted > 0.5);
1804
1805        // Old memory should not get boost
1806        let old = Utc::now() - chrono::Duration::hours(48);
1807        let not_boosted = engine.apply_recency_boost(0.5, old, 24, 1.2);
1808        assert!((not_boosted - 0.5).abs() < 0.001);
1809    }
1810
1811    #[test]
1812    fn test_entity_type_weight() {
1813        let engine = RelevanceEngine::new(Arc::new(crate::embeddings::NeuralNer::new_fallback(
1814            crate::embeddings::NerConfig::default(),
1815        )));
1816
1817        assert_eq!(engine.entity_type_weight("Person"), 1.0);
1818        assert_eq!(engine.entity_type_weight("organization"), 0.9);
1819        assert!(engine.entity_type_weight("unknown") < 1.0);
1820    }
1821
1822    #[test]
1823    fn test_detected_entity_serialization() {
1824        let entity = DetectedEntity {
1825            name: "Rust".to_string(),
1826            entity_type: "Technology".to_string(),
1827            confidence: 0.95,
1828        };
1829
1830        let json = serde_json::to_string(&entity).unwrap();
1831        assert!(json.contains("Rust"));
1832        assert!(json.contains("Technology"));
1833    }
1834
1835    #[test]
1836    fn test_learned_weights_default() {
1837        let weights = LearnedWeights::default();
1838
1839        // Should sum to 1.0 (includes momentum, access_count, and graph_strength)
1840        let sum = weights.semantic
1841            + weights.entity
1842            + weights.tag
1843            + weights.importance
1844            + weights.momentum
1845            + weights.access_count
1846            + weights.graph_strength;
1847        assert!((sum - 1.0).abs() < 0.001);
1848
1849        // Check default values
1850        assert_eq!(weights.semantic, DEFAULT_SEMANTIC_WEIGHT);
1851        assert_eq!(weights.entity, DEFAULT_ENTITY_WEIGHT);
1852        assert_eq!(weights.tag, DEFAULT_TAG_WEIGHT);
1853        assert_eq!(weights.importance, DEFAULT_IMPORTANCE_WEIGHT);
1854        assert_eq!(weights.momentum, DEFAULT_MOMENTUM_WEIGHT);
1855        assert_eq!(weights.access_count, DEFAULT_ACCESS_COUNT_WEIGHT);
1856        assert_eq!(weights.graph_strength, DEFAULT_GRAPH_STRENGTH_WEIGHT);
1857    }
1858
1859    #[test]
1860    fn test_learned_weights_normalize() {
1861        let mut weights = LearnedWeights {
1862            semantic: 0.5,
1863            entity: 0.5,
1864            tag: 0.5,
1865            importance: 0.5,
1866            momentum: 0.5,
1867            access_count: 0.5,
1868            graph_strength: 0.5, // CTX-3: added graph_strength
1869            update_count: 0,
1870            last_updated: None,
1871        };
1872
1873        weights.normalize();
1874
1875        let sum = weights.semantic
1876            + weights.entity
1877            + weights.tag
1878            + weights.importance
1879            + weights.momentum
1880            + weights.access_count
1881            + weights.graph_strength;
1882        assert!((sum - 1.0).abs() < 0.001);
1883        // 0.5/3.5 ≈ 0.143 (7 weights at 0.5 each = 3.5, each normalized = 0.5/3.5 = 1/7)
1884        assert!((weights.semantic - 1.0 / 7.0).abs() < 0.001);
1885    }
1886
1887    #[test]
1888    fn test_learned_weights_feedback_helpful() {
1889        let mut weights = LearnedWeights::default();
1890        let initial_semantic = weights.semantic;
1891        let initial_entity = weights.entity;
1892
1893        // Positive feedback on semantic and entity
1894        weights.apply_feedback(true, true, false, true);
1895
1896        // Semantic and entity should increase (relative to tag/importance)
1897        // After normalization, they may not be strictly higher, but update_count should increase
1898        assert_eq!(weights.update_count, 1);
1899        assert!(weights.last_updated.is_some());
1900
1901        // Weights should still sum to 1.0 (includes momentum, access_count, and graph_strength)
1902        let sum = weights.semantic
1903            + weights.entity
1904            + weights.tag
1905            + weights.importance
1906            + weights.momentum
1907            + weights.access_count
1908            + weights.graph_strength;
1909        assert!((sum - 1.0).abs() < 0.001);
1910
1911        // Semantic + entity together should have gained relative weight
1912        let new_se = weights.semantic + weights.entity;
1913        let old_se = initial_semantic + initial_entity;
1914        assert!(new_se >= old_se - 0.1); // Allow for normalization effects
1915    }
1916
1917    #[test]
1918    fn test_learned_weights_feedback_not_helpful() {
1919        let mut weights = LearnedWeights::default();
1920
1921        // Negative feedback - semantic was the main signal
1922        weights.apply_feedback(true, false, false, false);
1923
1924        // Weights should still sum to 1.0 (includes momentum, access_count, and graph_strength)
1925        let sum = weights.semantic
1926            + weights.entity
1927            + weights.tag
1928            + weights.importance
1929            + weights.momentum
1930            + weights.access_count
1931            + weights.graph_strength;
1932        assert!((sum - 1.0).abs() < 0.001);
1933    }
1934
1935    #[test]
1936    fn test_calibrate_score() {
1937        // High score should stay high
1938        let high = calibrate_score(0.9);
1939        assert!(high > 0.9);
1940
1941        // Low score should be reduced more
1942        let low = calibrate_score(0.1);
1943        assert!(low < 0.1);
1944
1945        // Mid-point score
1946        let mid = calibrate_score(SIGMOID_MIDPOINT);
1947        assert!((mid - 0.5).abs() < 0.001);
1948    }
1949
1950    #[test]
1951    fn test_score_fusion() {
1952        let weights = LearnedWeights::default();
1953
1954        // All high scores with high momentum, access count, and graph strength
1955        let high = weights.fuse_scores_full(0.9, 0.9, 0.9, 0.9, 0.9, 16, 0.9);
1956        assert!(high > 0.8, "high score was {}", high);
1957
1958        // All low scores with negative momentum, no access, and low graph strength
1959        let low = weights.fuse_scores_full(0.1, 0.1, 0.1, 0.1, -0.9, 0, 0.1);
1960        assert!(low < 0.3, "low score was {}", low);
1961
1962        // Mixed scores - result should be between
1963        let mixed = weights.fuse_scores_full(0.9, 0.1, 0.5, 0.7, 0.0, 2, 0.5);
1964        assert!(mixed > 0.2 && mixed < 0.8, "mixed score was {}", mixed);
1965
1966        // Test backwards compatibility - legacy fuse_scores uses neutral momentum, 0 access, and neutral graph
1967        let legacy = weights.fuse_scores(0.9, 0.9, 0.9, 0.9);
1968        assert!(legacy > 0.5, "legacy score was {}", legacy); // Lower threshold due to neutral momentum/access/graph
1969    }
1970
1971    #[test]
1972    fn test_tag_score_calculation() {
1973        let engine = RelevanceEngine::new(Arc::new(crate::embeddings::NeuralNer::new_fallback(
1974            crate::embeddings::NerConfig::default(),
1975        )));
1976
1977        // Exact match
1978        let score = engine.calculate_tag_score("I love Rust programming", &["rust".to_string()]);
1979        assert_eq!(score, 1.0);
1980
1981        // Partial match
1982        let score = engine
1983            .calculate_tag_score("Learning Rust", &["rust".to_string(), "python".to_string()]);
1984        assert_eq!(score, 0.5);
1985
1986        // No match
1987        let score = engine.calculate_tag_score("Hello world", &["rust".to_string()]);
1988        assert_eq!(score, 0.0);
1989
1990        // Empty tags
1991        let score = engine.calculate_tag_score("Test", &[]);
1992        assert_eq!(score, 0.0);
1993    }
1994
1995    #[test]
1996    fn test_min_weight_enforcement() {
1997        let mut weights = LearnedWeights {
1998            semantic: 0.1,
1999            entity: MIN_WEIGHT + 0.01, // 0.06
2000            tag: 0.3,                  // Reduced from 0.5 so sum < 1.0
2001            importance: 0.1,
2002            momentum: 0.1,
2003            access_count: 0.1,
2004            graph_strength: 0.1,
2005            update_count: 0,
2006            last_updated: None,
2007        };
2008
2009        // Apply negative feedback on entity (already near minimum)
2010        weights.apply_feedback(false, true, false, false);
2011
2012        // Entity should not go below MIN_WEIGHT after feedback + normalization
2013        // Before normalize: entity = 0.05 (clamped), sum = 0.85
2014        // After normalize: entity = 0.05 / 0.85 ≈ 0.059 >= 0.05 ✓
2015        assert!(
2016            weights.entity >= MIN_WEIGHT,
2017            "entity {} < MIN_WEIGHT {}",
2018            weights.entity,
2019            MIN_WEIGHT
2020        );
2021
2022        // Still normalized
2023        let sum = weights.semantic
2024            + weights.entity
2025            + weights.tag
2026            + weights.importance
2027            + weights.momentum
2028            + weights.access_count
2029            + weights.graph_strength;
2030        assert!((sum - 1.0).abs() < 0.001);
2031    }
2032
2033    #[test]
2034    fn test_fuse_scores_nan_inf_guard() {
2035        let weights = LearnedWeights::default();
2036
2037        // NaN input
2038        let result = weights.fuse_scores_full(f32::NAN, 0.5, 0.5, 0.5, 0.0, 1, 0.5);
2039        assert!(result.is_finite(), "NaN input should produce finite output");
2040
2041        // Inf input
2042        let result = weights.fuse_scores_full(0.5, f32::INFINITY, 0.5, 0.5, 0.0, 1, 0.5);
2043        assert!(result.is_finite(), "Inf input should produce finite output");
2044
2045        // -Inf input
2046        let result = weights.fuse_scores_full(0.5, 0.5, f32::NEG_INFINITY, 0.5, 0.0, 1, 0.5);
2047        assert!(
2048            result.is_finite(),
2049            "-Inf input should produce finite output"
2050        );
2051
2052        // Normal inputs still work
2053        let result = weights.fuse_scores_full(0.8, 0.5, 0.3, 0.6, 0.2, 3, 0.4);
2054        assert!(result.is_finite());
2055        assert!(result > 0.0);
2056    }
2057}