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#[derive(Debug, Clone)]
20pub struct EntityResolver {
21 aliases: HashMap<String, String>,
23 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#[derive(Debug, Clone, PartialEq)]
36pub struct ResolvedEntity {
37 pub canonical: String,
39 pub confidence: f32,
41 pub source: ResolutionSource,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum ResolutionSource {
47 Cache,
48 RuleBased,
49 Llm,
50 Identity,
52}
53
54impl EntityResolver {
55 pub fn new() -> Self {
56 Self {
57 aliases: HashMap::new(),
58 confidence: HashMap::new(),
59 }
60 }
61
62 pub fn resolve(&self, name: &str) -> ResolvedEntity {
65 let normalized = normalize_entity(name);
66
67 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 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 ResolvedEntity {
87 canonical: normalized,
88 confidence: 1.0,
89 source: ResolutionSource::Identity,
90 }
91 }
92
93 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 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 unresolved_indices.is_empty() {
117 return results;
118 }
119
120 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 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 pub fn learn_group(&mut self, group: &EntityMergeGroup) {
155 let canonical = normalize_entity(&group.canonical);
156
157 self.aliases.insert(canonical.clone(), canonical.clone());
159 self.confidence.insert(canonical.clone(), group.confidence);
160
161 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 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 pub fn get_canonical(&self, name: &str) -> Option<&String> {
182 let normalized = normalize_entity(name);
183 self.aliases.get(&normalized)
184 }
185
186 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 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 if canonical.contains(normalized) || normalized.contains(canonical.as_str()) {
213 return Some((canonical.clone(), SUBSTRING_CONFIDENCE));
214 }
215 }
216 None
217 }
218
219 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 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
265fn 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 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 let result = resolver.resolve("db");
328 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 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 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 assert_eq!(results[2].canonical, "bob");
448 assert_eq!(results[2].source, ResolutionSource::Identity);
449
450 assert_eq!(resolver.alias_count(), 3); }
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 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 assert_eq!(results[0].canonical, "alice smith");
472 assert_eq!(results[0].source, ResolutionSource::Cache);
473 }
474}