Skip to main content

ipfrs_semantic/
entity_linker.rs

1//! Semantic Entity Linker
2//!
3//! Links named entity mentions in queries to their canonical entities in a knowledge base,
4//! using embedding similarity and surface form matching.
5
6use std::collections::HashMap;
7
8/// The kind of match that linked a mention to an entity.
9#[derive(Clone, Debug, PartialEq, Eq, Hash)]
10pub enum MentionKind {
11    /// Surface form exactly matches entity canonical name.
12    ExactMatch,
13    /// Normalized surface form matches entity name.
14    FuzzyMatch,
15    /// Linked via embedding cosine similarity.
16    EmbeddingMatch,
17    /// Surface form is a known alias for the entity.
18    Alias,
19}
20
21/// A canonical entity stored in the knowledge base.
22#[derive(Clone, Debug)]
23pub struct KbEntity {
24    /// Unique identifier for this entity.
25    pub entity_id: u64,
26    /// The canonical (primary) name of the entity.
27    pub canonical_name: String,
28    /// Alternative surface forms that refer to this entity.
29    pub aliases: Vec<String>,
30    /// Prototype embedding vector for this entity.
31    pub embedding: Vec<f32>,
32    /// Semantic type, e.g. "person", "org", "location".
33    pub entity_type: String,
34}
35
36/// A resolved link from a mention text to a knowledge-base entity.
37#[derive(Clone, Debug)]
38pub struct LinkedMention {
39    /// The original mention text as it appeared in the query.
40    pub mention_text: String,
41    /// The entity that this mention was resolved to.
42    pub entity_id: u64,
43    /// The kind of evidence used to resolve this mention.
44    pub kind: MentionKind,
45    /// Confidence score in [0.0, 1.0].
46    pub confidence: f32,
47}
48
49/// Configuration for the `SemanticEntityLinker`.
50#[derive(Clone, Debug)]
51pub struct LinkerConfig {
52    /// Confidence assigned to an exact-name match.
53    pub exact_match_confidence: f32,
54    /// Confidence assigned when a mention matches a known alias.
55    pub alias_match_confidence: f32,
56    /// Minimum cosine similarity required to accept an embedding match.
57    pub embedding_threshold: f32,
58    /// `confidence = cosine_similarity * embedding_match_confidence_scale`.
59    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/// Aggregated statistics for a `SemanticEntityLinker` instance.
74#[derive(Clone, Debug, Default)]
75pub struct LinkerStats {
76    /// Number of entities currently registered.
77    pub total_entities: usize,
78    /// Total number of successful links produced.
79    pub total_links: u64,
80    /// Links resolved via exact name match.
81    pub exact_match_count: u64,
82    /// Links resolved via alias match.
83    pub alias_match_count: u64,
84    /// Links resolved via embedding similarity.
85    pub embedding_match_count: u64,
86    /// Mention texts that could not be resolved to any entity.
87    pub unlinked_count: u64,
88}
89
90/// Compute the cosine similarity between two equal-length float vectors.
91///
92/// Returns `0.0` if either vector has zero magnitude.
93pub 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
109/// A linker that resolves mention texts to canonical knowledge-base entities.
110///
111/// Resolution is attempted in priority order:
112/// 1. Exact name match (lowercase comparison against canonical names)
113/// 2. Alias match (lowercase comparison against known aliases)
114/// 3. Embedding similarity (cosine similarity above configured threshold)
115pub struct SemanticEntityLinker {
116    /// All registered entities, keyed by `entity_id`.
117    pub entities: HashMap<u64, KbEntity>,
118    /// Lowercase canonical name → entity_id.
119    pub name_index: HashMap<String, u64>,
120    /// Lowercase alias → entity_id.
121    pub alias_index: HashMap<String, u64>,
122    /// Configuration parameters.
123    pub config: LinkerConfig,
124    /// Running statistics.
125    pub stats: LinkerStats,
126}
127
128impl SemanticEntityLinker {
129    /// Create a new, empty linker with the given configuration.
130    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    /// Register an entity in the knowledge base.
141    ///
142    /// Indexes the entity's canonical name and all aliases (lowercased).
143    /// If an entity with the same `entity_id` was already registered, it is replaced.
144    pub fn register(&mut self, entity: KbEntity) {
145        let entity_id = entity.entity_id;
146
147        // Index canonical name (lowercased).
148        self.name_index
149            .insert(entity.canonical_name.to_lowercase(), entity_id);
150
151        // Index every alias (lowercased).
152        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    /// Attempt to link a single mention text to an entity.
161    ///
162    /// Resolution priority:
163    /// 1. **ExactMatch** — lowercase mention in `name_index`
164    /// 2. **Alias** — lowercase mention in `alias_index`
165    /// 3. **EmbeddingMatch** — highest cosine similarity ≥ `embedding_threshold`
166    ///
167    /// Returns `None` and increments `unlinked_count` if no match is found.
168    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        // 1. Exact name match.
176        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        // 2. Alias match.
189        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        // 3. Embedding similarity match.
202        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        // No match found.
231        self.stats.unlinked_count += 1;
232        None
233    }
234
235    /// Link a batch of mentions, each paired with an optional embedding.
236    ///
237    /// Returns one `Option<LinkedMention>` per input mention, in the same order.
238    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    /// Look up a registered entity by its `entity_id`.
249    pub fn entity(&self, entity_id: u64) -> Option<&KbEntity> {
250        self.entities.get(&entity_id)
251    }
252
253    /// Return a reference to the current linker statistics.
254    pub fn stats(&self) -> &LinkerStats {
255        &self.stats
256    }
257}
258
259// ─────────────────────────────────────────────────────────────────────────────
260// Tests
261// ─────────────────────────────────────────────────────────────────────────────
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    // ── helpers ──────────────────────────────────────────────────────────────
268
269    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    // Normalised 3-D unit vector: [1, 0, 0]
284    fn emb_a() -> Vec<f32> {
285        vec![1.0, 0.0, 0.0]
286    }
287    // Normalised 3-D vector close to emb_a: cosine ≈ 0.9487
288    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    // Orthogonal to emb_a: cosine = 0.0
294    fn emb_b() -> Vec<f32> {
295        vec![0.0, 1.0, 0.0]
296    }
297
298    // ── basic construction ────────────────────────────────────────────────────
299
300    #[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    // ── register ─────────────────────────────────────────────────────────────
311
312    #[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    // ── link: exact match ─────────────────────────────────────────────────────
344
345    #[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    // ── link: alias match ─────────────────────────────────────────────────────
381
382    #[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    // ── link: embedding match ─────────────────────────────────────────────────
419
420    #[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        // emb_a_close has cosine ≈ 0.894 with emb_a — above default threshold 0.80
425        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        // emb_b is orthogonal to emb_a; cosine = 0.0 < threshold
438        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    // ── link: priority ────────────────────────────────────────────────────────
456
457    #[test]
458    fn test_link_priority_exact_before_alias() {
459        let mut linker = default_linker();
460        // Entity 1 has canonical name "Alice", entity 2 has alias "alice"
461        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        // ExactMatch should win over Alias
467        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        // "mystery" is an alias → must win over embedding match
476        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    // ── link: no match ────────────────────────────────────────────────────────
483
484    #[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    // ── stats counters ────────────────────────────────────────────────────────
493
494    #[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); // exact
533        linker.link("Ali", None); // alias
534        linker.link("unknown", Some(&emb_a_close())); // embedding
535        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    // ── link_batch ────────────────────────────────────────────────────────────
559
560    #[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    // ── entity() lookup ───────────────────────────────────────────────────────
579
580    #[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    // ── cosine_sim ────────────────────────────────────────────────────────────
601
602    #[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}