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#[derive(Debug, Clone)]
20pub struct EntityResolver {
21 aliases: HashMap<String, String>,
23 confidence: HashMap<String, f32>,
25 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#[derive(Debug, Clone, PartialEq)]
41pub struct ResolvedEntity {
42 pub canonical: String,
44 pub confidence: f32,
46 pub source: ResolutionSource,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum ResolutionSource {
52 Cache,
53 RuleBased,
54 Llm,
55 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 pub fn resolve(&self, name: &str) -> ResolvedEntity {
71 let normalized = normalize_entity(name);
72
73 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 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 ResolvedEntity {
93 canonical: normalized,
94 confidence: 1.0,
95 source: ResolutionSource::Identity,
96 }
97 }
98
99 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 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 unresolved_indices.is_empty() {
123 return results;
124 }
125
126 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 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 pub fn learn_group(&mut self, group: &EntityMergeGroup) {
161 let canonical = normalize_entity(&group.canonical);
162
163 self.aliases.insert(canonical.clone(), canonical.clone());
165 self.confidence.insert(canonical.clone(), group.confidence);
166
167 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 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 pub fn get_canonical(&self, name: &str) -> Option<&String> {
188 let normalized = normalize_entity(name);
189 self.aliases.get(&normalized)
190 }
191
192 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 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 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 pub fn is_known_different(&self, a: &str, b: &str) -> bool {
238 self.negative_pairs.contains(&Self::negative_key(a, b))
239 }
240
241 pub fn mark_different(&mut self, a: &str, b: &str) {
243 self.negative_pairs.insert(Self::negative_key(a, b));
244 }
245
246 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 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 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 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
320fn 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 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 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 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 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 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 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 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 assert_eq!(results[2].canonical, "bob");
572 assert_eq!(results[2].source, ResolutionSource::Identity);
573
574 assert_eq!(resolver.alias_count(), 3); }
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 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 assert_eq!(results[0].canonical, "alice smith");
596 assert_eq!(results[0].source, ResolutionSource::Cache);
597 }
598}