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
27pub 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#[derive(Debug, Clone)]
79pub struct PhantomConfig {
80 pub stop_words: HashSet<String>,
82 pub max_warnings: usize,
84 pub min_word_length: usize,
86 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
114fn 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 if letters < 2 || t.chars().count() > 40 {
123 return false;
124 }
125 if t.contains('=') {
127 return false;
128 }
129 if t.contains(". ") || t.contains(", ") || t.contains("; ") || t.contains(": ") {
131 return false;
132 }
133 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 pub fn register_entity(&mut self, entity: &str) {
157 self.entity_registry.register(entity);
158 }
159
160 pub fn register_entities(&mut self, entities: &[&str]) {
162 self.entity_registry.register_batch(entities);
163 }
164
165 pub fn entity_registry(&self) -> &EntityRegistry {
167 &self.entity_registry
168 }
169
170 pub fn entity_registry_mut(&mut self) -> &mut EntityRegistry {
172 &mut self.entity_registry
173 }
174
175 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 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 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 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; 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 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 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 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 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 #[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 #[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 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 assert!(gaps.is_empty());
545 }
546
547 #[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 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 let gaps = tracker.detect_gaps("Deploy to Kubernetes using Terraform", &[], 1);
573 assert_eq!(gaps.len(), 1);
576 assert_eq!(gaps[0].source_reference, "Kubernetes");
577 }
578
579 #[test]
582 fn test_integration_register_detect_resolve() {
583 let mut tracker = PhantomTracker::default();
584 tracker.register_entities(&["Redis", "Postgres"]);
585
586 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 tracker.resolve(gaps[0].id.into());
598 assert!(tracker.get_active_phantoms().is_empty());
599 }
600
601 #[test]
604 fn test_heuristic_fallback_low_priority() {
605 let mut tracker = PhantomTracker::default();
606 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 assert_eq!(redis_gaps[0].priority, PhantomPriority::Medium);
630 }
631}