use crate::llm::{CognitiveLlmService, EntityCandidate, EntityMergeGroup, LlmJudge};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::Path;
const SNAPSHOT_VERSION: u32 = 2;
const WORD_MATCH_CONFIDENCE: f32 = 0.7;
const AMBIGUOUS_MATCH_CONFIDENCE: f32 = 0.5;
#[derive(Debug, Clone)]
pub struct EntityResolver {
aliases: HashMap<String, String>,
confidence: HashMap<String, f32>,
negative_pairs: HashSet<(String, String)>,
canonicals: HashMap<String, usize>,
}
#[derive(Serialize, Deserialize)]
struct Snapshot {
version: u32,
aliases: HashMap<String, String>,
confidence: HashMap<String, f32>,
#[serde(default)]
negative_pairs: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedEntity {
pub canonical: String,
pub confidence: f32,
pub source: ResolutionSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolutionSource {
Cache,
RuleBased,
Llm,
Identity,
}
impl EntityResolver {
pub fn new() -> Self {
Self {
aliases: HashMap::new(),
confidence: HashMap::new(),
negative_pairs: HashSet::new(),
canonicals: HashMap::new(),
}
}
fn link(&mut self, alias_norm: String, canonical_norm: String) {
match self.aliases.insert(alias_norm, canonical_norm.clone()) {
Some(prev) if prev == canonical_norm => {} Some(prev) => {
self.decr_canonical(&prev);
*self.canonicals.entry(canonical_norm).or_insert(0) += 1;
}
None => {
*self.canonicals.entry(canonical_norm).or_insert(0) += 1;
}
}
}
fn decr_canonical(&mut self, canonical: &str) {
if let Some(count) = self.canonicals.get_mut(canonical) {
*count -= 1;
if *count == 0 {
self.canonicals.remove(canonical);
}
}
}
pub fn resolve(&self, name: &str) -> ResolvedEntity {
let normalized = normalize_entity(name);
if let Some(canonical) = self.aliases.get(&normalized) {
return ResolvedEntity {
canonical: canonical.clone(),
confidence: self.confidence.get(&normalized).copied().unwrap_or(1.0),
source: ResolutionSource::Cache,
};
}
if let Some((canonical, conf)) = self.rule_based_match(&normalized) {
return ResolvedEntity {
canonical,
confidence: conf,
source: ResolutionSource::RuleBased,
};
}
ResolvedEntity {
canonical: normalized,
confidence: 1.0,
source: ResolutionSource::Identity,
}
}
pub async fn resolve_batch_with_llm<J: LlmJudge>(
&mut self,
names: &[String],
contexts: &[Option<String>],
llm: &CognitiveLlmService<J>,
) -> Vec<ResolvedEntity> {
let mut results = Vec::with_capacity(names.len());
let mut unresolved_indices = Vec::new();
for (i, name) in names.iter().enumerate() {
let resolved = self.resolve(name);
if resolved.source == ResolutionSource::Identity {
unresolved_indices.push(i);
}
results.push(resolved);
}
if unresolved_indices.is_empty() {
return results;
}
let candidates: Vec<EntityCandidate> = names
.iter()
.enumerate()
.map(|(i, name)| EntityCandidate {
name: name.clone(),
context: contexts.get(i).and_then(|c| c.clone()),
memory_id: None,
})
.collect();
if let Ok(groups) = llm.resolve_entities(&candidates).await {
for group in &groups {
self.learn_group(group);
}
for &i in &unresolved_indices {
let re_resolved = self.resolve(&names[i]);
if re_resolved.source == ResolutionSource::Cache {
results[i] = ResolvedEntity {
canonical: re_resolved.canonical,
confidence: re_resolved.confidence,
source: ResolutionSource::Llm,
};
}
}
}
results
}
pub fn learn_group(&mut self, group: &EntityMergeGroup) {
let canonical = normalize_entity(&group.canonical);
self.link(canonical.clone(), canonical.clone());
self.confidence.insert(canonical.clone(), group.confidence);
for alias in &group.aliases {
let normalized_alias = normalize_entity(alias);
if normalized_alias != canonical {
self.link(normalized_alias.clone(), canonical.clone());
self.confidence.insert(normalized_alias, group.confidence);
}
}
}
pub fn add_alias(&mut self, alias: &str, canonical: &str, confidence: f32) {
let alias_norm = normalize_entity(alias);
let canonical_norm = normalize_entity(canonical);
self.link(alias_norm.clone(), canonical_norm);
self.confidence.insert(alias_norm, confidence);
}
pub fn get_canonical(&self, name: &str) -> Option<&String> {
let normalized = normalize_entity(name);
self.aliases.get(&normalized)
}
pub fn known_entities(&self) -> Vec<String> {
let mut entities: Vec<String> = self.canonicals.keys().cloned().collect();
entities.sort();
entities
}
pub fn alias_count(&self) -> usize {
self.aliases.len()
}
fn rule_based_match(&self, normalized: &str) -> Option<(String, f32)> {
let input_words: HashSet<&str> = normalized
.split(|c: char| c.is_whitespace() || c == '-' || c == '_')
.filter(|w| !w.is_empty())
.collect();
if input_words.is_empty() {
return None;
}
let mut matches: Vec<&String> = Vec::new();
for canonical in self.canonicals.keys() {
if canonical == normalized {
continue;
}
let canon_words: HashSet<&str> = canonical
.split(|c: char| c.is_whitespace() || c == '-' || c == '_')
.filter(|w| !w.is_empty())
.collect();
if input_words.is_subset(&canon_words) || canon_words.is_subset(&input_words) {
matches.push(canonical);
}
}
match matches.len() {
0 => None,
1 => Some((matches[0].clone(), WORD_MATCH_CONFIDENCE)),
_ => {
matches.sort_by(|a, b| {
let ra = self.canonicals.get(*a).copied().unwrap_or(0);
let rb = self.canonicals.get(*b).copied().unwrap_or(0);
rb.cmp(&ra)
.then_with(|| a.len().cmp(&b.len()))
.then_with(|| a.as_str().cmp(b.as_str()))
});
Some((matches[0].clone(), AMBIGUOUS_MATCH_CONFIDENCE))
}
}
}
pub fn is_known_different(&self, a: &str, b: &str) -> bool {
self.negative_pairs.contains(&Self::negative_key(a, b))
}
pub fn mark_different(&mut self, a: &str, b: &str) {
self.negative_pairs.insert(Self::negative_key(a, b));
}
pub fn unresolved_names(&self, names: &[String]) -> Vec<String> {
names
.iter()
.filter(|name| {
let resolved = self.resolve(name);
resolved.source == ResolutionSource::Identity
})
.cloned()
.collect()
}
pub fn negative_count(&self) -> usize {
self.negative_pairs.len()
}
fn negative_key(a: &str, b: &str) -> (String, String) {
let na = normalize_entity(a);
let nb = normalize_entity(b);
if na <= nb { (na, nb) } else { (nb, na) }
}
pub fn save(&self, path: &Path) -> io::Result<()> {
let snapshot = Snapshot {
version: SNAPSHOT_VERSION,
aliases: self.aliases.clone(),
confidence: self.confidence.clone(),
negative_pairs: self.negative_pairs.iter().cloned().collect(),
};
let json = serde_json::to_string(&snapshot)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)
}
pub fn load(&mut self, path: &Path) -> io::Result<()> {
let json = std::fs::read_to_string(path)?;
let snapshot: Snapshot = serde_json::from_str(&json)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
if snapshot.version > SNAPSHOT_VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"unsupported entity snapshot version: {} (expected <= {})",
snapshot.version, SNAPSHOT_VERSION
),
));
}
for (alias, canonical) in snapshot.aliases {
if !self.aliases.contains_key(&alias) {
self.link(alias, canonical);
}
}
for (alias, conf) in snapshot.confidence {
self.confidence.entry(alias).or_insert(conf);
}
for pair in snapshot.negative_pairs {
self.negative_pairs.insert(pair);
}
Ok(())
}
}
impl Default for EntityResolver {
fn default() -> Self {
Self::new()
}
}
fn normalize_entity(raw: &str) -> String {
raw.split_whitespace()
.map(|w| w.to_lowercase())
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::{CognitiveLlmService, MockLlmJudge};
#[test]
fn test_normalize_entity() {
assert_eq!(normalize_entity("Alice Smith"), "alice smith");
assert_eq!(normalize_entity(" ALICE SMITH "), "alice smith");
assert_eq!(normalize_entity("alice"), "alice");
}
#[test]
fn test_add_alias_and_resolve() {
let mut resolver = EntityResolver::new();
resolver.add_alias("Alice", "Alice Smith", 0.95);
resolver.add_alias("my manager", "Alice Smith", 0.85);
let result = resolver.resolve("Alice");
assert_eq!(result.canonical, "alice smith");
assert_eq!(result.confidence, 0.95);
assert_eq!(result.source, ResolutionSource::Cache);
let result = resolver.resolve("MY MANAGER");
assert_eq!(result.canonical, "alice smith");
assert_eq!(result.source, ResolutionSource::Cache);
}
#[test]
fn test_unresolved_returns_identity() {
let resolver = EntityResolver::new();
let result = resolver.resolve("Bob");
assert_eq!(result.canonical, "bob");
assert_eq!(result.source, ResolutionSource::Identity);
}
#[test]
fn test_rule_based_word_match() {
let mut resolver = EntityResolver::new();
resolver.add_alias("alice smith", "alice smith", 1.0);
let result = resolver.resolve("Alice");
assert_eq!(result.canonical, "alice smith");
assert_eq!(result.source, ResolutionSource::RuleBased);
assert_eq!(result.confidence, WORD_MATCH_CONFIDENCE);
}
#[test]
fn test_word_match_rejects_java_javascript() {
let mut resolver = EntityResolver::new();
resolver.add_alias("javascript", "javascript", 1.0);
let result = resolver.resolve("Java");
assert_eq!(result.canonical, "java");
assert_eq!(result.source, ResolutionSource::Identity);
}
#[test]
fn test_hyphenated_names_match() {
let mut resolver = EntityResolver::new();
resolver.add_alias("gpt-4", "gpt-4", 1.0);
let result = resolver.resolve("gpt 4");
assert_eq!(result.canonical, "gpt-4");
assert_eq!(result.source, ResolutionSource::RuleBased);
}
#[test]
fn test_short_names_skip_word_match() {
let mut resolver = EntityResolver::new();
resolver.add_alias("db", "database", 1.0);
let result = resolver.resolve("db");
assert_eq!(result.canonical, "database");
assert_eq!(result.source, ResolutionSource::Cache);
}
#[test]
fn test_negative_cache() {
let mut resolver = EntityResolver::new();
assert!(!resolver.is_known_different("Python", "python snake"));
resolver.mark_different("Python", "python snake");
assert!(resolver.is_known_different("Python", "python snake"));
assert!(resolver.is_known_different("python snake", "Python"));
}
#[test]
fn test_negative_cache_persistence() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("entities.json");
let mut resolver = EntityResolver::new();
resolver.mark_different("Python", "Monty Python");
resolver.add_alias("alice", "alice smith", 0.9);
resolver.save(&path).unwrap();
let mut loaded = EntityResolver::new();
loaded.load(&path).unwrap();
assert!(loaded.is_known_different("Python", "Monty Python"));
assert_eq!(
loaded.get_canonical("alice"),
Some(&"alice smith".to_string())
);
}
#[test]
fn test_unresolved_names() {
let mut resolver = EntityResolver::new();
resolver.add_alias("alice", "alice smith", 0.9);
resolver.add_alias("bob", "bob jones", 0.9);
let names = vec![
"Alice".to_string(),
"Bob".to_string(),
"Charlie".to_string(),
"NYC".to_string(),
];
let unresolved = resolver.unresolved_names(&names);
assert_eq!(unresolved, vec!["Charlie".to_string(), "NYC".to_string()]);
}
#[test]
fn test_learn_group() {
let mut resolver = EntityResolver::new();
resolver.learn_group(&EntityMergeGroup {
canonical: "Alice Smith".to_string(),
aliases: vec!["Alice".to_string(), "my manager".to_string()],
confidence: 0.9,
});
assert_eq!(
resolver.get_canonical("alice"),
Some(&"alice smith".to_string())
);
assert_eq!(
resolver.get_canonical("my manager"),
Some(&"alice smith".to_string())
);
assert_eq!(
resolver.get_canonical("alice smith"),
Some(&"alice smith".to_string())
);
}
#[test]
fn test_known_entities() {
let mut resolver = EntityResolver::new();
resolver.learn_group(&EntityMergeGroup {
canonical: "Alice Smith".to_string(),
aliases: vec!["Alice".to_string()],
confidence: 0.9,
});
resolver.add_alias("postgres", "PostgreSQL", 1.0);
let entities = resolver.known_entities();
assert!(entities.contains(&"alice smith".to_string()));
assert!(entities.contains(&"postgresql".to_string()));
}
#[test]
fn test_canonical_set_tracks_overwrite() {
let mut resolver = EntityResolver::new();
resolver.add_alias("nick", "alice smith", 0.9);
assert!(
resolver
.known_entities()
.contains(&"alice smith".to_string())
);
resolver.add_alias("nick", "bob jones", 0.9);
let known = resolver.known_entities();
assert!(known.contains(&"bob jones".to_string()));
assert!(
!known.contains(&"alice smith".to_string()),
"orphaned canonical should be dropped once nothing maps to it"
);
resolver.add_alias("ally", "carol lee", 0.9);
resolver.add_alias("caz", "carol lee", 0.9);
resolver.add_alias("ally", "someone else", 0.9);
assert!(resolver.known_entities().contains(&"carol lee".to_string()));
}
#[test]
fn test_ambiguous_match_returns_lower_confidence() {
let mut resolver = EntityResolver::new();
resolver.add_alias("alice smith", "alice smith", 1.0);
resolver.add_alias("alice jones", "alice jones", 1.0);
let result = resolver.resolve("Alice");
assert_eq!(result.source, ResolutionSource::RuleBased);
assert_eq!(result.confidence, AMBIGUOUS_MATCH_CONFIDENCE);
assert_eq!(result.canonical, "alice jones");
assert_eq!(resolver.resolve("alice").canonical, "alice jones");
}
#[test]
fn test_single_match_keeps_full_confidence() {
let mut resolver = EntityResolver::new();
resolver.add_alias("alice smith", "alice smith", 1.0);
let result = resolver.resolve("Alice");
assert_eq!(result.confidence, WORD_MATCH_CONFIDENCE);
assert_eq!(result.canonical, "alice smith");
}
#[test]
fn test_persistence_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("entities.json");
let mut resolver = EntityResolver::new();
resolver.learn_group(&EntityMergeGroup {
canonical: "Alice Smith".to_string(),
aliases: vec!["Alice".to_string(), "my manager".to_string()],
confidence: 0.9,
});
resolver.save(&path).unwrap();
let mut loaded = EntityResolver::new();
loaded.load(&path).unwrap();
assert_eq!(
loaded.get_canonical("alice"),
Some(&"alice smith".to_string())
);
assert_eq!(
loaded.get_canonical("my manager"),
Some(&"alice smith".to_string())
);
}
#[test]
fn test_load_merges_existing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("entities.json");
let mut resolver1 = EntityResolver::new();
resolver1.add_alias("alice", "alice smith", 0.9);
resolver1.save(&path).unwrap();
let mut resolver2 = EntityResolver::new();
resolver2.add_alias("bob", "bob jones", 0.8);
resolver2.load(&path).unwrap();
assert_eq!(
resolver2.get_canonical("alice"),
Some(&"alice smith".to_string())
);
assert_eq!(
resolver2.get_canonical("bob"),
Some(&"bob jones".to_string())
);
}
#[tokio::test]
async fn test_resolve_batch_with_llm() {
let judge = MockLlmJudge::new(
r#"{"groups": [{"canonical": "Alice Smith", "aliases": ["Alice", "my manager"], "confidence": 0.9}]}"#,
);
let llm = CognitiveLlmService::new(judge);
let mut resolver = EntityResolver::new();
let names = vec![
"Alice".to_string(),
"my manager".to_string(),
"Bob".to_string(),
];
let contexts = vec![None, Some("Alice is my manager".to_string()), None];
let results = resolver
.resolve_batch_with_llm(&names, &contexts, &llm)
.await;
assert_eq!(results[0].canonical, "alice smith");
assert_eq!(results[0].source, ResolutionSource::Llm);
assert_eq!(results[1].canonical, "alice smith");
assert_eq!(results[1].source, ResolutionSource::Llm);
assert_eq!(results[2].canonical, "bob");
assert_eq!(results[2].source, ResolutionSource::Identity);
assert_eq!(resolver.alias_count(), 3); }
#[tokio::test]
async fn test_resolve_batch_skips_llm_when_cached() {
let judge = MockLlmJudge::new(r#"{"groups": []}"#);
let llm = CognitiveLlmService::new(judge);
let mut resolver = EntityResolver::new();
resolver.add_alias("alice", "alice smith", 0.95);
let names = vec!["Alice".to_string()];
let contexts = vec![None];
let results = resolver
.resolve_batch_with_llm(&names, &contexts, &llm)
.await;
assert_eq!(results[0].canonical, "alice smith");
assert_eq!(results[0].source, ResolutionSource::Cache);
}
}