Skip to main content

mentedb_cognitive/
phantom.rs

1use std::collections::HashSet;
2
3use ahash::AHashSet;
4use mentedb_core::types::{MemoryId, Timestamp};
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
9pub enum PhantomPriority {
10    Low,
11    Medium,
12    High,
13    Critical,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct PhantomMemory {
18    pub id: MemoryId,
19    pub gap_description: String,
20    pub source_reference: String,
21    pub source_turn: u64,
22    pub priority: PhantomPriority,
23    pub created_at: Timestamp,
24    pub resolved: bool,
25}
26
27/// Explicit registry of known entities. The AI client registers entities it
28/// cares about so that gap detection can be precise rather than heuristic.
29pub struct EntityRegistry {
30    known_entities: AHashSet<String>,
31}
32
33impl EntityRegistry {
34    pub fn new() -> Self {
35        Self {
36            known_entities: AHashSet::new(),
37        }
38    }
39
40    pub fn register(&mut self, entity: &str) {
41        self.known_entities.insert(entity.to_string());
42    }
43
44    pub fn register_batch(&mut self, entities: &[&str]) {
45        for entity in entities {
46            self.known_entities.insert((*entity).to_string());
47        }
48    }
49
50    pub fn unregister(&mut self, entity: &str) {
51        self.known_entities.remove(entity);
52    }
53
54    pub fn is_known(&self, entity: &str) -> bool {
55        self.known_entities.contains(entity)
56    }
57
58    pub fn list(&self) -> Vec<&str> {
59        self.known_entities.iter().map(|s| s.as_str()).collect()
60    }
61
62    pub fn len(&self) -> usize {
63        self.known_entities.len()
64    }
65
66    pub fn is_empty(&self) -> bool {
67        self.known_entities.is_empty()
68    }
69}
70
71impl Default for EntityRegistry {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77/// Configuration for phantom memory detection.
78#[derive(Debug, Clone)]
79pub struct PhantomConfig {
80    /// Words to skip when detecting capitalized entity references.
81    pub stop_words: HashSet<String>,
82    /// Maximum number of warnings to include in formatted output.
83    pub max_warnings: usize,
84    /// Minimum word length for technical term detection.
85    pub min_word_length: usize,
86    /// Whether to use heuristic (capitalized word) detection as a fallback.
87    /// Default `true` for backward compatibility; set to `false` to rely
88    /// solely on the explicit entity registry.
89    pub use_heuristic_detection: bool,
90}
91
92impl Default for PhantomConfig {
93    fn default() -> Self {
94        let stop_words: HashSet<String> = [
95            "the", "a", "an", "is", "are", "was", "were", "it", "this", "that", "we", "you",
96            "they", "he", "she", "i", "my", "our", "but", "and", "or", "if", "then", "when", "how",
97            "what", "where", "who", "do", "does", "did", "have", "has", "had", "be", "been",
98            "being", "so", "also", "just", "very", "too", "not", "no", "yes", "can", "will",
99            "should", "would", "could", "may", "might", "must", "shall", "note", "see", "use",
100            "make", "let", "new", "set", "get",
101        ]
102        .iter()
103        .map(|s| s.to_string())
104        .collect();
105        Self {
106            stop_words,
107            max_warnings: 5,
108            min_word_length: 3,
109            use_heuristic_detection: true,
110        }
111    }
112}
113
114pub struct PhantomTracker {
115    phantoms: Vec<PhantomMemory>,
116    config: PhantomConfig,
117    entity_registry: EntityRegistry,
118}
119
120impl PhantomTracker {
121    pub fn new(config: PhantomConfig) -> Self {
122        Self {
123            phantoms: Vec::new(),
124            config,
125            entity_registry: EntityRegistry::new(),
126        }
127    }
128
129    /// Convenience: register an entity in the embedded registry.
130    pub fn register_entity(&mut self, entity: &str) {
131        self.entity_registry.register(entity);
132    }
133
134    /// Convenience: register multiple entities at once.
135    pub fn register_entities(&mut self, entities: &[&str]) {
136        self.entity_registry.register_batch(entities);
137    }
138
139    /// Return a reference to the entity registry.
140    pub fn entity_registry(&self) -> &EntityRegistry {
141        &self.entity_registry
142    }
143
144    /// Return a mutable reference to the entity registry.
145    pub fn entity_registry_mut(&mut self) -> &mut EntityRegistry {
146        &mut self.entity_registry
147    }
148
149    /// Scan content for entity references not in `known_entities`.
150    ///
151    /// Two detection strategies are combined:
152    /// 1. **Registry-based** (primary): any registered entity found in
153    ///    `content` that is NOT in `known_entities` is flagged at
154    ///    `Medium`/`High` priority.
155    /// 2. **Heuristic** (fallback): capitalized words, quoted terms, and
156    ///    technical terms are flagged at `Low` priority. Disabled when
157    ///    `PhantomConfig::use_heuristic_detection` is `false`.
158    pub fn detect_gaps(
159        &mut self,
160        content: &str,
161        known_entities: &[String],
162        turn_id: u64,
163    ) -> Vec<PhantomMemory> {
164        let known_lower: Vec<String> = known_entities.iter().map(|e| e.to_lowercase()).collect();
165        let mut detected: Vec<(String, PhantomPriority)> = Vec::new();
166        let mut seen = AHashSet::new();
167
168        // --- Primary: registry-based detection ---
169        let content_lower = content.to_lowercase();
170        for entity in self.entity_registry.list() {
171            let entity_lower = entity.to_lowercase();
172            if content_lower.contains(&entity_lower)
173                && !known_lower.contains(&entity_lower)
174                && seen.insert(entity_lower)
175            {
176                let priority = if entity.split_whitespace().count() > 1 {
177                    PhantomPriority::High
178                } else {
179                    PhantomPriority::Medium
180                };
181                detected.push((entity.to_string(), priority));
182            }
183        }
184
185        // --- Fallback: heuristic detection (only when enabled) ---
186        if self.config.use_heuristic_detection {
187            let heuristic = self.heuristic_detect(content, &known_lower, &mut seen);
188            for entity in heuristic {
189                detected.push((entity, PhantomPriority::Low));
190            }
191        }
192
193        let now = std::time::SystemTime::now()
194            .duration_since(std::time::UNIX_EPOCH)
195            .unwrap_or_default()
196            .as_micros() as u64;
197
198        let new_phantoms: Vec<PhantomMemory> = detected
199            .into_iter()
200            .map(|(entity, priority)| PhantomMemory {
201                id: MemoryId::new(),
202                gap_description: format!("No stored knowledge about '{}'", entity),
203                source_reference: entity,
204                source_turn: turn_id,
205                priority,
206                created_at: now,
207                resolved: false,
208            })
209            .collect();
210
211        self.phantoms.extend(new_phantoms.clone());
212        new_phantoms
213    }
214
215    /// Preferred explicit API: the caller provides exactly which entities
216    /// were mentioned. Entities not in the registry are flagged as gaps.
217    pub fn detect_gaps_explicit(
218        &mut self,
219        content: &str,
220        mentioned_entities: &[&str],
221        turn_id: u64,
222    ) -> Vec<PhantomMemory> {
223        let now = std::time::SystemTime::now()
224            .duration_since(std::time::UNIX_EPOCH)
225            .unwrap_or_default()
226            .as_micros() as u64;
227
228        let _ = content; // available for future use; presence keeps API uniform
229
230        let new_phantoms: Vec<PhantomMemory> = mentioned_entities
231            .iter()
232            .filter(|e| !self.entity_registry.is_known(e))
233            .map(|entity| PhantomMemory {
234                id: MemoryId::new(),
235                gap_description: format!("No stored knowledge about '{}'", entity),
236                source_reference: (*entity).to_string(),
237                source_turn: turn_id,
238                priority: PhantomPriority::Medium,
239                created_at: now,
240                resolved: false,
241            })
242            .collect();
243
244        self.phantoms.extend(new_phantoms.clone());
245        new_phantoms
246    }
247
248    /// Heuristic entity detection (capitalized words, quotes, technical terms).
249    fn heuristic_detect(
250        &self,
251        content: &str,
252        known_lower: &[String],
253        seen: &mut AHashSet<String>,
254    ) -> Vec<String> {
255        let mut detected = Vec::new();
256
257        // Detect quoted terms
258        let mut in_quote = false;
259        let mut quote_start = 0;
260        for (i, ch) in content.char_indices() {
261            if ch == '\'' || ch == '"' || ch == '\u{2018}' || ch == '\u{2019}' {
262                if in_quote {
263                    let term = &content[quote_start..i];
264                    let trimmed = term.trim();
265                    if !trimmed.is_empty()
266                        && trimmed.len() >= 2
267                        && !known_lower.contains(&trimmed.to_lowercase())
268                        && seen.insert(trimmed.to_lowercase())
269                    {
270                        detected.push(trimmed.to_string());
271                    }
272                    in_quote = false;
273                } else {
274                    in_quote = true;
275                    quote_start = i + ch.len_utf8();
276                }
277            }
278        }
279
280        // Detect capitalized sequences (e.g., "Kubernetes Cluster", "JWT Token")
281        let words: Vec<&str> = content.split_whitespace().collect();
282        let mut i = 0;
283        while i < words.len() {
284            let w = words[i]
285                .trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.');
286            if !w.is_empty() && w.chars().next().is_some_and(|c| c.is_uppercase()) && w.len() >= 2 {
287                let mut entity_parts = vec![w.to_string()];
288                let mut j = i + 1;
289                while j < words.len() {
290                    let next = words[j].trim_matches(|c: char| {
291                        !c.is_alphanumeric() && c != '-' && c != '_' && c != '.'
292                    });
293                    if !next.is_empty() && next.chars().next().is_some_and(|c| c.is_uppercase()) {
294                        entity_parts.push(next.to_string());
295                        j += 1;
296                    } else {
297                        break;
298                    }
299                }
300
301                let entity = entity_parts.join(" ");
302                if entity.split_whitespace().count() == 1
303                    && self.config.stop_words.contains(&entity.to_lowercase())
304                {
305                    i = j;
306                    continue;
307                }
308
309                if !known_lower.contains(&entity.to_lowercase())
310                    && seen.insert(entity.to_lowercase())
311                {
312                    detected.push(entity);
313                }
314                i = j;
315            } else {
316                if !w.is_empty() && w.len() >= self.config.min_word_length {
317                    let is_technical = w.contains('-')
318                        || w.contains('.')
319                        || w.contains('_')
320                        || (w.len() >= self.config.min_word_length
321                            && w.chars()
322                                .all(|c| c.is_uppercase() || c.is_ascii_digit() || c == '_'));
323
324                    if is_technical
325                        && !known_lower.contains(&w.to_lowercase())
326                        && seen.insert(w.to_lowercase())
327                    {
328                        detected.push(w.to_string());
329                    }
330                }
331                i += 1;
332            }
333        }
334
335        detected
336    }
337
338    pub fn resolve(&mut self, phantom_id: Uuid) {
339        if let Some(p) = self
340            .phantoms
341            .iter_mut()
342            .find(|p| p.id == MemoryId::from(phantom_id))
343        {
344            p.resolved = true;
345        }
346    }
347
348    pub fn get_active_phantoms(&self) -> Vec<&PhantomMemory> {
349        let mut active: Vec<&PhantomMemory> =
350            self.phantoms.iter().filter(|p| !p.resolved).collect();
351        active.sort_by_key(|x| std::cmp::Reverse(x.priority));
352        active
353    }
354
355    pub fn format_phantom_warnings(&self) -> String {
356        let active = self.get_active_phantoms();
357        if active.is_empty() {
358            return String::new();
359        }
360
361        active
362            .iter()
363            .take(self.config.max_warnings)
364            .map(|p| {
365                format!(
366                    "WARNING: User referenced '{}' but no details stored. Consider asking.",
367                    p.source_reference
368                )
369            })
370            .collect::<Vec<_>>()
371            .join("\n")
372    }
373}
374
375impl Default for PhantomTracker {
376    fn default() -> Self {
377        Self::new(PhantomConfig::default())
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_detect_unknown_entities() {
387        let mut tracker = PhantomTracker::default();
388        let known = vec!["React".to_string(), "TypeScript".to_string()];
389        let phantoms = tracker.detect_gaps(
390            "We need to deploy to the Kubernetes cluster using Terraform",
391            &known,
392            1,
393        );
394        let refs: Vec<&str> = phantoms
395            .iter()
396            .map(|p| p.source_reference.as_str())
397            .collect();
398        assert!(
399            refs.iter().any(|r| r.contains("Kubernetes")),
400            "Expected Kubernetes, got: {:?}",
401            refs
402        );
403        assert!(
404            refs.iter().any(|r| r.contains("Terraform")),
405            "Expected Terraform, got: {:?}",
406            refs
407        );
408    }
409
410    #[test]
411    fn test_resolve_phantom() {
412        let mut tracker = PhantomTracker::default();
413        let phantoms = tracker.detect_gaps("Check the Redis cache", &[], 1);
414        assert!(!phantoms.is_empty());
415        let pid = phantoms[0].id;
416        tracker.resolve(pid.into());
417        assert!(tracker.get_active_phantoms().iter().all(|p| p.id != pid));
418    }
419
420    #[test]
421    fn test_format_warnings() {
422        let mut tracker = PhantomTracker::default();
423        tracker.detect_gaps("Deploy to Kubernetes using Helm", &[], 1);
424        let warnings = tracker.format_phantom_warnings();
425        assert!(warnings.contains("WARNING"));
426    }
427
428    // --- EntityRegistry CRUD ---
429
430    #[test]
431    fn test_entity_registry_crud() {
432        let mut reg = EntityRegistry::new();
433        assert!(reg.is_empty());
434
435        reg.register("Kubernetes");
436        reg.register("Terraform");
437        assert_eq!(reg.len(), 2);
438        assert!(reg.is_known("Kubernetes"));
439        assert!(!reg.is_known("Docker"));
440
441        reg.unregister("Kubernetes");
442        assert!(!reg.is_known("Kubernetes"));
443        assert_eq!(reg.len(), 1);
444    }
445
446    #[test]
447    fn test_entity_registry_batch() {
448        let mut reg = EntityRegistry::new();
449        reg.register_batch(&["Redis", "Postgres", "Kafka"]);
450        assert_eq!(reg.len(), 3);
451        assert!(reg.is_known("Redis"));
452        assert!(reg.is_known("Kafka"));
453    }
454
455    #[test]
456    fn test_entity_registry_list() {
457        let mut reg = EntityRegistry::new();
458        reg.register_batch(&["B", "A", "C"]);
459        let mut items = reg.list();
460        items.sort();
461        assert_eq!(items, vec!["A", "B", "C"]);
462    }
463
464    // --- detect_gaps_explicit ---
465
466    #[test]
467    fn test_detect_gaps_explicit_finds_unknown() {
468        let mut tracker = PhantomTracker::default();
469        tracker.register_entities(&["Kubernetes", "Terraform"]);
470
471        let gaps = tracker.detect_gaps_explicit(
472            "Deploy with Kubernetes and Docker",
473            &["Kubernetes", "Docker"],
474            1,
475        );
476
477        // Docker is not registered, so it should be flagged.
478        assert_eq!(gaps.len(), 1);
479        assert_eq!(gaps[0].source_reference, "Docker");
480    }
481
482    #[test]
483    fn test_detect_gaps_explicit_no_false_positives() {
484        let mut tracker = PhantomTracker::default();
485        tracker.register_entities(&["Kubernetes", "Terraform", "Helm"]);
486
487        let gaps =
488            tracker.detect_gaps_explicit("Using Kubernetes and Helm", &["Kubernetes", "Helm"], 1);
489
490        // Both are registered — no gaps.
491        assert!(gaps.is_empty());
492    }
493
494    // --- Heuristic disabled via config ---
495
496    #[test]
497    fn test_heuristic_disabled() {
498        let mut config = PhantomConfig::default();
499        config.use_heuristic_detection = false;
500        let mut tracker = PhantomTracker::new(config);
501
502        // No entities registered and heuristic off — should find nothing.
503        let gaps = tracker.detect_gaps("Deploy to Kubernetes using Terraform", &[], 1);
504        assert!(
505            gaps.is_empty(),
506            "Expected no gaps with heuristic disabled and empty registry, got: {:?}",
507            gaps.iter().map(|g| &g.source_reference).collect::<Vec<_>>()
508        );
509    }
510
511    #[test]
512    fn test_heuristic_disabled_with_registry() {
513        let mut config = PhantomConfig::default();
514        config.use_heuristic_detection = false;
515        let mut tracker = PhantomTracker::new(config);
516        tracker.register_entity("Kubernetes");
517
518        // Registry-based detection should still work even with heuristic off.
519        let gaps = tracker.detect_gaps("Deploy to Kubernetes using Terraform", &[], 1);
520        // Kubernetes is registered and referenced but not in known_entities → flagged.
521        // Terraform is NOT registered and heuristic is off → not flagged.
522        assert_eq!(gaps.len(), 1);
523        assert_eq!(gaps[0].source_reference, "Kubernetes");
524    }
525
526    // --- Integration: register, detect, resolve ---
527
528    #[test]
529    fn test_integration_register_detect_resolve() {
530        let mut tracker = PhantomTracker::default();
531        tracker.register_entities(&["Redis", "Postgres"]);
532
533        // Caller mentions Redis, Kafka, Postgres — Kafka is unknown.
534        let gaps = tracker.detect_gaps_explicit(
535            "Need Redis and Kafka and Postgres",
536            &["Redis", "Kafka", "Postgres"],
537            1,
538        );
539        assert_eq!(gaps.len(), 1);
540        assert_eq!(gaps[0].source_reference, "Kafka");
541        assert!(!gaps[0].resolved);
542
543        // Resolve the gap.
544        tracker.resolve(gaps[0].id.into());
545        assert!(tracker.get_active_phantoms().is_empty());
546    }
547
548    // --- Heuristic fallback produces Low priority ---
549
550    #[test]
551    fn test_heuristic_fallback_low_priority() {
552        let mut tracker = PhantomTracker::default();
553        // No entities registered — heuristic only.
554        // Use lowercase prefix so "Redis" is detected as a standalone capitalized word.
555        let gaps = tracker.detect_gaps("we use Redis for caching", &[], 1);
556        let redis_gaps: Vec<_> = gaps
557            .iter()
558            .filter(|g| g.source_reference == "Redis")
559            .collect();
560        assert_eq!(redis_gaps.len(), 1);
561        assert_eq!(redis_gaps[0].priority, PhantomPriority::Low);
562    }
563
564    #[test]
565    fn test_registry_detection_higher_priority() {
566        let mut tracker = PhantomTracker::default();
567        tracker.register_entity("Redis");
568
569        let gaps = tracker.detect_gaps("we use Redis for caching", &[], 1);
570        let redis_gaps: Vec<_> = gaps
571            .iter()
572            .filter(|g| g.source_reference == "Redis")
573            .collect();
574        assert_eq!(redis_gaps.len(), 1);
575        // Registry-based single-word entity → Medium priority.
576        assert_eq!(redis_gaps[0].priority, PhantomPriority::Medium);
577    }
578}