1use std::collections::HashMap;
7
8#[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#[derive(Debug, Clone)]
21pub struct CanonicalEntity {
22 pub entity_id: String,
24 pub canonical_name: String,
26 pub entity_type: EntityType,
28 pub aliases: Vec<String>,
30 pub embedding: Option<Vec<f64>>,
32 pub confidence: f64,
34}
35
36#[derive(Debug, Clone)]
38pub struct EntityMention {
39 pub text: String,
41 pub start: usize,
43 pub end: usize,
45 pub context: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum ResolutionMethod {
52 ExactMatch,
54 AliasMatch,
56 FuzzyMatch,
58 EmbeddingMatch,
60 Unresolved,
62}
63
64#[derive(Debug, Clone)]
66pub struct ResolutionResult {
67 pub mention: EntityMention,
69 pub entity_id: Option<String>,
71 pub canonical_name: Option<String>,
73 pub confidence: f64,
75 pub method: ResolutionMethod,
77}
78
79#[derive(Debug, Clone)]
81pub struct ResolverConfig {
82 pub fuzzy_threshold: f64,
84 pub embedding_threshold: f64,
86 pub max_candidates: usize,
88 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#[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
114pub struct EntityResolver {
123 config: ResolverConfig,
124 entities: HashMap<String, CanonicalEntity>,
126 alias_index: HashMap<String, String>,
128 stats: ResolverStats,
129}
130
131impl EntityResolver {
132 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 pub fn register_entity(&mut self, entity: CanonicalEntity) -> bool {
145 if self.entities.contains_key(&entity.entity_id) {
146 return false;
147 }
148
149 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 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 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 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 let max_candidates = self.config.max_candidates;
201 let fuzzy_threshold = self.config.fuzzy_threshold;
202
203 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 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 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 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 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 let fuzzy_threshold = self.config.fuzzy_threshold;
297 let mut best_fuzzy: Option<(String, String, f64, f64)> = None; 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 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 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 pub fn resolve_batch(&mut self, mentions: Vec<EntityMention>) -> Vec<ResolutionResult> {
371 mentions.into_iter().map(|m| self.resolve(m)).collect()
372 }
373
374 pub fn normalize(text: &str) -> String {
376 text.trim()
377 .to_lowercase()
378 .split_whitespace()
379 .collect::<Vec<_>>()
380 .join(" ")
381 }
382
383 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 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 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 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 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 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 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 pub fn entity_count(&self) -> usize {
482 self.entities.len()
483 }
484
485 pub fn get_entity(&self, entity_id: &str) -> Option<&CanonicalEntity> {
487 self.entities.get(entity_id)
488 }
489
490 pub fn stats(&self) -> &ResolverStats {
492 &self.stats
493 }
494}
495
496#[cfg(test)]
501mod tests {
502 use super::*;
503
504 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 #[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 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 #[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 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 assert!(s > 0.5 && s < 0.7);
595 }
596
597 #[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 #[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 #[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 assert_eq!(
661 r.get_entity("e1").map(|e| e.canonical_name.as_str()),
662 Some("Apple")
663 );
664 }
665
666 #[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 #[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 #[test]
718 fn test_resolve_fuzzy_match_above_threshold() {
719 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 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 #[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]; let mut r = EntityResolver::new(ResolverConfig {
757 fuzzy_threshold: 0.99, 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 #[test]
777 fn test_resolve_falls_back_through_methods() {
778 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 let res = r.resolve(mention("Micr0soft"));
794 assert_eq!(res.method, ResolutionMethod::FuzzyMatch);
795 }
796
797 #[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 #[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, ..ResolverConfig::default()
819 });
820 r.register_entity(make_entity(
821 "e1",
822 "Apple",
823 EntityType::Organization,
824 vec![],
825 None,
826 ));
827 let res = r.resolve(mention("APPLE"));
831 assert_eq!(res.entity_id.as_deref(), None);
832 }
833
834 #[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 r.resolve(mention("Gogle"));
900 let s = r.stats();
901 assert_eq!(s.fuzzy_matches, 1);
902 }
903
904 #[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 assert_eq!(res.method, ResolutionMethod::Unresolved);
919 }
920
921 #[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 #[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 #[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 #[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}