1use std::collections::HashMap;
7
8#[derive(Clone, Debug, PartialEq, Eq, Hash)]
10pub enum MentionKind {
11 ExactMatch,
13 FuzzyMatch,
15 EmbeddingMatch,
17 Alias,
19}
20
21#[derive(Clone, Debug)]
23pub struct KbEntity {
24 pub entity_id: u64,
26 pub canonical_name: String,
28 pub aliases: Vec<String>,
30 pub embedding: Vec<f32>,
32 pub entity_type: String,
34}
35
36#[derive(Clone, Debug)]
38pub struct LinkedMention {
39 pub mention_text: String,
41 pub entity_id: u64,
43 pub kind: MentionKind,
45 pub confidence: f32,
47}
48
49#[derive(Clone, Debug)]
51pub struct LinkerConfig {
52 pub exact_match_confidence: f32,
54 pub alias_match_confidence: f32,
56 pub embedding_threshold: f32,
58 pub embedding_match_confidence_scale: f32,
60}
61
62impl Default for LinkerConfig {
63 fn default() -> Self {
64 Self {
65 exact_match_confidence: 1.0,
66 alias_match_confidence: 0.95,
67 embedding_threshold: 0.80,
68 embedding_match_confidence_scale: 0.9,
69 }
70 }
71}
72
73#[derive(Clone, Debug, Default)]
75pub struct LinkerStats {
76 pub total_entities: usize,
78 pub total_links: u64,
80 pub exact_match_count: u64,
82 pub alias_match_count: u64,
84 pub embedding_match_count: u64,
86 pub unlinked_count: u64,
88}
89
90pub fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
94 if a.len() != b.len() || a.is_empty() {
95 return 0.0;
96 }
97
98 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
99 let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
100 let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
101
102 if norm_a == 0.0 || norm_b == 0.0 {
103 return 0.0;
104 }
105
106 dot / (norm_a * norm_b)
107}
108
109pub struct SemanticEntityLinker {
116 pub entities: HashMap<u64, KbEntity>,
118 pub name_index: HashMap<String, u64>,
120 pub alias_index: HashMap<String, u64>,
122 pub config: LinkerConfig,
124 pub stats: LinkerStats,
126}
127
128impl SemanticEntityLinker {
129 pub fn new(config: LinkerConfig) -> Self {
131 Self {
132 entities: HashMap::new(),
133 name_index: HashMap::new(),
134 alias_index: HashMap::new(),
135 config,
136 stats: LinkerStats::default(),
137 }
138 }
139
140 pub fn register(&mut self, entity: KbEntity) {
145 let entity_id = entity.entity_id;
146
147 self.name_index
149 .insert(entity.canonical_name.to_lowercase(), entity_id);
150
151 for alias in &entity.aliases {
153 self.alias_index.insert(alias.to_lowercase(), entity_id);
154 }
155
156 self.entities.insert(entity_id, entity);
157 self.stats.total_entities = self.entities.len();
158 }
159
160 pub fn link(
169 &mut self,
170 mention_text: &str,
171 mention_embedding: Option<&[f32]>,
172 ) -> Option<LinkedMention> {
173 let lower = mention_text.to_lowercase();
174
175 if let Some(&entity_id) = self.name_index.get(&lower) {
177 let confidence = self.config.exact_match_confidence;
178 self.stats.exact_match_count += 1;
179 self.stats.total_links += 1;
180 return Some(LinkedMention {
181 mention_text: mention_text.to_owned(),
182 entity_id,
183 kind: MentionKind::ExactMatch,
184 confidence,
185 });
186 }
187
188 if let Some(&entity_id) = self.alias_index.get(&lower) {
190 let confidence = self.config.alias_match_confidence;
191 self.stats.alias_match_count += 1;
192 self.stats.total_links += 1;
193 return Some(LinkedMention {
194 mention_text: mention_text.to_owned(),
195 entity_id,
196 kind: MentionKind::Alias,
197 confidence,
198 });
199 }
200
201 if let Some(query_emb) = mention_embedding {
203 let threshold = self.config.embedding_threshold;
204 let scale = self.config.embedding_match_confidence_scale;
205
206 let best = self
207 .entities
208 .values()
209 .filter(|e| !e.embedding.is_empty())
210 .map(|e| {
211 let sim = cosine_sim(query_emb, &e.embedding);
212 (e.entity_id, sim)
213 })
214 .filter(|&(_, sim)| sim >= threshold)
215 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
216
217 if let Some((entity_id, sim)) = best {
218 let confidence = sim * scale;
219 self.stats.embedding_match_count += 1;
220 self.stats.total_links += 1;
221 return Some(LinkedMention {
222 mention_text: mention_text.to_owned(),
223 entity_id,
224 kind: MentionKind::EmbeddingMatch,
225 confidence,
226 });
227 }
228 }
229
230 self.stats.unlinked_count += 1;
232 None
233 }
234
235 pub fn link_batch(
239 &mut self,
240 mentions: Vec<(String, Option<Vec<f32>>)>,
241 ) -> Vec<Option<LinkedMention>> {
242 mentions
243 .into_iter()
244 .map(|(text, emb)| self.link(&text, emb.as_deref()))
245 .collect()
246 }
247
248 pub fn entity(&self, entity_id: u64) -> Option<&KbEntity> {
250 self.entities.get(&entity_id)
251 }
252
253 pub fn stats(&self) -> &LinkerStats {
255 &self.stats
256 }
257}
258
259#[cfg(test)]
264mod tests {
265 use super::*;
266
267 fn make_entity(id: u64, name: &str, aliases: &[&str], emb: Vec<f32>, etype: &str) -> KbEntity {
270 KbEntity {
271 entity_id: id,
272 canonical_name: name.to_owned(),
273 aliases: aliases.iter().map(|s| s.to_string()).collect(),
274 embedding: emb,
275 entity_type: etype.to_owned(),
276 }
277 }
278
279 fn default_linker() -> SemanticEntityLinker {
280 SemanticEntityLinker::new(LinkerConfig::default())
281 }
282
283 fn emb_a() -> Vec<f32> {
285 vec![1.0, 0.0, 0.0]
286 }
287 fn emb_a_close() -> Vec<f32> {
289 let raw = vec![2.0_f32, 1.0, 0.0];
290 let norm = raw.iter().map(|x| x * x).sum::<f32>().sqrt();
291 raw.into_iter().map(|x| x / norm).collect()
292 }
293 fn emb_b() -> Vec<f32> {
295 vec![0.0, 1.0, 0.0]
296 }
297
298 #[test]
301 fn test_new_starts_empty() {
302 let linker = default_linker();
303 assert!(linker.entities.is_empty());
304 assert!(linker.name_index.is_empty());
305 assert!(linker.alias_index.is_empty());
306 assert_eq!(linker.stats().total_entities, 0);
307 assert_eq!(linker.stats().total_links, 0);
308 }
309
310 #[test]
313 fn test_register_stores_entity() {
314 let mut linker = default_linker();
315 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
316 assert_eq!(linker.entities.len(), 1);
317 assert!(linker.entity(1).is_some());
318 }
319
320 #[test]
321 fn test_register_indexes_canonical_name() {
322 let mut linker = default_linker();
323 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
324 assert!(linker.name_index.contains_key("alice"));
325 }
326
327 #[test]
328 fn test_register_indexes_aliases() {
329 let mut linker = default_linker();
330 linker.register(make_entity(1, "Alice", &["Ali", "Al"], emb_a(), "person"));
331 assert!(linker.alias_index.contains_key("ali"));
332 assert!(linker.alias_index.contains_key("al"));
333 }
334
335 #[test]
336 fn test_register_updates_total_entities_stat() {
337 let mut linker = default_linker();
338 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
339 linker.register(make_entity(2, "Bob", &[], emb_b(), "person"));
340 assert_eq!(linker.stats().total_entities, 2);
341 }
342
343 #[test]
346 fn test_link_exact_match_same_case() {
347 let mut linker = default_linker();
348 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
349 let result = linker.link("Alice", None);
350 assert!(result.is_some());
351 let m = result.expect("test: exact match same case should link");
352 assert_eq!(m.entity_id, 1);
353 assert_eq!(m.kind, MentionKind::ExactMatch);
354 }
355
356 #[test]
357 fn test_link_exact_match_case_insensitive() {
358 let mut linker = default_linker();
359 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
360 let result = linker.link("ALICE", None);
361 assert!(result.is_some());
362 assert_eq!(
363 result
364 .expect("test: exact match case insensitive should link")
365 .kind,
366 MentionKind::ExactMatch
367 );
368 }
369
370 #[test]
371 fn test_link_exact_match_confidence() {
372 let mut linker = default_linker();
373 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
374 let m = linker
375 .link("Alice", None)
376 .expect("test: exact match confidence should link");
377 assert!((m.confidence - 1.0).abs() < 1e-6);
378 }
379
380 #[test]
383 fn test_link_alias_match() {
384 let mut linker = default_linker();
385 linker.register(make_entity(1, "Alice", &["Ali"], emb_a(), "person"));
386 let result = linker.link("Ali", None);
387 assert!(result.is_some());
388 assert_eq!(
389 result.expect("test: alias match should link").kind,
390 MentionKind::Alias
391 );
392 }
393
394 #[test]
395 fn test_link_alias_case_insensitive() {
396 let mut linker = default_linker();
397 linker.register(make_entity(1, "Alice", &["Ali"], emb_a(), "person"));
398 let result = linker.link("ALI", None);
399 assert!(result.is_some());
400 assert_eq!(
401 result
402 .expect("test: alias case insensitive should link")
403 .kind,
404 MentionKind::Alias
405 );
406 }
407
408 #[test]
409 fn test_link_alias_confidence() {
410 let mut linker = default_linker();
411 linker.register(make_entity(1, "Alice", &["Ali"], emb_a(), "person"));
412 let m = linker
413 .link("Ali", None)
414 .expect("test: alias confidence should link");
415 assert!((m.confidence - 0.95).abs() < 1e-6);
416 }
417
418 #[test]
421 fn test_link_embedding_match_above_threshold() {
422 let mut linker = default_linker();
423 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
424 let result = linker.link("unknown", Some(&emb_a_close()));
426 assert!(result.is_some());
427 assert_eq!(
428 result.expect("test: embedding match above threshold").kind,
429 MentionKind::EmbeddingMatch
430 );
431 }
432
433 #[test]
434 fn test_link_embedding_match_below_threshold_returns_none() {
435 let mut linker = default_linker();
436 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
437 let result = linker.link("unknown", Some(&emb_b()));
439 assert!(result.is_none());
440 }
441
442 #[test]
443 fn test_link_embedding_confidence_equals_sim_times_scale() {
444 let mut linker = default_linker();
445 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
446 let query = emb_a_close();
447 let expected_sim = cosine_sim(&query, &emb_a());
448 let m = linker
449 .link("unknown", Some(&query))
450 .expect("test: embedding confidence link");
451 let expected_conf = expected_sim * linker.config.embedding_match_confidence_scale;
452 assert!((m.confidence - expected_conf).abs() < 1e-5);
453 }
454
455 #[test]
458 fn test_link_priority_exact_before_alias() {
459 let mut linker = default_linker();
460 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
462 linker.register(make_entity(2, "Bob", &["alice"], emb_b(), "person"));
463 let m = linker
464 .link("alice", None)
465 .expect("test: exact beats alias priority");
466 assert_eq!(m.kind, MentionKind::ExactMatch);
468 assert_eq!(m.entity_id, 1);
469 }
470
471 #[test]
472 fn test_link_priority_alias_before_embedding() {
473 let mut linker = default_linker();
474 linker.register(make_entity(1, "Alice", &["mystery"], emb_b(), "person"));
475 let result = linker.link("mystery", Some(&emb_a()));
477 let m = result.expect("test: alias beats embedding priority");
478 assert_eq!(m.kind, MentionKind::Alias);
479 assert_eq!(m.entity_id, 1);
480 }
481
482 #[test]
485 fn test_link_none_when_no_match() {
486 let mut linker = default_linker();
487 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
488 let result = linker.link("Completely Unknown Entity", None);
489 assert!(result.is_none());
490 }
491
492 #[test]
495 fn test_exact_match_count_increments() {
496 let mut linker = default_linker();
497 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
498 linker.link("Alice", None);
499 linker.link("Alice", None);
500 assert_eq!(linker.stats().exact_match_count, 2);
501 }
502
503 #[test]
504 fn test_alias_match_count_increments() {
505 let mut linker = default_linker();
506 linker.register(make_entity(1, "Alice", &["Ali"], emb_a(), "person"));
507 linker.link("Ali", None);
508 assert_eq!(linker.stats().alias_match_count, 1);
509 }
510
511 #[test]
512 fn test_embedding_match_count_increments() {
513 let mut linker = default_linker();
514 linker.register(make_entity(1, "Alice", &[], emb_a(), "person"));
515 linker.link("unknown1", Some(&emb_a_close()));
516 linker.link("unknown2", Some(&emb_a_close()));
517 assert_eq!(linker.stats().embedding_match_count, 2);
518 }
519
520 #[test]
521 fn test_unlinked_count_increments() {
522 let mut linker = default_linker();
523 linker.link("nobody", None);
524 linker.link("nobody2", None);
525 assert_eq!(linker.stats().unlinked_count, 2);
526 }
527
528 #[test]
529 fn test_total_links_accumulates() {
530 let mut linker = default_linker();
531 linker.register(make_entity(1, "Alice", &["Ali"], emb_a(), "person"));
532 linker.link("Alice", None); linker.link("Ali", None); linker.link("unknown", Some(&emb_a_close())); assert_eq!(linker.stats().total_links, 3);
536 }
537
538 #[test]
539 fn test_linked_mention_kind_set_correctly() {
540 let mut linker = default_linker();
541 linker.register(make_entity(1, "Alice", &["Ali"], emb_a(), "person"));
542 assert_eq!(
543 linker
544 .link("Alice", None)
545 .expect("test: mention kind ExactMatch")
546 .kind,
547 MentionKind::ExactMatch
548 );
549 assert_eq!(
550 linker
551 .link("Ali", None)
552 .expect("test: mention kind Alias")
553 .kind,
554 MentionKind::Alias
555 );
556 }
557
558 #[test]
561 fn test_link_batch_processes_multiple_mentions() {
562 let mut linker = default_linker();
563 linker.register(make_entity(1, "Alice", &["Ali"], emb_a(), "person"));
564 linker.register(make_entity(2, "Bob", &[], emb_b(), "person"));
565
566 let mentions: Vec<(String, Option<Vec<f32>>)> = vec![
567 ("Alice".to_owned(), None),
568 ("Ali".to_owned(), None),
569 ("nobody".to_owned(), None),
570 ];
571 let results = linker.link_batch(mentions);
572 assert_eq!(results.len(), 3);
573 assert!(results[0].is_some());
574 assert!(results[1].is_some());
575 assert!(results[2].is_none());
576 }
577
578 #[test]
581 fn test_entity_some() {
582 let mut linker = default_linker();
583 linker.register(make_entity(42, "Alice", &[], emb_a(), "person"));
584 assert!(linker.entity(42).is_some());
585 assert_eq!(
586 linker
587 .entity(42)
588 .expect("test: entity 42 should exist")
589 .canonical_name,
590 "Alice"
591 );
592 }
593
594 #[test]
595 fn test_entity_none() {
596 let linker = default_linker();
597 assert!(linker.entity(999).is_none());
598 }
599
600 #[test]
603 fn test_cosine_sim_zero_vector_returns_zero() {
604 let zero = vec![0.0_f32, 0.0, 0.0];
605 let a = vec![1.0_f32, 0.0, 0.0];
606 assert_eq!(cosine_sim(&zero, &a), 0.0);
607 assert_eq!(cosine_sim(&a, &zero), 0.0);
608 assert_eq!(cosine_sim(&zero, &zero), 0.0);
609 }
610
611 #[test]
612 fn test_cosine_sim_identical_vectors() {
613 let a = vec![1.0_f32, 2.0, 3.0];
614 let sim = cosine_sim(&a, &a);
615 assert!(
616 (sim - 1.0).abs() < 1e-6,
617 "identical vectors → sim = 1.0, got {sim}"
618 );
619 }
620
621 #[test]
622 fn test_cosine_sim_orthogonal_vectors() {
623 let a = vec![1.0_f32, 0.0, 0.0];
624 let b = vec![0.0_f32, 1.0, 0.0];
625 assert!((cosine_sim(&a, &b)).abs() < 1e-6);
626 }
627}