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