Skip to main content

mentedb_cognitive/
entity.rs

1use crate::llm::{CognitiveLlmService, EntityCandidate, EntityMergeGroup, LlmJudge};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::io;
5use std::path::Path;
6
7const SNAPSHOT_VERSION: u32 = 1;
8const MIN_SUBSTRING_LEN: usize = 3;
9const SUBSTRING_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}
26
27#[derive(Serialize, Deserialize)]
28struct Snapshot {
29    version: u32,
30    aliases: HashMap<String, String>,
31    confidence: HashMap<String, f32>,
32}
33
34/// Result of resolving an entity reference.
35#[derive(Debug, Clone, PartialEq)]
36pub struct ResolvedEntity {
37    /// The canonical name for this entity.
38    pub canonical: String,
39    /// How confident we are in this resolution (0.0 to 1.0).
40    pub confidence: f32,
41    /// Whether this came from the cache, rules, or LLM.
42    pub source: ResolutionSource,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum ResolutionSource {
47    Cache,
48    RuleBased,
49    Llm,
50    /// No resolution found, returned as-is.
51    Identity,
52}
53
54impl EntityResolver {
55    pub fn new() -> Self {
56        Self {
57            aliases: HashMap::new(),
58            confidence: HashMap::new(),
59        }
60    }
61
62    /// Resolve an entity reference using the learned cache and rule-based matching.
63    /// Does not call the LLM — use `resolve_with_llm` for the full pipeline.
64    pub fn resolve(&self, name: &str) -> ResolvedEntity {
65        let normalized = normalize_entity(name);
66
67        // Tier 1: Exact cache hit
68        if let Some(canonical) = self.aliases.get(&normalized) {
69            return ResolvedEntity {
70                canonical: canonical.clone(),
71                confidence: self.confidence.get(&normalized).copied().unwrap_or(1.0),
72                source: ResolutionSource::Cache,
73            };
74        }
75
76        // Tier 2: Rule-based substring matching against known canonicals
77        if let Some((canonical, conf)) = self.rule_based_match(&normalized) {
78            return ResolvedEntity {
79                canonical,
80                confidence: conf,
81                source: ResolutionSource::RuleBased,
82            };
83        }
84
85        // No resolution — return as-is
86        ResolvedEntity {
87            canonical: normalized,
88            confidence: 1.0,
89            source: ResolutionSource::Identity,
90        }
91    }
92
93    /// Full three-tier resolution: cache → rules → LLM.
94    ///
95    /// Resolves a batch of entity references. Any LLM-confirmed merges
96    /// are automatically added to the alias table for future cache hits.
97    pub async fn resolve_batch_with_llm<J: LlmJudge>(
98        &mut self,
99        names: &[String],
100        contexts: &[Option<String>],
101        llm: &CognitiveLlmService<J>,
102    ) -> Vec<ResolvedEntity> {
103        let mut results = Vec::with_capacity(names.len());
104        let mut unresolved_indices = Vec::new();
105
106        // First pass: cache + rules
107        for (i, name) in names.iter().enumerate() {
108            let resolved = self.resolve(name);
109            if resolved.source == ResolutionSource::Identity {
110                unresolved_indices.push(i);
111            }
112            results.push(resolved);
113        }
114
115        // If everything resolved, skip LLM
116        if unresolved_indices.is_empty() {
117            return results;
118        }
119
120        // Build candidates for the LLM — include ALL names for context,
121        // not just unresolved ones, so the LLM can see the full picture
122        let candidates: Vec<EntityCandidate> = names
123            .iter()
124            .enumerate()
125            .map(|(i, name)| EntityCandidate {
126                name: name.clone(),
127                context: contexts.get(i).and_then(|c| c.clone()),
128                memory_id: None,
129            })
130            .collect();
131
132        if let Ok(groups) = llm.resolve_entities(&candidates).await {
133            for group in &groups {
134                self.learn_group(group);
135            }
136
137            // Re-resolve unresolved entries using the newly learned aliases
138            for &i in &unresolved_indices {
139                let re_resolved = self.resolve(&names[i]);
140                if re_resolved.source == ResolutionSource::Cache {
141                    results[i] = ResolvedEntity {
142                        canonical: re_resolved.canonical,
143                        confidence: re_resolved.confidence,
144                        source: ResolutionSource::Llm,
145                    };
146                }
147            }
148        }
149
150        results
151    }
152
153    /// Learn a merge group from the LLM, adding all aliases to the cache.
154    pub fn learn_group(&mut self, group: &EntityMergeGroup) {
155        let canonical = normalize_entity(&group.canonical);
156
157        // Map canonical to itself
158        self.aliases.insert(canonical.clone(), canonical.clone());
159        self.confidence.insert(canonical.clone(), group.confidence);
160
161        // Map each alias to the canonical
162        for alias in &group.aliases {
163            let normalized_alias = normalize_entity(alias);
164            if normalized_alias != canonical {
165                self.aliases
166                    .insert(normalized_alias.clone(), canonical.clone());
167                self.confidence.insert(normalized_alias, group.confidence);
168            }
169        }
170    }
171
172    /// Manually register an alias mapping.
173    pub fn add_alias(&mut self, alias: &str, canonical: &str, confidence: f32) {
174        let alias_norm = normalize_entity(alias);
175        let canonical_norm = normalize_entity(canonical);
176        self.aliases.insert(alias_norm.clone(), canonical_norm);
177        self.confidence.insert(alias_norm, confidence);
178    }
179
180    /// Get the canonical name for an alias, if known.
181    pub fn get_canonical(&self, name: &str) -> Option<&String> {
182        let normalized = normalize_entity(name);
183        self.aliases.get(&normalized)
184    }
185
186    /// Returns all known canonical entity names.
187    pub fn known_entities(&self) -> Vec<String> {
188        let mut entities: Vec<String> = self.aliases.values().cloned().collect();
189        entities.sort();
190        entities.dedup();
191        entities
192    }
193
194    pub fn alias_count(&self) -> usize {
195        self.aliases.len()
196    }
197
198    /// Rule-based matching: check if the input is a substring of a known
199    /// canonical or vice versa. Handles "Alice" matching "Alice Smith".
200    fn rule_based_match(&self, normalized: &str) -> Option<(String, f32)> {
201        if normalized.len() < MIN_SUBSTRING_LEN {
202            return None;
203        }
204
205        let canonicals = self.known_entities();
206        for canonical in &canonicals {
207            if canonical == normalized {
208                continue;
209            }
210
211            // "alice" is a substring of "alice smith"
212            if canonical.contains(normalized) || normalized.contains(canonical.as_str()) {
213                return Some((canonical.clone(), SUBSTRING_CONFIDENCE));
214            }
215        }
216        None
217    }
218
219    /// Save the alias table to a JSON file.
220    pub fn save(&self, path: &Path) -> io::Result<()> {
221        let snapshot = Snapshot {
222            version: SNAPSHOT_VERSION,
223            aliases: self.aliases.clone(),
224            confidence: self.confidence.clone(),
225        };
226        let json = serde_json::to_string(&snapshot)
227            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
228        let tmp = path.with_extension("tmp");
229        std::fs::write(&tmp, json)?;
230        std::fs::rename(&tmp, path)
231    }
232
233    /// Load the alias table from a JSON file, merging with existing entries.
234    pub fn load(&mut self, path: &Path) -> io::Result<()> {
235        let json = std::fs::read_to_string(path)?;
236        let snapshot: Snapshot = serde_json::from_str(&json)
237            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
238
239        if snapshot.version > SNAPSHOT_VERSION {
240            return Err(io::Error::new(
241                io::ErrorKind::InvalidData,
242                format!(
243                    "unsupported entity snapshot version: {} (expected <= {})",
244                    snapshot.version, SNAPSHOT_VERSION
245                ),
246            ));
247        }
248
249        for (alias, canonical) in snapshot.aliases {
250            self.aliases.entry(alias).or_insert(canonical);
251        }
252        for (alias, conf) in snapshot.confidence {
253            self.confidence.entry(alias).or_insert(conf);
254        }
255        Ok(())
256    }
257}
258
259impl Default for EntityResolver {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265/// Normalize an entity reference: lowercase, collapse whitespace, trim.
266fn normalize_entity(raw: &str) -> String {
267    raw.split_whitespace()
268        .map(|w| w.to_lowercase())
269        .collect::<Vec<_>>()
270        .join(" ")
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::llm::{CognitiveLlmService, MockLlmJudge};
277
278    #[test]
279    fn test_normalize_entity() {
280        assert_eq!(normalize_entity("Alice Smith"), "alice smith");
281        assert_eq!(normalize_entity("  ALICE   SMITH  "), "alice smith");
282        assert_eq!(normalize_entity("alice"), "alice");
283    }
284
285    #[test]
286    fn test_add_alias_and_resolve() {
287        let mut resolver = EntityResolver::new();
288        resolver.add_alias("Alice", "Alice Smith", 0.95);
289        resolver.add_alias("my manager", "Alice Smith", 0.85);
290
291        let result = resolver.resolve("Alice");
292        assert_eq!(result.canonical, "alice smith");
293        assert_eq!(result.confidence, 0.95);
294        assert_eq!(result.source, ResolutionSource::Cache);
295
296        let result = resolver.resolve("MY MANAGER");
297        assert_eq!(result.canonical, "alice smith");
298        assert_eq!(result.source, ResolutionSource::Cache);
299    }
300
301    #[test]
302    fn test_unresolved_returns_identity() {
303        let resolver = EntityResolver::new();
304        let result = resolver.resolve("Bob");
305        assert_eq!(result.canonical, "bob");
306        assert_eq!(result.source, ResolutionSource::Identity);
307    }
308
309    #[test]
310    fn test_rule_based_substring_match() {
311        let mut resolver = EntityResolver::new();
312        resolver.add_alias("alice smith", "alice smith", 1.0);
313
314        // "alice" is a substring of "alice smith"
315        let result = resolver.resolve("Alice");
316        assert_eq!(result.canonical, "alice smith");
317        assert_eq!(result.source, ResolutionSource::RuleBased);
318        assert_eq!(result.confidence, SUBSTRING_CONFIDENCE);
319    }
320
321    #[test]
322    fn test_short_names_skip_substring_match() {
323        let mut resolver = EntityResolver::new();
324        resolver.add_alias("db", "database", 1.0);
325
326        // "db" is too short for substring matching (< 3 chars)
327        let result = resolver.resolve("db");
328        // Should hit cache directly since we added it as an alias
329        assert_eq!(result.canonical, "database");
330        assert_eq!(result.source, ResolutionSource::Cache);
331    }
332
333    #[test]
334    fn test_learn_group() {
335        let mut resolver = EntityResolver::new();
336        resolver.learn_group(&EntityMergeGroup {
337            canonical: "Alice Smith".to_string(),
338            aliases: vec!["Alice".to_string(), "my manager".to_string()],
339            confidence: 0.9,
340        });
341
342        assert_eq!(
343            resolver.get_canonical("alice"),
344            Some(&"alice smith".to_string())
345        );
346        assert_eq!(
347            resolver.get_canonical("my manager"),
348            Some(&"alice smith".to_string())
349        );
350        assert_eq!(
351            resolver.get_canonical("alice smith"),
352            Some(&"alice smith".to_string())
353        );
354    }
355
356    #[test]
357    fn test_known_entities() {
358        let mut resolver = EntityResolver::new();
359        resolver.learn_group(&EntityMergeGroup {
360            canonical: "Alice Smith".to_string(),
361            aliases: vec!["Alice".to_string()],
362            confidence: 0.9,
363        });
364        resolver.add_alias("postgres", "PostgreSQL", 1.0);
365
366        let entities = resolver.known_entities();
367        assert!(entities.contains(&"alice smith".to_string()));
368        assert!(entities.contains(&"postgresql".to_string()));
369    }
370
371    #[test]
372    fn test_persistence_roundtrip() {
373        let dir = tempfile::tempdir().unwrap();
374        let path = dir.path().join("entities.json");
375
376        let mut resolver = EntityResolver::new();
377        resolver.learn_group(&EntityMergeGroup {
378            canonical: "Alice Smith".to_string(),
379            aliases: vec!["Alice".to_string(), "my manager".to_string()],
380            confidence: 0.9,
381        });
382        resolver.save(&path).unwrap();
383
384        let mut loaded = EntityResolver::new();
385        loaded.load(&path).unwrap();
386
387        assert_eq!(
388            loaded.get_canonical("alice"),
389            Some(&"alice smith".to_string())
390        );
391        assert_eq!(
392            loaded.get_canonical("my manager"),
393            Some(&"alice smith".to_string())
394        );
395    }
396
397    #[test]
398    fn test_load_merges_existing() {
399        let dir = tempfile::tempdir().unwrap();
400        let path = dir.path().join("entities.json");
401
402        let mut resolver1 = EntityResolver::new();
403        resolver1.add_alias("alice", "alice smith", 0.9);
404        resolver1.save(&path).unwrap();
405
406        let mut resolver2 = EntityResolver::new();
407        resolver2.add_alias("bob", "bob jones", 0.8);
408        resolver2.load(&path).unwrap();
409
410        // Both aliases should exist
411        assert_eq!(
412            resolver2.get_canonical("alice"),
413            Some(&"alice smith".to_string())
414        );
415        assert_eq!(
416            resolver2.get_canonical("bob"),
417            Some(&"bob jones".to_string())
418        );
419    }
420
421    #[tokio::test]
422    async fn test_resolve_batch_with_llm() {
423        let judge = MockLlmJudge::new(
424            r#"{"groups": [{"canonical": "Alice Smith", "aliases": ["Alice", "my manager"], "confidence": 0.9}]}"#,
425        );
426        let llm = CognitiveLlmService::new(judge);
427        let mut resolver = EntityResolver::new();
428
429        let names = vec![
430            "Alice".to_string(),
431            "my manager".to_string(),
432            "Bob".to_string(),
433        ];
434        let contexts = vec![None, Some("Alice is my manager".to_string()), None];
435
436        let results = resolver
437            .resolve_batch_with_llm(&names, &contexts, &llm)
438            .await;
439
440        // Alice and my manager should resolve to alice smith via LLM
441        assert_eq!(results[0].canonical, "alice smith");
442        assert_eq!(results[0].source, ResolutionSource::Llm);
443        assert_eq!(results[1].canonical, "alice smith");
444        assert_eq!(results[1].source, ResolutionSource::Llm);
445
446        // Bob wasn't in the LLM response, stays identity
447        assert_eq!(results[2].canonical, "bob");
448        assert_eq!(results[2].source, ResolutionSource::Identity);
449
450        // Aliases should be cached for next time
451        assert_eq!(resolver.alias_count(), 3); // alice smith, alice, my manager
452    }
453
454    #[tokio::test]
455    async fn test_resolve_batch_skips_llm_when_cached() {
456        let judge = MockLlmJudge::new(r#"{"groups": []}"#);
457        let llm = CognitiveLlmService::new(judge);
458        let mut resolver = EntityResolver::new();
459
460        // Pre-teach the cache
461        resolver.add_alias("alice", "alice smith", 0.95);
462
463        let names = vec!["Alice".to_string()];
464        let contexts = vec![None];
465
466        let results = resolver
467            .resolve_batch_with_llm(&names, &contexts, &llm)
468            .await;
469
470        // Should come from cache, not LLM
471        assert_eq!(results[0].canonical, "alice smith");
472        assert_eq!(results[0].source, ResolutionSource::Cache);
473    }
474}