Skip to main content

mentedb_cognitive/
entity.rs

1use crate::llm::{CognitiveLlmService, EntityCandidate, EntityMergeGroup, LlmJudge};
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use std::io;
5use std::path::Path;
6
7const SNAPSHOT_VERSION: u32 = 2;
8
9const WORD_MATCH_CONFIDENCE: f32 = 0.7;
10
11/// Resolves entity references to canonical names using a three-tier strategy:
12///
13/// 1. **Learned cache** — instant lookup from alias table (no LLM call)
14/// 2. **Rule-based** — case normalization, substring matching
15/// 3. **LLM-powered** — CognitiveLlmService.resolve_entities() for ambiguous cases
16///
17/// The alias table persists across sessions so the LLM is only consulted
18/// for genuinely new entity references.
19#[derive(Debug, Clone)]
20pub struct EntityResolver {
21    /// Maps normalized alias → canonical name.
22    aliases: HashMap<String, String>,
23    /// Tracks confidence for each learned merge.
24    confidence: HashMap<String, f32>,
25    /// Pairs of entity names confirmed to be DIFFERENT (negative cache).
26    /// Stored as sorted (a, b) tuples to avoid (A,B) vs (B,A) duplication.
27    negative_pairs: HashSet<(String, String)>,
28}
29
30#[derive(Serialize, Deserialize)]
31struct Snapshot {
32    version: u32,
33    aliases: HashMap<String, String>,
34    confidence: HashMap<String, f32>,
35    #[serde(default)]
36    negative_pairs: Vec<(String, String)>,
37}
38
39/// Result of resolving an entity reference.
40#[derive(Debug, Clone, PartialEq)]
41pub struct ResolvedEntity {
42    /// The canonical name for this entity.
43    pub canonical: String,
44    /// How confident we are in this resolution (0.0 to 1.0).
45    pub confidence: f32,
46    /// Whether this came from the cache, rules, or LLM.
47    pub source: ResolutionSource,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum ResolutionSource {
52    Cache,
53    RuleBased,
54    Llm,
55    /// No resolution found, returned as-is.
56    Identity,
57}
58
59impl EntityResolver {
60    pub fn new() -> Self {
61        Self {
62            aliases: HashMap::new(),
63            confidence: HashMap::new(),
64            negative_pairs: HashSet::new(),
65        }
66    }
67
68    /// Resolve an entity reference using the learned cache and rule-based matching.
69    /// Does not call the LLM — use `resolve_with_llm` for the full pipeline.
70    pub fn resolve(&self, name: &str) -> ResolvedEntity {
71        let normalized = normalize_entity(name);
72
73        // Tier 1: Exact cache hit
74        if let Some(canonical) = self.aliases.get(&normalized) {
75            return ResolvedEntity {
76                canonical: canonical.clone(),
77                confidence: self.confidence.get(&normalized).copied().unwrap_or(1.0),
78                source: ResolutionSource::Cache,
79            };
80        }
81
82        // Tier 2: Rule-based substring matching against known canonicals
83        if let Some((canonical, conf)) = self.rule_based_match(&normalized) {
84            return ResolvedEntity {
85                canonical,
86                confidence: conf,
87                source: ResolutionSource::RuleBased,
88            };
89        }
90
91        // No resolution — return as-is
92        ResolvedEntity {
93            canonical: normalized,
94            confidence: 1.0,
95            source: ResolutionSource::Identity,
96        }
97    }
98
99    /// Full three-tier resolution: cache → rules → LLM.
100    ///
101    /// Resolves a batch of entity references. Any LLM-confirmed merges
102    /// are automatically added to the alias table for future cache hits.
103    pub async fn resolve_batch_with_llm<J: LlmJudge>(
104        &mut self,
105        names: &[String],
106        contexts: &[Option<String>],
107        llm: &CognitiveLlmService<J>,
108    ) -> Vec<ResolvedEntity> {
109        let mut results = Vec::with_capacity(names.len());
110        let mut unresolved_indices = Vec::new();
111
112        // First pass: cache + rules
113        for (i, name) in names.iter().enumerate() {
114            let resolved = self.resolve(name);
115            if resolved.source == ResolutionSource::Identity {
116                unresolved_indices.push(i);
117            }
118            results.push(resolved);
119        }
120
121        // If everything resolved, skip LLM
122        if unresolved_indices.is_empty() {
123            return results;
124        }
125
126        // Build candidates for the LLM — include ALL names for context,
127        // not just unresolved ones, so the LLM can see the full picture
128        let candidates: Vec<EntityCandidate> = names
129            .iter()
130            .enumerate()
131            .map(|(i, name)| EntityCandidate {
132                name: name.clone(),
133                context: contexts.get(i).and_then(|c| c.clone()),
134                memory_id: None,
135            })
136            .collect();
137
138        if let Ok(groups) = llm.resolve_entities(&candidates).await {
139            for group in &groups {
140                self.learn_group(group);
141            }
142
143            // Re-resolve unresolved entries using the newly learned aliases
144            for &i in &unresolved_indices {
145                let re_resolved = self.resolve(&names[i]);
146                if re_resolved.source == ResolutionSource::Cache {
147                    results[i] = ResolvedEntity {
148                        canonical: re_resolved.canonical,
149                        confidence: re_resolved.confidence,
150                        source: ResolutionSource::Llm,
151                    };
152                }
153            }
154        }
155
156        results
157    }
158
159    /// Learn a merge group from the LLM, adding all aliases to the cache.
160    pub fn learn_group(&mut self, group: &EntityMergeGroup) {
161        let canonical = normalize_entity(&group.canonical);
162
163        // Map canonical to itself
164        self.aliases.insert(canonical.clone(), canonical.clone());
165        self.confidence.insert(canonical.clone(), group.confidence);
166
167        // Map each alias to the canonical
168        for alias in &group.aliases {
169            let normalized_alias = normalize_entity(alias);
170            if normalized_alias != canonical {
171                self.aliases
172                    .insert(normalized_alias.clone(), canonical.clone());
173                self.confidence.insert(normalized_alias, group.confidence);
174            }
175        }
176    }
177
178    /// Manually register an alias mapping.
179    pub fn add_alias(&mut self, alias: &str, canonical: &str, confidence: f32) {
180        let alias_norm = normalize_entity(alias);
181        let canonical_norm = normalize_entity(canonical);
182        self.aliases.insert(alias_norm.clone(), canonical_norm);
183        self.confidence.insert(alias_norm, confidence);
184    }
185
186    /// Get the canonical name for an alias, if known.
187    pub fn get_canonical(&self, name: &str) -> Option<&String> {
188        let normalized = normalize_entity(name);
189        self.aliases.get(&normalized)
190    }
191
192    /// Returns all known canonical entity names.
193    pub fn known_entities(&self) -> Vec<String> {
194        let mut entities: Vec<String> = self.aliases.values().cloned().collect();
195        entities.sort();
196        entities.dedup();
197        entities
198    }
199
200    pub fn alias_count(&self) -> usize {
201        self.aliases.len()
202    }
203
204    /// Rule-based matching: check if the input's words are a subset of a known
205    /// canonical's words or vice versa. Handles "Alice" matching "Alice Smith"
206    /// but correctly rejects "Java" matching "JavaScript".
207    fn rule_based_match(&self, normalized: &str) -> Option<(String, f32)> {
208        let input_words: HashSet<&str> = normalized
209            .split(|c: char| c.is_whitespace() || c == '-' || c == '_')
210            .filter(|w| !w.is_empty())
211            .collect();
212        if input_words.is_empty() {
213            return None;
214        }
215
216        let canonicals = self.known_entities();
217        for canonical in &canonicals {
218            if canonical == normalized {
219                continue;
220            }
221
222            let canon_words: HashSet<&str> = canonical
223                .split(|c: char| c.is_whitespace() || c == '-' || c == '_')
224                .filter(|w| !w.is_empty())
225                .collect();
226
227            // "alice" ⊂ {"alice", "smith"} → match
228            // "java" ⊄ {"javascript"} → no match (correct!)
229            if input_words.is_subset(&canon_words) || canon_words.is_subset(&input_words) {
230                return Some((canonical.clone(), WORD_MATCH_CONFIDENCE));
231            }
232        }
233        None
234    }
235
236    /// Check if two entity names are known to be different (negative cache).
237    pub fn is_known_different(&self, a: &str, b: &str) -> bool {
238        self.negative_pairs.contains(&Self::negative_key(a, b))
239    }
240
241    /// Mark two entity names as confirmed different (negative cache).
242    pub fn mark_different(&mut self, a: &str, b: &str) {
243        self.negative_pairs.insert(Self::negative_key(a, b));
244    }
245
246    /// Returns the set of entity names that are NOT yet resolved
247    /// (no cache hit, no rule-based match). These need LLM resolution.
248    pub fn unresolved_names(&self, names: &[String]) -> Vec<String> {
249        names
250            .iter()
251            .filter(|name| {
252                let resolved = self.resolve(name);
253                resolved.source == ResolutionSource::Identity
254            })
255            .cloned()
256            .collect()
257    }
258
259    /// Number of negative-cached pairs.
260    pub fn negative_count(&self) -> usize {
261        self.negative_pairs.len()
262    }
263
264    fn negative_key(a: &str, b: &str) -> (String, String) {
265        let na = normalize_entity(a);
266        let nb = normalize_entity(b);
267        if na <= nb { (na, nb) } else { (nb, na) }
268    }
269
270    /// Save the alias table to a JSON file.
271    pub fn save(&self, path: &Path) -> io::Result<()> {
272        let snapshot = Snapshot {
273            version: SNAPSHOT_VERSION,
274            aliases: self.aliases.clone(),
275            confidence: self.confidence.clone(),
276            negative_pairs: self.negative_pairs.iter().cloned().collect(),
277        };
278        let json = serde_json::to_string(&snapshot)
279            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
280        let tmp = path.with_extension("tmp");
281        std::fs::write(&tmp, json)?;
282        std::fs::rename(&tmp, path)
283    }
284
285    /// Load the alias table from a JSON file, merging with existing entries.
286    pub fn load(&mut self, path: &Path) -> io::Result<()> {
287        let json = std::fs::read_to_string(path)?;
288        let snapshot: Snapshot = serde_json::from_str(&json)
289            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
290
291        if snapshot.version > SNAPSHOT_VERSION {
292            return Err(io::Error::new(
293                io::ErrorKind::InvalidData,
294                format!(
295                    "unsupported entity snapshot version: {} (expected <= {})",
296                    snapshot.version, SNAPSHOT_VERSION
297                ),
298            ));
299        }
300
301        for (alias, canonical) in snapshot.aliases {
302            self.aliases.entry(alias).or_insert(canonical);
303        }
304        for (alias, conf) in snapshot.confidence {
305            self.confidence.entry(alias).or_insert(conf);
306        }
307        for pair in snapshot.negative_pairs {
308            self.negative_pairs.insert(pair);
309        }
310        Ok(())
311    }
312}
313
314impl Default for EntityResolver {
315    fn default() -> Self {
316        Self::new()
317    }
318}
319
320/// Normalize an entity reference: lowercase, collapse whitespace, trim.
321fn normalize_entity(raw: &str) -> String {
322    raw.split_whitespace()
323        .map(|w| w.to_lowercase())
324        .collect::<Vec<_>>()
325        .join(" ")
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::llm::{CognitiveLlmService, MockLlmJudge};
332
333    #[test]
334    fn test_normalize_entity() {
335        assert_eq!(normalize_entity("Alice Smith"), "alice smith");
336        assert_eq!(normalize_entity("  ALICE   SMITH  "), "alice smith");
337        assert_eq!(normalize_entity("alice"), "alice");
338    }
339
340    #[test]
341    fn test_add_alias_and_resolve() {
342        let mut resolver = EntityResolver::new();
343        resolver.add_alias("Alice", "Alice Smith", 0.95);
344        resolver.add_alias("my manager", "Alice Smith", 0.85);
345
346        let result = resolver.resolve("Alice");
347        assert_eq!(result.canonical, "alice smith");
348        assert_eq!(result.confidence, 0.95);
349        assert_eq!(result.source, ResolutionSource::Cache);
350
351        let result = resolver.resolve("MY MANAGER");
352        assert_eq!(result.canonical, "alice smith");
353        assert_eq!(result.source, ResolutionSource::Cache);
354    }
355
356    #[test]
357    fn test_unresolved_returns_identity() {
358        let resolver = EntityResolver::new();
359        let result = resolver.resolve("Bob");
360        assert_eq!(result.canonical, "bob");
361        assert_eq!(result.source, ResolutionSource::Identity);
362    }
363
364    #[test]
365    fn test_rule_based_word_match() {
366        let mut resolver = EntityResolver::new();
367        resolver.add_alias("alice smith", "alice smith", 1.0);
368
369        // "alice" is a word subset of "alice smith" → match
370        let result = resolver.resolve("Alice");
371        assert_eq!(result.canonical, "alice smith");
372        assert_eq!(result.source, ResolutionSource::RuleBased);
373        assert_eq!(result.confidence, WORD_MATCH_CONFIDENCE);
374    }
375
376    #[test]
377    fn test_word_match_rejects_java_javascript() {
378        let mut resolver = EntityResolver::new();
379        resolver.add_alias("javascript", "javascript", 1.0);
380
381        // "java" is NOT a word match for "javascript" — different words
382        let result = resolver.resolve("Java");
383        assert_eq!(result.canonical, "java");
384        assert_eq!(result.source, ResolutionSource::Identity);
385    }
386
387    #[test]
388    fn test_hyphenated_names_match() {
389        let mut resolver = EntityResolver::new();
390        resolver.add_alias("gpt-4", "gpt-4", 1.0);
391
392        // "gpt 4" should match "gpt-4" via hyphen-aware word splitting
393        let result = resolver.resolve("gpt 4");
394        assert_eq!(result.canonical, "gpt-4");
395        assert_eq!(result.source, ResolutionSource::RuleBased);
396    }
397
398    #[test]
399    fn test_short_names_skip_word_match() {
400        let mut resolver = EntityResolver::new();
401        resolver.add_alias("db", "database", 1.0);
402
403        let result = resolver.resolve("db");
404        // Should hit cache directly since we added it as an alias
405        assert_eq!(result.canonical, "database");
406        assert_eq!(result.source, ResolutionSource::Cache);
407    }
408
409    #[test]
410    fn test_negative_cache() {
411        let mut resolver = EntityResolver::new();
412
413        assert!(!resolver.is_known_different("Python", "python snake"));
414
415        resolver.mark_different("Python", "python snake");
416        assert!(resolver.is_known_different("Python", "python snake"));
417        // Order shouldn't matter
418        assert!(resolver.is_known_different("python snake", "Python"));
419    }
420
421    #[test]
422    fn test_negative_cache_persistence() {
423        let dir = tempfile::tempdir().unwrap();
424        let path = dir.path().join("entities.json");
425
426        let mut resolver = EntityResolver::new();
427        resolver.mark_different("Python", "Monty Python");
428        resolver.add_alias("alice", "alice smith", 0.9);
429        resolver.save(&path).unwrap();
430
431        let mut loaded = EntityResolver::new();
432        loaded.load(&path).unwrap();
433
434        assert!(loaded.is_known_different("Python", "Monty Python"));
435        assert_eq!(
436            loaded.get_canonical("alice"),
437            Some(&"alice smith".to_string())
438        );
439    }
440
441    #[test]
442    fn test_unresolved_names() {
443        let mut resolver = EntityResolver::new();
444        resolver.add_alias("alice", "alice smith", 0.9);
445        resolver.add_alias("bob", "bob jones", 0.9);
446
447        let names = vec![
448            "Alice".to_string(),
449            "Bob".to_string(),
450            "Charlie".to_string(),
451            "NYC".to_string(),
452        ];
453        let unresolved = resolver.unresolved_names(&names);
454        assert_eq!(unresolved, vec!["Charlie".to_string(), "NYC".to_string()]);
455    }
456
457    #[test]
458    fn test_learn_group() {
459        let mut resolver = EntityResolver::new();
460        resolver.learn_group(&EntityMergeGroup {
461            canonical: "Alice Smith".to_string(),
462            aliases: vec!["Alice".to_string(), "my manager".to_string()],
463            confidence: 0.9,
464        });
465
466        assert_eq!(
467            resolver.get_canonical("alice"),
468            Some(&"alice smith".to_string())
469        );
470        assert_eq!(
471            resolver.get_canonical("my manager"),
472            Some(&"alice smith".to_string())
473        );
474        assert_eq!(
475            resolver.get_canonical("alice smith"),
476            Some(&"alice smith".to_string())
477        );
478    }
479
480    #[test]
481    fn test_known_entities() {
482        let mut resolver = EntityResolver::new();
483        resolver.learn_group(&EntityMergeGroup {
484            canonical: "Alice Smith".to_string(),
485            aliases: vec!["Alice".to_string()],
486            confidence: 0.9,
487        });
488        resolver.add_alias("postgres", "PostgreSQL", 1.0);
489
490        let entities = resolver.known_entities();
491        assert!(entities.contains(&"alice smith".to_string()));
492        assert!(entities.contains(&"postgresql".to_string()));
493    }
494
495    #[test]
496    fn test_persistence_roundtrip() {
497        let dir = tempfile::tempdir().unwrap();
498        let path = dir.path().join("entities.json");
499
500        let mut resolver = EntityResolver::new();
501        resolver.learn_group(&EntityMergeGroup {
502            canonical: "Alice Smith".to_string(),
503            aliases: vec!["Alice".to_string(), "my manager".to_string()],
504            confidence: 0.9,
505        });
506        resolver.save(&path).unwrap();
507
508        let mut loaded = EntityResolver::new();
509        loaded.load(&path).unwrap();
510
511        assert_eq!(
512            loaded.get_canonical("alice"),
513            Some(&"alice smith".to_string())
514        );
515        assert_eq!(
516            loaded.get_canonical("my manager"),
517            Some(&"alice smith".to_string())
518        );
519    }
520
521    #[test]
522    fn test_load_merges_existing() {
523        let dir = tempfile::tempdir().unwrap();
524        let path = dir.path().join("entities.json");
525
526        let mut resolver1 = EntityResolver::new();
527        resolver1.add_alias("alice", "alice smith", 0.9);
528        resolver1.save(&path).unwrap();
529
530        let mut resolver2 = EntityResolver::new();
531        resolver2.add_alias("bob", "bob jones", 0.8);
532        resolver2.load(&path).unwrap();
533
534        // Both aliases should exist
535        assert_eq!(
536            resolver2.get_canonical("alice"),
537            Some(&"alice smith".to_string())
538        );
539        assert_eq!(
540            resolver2.get_canonical("bob"),
541            Some(&"bob jones".to_string())
542        );
543    }
544
545    #[tokio::test]
546    async fn test_resolve_batch_with_llm() {
547        let judge = MockLlmJudge::new(
548            r#"{"groups": [{"canonical": "Alice Smith", "aliases": ["Alice", "my manager"], "confidence": 0.9}]}"#,
549        );
550        let llm = CognitiveLlmService::new(judge);
551        let mut resolver = EntityResolver::new();
552
553        let names = vec![
554            "Alice".to_string(),
555            "my manager".to_string(),
556            "Bob".to_string(),
557        ];
558        let contexts = vec![None, Some("Alice is my manager".to_string()), None];
559
560        let results = resolver
561            .resolve_batch_with_llm(&names, &contexts, &llm)
562            .await;
563
564        // Alice and my manager should resolve to alice smith via LLM
565        assert_eq!(results[0].canonical, "alice smith");
566        assert_eq!(results[0].source, ResolutionSource::Llm);
567        assert_eq!(results[1].canonical, "alice smith");
568        assert_eq!(results[1].source, ResolutionSource::Llm);
569
570        // Bob wasn't in the LLM response, stays identity
571        assert_eq!(results[2].canonical, "bob");
572        assert_eq!(results[2].source, ResolutionSource::Identity);
573
574        // Aliases should be cached for next time
575        assert_eq!(resolver.alias_count(), 3); // alice smith, alice, my manager
576    }
577
578    #[tokio::test]
579    async fn test_resolve_batch_skips_llm_when_cached() {
580        let judge = MockLlmJudge::new(r#"{"groups": []}"#);
581        let llm = CognitiveLlmService::new(judge);
582        let mut resolver = EntityResolver::new();
583
584        // Pre-teach the cache
585        resolver.add_alias("alice", "alice smith", 0.95);
586
587        let names = vec!["Alice".to_string()];
588        let contexts = vec![None];
589
590        let results = resolver
591            .resolve_batch_with_llm(&names, &contexts, &llm)
592            .await;
593
594        // Should come from cache, not LLM
595        assert_eq!(results[0].canonical, "alice smith");
596        assert_eq!(results[0].source, ResolutionSource::Cache);
597    }
598}