1use crate::knowledge::{EntityId, EntityKey, EntityTypeRef, ResolutionMethod};
13use oxibrain_index::ngram;
14use std::collections::BTreeMap;
15
16#[derive(Debug, Clone)]
24pub struct PerType<T: Clone> {
25 default: T,
26 overrides: BTreeMap<String, T>,
27}
28
29impl<T: Clone> PerType<T> {
30 pub fn new(default: T) -> Self {
31 Self {
32 default,
33 overrides: BTreeMap::new(),
34 }
35 }
36
37 pub fn set(&mut self, ty: &str, value: T) {
39 self.overrides.insert(ty.to_string(), value);
40 }
41
42 pub fn get(&self, ty: &str) -> &T {
44 self.overrides.get(ty).unwrap_or(&self.default)
45 }
46}
47
48impl PerType<f64> {
49 pub fn weight(&self, ty: &str) -> f64 {
51 *self.get(ty)
52 }
53}
54
55#[derive(Debug, Clone)]
59pub struct ResolutionConfig {
60 pub tau_high: f64,
61 pub tau_low: f64,
62 pub w_exact: f64,
63 pub w_ngram: f64,
64 pub w_graph: f64,
65 pub w_embedding: PerType<f64>,
66}
67
68impl Default for ResolutionConfig {
69 fn default() -> Self {
70 Self {
71 tau_high: 0.75,
77 tau_low: 0.25,
78 w_exact: 1.0,
79 w_ngram: 1.0,
80 w_graph: 0.4,
81 w_embedding: {
86 let mut w = PerType::new(0.3);
87 w.set("Person", 0.1);
88 w.set("Organization", 0.1);
89 w.set("Concept", 0.6);
90 w
91 },
92 }
93 }
94}
95
96#[derive(Debug, Clone)]
100pub enum Decision {
101 Link {
103 entity: EntityId,
104 method: ResolutionMethod,
105 score: f64,
106 },
107 New {
109 method: ResolutionMethod,
110 score: f64,
111 },
112 Candidate {
115 new_entity: EntityId,
116 existing: EntityId,
117 score: f64,
118 },
119}
120
121pub fn normalize(surface: &str, _ty: &EntityTypeRef) -> String {
127 use unicode_normalization::UnicodeNormalization;
128 surface
129 .nfkc()
130 .collect::<String>()
131 .to_lowercase()
132 .split_whitespace()
133 .collect::<Vec<_>>()
134 .join(" ")
135}
136
137pub fn score(
153 candidate: &EntityKey,
154 mention_normalized: &str,
155 mention_type: &EntityTypeRef,
156 graph_context: f64,
157 embedding_sim: f64,
158 config: &ResolutionConfig,
159) -> f64 {
160 if candidate.ty != *mention_type {
162 return 0.0;
163 }
164
165 let exact = if candidate.normalized == mention_normalized {
166 1.0
167 } else {
168 0.0
169 };
170
171 let cand_shingles = ngram::shingles(&candidate.normalized, 3);
173 let ment_shingles = ngram::shingles(mention_normalized, 3);
174 let j = ngram::jaccard(&cand_shingles, &ment_shingles);
175
176 let emb_weight = config.w_embedding.weight(mention_type);
177
178 let raw = config.w_exact * exact
179 + config.w_ngram * j
180 + config.w_graph * graph_context
181 + emb_weight * embedding_sim;
182
183 raw.clamp(0.0, 1.0)
184}
185
186pub fn resolve(
199 mention_normalized: &str,
200 mention_type: &EntityTypeRef,
201 candidates: &[EntityKey],
202 graph_context: impl Fn(&EntityId) -> f64,
203 embedding_sim: impl Fn(&EntityId) -> f64,
204 config: &ResolutionConfig,
205) -> Decision {
206 let mut scored: Vec<(f64, &EntityKey)> = Vec::new();
208 for c in candidates {
209 let ctx = graph_context(&c.entity);
210 let emb = embedding_sim(&c.entity);
211 let s = score(c, mention_normalized, mention_type, ctx, emb, config);
212 if s > 0.0 {
213 scored.push((s, c));
214 }
215 }
216 scored.sort_by(|a, b| {
218 b.0.partial_cmp(&a.0)
219 .unwrap_or(std::cmp::Ordering::Equal)
220 .then(a.1.entity.cmp(&b.1.entity))
221 });
222
223 match scored.first() {
224 None => Decision::New {
225 method: ResolutionMethod::New,
226 score: 0.0,
227 },
228 Some(&(best, c)) if best >= config.tau_high => {
229 let method = if c.normalized == mention_normalized {
230 ResolutionMethod::ExactKey
231 } else {
232 ResolutionMethod::Lexical { score: best }
233 };
234 Decision::Link {
235 entity: c.entity.clone(),
236 method,
237 score: best,
238 }
239 }
240 Some(&(best, _c)) if best <= config.tau_low => Decision::New {
241 method: ResolutionMethod::New,
242 score: best,
243 },
244 Some(&(best, c)) => {
245 Decision::Candidate {
247 new_entity: String::new(), existing: c.entity.clone(),
249 score: best,
250 }
251 }
252 }
253}
254
255#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::knowledge::KeyOrigin;
261
262 fn make_key(entity: &str, normalized: &str, ty: &str) -> EntityKey {
263 EntityKey {
264 id: format!("k_{entity}_{normalized}"),
265 space: "s1".into(),
266 entity: entity.into(),
267 ty: ty.into(),
268 normalized: normalized.into(),
269 surface: normalized.into(),
270 origin: KeyOrigin::UserDeclared,
271 }
272 }
273
274 #[test]
275 fn exact_match_links() {
276 let cands = vec![make_key("e1", "alice", "Person")];
277 let dec = resolve(
278 "alice",
279 &"Person".to_string(),
280 &cands,
281 |_| 0.0,
282 |_| 0.0,
283 &ResolutionConfig::default(),
284 );
285 match dec {
286 Decision::Link {
287 entity,
288 method,
289 score,
290 } => {
291 assert_eq!(entity, "e1");
292 assert!(score >= 0.75);
293 assert!(matches!(method, ResolutionMethod::ExactKey));
294 }
295 _ => panic!("expected Link"),
296 }
297 }
298
299 #[test]
300 fn type_mismatch_rejected() {
301 let cands = vec![make_key("e1", "alice", "Organization")];
302 let dec = resolve(
303 "alice",
304 &"Person".to_string(),
305 &cands,
306 |_| 0.0,
307 |_| 0.0,
308 &ResolutionConfig::default(),
309 );
310 assert!(matches!(dec, Decision::New { .. }));
311 }
312
313 #[test]
314 fn no_candidates_is_new() {
315 let dec = resolve(
316 "alice",
317 &"Person".to_string(),
318 &[],
319 |_| 0.0,
320 |_| 0.0,
321 &ResolutionConfig::default(),
322 );
323 assert!(matches!(dec, Decision::New { .. }));
324 }
325
326 #[test]
327 fn normalize_basic() {
328 assert_eq!(normalize("Alice", &"Person".to_string()), "alice");
329 assert_eq!(
330 normalize(" Alice Smith ", &"Person".to_string()),
331 "alice smith"
332 );
333 }
334
335 #[test]
336 fn low_similarity_is_new() {
337 let cands = vec![make_key("e1", "zzzzzzzzz", "Person")];
338 let dec = resolve(
339 "alice",
340 &"Person".to_string(),
341 &cands,
342 |_| 0.0,
343 |_| 0.0,
344 &ResolutionConfig::default(),
345 );
346 assert!(matches!(dec, Decision::New { .. }));
347 }
348
349 #[test]
352 fn near_match_without_context_is_candidate() {
353 let cands = vec![make_key("e1", "alicia", "Person")];
356 let dec = resolve(
357 "alice",
358 &"Person".to_string(),
359 &cands,
360 |_| 0.0,
361 |_| 0.0,
362 &ResolutionConfig::default(),
363 );
364 assert!(
365 matches!(dec, Decision::Candidate { .. }),
366 "near match without context should be Candidate, got {dec:?}"
367 );
368 }
369
370 #[test]
371 fn near_match_with_context_links() {
372 let cands = vec![make_key("e1", "alicia", "Person")];
374 let dec = resolve(
375 "alice",
376 &"Person".to_string(),
377 &cands,
378 |_| 1.0, |_| 0.0,
380 &ResolutionConfig::default(),
381 );
382 assert!(
383 matches!(dec, Decision::Link { .. }),
384 "near match with context should Link, got {dec:?}"
385 );
386 }
387
388 #[test]
389 fn prefix_sharing_does_not_inflate_score() {
390 let cands = vec![make_key("e1", "김서연", "Person")];
394 let dec = resolve(
395 "김민수",
396 &"Person".to_string(),
397 &cands,
398 |_| 0.0,
399 |_| 0.0,
400 &ResolutionConfig::default(),
401 );
402 assert!(
405 !matches!(dec, Decision::Link { .. }),
406 "shared surname should not Link without context, got {dec:?}"
407 );
408 }
409
410 #[test]
413 fn pertype_default_and_override() {
414 let mut pt = PerType::new(0.0);
415 assert_eq!(pt.weight("Person"), 0.0);
416 pt.set("Concept", 0.3);
417 assert_eq!(pt.weight("Concept"), 0.3);
418 assert_eq!(pt.weight("Person"), 0.0); }
420}