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
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 pub fn register_entity(&mut self, entity: &str) {
131 self.entity_registry.register(entity);
132 }
133
134 pub fn register_entities(&mut self, entities: &[&str]) {
136 self.entity_registry.register_batch(entities);
137 }
138
139 pub fn entity_registry(&self) -> &EntityRegistry {
141 &self.entity_registry
142 }
143
144 pub fn entity_registry_mut(&mut self) -> &mut EntityRegistry {
146 &mut self.entity_registry
147 }
148
149 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 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 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 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; 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 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 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 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 #[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 #[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 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 assert!(gaps.is_empty());
492 }
493
494 #[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 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 let gaps = tracker.detect_gaps("Deploy to Kubernetes using Terraform", &[], 1);
520 assert_eq!(gaps.len(), 1);
523 assert_eq!(gaps[0].source_reference, "Kubernetes");
524 }
525
526 #[test]
529 fn test_integration_register_detect_resolve() {
530 let mut tracker = PhantomTracker::default();
531 tracker.register_entities(&["Redis", "Postgres"]);
532
533 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 tracker.resolve(gaps[0].id.into());
545 assert!(tracker.get_active_phantoms().is_empty());
546 }
547
548 #[test]
551 fn test_heuristic_fallback_low_priority() {
552 let mut tracker = PhantomTracker::default();
553 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 assert_eq!(redis_gaps[0].priority, PhantomPriority::Medium);
577 }
578}