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
11const AMBIGUOUS_MATCH_CONFIDENCE: f32 = 0.5;
15
16#[derive(Debug, Clone)]
25pub struct EntityResolver {
26 aliases: HashMap<String, String>,
28 confidence: HashMap<String, f32>,
30 negative_pairs: HashSet<(String, String)>,
33 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#[derive(Debug, Clone, PartialEq)]
50pub struct ResolvedEntity {
51 pub canonical: String,
53 pub confidence: f32,
55 pub source: ResolutionSource,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum ResolutionSource {
61 Cache,
62 RuleBased,
63 Llm,
64 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 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 => {} 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 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 pub fn resolve(&self, name: &str) -> ResolvedEntity {
106 let normalized = normalize_entity(name);
107
108 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 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 ResolvedEntity {
128 canonical: normalized,
129 confidence: 1.0,
130 source: ResolutionSource::Identity,
131 }
132 }
133
134 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 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 unresolved_indices.is_empty() {
158 return results;
159 }
160
161 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 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 pub fn learn_group(&mut self, group: &EntityMergeGroup) {
196 let canonical = normalize_entity(&group.canonical);
197
198 self.link(canonical.clone(), canonical.clone());
200 self.confidence.insert(canonical.clone(), group.confidence);
201
202 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 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 pub fn get_canonical(&self, name: &str) -> Option<&String> {
222 let normalized = normalize_entity(name);
223 self.aliases.get(&normalized)
224 }
225
226 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 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 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 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 pub fn is_known_different(&self, a: &str, b: &str) -> bool {
289 self.negative_pairs.contains(&Self::negative_key(a, b))
290 }
291
292 pub fn mark_different(&mut self, a: &str, b: &str) {
294 self.negative_pairs.insert(Self::negative_key(a, b));
295 }
296
297 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 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 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 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 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
375fn 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(results[2].canonical, "bob");
680 assert_eq!(results[2].source, ResolutionSource::Identity);
681
682 assert_eq!(resolver.alias_count(), 3); }
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 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 assert_eq!(results[0].canonical, "alice smith");
704 assert_eq!(results[0].source, ResolutionSource::Cache);
705 }
706}