Skip to main content

mentedb_cognitive/
phantom.rs

1use std::collections::HashSet;
2
3use ahash::AHashSet;
4use mentedb_core::types::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: Uuid,
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: Uuid::new_v4(),
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: Uuid::new_v4(),
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.phantoms.iter_mut().find(|p| p.id == phantom_id) {
340            p.resolved = true;
341        }
342    }
343
344    pub fn get_active_phantoms(&self) -> Vec<&PhantomMemory> {
345        let mut active: Vec<&PhantomMemory> =
346            self.phantoms.iter().filter(|p| !p.resolved).collect();
347        active.sort_by(|a, b| b.priority.cmp(&a.priority));
348        active
349    }
350
351    pub fn format_phantom_warnings(&self) -> String {
352        let active = self.get_active_phantoms();
353        if active.is_empty() {
354            return String::new();
355        }
356
357        active
358            .iter()
359            .take(self.config.max_warnings)
360            .map(|p| {
361                format!(
362                    "WARNING: User referenced '{}' but no details stored. Consider asking.",
363                    p.source_reference
364                )
365            })
366            .collect::<Vec<_>>()
367            .join("\n")
368    }
369}
370
371impl Default for PhantomTracker {
372    fn default() -> Self {
373        Self::new(PhantomConfig::default())
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_detect_unknown_entities() {
383        let mut tracker = PhantomTracker::default();
384        let known = vec!["React".to_string(), "TypeScript".to_string()];
385        let phantoms = tracker.detect_gaps(
386            "We need to deploy to the Kubernetes cluster using Terraform",
387            &known,
388            1,
389        );
390        let refs: Vec<&str> = phantoms
391            .iter()
392            .map(|p| p.source_reference.as_str())
393            .collect();
394        assert!(
395            refs.iter().any(|r| r.contains("Kubernetes")),
396            "Expected Kubernetes, got: {:?}",
397            refs
398        );
399        assert!(
400            refs.iter().any(|r| r.contains("Terraform")),
401            "Expected Terraform, got: {:?}",
402            refs
403        );
404    }
405
406    #[test]
407    fn test_resolve_phantom() {
408        let mut tracker = PhantomTracker::default();
409        let phantoms = tracker.detect_gaps("Check the Redis cache", &[], 1);
410        assert!(!phantoms.is_empty());
411        let pid = phantoms[0].id;
412        tracker.resolve(pid);
413        assert!(tracker.get_active_phantoms().iter().all(|p| p.id != pid));
414    }
415
416    #[test]
417    fn test_format_warnings() {
418        let mut tracker = PhantomTracker::default();
419        tracker.detect_gaps("Deploy to Kubernetes using Helm", &[], 1);
420        let warnings = tracker.format_phantom_warnings();
421        assert!(warnings.contains("WARNING"));
422    }
423
424    // --- EntityRegistry CRUD ---
425
426    #[test]
427    fn test_entity_registry_crud() {
428        let mut reg = EntityRegistry::new();
429        assert!(reg.is_empty());
430
431        reg.register("Kubernetes");
432        reg.register("Terraform");
433        assert_eq!(reg.len(), 2);
434        assert!(reg.is_known("Kubernetes"));
435        assert!(!reg.is_known("Docker"));
436
437        reg.unregister("Kubernetes");
438        assert!(!reg.is_known("Kubernetes"));
439        assert_eq!(reg.len(), 1);
440    }
441
442    #[test]
443    fn test_entity_registry_batch() {
444        let mut reg = EntityRegistry::new();
445        reg.register_batch(&["Redis", "Postgres", "Kafka"]);
446        assert_eq!(reg.len(), 3);
447        assert!(reg.is_known("Redis"));
448        assert!(reg.is_known("Kafka"));
449    }
450
451    #[test]
452    fn test_entity_registry_list() {
453        let mut reg = EntityRegistry::new();
454        reg.register_batch(&["B", "A", "C"]);
455        let mut items = reg.list();
456        items.sort();
457        assert_eq!(items, vec!["A", "B", "C"]);
458    }
459
460    // --- detect_gaps_explicit ---
461
462    #[test]
463    fn test_detect_gaps_explicit_finds_unknown() {
464        let mut tracker = PhantomTracker::default();
465        tracker.register_entities(&["Kubernetes", "Terraform"]);
466
467        let gaps = tracker.detect_gaps_explicit(
468            "Deploy with Kubernetes and Docker",
469            &["Kubernetes", "Docker"],
470            1,
471        );
472
473        // Docker is not registered, so it should be flagged.
474        assert_eq!(gaps.len(), 1);
475        assert_eq!(gaps[0].source_reference, "Docker");
476    }
477
478    #[test]
479    fn test_detect_gaps_explicit_no_false_positives() {
480        let mut tracker = PhantomTracker::default();
481        tracker.register_entities(&["Kubernetes", "Terraform", "Helm"]);
482
483        let gaps =
484            tracker.detect_gaps_explicit("Using Kubernetes and Helm", &["Kubernetes", "Helm"], 1);
485
486        // Both are registered — no gaps.
487        assert!(gaps.is_empty());
488    }
489
490    // --- Heuristic disabled via config ---
491
492    #[test]
493    fn test_heuristic_disabled() {
494        let mut config = PhantomConfig::default();
495        config.use_heuristic_detection = false;
496        let mut tracker = PhantomTracker::new(config);
497
498        // No entities registered and heuristic off — should find nothing.
499        let gaps = tracker.detect_gaps("Deploy to Kubernetes using Terraform", &[], 1);
500        assert!(
501            gaps.is_empty(),
502            "Expected no gaps with heuristic disabled and empty registry, got: {:?}",
503            gaps.iter().map(|g| &g.source_reference).collect::<Vec<_>>()
504        );
505    }
506
507    #[test]
508    fn test_heuristic_disabled_with_registry() {
509        let mut config = PhantomConfig::default();
510        config.use_heuristic_detection = false;
511        let mut tracker = PhantomTracker::new(config);
512        tracker.register_entity("Kubernetes");
513
514        // Registry-based detection should still work even with heuristic off.
515        let gaps = tracker.detect_gaps("Deploy to Kubernetes using Terraform", &[], 1);
516        // Kubernetes is registered and referenced but not in known_entities → flagged.
517        // Terraform is NOT registered and heuristic is off → not flagged.
518        assert_eq!(gaps.len(), 1);
519        assert_eq!(gaps[0].source_reference, "Kubernetes");
520    }
521
522    // --- Integration: register, detect, resolve ---
523
524    #[test]
525    fn test_integration_register_detect_resolve() {
526        let mut tracker = PhantomTracker::default();
527        tracker.register_entities(&["Redis", "Postgres"]);
528
529        // Caller mentions Redis, Kafka, Postgres — Kafka is unknown.
530        let gaps = tracker.detect_gaps_explicit(
531            "Need Redis and Kafka and Postgres",
532            &["Redis", "Kafka", "Postgres"],
533            1,
534        );
535        assert_eq!(gaps.len(), 1);
536        assert_eq!(gaps[0].source_reference, "Kafka");
537        assert!(!gaps[0].resolved);
538
539        // Resolve the gap.
540        tracker.resolve(gaps[0].id);
541        assert!(tracker.get_active_phantoms().is_empty());
542    }
543
544    // --- Heuristic fallback produces Low priority ---
545
546    #[test]
547    fn test_heuristic_fallback_low_priority() {
548        let mut tracker = PhantomTracker::default();
549        // No entities registered — heuristic only.
550        // Use lowercase prefix so "Redis" is detected as a standalone capitalized word.
551        let gaps = tracker.detect_gaps("we use Redis for caching", &[], 1);
552        let redis_gaps: Vec<_> = gaps
553            .iter()
554            .filter(|g| g.source_reference == "Redis")
555            .collect();
556        assert_eq!(redis_gaps.len(), 1);
557        assert_eq!(redis_gaps[0].priority, PhantomPriority::Low);
558    }
559
560    #[test]
561    fn test_registry_detection_higher_priority() {
562        let mut tracker = PhantomTracker::default();
563        tracker.register_entity("Redis");
564
565        let gaps = tracker.detect_gaps("we use Redis for caching", &[], 1);
566        let redis_gaps: Vec<_> = gaps
567            .iter()
568            .filter(|g| g.source_reference == "Redis")
569            .collect();
570        assert_eq!(redis_gaps.len(), 1);
571        // Registry-based single-word entity → Medium priority.
572        assert_eq!(redis_gaps[0].priority, PhantomPriority::Medium);
573    }
574}