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
114/// A heuristic candidate is only worth surfacing as a knowledge gap if it reads
115/// like a genuine named reference, not a sentence fragment, a data value, or a
116/// possessive the tokenizer split off. These are cheap structural checks, used
117/// because this path has no LLM entity extraction to lean on.
118fn is_entity_like(term: &str) -> bool {
119    let t = term.trim().trim_end_matches(['.', ',', ';', ':', '!', '?']);
120    let letters = t.chars().filter(|c| c.is_alphabetic()).count();
121    // Needs real letters, and short enough to be a name rather than prose.
122    if letters < 2 || t.chars().count() > 40 {
123        return false;
124    }
125    // key=value fragments and data readouts are not concepts.
126    if t.contains('=') {
127        return false;
128    }
129    // Mid-string sentence punctuation means this is prose, not a reference.
130    if t.contains(". ") || t.contains(", ") || t.contains("; ") || t.contains(": ") {
131        return false;
132    }
133    // A lone lowercase word ("exactly") is not a named entity.
134    if t.split_whitespace().count() == 1 && t.chars().next().is_some_and(|c| c.is_lowercase()) {
135        return false;
136    }
137    true
138}
139
140pub struct PhantomTracker {
141    phantoms: Vec<PhantomMemory>,
142    config: PhantomConfig,
143    entity_registry: EntityRegistry,
144}
145
146impl PhantomTracker {
147    pub fn new(config: PhantomConfig) -> Self {
148        Self {
149            phantoms: Vec::new(),
150            config,
151            entity_registry: EntityRegistry::new(),
152        }
153    }
154
155    /// Convenience: register an entity in the embedded registry.
156    pub fn register_entity(&mut self, entity: &str) {
157        self.entity_registry.register(entity);
158    }
159
160    /// Convenience: register multiple entities at once.
161    pub fn register_entities(&mut self, entities: &[&str]) {
162        self.entity_registry.register_batch(entities);
163    }
164
165    /// Return a reference to the entity registry.
166    pub fn entity_registry(&self) -> &EntityRegistry {
167        &self.entity_registry
168    }
169
170    /// Return a mutable reference to the entity registry.
171    pub fn entity_registry_mut(&mut self) -> &mut EntityRegistry {
172        &mut self.entity_registry
173    }
174
175    /// Scan content for entity references not in `known_entities`.
176    ///
177    /// Two detection strategies are combined:
178    /// 1. **Registry-based** (primary): any registered entity found in
179    ///    `content` that is NOT in `known_entities` is flagged at
180    ///    `Medium`/`High` priority.
181    /// 2. **Heuristic** (fallback): capitalized words, quoted terms, and
182    ///    technical terms are flagged at `Low` priority. Disabled when
183    ///    `PhantomConfig::use_heuristic_detection` is `false`.
184    pub fn detect_gaps(
185        &mut self,
186        content: &str,
187        known_entities: &[String],
188        turn_id: u64,
189    ) -> Vec<PhantomMemory> {
190        let known_lower: Vec<String> = known_entities.iter().map(|e| e.to_lowercase()).collect();
191        let mut detected: Vec<(String, PhantomPriority)> = Vec::new();
192        let mut seen = AHashSet::new();
193
194        // --- Primary: registry-based detection ---
195        let content_lower = content.to_lowercase();
196        for entity in self.entity_registry.list() {
197            let entity_lower = entity.to_lowercase();
198            if content_lower.contains(&entity_lower)
199                && !known_lower.contains(&entity_lower)
200                && seen.insert(entity_lower)
201            {
202                let priority = if entity.split_whitespace().count() > 1 {
203                    PhantomPriority::High
204                } else {
205                    PhantomPriority::Medium
206                };
207                detected.push((entity.to_string(), priority));
208            }
209        }
210
211        // --- Fallback: heuristic detection (only when enabled) ---
212        if self.config.use_heuristic_detection {
213            let heuristic = self.heuristic_detect(content, &known_lower, &mut seen);
214            for entity in heuristic {
215                detected.push((entity, PhantomPriority::Low));
216            }
217        }
218
219        let now = std::time::SystemTime::now()
220            .duration_since(std::time::UNIX_EPOCH)
221            .unwrap_or_default()
222            .as_micros() as u64;
223
224        let new_phantoms: Vec<PhantomMemory> = detected
225            .into_iter()
226            .map(|(entity, priority)| PhantomMemory {
227                id: MemoryId::new(),
228                gap_description: format!("No stored knowledge about '{}'", entity),
229                source_reference: entity,
230                source_turn: turn_id,
231                priority,
232                created_at: now,
233                resolved: false,
234            })
235            .collect();
236
237        self.phantoms.extend(new_phantoms.clone());
238        new_phantoms
239    }
240
241    /// Preferred explicit API: the caller provides exactly which entities
242    /// were mentioned. Entities not in the registry are flagged as gaps.
243    pub fn detect_gaps_explicit(
244        &mut self,
245        content: &str,
246        mentioned_entities: &[&str],
247        turn_id: u64,
248    ) -> Vec<PhantomMemory> {
249        let now = std::time::SystemTime::now()
250            .duration_since(std::time::UNIX_EPOCH)
251            .unwrap_or_default()
252            .as_micros() as u64;
253
254        let _ = content; // available for future use; presence keeps API uniform
255
256        let new_phantoms: Vec<PhantomMemory> = mentioned_entities
257            .iter()
258            .filter(|e| !self.entity_registry.is_known(e))
259            .map(|entity| PhantomMemory {
260                id: MemoryId::new(),
261                gap_description: format!("No stored knowledge about '{}'", entity),
262                source_reference: (*entity).to_string(),
263                source_turn: turn_id,
264                priority: PhantomPriority::Medium,
265                created_at: now,
266                resolved: false,
267            })
268            .collect();
269
270        self.phantoms.extend(new_phantoms.clone());
271        new_phantoms
272    }
273
274    /// Heuristic entity detection (capitalized words, quotes, technical terms).
275    fn heuristic_detect(
276        &self,
277        content: &str,
278        known_lower: &[String],
279        seen: &mut AHashSet<String>,
280    ) -> Vec<String> {
281        let mut detected = Vec::new();
282
283        // Detect double-quoted terms. Apostrophes are deliberately excluded:
284        // treating them as quotes grabbed possessives and contractions
285        // ("Matt's", "we're") as phantom references, which was pure noise.
286        let mut in_quote = false;
287        let mut quote_start = 0;
288        for (i, ch) in content.char_indices() {
289            if ch == '"' || ch == '\u{201C}' || ch == '\u{201D}' {
290                if in_quote {
291                    let term = &content[quote_start..i];
292                    let trimmed = term.trim();
293                    if is_entity_like(trimmed)
294                        && !known_lower.contains(&trimmed.to_lowercase())
295                        && seen.insert(trimmed.to_lowercase())
296                    {
297                        detected.push(trimmed.to_string());
298                    }
299                    in_quote = false;
300                } else {
301                    in_quote = true;
302                    quote_start = i + ch.len_utf8();
303                }
304            }
305        }
306
307        // Detect capitalized sequences (e.g., "Kubernetes Cluster", "JWT Token")
308        let words: Vec<&str> = content.split_whitespace().collect();
309        let mut i = 0;
310        while i < words.len() {
311            let w = words[i]
312                .trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.');
313            if !w.is_empty() && w.chars().next().is_some_and(|c| c.is_uppercase()) && w.len() >= 2 {
314                let mut entity_parts = vec![w.to_string()];
315                let mut j = i + 1;
316                while j < words.len() {
317                    let next = words[j].trim_matches(|c: char| {
318                        !c.is_alphanumeric() && c != '-' && c != '_' && c != '.'
319                    });
320                    if !next.is_empty() && next.chars().next().is_some_and(|c| c.is_uppercase()) {
321                        entity_parts.push(next.to_string());
322                        j += 1;
323                    } else {
324                        break;
325                    }
326                }
327
328                let entity = entity_parts.join(" ");
329                if entity.split_whitespace().count() == 1
330                    && self.config.stop_words.contains(&entity.to_lowercase())
331                {
332                    i = j;
333                    continue;
334                }
335
336                if is_entity_like(&entity)
337                    && !known_lower.contains(&entity.to_lowercase())
338                    && seen.insert(entity.to_lowercase())
339                {
340                    detected.push(entity);
341                }
342                i = j;
343            } else {
344                if !w.is_empty() && w.len() >= self.config.min_word_length {
345                    let is_technical = w.contains('-')
346                        || w.contains('.')
347                        || w.contains('_')
348                        || (w.len() >= self.config.min_word_length
349                            && w.chars()
350                                .all(|c| c.is_uppercase() || c.is_ascii_digit() || c == '_'));
351
352                    if is_technical
353                        && is_entity_like(w)
354                        && !known_lower.contains(&w.to_lowercase())
355                        && seen.insert(w.to_lowercase())
356                    {
357                        detected.push(w.to_string());
358                    }
359                }
360                i += 1;
361            }
362        }
363
364        detected
365    }
366
367    pub fn resolve(&mut self, phantom_id: Uuid) {
368        if let Some(p) = self
369            .phantoms
370            .iter_mut()
371            .find(|p| p.id == MemoryId::from(phantom_id))
372        {
373            p.resolved = true;
374        }
375    }
376
377    pub fn get_active_phantoms(&self) -> Vec<&PhantomMemory> {
378        let mut active: Vec<&PhantomMemory> =
379            self.phantoms.iter().filter(|p| !p.resolved).collect();
380        active.sort_by_key(|x| std::cmp::Reverse(x.priority));
381        active
382    }
383
384    pub fn format_phantom_warnings(&self) -> String {
385        let active = self.get_active_phantoms();
386        if active.is_empty() {
387            return String::new();
388        }
389
390        active
391            .iter()
392            .take(self.config.max_warnings)
393            .map(|p| {
394                format!(
395                    "WARNING: User referenced '{}' but no details stored. Consider asking.",
396                    p.source_reference
397                )
398            })
399            .collect::<Vec<_>>()
400            .join("\n")
401    }
402}
403
404impl Default for PhantomTracker {
405    fn default() -> Self {
406        Self::new(PhantomConfig::default())
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn test_detect_unknown_entities() {
416        let mut tracker = PhantomTracker::default();
417        let known = vec!["React".to_string(), "TypeScript".to_string()];
418        let phantoms = tracker.detect_gaps(
419            "We need to deploy to the Kubernetes cluster using Terraform",
420            &known,
421            1,
422        );
423        let refs: Vec<&str> = phantoms
424            .iter()
425            .map(|p| p.source_reference.as_str())
426            .collect();
427        assert!(
428            refs.iter().any(|r| r.contains("Kubernetes")),
429            "Expected Kubernetes, got: {:?}",
430            refs
431        );
432        assert!(
433            refs.iter().any(|r| r.contains("Terraform")),
434            "Expected Terraform, got: {:?}",
435            refs
436        );
437    }
438
439    #[test]
440    fn heuristic_rejects_data_values_keeps_entities() {
441        // The tightened heuristic keeps genuine named entities but drops
442        // key=value data readouts that are not concepts worth teaching.
443        let mut tracker = PhantomTracker::default();
444        let phantoms = tracker.detect_gaps(
445            "The Payment Gateway service reported on_hand=0 for that item.",
446            &[],
447            1,
448        );
449        let refs: Vec<String> = phantoms
450            .iter()
451            .map(|p| p.source_reference.clone())
452            .collect();
453        assert!(
454            refs.iter().any(|r| r.contains("Payment Gateway")),
455            "should keep 'Payment Gateway', got: {refs:?}"
456        );
457        assert!(
458            !refs.iter().any(|r| r.contains('=')),
459            "should drop key=value data fragments like on_hand=0, got: {refs:?}"
460        );
461    }
462
463    #[test]
464    fn test_resolve_phantom() {
465        let mut tracker = PhantomTracker::default();
466        let phantoms = tracker.detect_gaps("Check the Redis cache", &[], 1);
467        assert!(!phantoms.is_empty());
468        let pid = phantoms[0].id;
469        tracker.resolve(pid.into());
470        assert!(tracker.get_active_phantoms().iter().all(|p| p.id != pid));
471    }
472
473    #[test]
474    fn test_format_warnings() {
475        let mut tracker = PhantomTracker::default();
476        tracker.detect_gaps("Deploy to Kubernetes using Helm", &[], 1);
477        let warnings = tracker.format_phantom_warnings();
478        assert!(warnings.contains("WARNING"));
479    }
480
481    // --- EntityRegistry CRUD ---
482
483    #[test]
484    fn test_entity_registry_crud() {
485        let mut reg = EntityRegistry::new();
486        assert!(reg.is_empty());
487
488        reg.register("Kubernetes");
489        reg.register("Terraform");
490        assert_eq!(reg.len(), 2);
491        assert!(reg.is_known("Kubernetes"));
492        assert!(!reg.is_known("Docker"));
493
494        reg.unregister("Kubernetes");
495        assert!(!reg.is_known("Kubernetes"));
496        assert_eq!(reg.len(), 1);
497    }
498
499    #[test]
500    fn test_entity_registry_batch() {
501        let mut reg = EntityRegistry::new();
502        reg.register_batch(&["Redis", "Postgres", "Kafka"]);
503        assert_eq!(reg.len(), 3);
504        assert!(reg.is_known("Redis"));
505        assert!(reg.is_known("Kafka"));
506    }
507
508    #[test]
509    fn test_entity_registry_list() {
510        let mut reg = EntityRegistry::new();
511        reg.register_batch(&["B", "A", "C"]);
512        let mut items = reg.list();
513        items.sort();
514        assert_eq!(items, vec!["A", "B", "C"]);
515    }
516
517    // --- detect_gaps_explicit ---
518
519    #[test]
520    fn test_detect_gaps_explicit_finds_unknown() {
521        let mut tracker = PhantomTracker::default();
522        tracker.register_entities(&["Kubernetes", "Terraform"]);
523
524        let gaps = tracker.detect_gaps_explicit(
525            "Deploy with Kubernetes and Docker",
526            &["Kubernetes", "Docker"],
527            1,
528        );
529
530        // Docker is not registered, so it should be flagged.
531        assert_eq!(gaps.len(), 1);
532        assert_eq!(gaps[0].source_reference, "Docker");
533    }
534
535    #[test]
536    fn test_detect_gaps_explicit_no_false_positives() {
537        let mut tracker = PhantomTracker::default();
538        tracker.register_entities(&["Kubernetes", "Terraform", "Helm"]);
539
540        let gaps =
541            tracker.detect_gaps_explicit("Using Kubernetes and Helm", &["Kubernetes", "Helm"], 1);
542
543        // Both are registered — no gaps.
544        assert!(gaps.is_empty());
545    }
546
547    // --- Heuristic disabled via config ---
548
549    #[test]
550    fn test_heuristic_disabled() {
551        let mut config = PhantomConfig::default();
552        config.use_heuristic_detection = false;
553        let mut tracker = PhantomTracker::new(config);
554
555        // No entities registered and heuristic off — should find nothing.
556        let gaps = tracker.detect_gaps("Deploy to Kubernetes using Terraform", &[], 1);
557        assert!(
558            gaps.is_empty(),
559            "Expected no gaps with heuristic disabled and empty registry, got: {:?}",
560            gaps.iter().map(|g| &g.source_reference).collect::<Vec<_>>()
561        );
562    }
563
564    #[test]
565    fn test_heuristic_disabled_with_registry() {
566        let mut config = PhantomConfig::default();
567        config.use_heuristic_detection = false;
568        let mut tracker = PhantomTracker::new(config);
569        tracker.register_entity("Kubernetes");
570
571        // Registry-based detection should still work even with heuristic off.
572        let gaps = tracker.detect_gaps("Deploy to Kubernetes using Terraform", &[], 1);
573        // Kubernetes is registered and referenced but not in known_entities → flagged.
574        // Terraform is NOT registered and heuristic is off → not flagged.
575        assert_eq!(gaps.len(), 1);
576        assert_eq!(gaps[0].source_reference, "Kubernetes");
577    }
578
579    // --- Integration: register, detect, resolve ---
580
581    #[test]
582    fn test_integration_register_detect_resolve() {
583        let mut tracker = PhantomTracker::default();
584        tracker.register_entities(&["Redis", "Postgres"]);
585
586        // Caller mentions Redis, Kafka, Postgres — Kafka is unknown.
587        let gaps = tracker.detect_gaps_explicit(
588            "Need Redis and Kafka and Postgres",
589            &["Redis", "Kafka", "Postgres"],
590            1,
591        );
592        assert_eq!(gaps.len(), 1);
593        assert_eq!(gaps[0].source_reference, "Kafka");
594        assert!(!gaps[0].resolved);
595
596        // Resolve the gap.
597        tracker.resolve(gaps[0].id.into());
598        assert!(tracker.get_active_phantoms().is_empty());
599    }
600
601    // --- Heuristic fallback produces Low priority ---
602
603    #[test]
604    fn test_heuristic_fallback_low_priority() {
605        let mut tracker = PhantomTracker::default();
606        // No entities registered — heuristic only.
607        // Use lowercase prefix so "Redis" is detected as a standalone capitalized word.
608        let gaps = tracker.detect_gaps("we use Redis for caching", &[], 1);
609        let redis_gaps: Vec<_> = gaps
610            .iter()
611            .filter(|g| g.source_reference == "Redis")
612            .collect();
613        assert_eq!(redis_gaps.len(), 1);
614        assert_eq!(redis_gaps[0].priority, PhantomPriority::Low);
615    }
616
617    #[test]
618    fn test_registry_detection_higher_priority() {
619        let mut tracker = PhantomTracker::default();
620        tracker.register_entity("Redis");
621
622        let gaps = tracker.detect_gaps("we use Redis for caching", &[], 1);
623        let redis_gaps: Vec<_> = gaps
624            .iter()
625            .filter(|g| g.source_reference == "Redis")
626            .collect();
627        assert_eq!(redis_gaps.len(), 1);
628        // Registry-based single-word entity → Medium priority.
629        assert_eq!(redis_gaps[0].priority, PhantomPriority::Medium);
630    }
631}