Skip to main content

ipfrs_semantic/
entity_resolution.rs

1//! Entity Disambiguation and Resolution
2//!
3//! Links entity mentions to canonical entities via exact match, alias lookup,
4//! fuzzy string similarity, and embedding cosine similarity — in that priority order.
5
6use std::collections::HashMap;
7
8/// Broad semantic category for a canonical entity.
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10pub enum EntityType {
11    Person,
12    Organization,
13    Location,
14    Product,
15    Concept,
16    Other(String),
17}
18
19/// A canonical entity stored in the resolver's registry.
20#[derive(Debug, Clone)]
21pub struct CanonicalEntity {
22    /// Stable unique identifier.
23    pub entity_id: String,
24    /// The primary, canonical surface form.
25    pub canonical_name: String,
26    /// Semantic category.
27    pub entity_type: EntityType,
28    /// Alternative surface forms (aliases, abbreviations, etc.).
29    pub aliases: Vec<String>,
30    /// Optional prototype embedding vector (f64 to match workspace conventions).
31    pub embedding: Option<Vec<f64>>,
32    /// Confidence in the entity's representation quality; [0.0, 1.0].
33    pub confidence: f64,
34}
35
36/// A mention of an entity as it appears in source text.
37#[derive(Debug, Clone)]
38pub struct EntityMention {
39    /// The raw mention text (surface form).
40    pub text: String,
41    /// Byte offset of the first character of the mention in the source.
42    pub start: usize,
43    /// Byte offset one past the last character of the mention.
44    pub end: usize,
45    /// Surrounding text providing additional disambiguation signal.
46    pub context: String,
47}
48
49/// How a mention was resolved to a canonical entity.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum ResolutionMethod {
52    /// Normalized mention text equals normalized canonical name.
53    ExactMatch,
54    /// Normalized mention text equals a normalized alias.
55    AliasMatch,
56    /// Edit-distance similarity meets the configured fuzzy threshold.
57    FuzzyMatch,
58    /// Embedding cosine similarity meets the configured threshold.
59    EmbeddingMatch,
60    /// No entity could be linked.
61    Unresolved,
62}
63
64/// The result of resolving a single [`EntityMention`].
65#[derive(Debug, Clone)]
66pub struct ResolutionResult {
67    /// The mention that was resolved.
68    pub mention: EntityMention,
69    /// Resolved entity ID, if any.
70    pub entity_id: Option<String>,
71    /// Resolved canonical name, if any.
72    pub canonical_name: Option<String>,
73    /// Confidence score in [0.0, 1.0].
74    pub confidence: f64,
75    /// Method used to resolve (or fail to resolve) this mention.
76    pub method: ResolutionMethod,
77}
78
79/// Tunable parameters for [`EntityResolver`].
80#[derive(Debug, Clone)]
81pub struct ResolverConfig {
82    /// Minimum string-similarity score (0.0–1.0) for a fuzzy match to be accepted.
83    pub fuzzy_threshold: f64,
84    /// Minimum cosine similarity (0.0–1.0) for an embedding match to be accepted.
85    pub embedding_threshold: f64,
86    /// Maximum number of candidate entities examined per mention.
87    pub max_candidates: usize,
88    /// When `false` (default), all comparisons are case-insensitive.
89    pub case_sensitive: bool,
90}
91
92impl Default for ResolverConfig {
93    fn default() -> Self {
94        Self {
95            fuzzy_threshold: 0.8,
96            embedding_threshold: 0.85,
97            max_candidates: 10,
98            case_sensitive: false,
99        }
100    }
101}
102
103/// Running counters updated by [`EntityResolver`] during resolution.
104#[derive(Debug, Clone, Default)]
105pub struct ResolverStats {
106    pub total_resolved: u64,
107    pub exact_matches: u64,
108    pub alias_matches: u64,
109    pub fuzzy_matches: u64,
110    pub embedding_matches: u64,
111    pub unresolved: u64,
112}
113
114/// Disambiguates and resolves entity mentions to canonical entities.
115///
116/// Resolution priority (highest to lowest):
117/// 1. Exact match on normalized canonical name.
118/// 2. Alias match on any normalized alias.
119/// 3. Fuzzy (edit-distance) match if similarity ≥ `fuzzy_threshold`.
120/// 4. Embedding cosine similarity match if score ≥ `embedding_threshold`.
121/// 5. Unresolved.
122pub struct EntityResolver {
123    config: ResolverConfig,
124    /// Primary store: entity_id → entity.
125    entities: HashMap<String, CanonicalEntity>,
126    /// Inverted index: normalized surface form / alias → entity_id.
127    alias_index: HashMap<String, String>,
128    stats: ResolverStats,
129}
130
131impl EntityResolver {
132    /// Create a new resolver with the given configuration.
133    pub fn new(config: ResolverConfig) -> Self {
134        Self {
135            config,
136            entities: HashMap::new(),
137            alias_index: HashMap::new(),
138            stats: ResolverStats::default(),
139        }
140    }
141
142    /// Register a canonical entity.  Returns `true` if the entity was inserted,
143    /// `false` if an entity with the same `entity_id` already exists (no update).
144    pub fn register_entity(&mut self, entity: CanonicalEntity) -> bool {
145        if self.entities.contains_key(&entity.entity_id) {
146            return false;
147        }
148
149        // Index canonical name.
150        let norm_canonical = Self::normalize(&entity.canonical_name);
151        self.alias_index
152            .entry(norm_canonical)
153            .or_insert_with(|| entity.entity_id.clone());
154
155        // Index each alias.
156        for alias in &entity.aliases {
157            let norm_alias = Self::normalize(alias);
158            self.alias_index
159                .entry(norm_alias)
160                .or_insert_with(|| entity.entity_id.clone());
161        }
162
163        self.entities.insert(entity.entity_id.clone(), entity);
164        true
165    }
166
167    /// Resolve a single mention to a canonical entity.
168    pub fn resolve(&mut self, mention: EntityMention) -> ResolutionResult {
169        let norm_mention = if self.config.case_sensitive {
170            mention.text.trim().to_string()
171        } else {
172            Self::normalize(&mention.text)
173        };
174
175        // 1. Exact / alias match via inverted index (O(1)).
176        if let Some(entity_id) = self.alias_index.get(&norm_mention) {
177            if let Some(entity) = self.entities.get(entity_id) {
178                let norm_canonical = Self::normalize(&entity.canonical_name);
179                let method = if norm_mention == norm_canonical {
180                    self.stats.exact_matches += 1;
181                    ResolutionMethod::ExactMatch
182                } else {
183                    self.stats.alias_matches += 1;
184                    ResolutionMethod::AliasMatch
185                };
186                self.stats.total_resolved += 1;
187                return ResolutionResult {
188                    mention,
189                    entity_id: Some(entity_id.clone()),
190                    canonical_name: Some(entity.canonical_name.clone()),
191                    confidence: entity.confidence,
192                    method,
193                };
194            }
195        }
196
197        // 2. Fuzzy scan over top-N candidates.
198        // Collect owned data up-front so we don't hold borrows into `self`
199        // when we later mutate `self.stats`.
200        let max_candidates = self.config.max_candidates;
201        let fuzzy_threshold = self.config.fuzzy_threshold;
202
203        // Each entry: (entity_id, canonical_name, best_sim, entity_confidence)
204        let candidate_data: Vec<(String, String, f64, f64)> = {
205            let candidates = self.find_candidates(&norm_mention, max_candidates);
206            candidates
207                .iter()
208                .map(|e| {
209                    let norm_cand = Self::normalize(&e.canonical_name);
210                    let sim = Self::string_similarity(&norm_mention, &norm_cand);
211                    (
212                        e.entity_id.clone(),
213                        e.canonical_name.clone(),
214                        sim,
215                        e.confidence,
216                    )
217                })
218                .collect()
219        };
220
221        // --- Fuzzy pass ---
222        let best_fuzzy = candidate_data
223            .iter()
224            .filter(|(_, _, sim, _)| *sim >= fuzzy_threshold)
225            .max_by(|(_, _, sa, _), (_, _, sb, _)| {
226                sa.partial_cmp(sb).unwrap_or(std::cmp::Ordering::Equal)
227            });
228
229        if let Some((entity_id, canonical_name, sim, conf)) = best_fuzzy {
230            self.stats.fuzzy_matches += 1;
231            self.stats.total_resolved += 1;
232            return ResolutionResult {
233                mention,
234                entity_id: Some(entity_id.clone()),
235                canonical_name: Some(canonical_name.clone()),
236                confidence: sim * conf,
237                method: ResolutionMethod::FuzzyMatch,
238            };
239        }
240
241        // --- Embedding pass (no mention embedding available in `resolve`) ---
242        // Callers with a query embedding should use `resolve_with_embedding`.
243        // Here we simply fall through to Unresolved.
244
245        // Unresolved.
246        self.stats.unresolved += 1;
247        ResolutionResult {
248            mention,
249            entity_id: None,
250            canonical_name: None,
251            confidence: 0.0,
252            method: ResolutionMethod::Unresolved,
253        }
254    }
255
256    /// Resolve a mention when the caller also supplies a query embedding.
257    ///
258    /// Falls through the same priority chain as `resolve` but adds an
259    /// embedding-cosine step before returning Unresolved.
260    pub fn resolve_with_embedding(
261        &mut self,
262        mention: EntityMention,
263        query_embedding: &[f64],
264    ) -> ResolutionResult {
265        let norm_mention = if self.config.case_sensitive {
266            mention.text.trim().to_string()
267        } else {
268            Self::normalize(&mention.text)
269        };
270
271        // 1. Exact / alias via index.
272        if let Some(entity_id) = self.alias_index.get(&norm_mention).cloned() {
273            if let Some(entity) = self.entities.get(&entity_id) {
274                let norm_canonical = Self::normalize(&entity.canonical_name);
275                let method = if norm_mention == norm_canonical {
276                    self.stats.exact_matches += 1;
277                    ResolutionMethod::ExactMatch
278                } else {
279                    self.stats.alias_matches += 1;
280                    ResolutionMethod::AliasMatch
281                };
282                self.stats.total_resolved += 1;
283                return ResolutionResult {
284                    mention,
285                    entity_id: Some(entity_id),
286                    canonical_name: Some(entity.canonical_name.clone()),
287                    confidence: entity.confidence,
288                    method,
289                };
290            }
291        }
292
293        let candidates = self.find_candidates(&norm_mention, self.config.max_candidates);
294
295        // 2. Fuzzy.
296        let fuzzy_threshold = self.config.fuzzy_threshold;
297        let mut best_fuzzy: Option<(String, String, f64, f64)> = None; // (id, name, sim, conf)
298        for candidate in &candidates {
299            let norm_cand = Self::normalize(&candidate.canonical_name);
300            let sim = Self::string_similarity(&norm_mention, &norm_cand);
301            if sim >= fuzzy_threshold {
302                let better = best_fuzzy
303                    .as_ref()
304                    .is_none_or(|(_, _, prev, _)| sim > *prev);
305                if better {
306                    best_fuzzy = Some((
307                        candidate.entity_id.clone(),
308                        candidate.canonical_name.clone(),
309                        sim,
310                        candidate.confidence,
311                    ));
312                }
313            }
314        }
315        if let Some((entity_id, canonical_name, sim, conf)) = best_fuzzy {
316            self.stats.fuzzy_matches += 1;
317            self.stats.total_resolved += 1;
318            return ResolutionResult {
319                mention,
320                entity_id: Some(entity_id),
321                canonical_name: Some(canonical_name),
322                confidence: sim * conf,
323                method: ResolutionMethod::FuzzyMatch,
324            };
325        }
326
327        // 3. Embedding cosine similarity.
328        let embedding_threshold = self.config.embedding_threshold;
329        let mut best_emb: Option<(String, String, f64, f64)> = None;
330        for candidate in &candidates {
331            if let Some(emb) = &candidate.embedding {
332                let sim = Self::cosine_similarity(query_embedding, emb);
333                if sim >= embedding_threshold {
334                    let better = best_emb.as_ref().is_none_or(|(_, _, prev, _)| sim > *prev);
335                    if better {
336                        best_emb = Some((
337                            candidate.entity_id.clone(),
338                            candidate.canonical_name.clone(),
339                            sim,
340                            candidate.confidence,
341                        ));
342                    }
343                }
344            }
345        }
346        if let Some((entity_id, canonical_name, sim, conf)) = best_emb {
347            self.stats.embedding_matches += 1;
348            self.stats.total_resolved += 1;
349            return ResolutionResult {
350                mention,
351                entity_id: Some(entity_id),
352                canonical_name: Some(canonical_name),
353                confidence: sim * conf,
354                method: ResolutionMethod::EmbeddingMatch,
355            };
356        }
357
358        // Unresolved.
359        self.stats.unresolved += 1;
360        ResolutionResult {
361            mention,
362            entity_id: None,
363            canonical_name: None,
364            confidence: 0.0,
365            method: ResolutionMethod::Unresolved,
366        }
367    }
368
369    /// Resolve a batch of mentions, returning one [`ResolutionResult`] per mention.
370    pub fn resolve_batch(&mut self, mentions: Vec<EntityMention>) -> Vec<ResolutionResult> {
371        mentions.into_iter().map(|m| self.resolve(m)).collect()
372    }
373
374    /// Normalize a string: trim, lowercase, collapse interior whitespace runs.
375    pub fn normalize(text: &str) -> String {
376        text.trim()
377            .to_lowercase()
378            .split_whitespace()
379            .collect::<Vec<_>>()
380            .join(" ")
381    }
382
383    /// Classic Levenshtein edit distance between two strings.
384    pub fn edit_distance(a: &str, b: &str) -> usize {
385        let a_chars: Vec<char> = a.chars().collect();
386        let b_chars: Vec<char> = b.chars().collect();
387        let m = a_chars.len();
388        let n = b_chars.len();
389
390        if m == 0 {
391            return n;
392        }
393        if n == 0 {
394            return m;
395        }
396
397        // Use two rows to keep memory O(n).
398        let mut prev: Vec<usize> = (0..=n).collect();
399        let mut curr = vec![0usize; n + 1];
400
401        for i in 1..=m {
402            curr[0] = i;
403            for j in 1..=n {
404                let cost = if a_chars[i - 1] == b_chars[j - 1] {
405                    0
406                } else {
407                    1
408                };
409                curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
410            }
411            std::mem::swap(&mut prev, &mut curr);
412        }
413
414        prev[n]
415    }
416
417    /// String similarity in [0.0, 1.0]: `1.0 - edit_distance / max_len`.
418    /// Returns 1.0 if both strings are empty.
419    pub fn string_similarity(a: &str, b: &str) -> f64 {
420        let max_len = a.chars().count().max(b.chars().count());
421        if max_len == 0 {
422            return 1.0;
423        }
424        let dist = Self::edit_distance(a, b);
425        1.0 - (dist as f64 / max_len as f64)
426    }
427
428    /// Cosine similarity between two vectors; returns 0.0 for zero-norm inputs.
429    pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
430        if a.is_empty() || b.is_empty() || a.len() != b.len() {
431            return 0.0;
432        }
433        let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
434        let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
435        let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
436        if norm_a == 0.0 || norm_b == 0.0 {
437            return 0.0;
438        }
439        (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
440    }
441
442    /// Return up to `n` candidate entities ranked by string similarity to `mention`.
443    ///
444    /// This is an O(|entities|) scan.  For very large registries a dedicated
445    /// inverted-index or BK-tree would be more efficient, but correctness is
446    /// the priority here.
447    pub fn find_candidates<'a>(&'a self, mention: &str, n: usize) -> Vec<&'a CanonicalEntity> {
448        if n == 0 {
449            return Vec::new();
450        }
451
452        let norm_mention = Self::normalize(mention);
453        let mut scored: Vec<(f64, &CanonicalEntity)> = self
454            .entities
455            .values()
456            .map(|e| {
457                let norm_name = Self::normalize(&e.canonical_name);
458                let name_sim = Self::string_similarity(&norm_mention, &norm_name);
459                // Also score against each alias, take the best.
460                let alias_sim = e
461                    .aliases
462                    .iter()
463                    .map(|a| Self::string_similarity(&norm_mention, &Self::normalize(a)))
464                    .fold(0.0_f64, f64::max);
465                let best_sim = name_sim.max(alias_sim);
466                (best_sim, e)
467            })
468            .collect();
469
470        // Sort descending by similarity, then alphabetically for determinism.
471        scored.sort_by(|(sa, ea), (sb, eb)| {
472            sb.partial_cmp(sa)
473                .unwrap_or(std::cmp::Ordering::Equal)
474                .then_with(|| ea.entity_id.cmp(&eb.entity_id))
475        });
476
477        scored.into_iter().take(n).map(|(_, e)| e).collect()
478    }
479
480    /// Number of registered canonical entities.
481    pub fn entity_count(&self) -> usize {
482        self.entities.len()
483    }
484
485    /// Look up a canonical entity by its stable ID.
486    pub fn get_entity(&self, entity_id: &str) -> Option<&CanonicalEntity> {
487        self.entities.get(entity_id)
488    }
489
490    /// Current resolution statistics.
491    pub fn stats(&self) -> &ResolverStats {
492        &self.stats
493    }
494}
495
496// ─────────────────────────────────────────────────────────────────────────────
497// Tests
498// ─────────────────────────────────────────────────────────────────────────────
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    // ── Helpers ───────────────────────────────────────────────────────────────
505
506    fn make_entity(
507        id: &str,
508        name: &str,
509        ty: EntityType,
510        aliases: Vec<&str>,
511        embedding: Option<Vec<f64>>,
512    ) -> CanonicalEntity {
513        CanonicalEntity {
514            entity_id: id.to_string(),
515            canonical_name: name.to_string(),
516            entity_type: ty,
517            aliases: aliases.into_iter().map(|s| s.to_string()).collect(),
518            embedding,
519            confidence: 1.0,
520        }
521    }
522
523    fn mention(text: &str) -> EntityMention {
524        EntityMention {
525            text: text.to_string(),
526            start: 0,
527            end: text.len(),
528            context: String::new(),
529        }
530    }
531
532    fn default_resolver() -> EntityResolver {
533        EntityResolver::new(ResolverConfig::default())
534    }
535
536    // ── edit_distance ─────────────────────────────────────────────────────────
537
538    #[test]
539    fn test_edit_distance_identical() {
540        assert_eq!(EntityResolver::edit_distance("hello", "hello"), 0);
541    }
542
543    #[test]
544    fn test_edit_distance_empty_left() {
545        assert_eq!(EntityResolver::edit_distance("", "abc"), 3);
546    }
547
548    #[test]
549    fn test_edit_distance_empty_right() {
550        assert_eq!(EntityResolver::edit_distance("abc", ""), 3);
551    }
552
553    #[test]
554    fn test_edit_distance_both_empty() {
555        assert_eq!(EntityResolver::edit_distance("", ""), 0);
556    }
557
558    #[test]
559    fn test_edit_distance_kitten_sitting() {
560        // Classic example: kitten → sitting = 3
561        assert_eq!(EntityResolver::edit_distance("kitten", "sitting"), 3);
562    }
563
564    #[test]
565    fn test_edit_distance_one_insertion() {
566        assert_eq!(EntityResolver::edit_distance("cat", "cats"), 1);
567    }
568
569    // ── string_similarity ────────────────────────────────────────────────────
570
571    #[test]
572    fn test_string_similarity_identical() {
573        let s = EntityResolver::string_similarity("apple", "apple");
574        assert!((s - 1.0).abs() < 1e-10);
575    }
576
577    #[test]
578    fn test_string_similarity_both_empty() {
579        let s = EntityResolver::string_similarity("", "");
580        assert!((s - 1.0).abs() < 1e-10);
581    }
582
583    #[test]
584    fn test_string_similarity_completely_different() {
585        // "ab" vs "cd" → edit distance 2, max_len 2 → sim 0.0
586        let s = EntityResolver::string_similarity("ab", "cd");
587        assert!((s - 0.0).abs() < 1e-10);
588    }
589
590    #[test]
591    fn test_string_similarity_partial() {
592        let s = EntityResolver::string_similarity("kitten", "sitting");
593        // 1 - 3/7 ≈ 0.571
594        assert!(s > 0.5 && s < 0.7);
595    }
596
597    // ── cosine_similarity ────────────────────────────────────────────────────
598
599    #[test]
600    fn test_cosine_similarity_identical() {
601        let v = vec![1.0, 0.0, 0.0];
602        let s = EntityResolver::cosine_similarity(&v, &v);
603        assert!((s - 1.0).abs() < 1e-10);
604    }
605
606    #[test]
607    fn test_cosine_similarity_orthogonal() {
608        let a = vec![1.0, 0.0];
609        let b = vec![0.0, 1.0];
610        let s = EntityResolver::cosine_similarity(&a, &b);
611        assert!(s.abs() < 1e-10);
612    }
613
614    #[test]
615    fn test_cosine_similarity_zero_vector() {
616        let a = vec![0.0, 0.0];
617        let b = vec![1.0, 1.0];
618        let s = EntityResolver::cosine_similarity(&a, &b);
619        assert!(s.abs() < 1e-10);
620    }
621
622    #[test]
623    fn test_cosine_similarity_mismatched_len() {
624        let a = vec![1.0, 0.0];
625        let b = vec![1.0, 0.0, 0.0];
626        let s = EntityResolver::cosine_similarity(&a, &b);
627        assert!(s.abs() < 1e-10);
628    }
629
630    // ── normalize ─────────────────────────────────────────────────────────────
631
632    #[test]
633    fn test_normalize_trims_and_lowercases() {
634        assert_eq!(EntityResolver::normalize("  Hello World  "), "hello world");
635    }
636
637    #[test]
638    fn test_normalize_collapses_whitespace() {
639        assert_eq!(EntityResolver::normalize("foo   bar\tbaz"), "foo bar baz");
640    }
641
642    // ── register_entity ───────────────────────────────────────────────────────
643
644    #[test]
645    fn test_register_entity_success() {
646        let mut r = default_resolver();
647        let e = make_entity("e1", "Apple", EntityType::Organization, vec!["AAPL"], None);
648        assert!(r.register_entity(e));
649        assert_eq!(r.entity_count(), 1);
650    }
651
652    #[test]
653    fn test_register_entity_duplicate_returns_false() {
654        let mut r = default_resolver();
655        let e1 = make_entity("e1", "Apple", EntityType::Organization, vec![], None);
656        let e2 = make_entity("e1", "Apple Inc.", EntityType::Organization, vec![], None);
657        assert!(r.register_entity(e1));
658        assert!(!r.register_entity(e2));
659        // Original should still be there.
660        assert_eq!(
661            r.get_entity("e1").map(|e| e.canonical_name.as_str()),
662            Some("Apple")
663        );
664    }
665
666    // ── resolve — exact match ─────────────────────────────────────────────────
667
668    #[test]
669    fn test_resolve_exact_match() {
670        let mut r = default_resolver();
671        r.register_entity(make_entity(
672            "e1",
673            "Apple",
674            EntityType::Organization,
675            vec![],
676            None,
677        ));
678        let res = r.resolve(mention("Apple"));
679        assert_eq!(res.method, ResolutionMethod::ExactMatch);
680        assert_eq!(res.entity_id.as_deref(), Some("e1"));
681    }
682
683    #[test]
684    fn test_resolve_exact_match_case_insensitive() {
685        let mut r = default_resolver();
686        r.register_entity(make_entity(
687            "e1",
688            "Apple",
689            EntityType::Organization,
690            vec![],
691            None,
692        ));
693        let res = r.resolve(mention("APPLE"));
694        assert_eq!(res.method, ResolutionMethod::ExactMatch);
695        assert_eq!(res.entity_id.as_deref(), Some("e1"));
696    }
697
698    // ── resolve — alias match ─────────────────────────────────────────────────
699
700    #[test]
701    fn test_resolve_alias_match() {
702        let mut r = default_resolver();
703        r.register_entity(make_entity(
704            "e1",
705            "Apple Inc.",
706            EntityType::Organization,
707            vec!["Apple", "AAPL"],
708            None,
709        ));
710        let res = r.resolve(mention("aapl"));
711        assert_eq!(res.method, ResolutionMethod::AliasMatch);
712        assert_eq!(res.entity_id.as_deref(), Some("e1"));
713    }
714
715    // ── resolve — fuzzy match ─────────────────────────────────────────────────
716
717    #[test]
718    fn test_resolve_fuzzy_match_above_threshold() {
719        // "Aple" vs "Apple": edit_distance = 1, max_len = 5, sim = 0.8 → meets default 0.8
720        let mut r = default_resolver();
721        r.register_entity(make_entity(
722            "e1",
723            "Apple",
724            EntityType::Organization,
725            vec![],
726            None,
727        ));
728        let res = r.resolve(mention("Aple"));
729        assert_eq!(res.method, ResolutionMethod::FuzzyMatch);
730        assert_eq!(res.entity_id.as_deref(), Some("e1"));
731    }
732
733    #[test]
734    fn test_resolve_fuzzy_below_threshold_is_unresolved() {
735        // Very dissimilar string — should not meet threshold
736        let mut r = default_resolver();
737        r.register_entity(make_entity(
738            "e1",
739            "Apple",
740            EntityType::Organization,
741            vec![],
742            None,
743        ));
744        let res = r.resolve(mention("XYZ"));
745        assert_eq!(res.method, ResolutionMethod::Unresolved);
746        assert!(res.entity_id.is_none());
747    }
748
749    // ── resolve — embedding match ─────────────────────────────────────────────
750
751    #[test]
752    fn test_resolve_embedding_match() {
753        let emb = vec![1.0, 0.0, 0.0];
754        let query = vec![0.99, 0.14, 0.0]; // cosine ~ 0.99
755
756        let mut r = EntityResolver::new(ResolverConfig {
757            fuzzy_threshold: 0.99, // force fuzzy to fail
758            embedding_threshold: 0.9,
759            max_candidates: 10,
760            case_sensitive: false,
761        });
762        r.register_entity(make_entity(
763            "e1",
764            "TechCorp",
765            EntityType::Organization,
766            vec![],
767            Some(emb),
768        ));
769        let res = r.resolve_with_embedding(mention("TechCorp-X"), &query);
770        assert_eq!(res.method, ResolutionMethod::EmbeddingMatch);
771        assert_eq!(res.entity_id.as_deref(), Some("e1"));
772    }
773
774    // ── resolve — multi-method fallback ──────────────────────────────────────
775
776    #[test]
777    fn test_resolve_falls_back_through_methods() {
778        // Register entity; query with something close but not exact/alias.
779        let mut r = EntityResolver::new(ResolverConfig {
780            fuzzy_threshold: 0.6,
781            embedding_threshold: 0.9,
782            max_candidates: 10,
783            case_sensitive: false,
784        });
785        r.register_entity(make_entity(
786            "e1",
787            "Microsoft",
788            EntityType::Organization,
789            vec![],
790            None,
791        ));
792        // "Micr0soft" — one substitution; similarity = 1 - 1/9 ≈ 0.89 ≥ 0.6
793        let res = r.resolve(mention("Micr0soft"));
794        assert_eq!(res.method, ResolutionMethod::FuzzyMatch);
795    }
796
797    // ── batch resolution ─────────────────────────────────────────────────────
798
799    #[test]
800    fn test_resolve_batch() {
801        let mut r = default_resolver();
802        r.register_entity(make_entity("e1", "Alice", EntityType::Person, vec![], None));
803        r.register_entity(make_entity("e2", "Bob", EntityType::Person, vec![], None));
804        let results = r.resolve_batch(vec![mention("Alice"), mention("Bob"), mention("Unknown")]);
805        assert_eq!(results.len(), 3);
806        assert_eq!(results[0].entity_id.as_deref(), Some("e1"));
807        assert_eq!(results[1].entity_id.as_deref(), Some("e2"));
808        assert!(results[2].entity_id.is_none());
809    }
810
811    // ── case sensitivity ─────────────────────────────────────────────────────
812
813    #[test]
814    fn test_resolve_case_sensitive_no_match() {
815        let mut r = EntityResolver::new(ResolverConfig {
816            case_sensitive: true,
817            fuzzy_threshold: 1.1, // impossible threshold — disable fuzzy
818            ..ResolverConfig::default()
819        });
820        r.register_entity(make_entity(
821            "e1",
822            "Apple",
823            EntityType::Organization,
824            vec![],
825            None,
826        ));
827        // The alias index is keyed by normalize("Apple") = "apple".
828        // When case_sensitive=true the lookup key is the raw mention text.
829        // Mention "APPLE" (all caps) → raw key "APPLE" ≠ alias key "apple" → no match.
830        let res = r.resolve(mention("APPLE"));
831        assert_eq!(res.entity_id.as_deref(), None);
832    }
833
834    // ── stats tracking ────────────────────────────────────────────────────────
835
836    #[test]
837    fn test_stats_exact_match_increments() {
838        let mut r = default_resolver();
839        r.register_entity(make_entity(
840            "e1",
841            "Google",
842            EntityType::Organization,
843            vec![],
844            None,
845        ));
846        r.resolve(mention("Google"));
847        let s = r.stats();
848        assert_eq!(s.exact_matches, 1);
849        assert_eq!(s.total_resolved, 1);
850        assert_eq!(s.unresolved, 0);
851    }
852
853    #[test]
854    fn test_stats_alias_match_increments() {
855        let mut r = default_resolver();
856        r.register_entity(make_entity(
857            "e1",
858            "Alphabet",
859            EntityType::Organization,
860            vec!["Google"],
861            None,
862        ));
863        r.resolve(mention("Google"));
864        let s = r.stats();
865        assert_eq!(s.alias_matches, 1);
866        assert_eq!(s.total_resolved, 1);
867    }
868
869    #[test]
870    fn test_stats_unresolved_increments() {
871        let mut r = default_resolver();
872        r.register_entity(make_entity(
873            "e1",
874            "Google",
875            EntityType::Organization,
876            vec![],
877            None,
878        ));
879        r.resolve(mention("zzzzzzz"));
880        let s = r.stats();
881        assert_eq!(s.unresolved, 1);
882        assert_eq!(s.total_resolved, 0);
883    }
884
885    #[test]
886    fn test_stats_fuzzy_increments() {
887        let mut r = EntityResolver::new(ResolverConfig {
888            fuzzy_threshold: 0.6,
889            ..ResolverConfig::default()
890        });
891        r.register_entity(make_entity(
892            "e1",
893            "Google",
894            EntityType::Organization,
895            vec![],
896            None,
897        ));
898        // "Gogle" — 1 deletion from "google", sim = 5/6 ≈ 0.83 ≥ 0.6
899        r.resolve(mention("Gogle"));
900        let s = r.stats();
901        assert_eq!(s.fuzzy_matches, 1);
902    }
903
904    // ── empty mention ─────────────────────────────────────────────────────────
905
906    #[test]
907    fn test_resolve_empty_mention() {
908        let mut r = default_resolver();
909        r.register_entity(make_entity(
910            "e1",
911            "Apple",
912            EntityType::Organization,
913            vec![],
914            None,
915        ));
916        let res = r.resolve(mention(""));
917        // Empty string has similarity 0 with "apple" (max_len = 5, dist = 5, sim = 0).
918        assert_eq!(res.method, ResolutionMethod::Unresolved);
919    }
920
921    // ── get_entity / entity_count ─────────────────────────────────────────────
922
923    #[test]
924    fn test_get_entity_present() {
925        let mut r = default_resolver();
926        r.register_entity(make_entity(
927            "e1",
928            "Apple",
929            EntityType::Organization,
930            vec![],
931            None,
932        ));
933        let e = r.get_entity("e1");
934        assert!(e.is_some());
935        assert_eq!(e.map(|x| x.canonical_name.as_str()), Some("Apple"));
936    }
937
938    #[test]
939    fn test_get_entity_absent() {
940        let r = default_resolver();
941        assert!(r.get_entity("nonexistent").is_none());
942    }
943
944    #[test]
945    fn test_entity_count_empty() {
946        let r = default_resolver();
947        assert_eq!(r.entity_count(), 0);
948    }
949
950    #[test]
951    fn test_entity_count_after_registration() {
952        let mut r = default_resolver();
953        r.register_entity(make_entity("e1", "A", EntityType::Concept, vec![], None));
954        r.register_entity(make_entity("e2", "B", EntityType::Concept, vec![], None));
955        assert_eq!(r.entity_count(), 2);
956    }
957
958    // ── EntityType ────────────────────────────────────────────────────────────
959
960    #[test]
961    fn test_entity_type_other_equality() {
962        let t1 = EntityType::Other("custom".to_string());
963        let t2 = EntityType::Other("custom".to_string());
964        let t3 = EntityType::Other("other".to_string());
965        assert_eq!(t1, t2);
966        assert_ne!(t1, t3);
967    }
968
969    // ── find_candidates ───────────────────────────────────────────────────────
970
971    #[test]
972    fn test_find_candidates_limits_results() {
973        let mut r = default_resolver();
974        for i in 0..20_u32 {
975            r.register_entity(make_entity(
976                &format!("e{i}"),
977                &format!("entity{i}"),
978                EntityType::Concept,
979                vec![],
980                None,
981            ));
982        }
983        let candidates = r.find_candidates("entity", 5);
984        assert_eq!(candidates.len(), 5);
985    }
986
987    #[test]
988    fn test_find_candidates_empty_registry() {
989        let r = default_resolver();
990        let candidates = r.find_candidates("anything", 10);
991        assert!(candidates.is_empty());
992    }
993
994    // ── ResolverStats default ─────────────────────────────────────────────────
995
996    #[test]
997    fn test_resolver_stats_default_zeroes() {
998        let s = ResolverStats::default();
999        assert_eq!(s.total_resolved, 0);
1000        assert_eq!(s.exact_matches, 0);
1001        assert_eq!(s.alias_matches, 0);
1002        assert_eq!(s.fuzzy_matches, 0);
1003        assert_eq!(s.embedding_matches, 0);
1004        assert_eq!(s.unresolved, 0);
1005    }
1006}