Skip to main content

oxibrain_core/
resolution.rs

1//! Identity and resolution (ARCHITECTURE.md §8, §10).
2//!
3//! Lexical matching uses **n-gram Jaccard** (§7.7) — order-insensitive,
4//! prefix-neutral, identical in every script. The Jaro-Winkler prefix bonus
5//! that systematically boosted shared surnames (F28, D30) is gone.
6//!
7//! Embeddings and graph context are wired but may remain zero until M9 — they
8//! are **not** deleted and **not** hardcoded in the caller (that is how F13
9//! happened). The caller passes the actual values; the architecture decides
10//! whether they are zero.
11
12use crate::knowledge::{EntityId, EntityKey, EntityTypeRef, ResolutionMethod};
13use oxibrain_index::ngram;
14use std::collections::BTreeMap;
15
16// ─── PerType ───────────────────────────────────────────────────────────────
17
18/// A per-entity-type value with a default for unmapped types (§10.3).
19///
20/// Embedding weights differ by type: low for Person/Organization (where names
21/// are literal), higher for Concept (where paraphrase is normal). The default
22/// covers types not explicitly configured.
23#[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    /// Set the value for a specific entity type.
38    pub fn set(&mut self, ty: &str, value: T) {
39        self.overrides.insert(ty.to_string(), value);
40    }
41
42    /// Look up the value for a type, falling back to the default.
43    pub fn get(&self, ty: &str) -> &T {
44        self.overrides.get(ty).unwrap_or(&self.default)
45    }
46}
47
48impl PerType<f64> {
49    /// Convenience: return the weight as a plain f64.
50    pub fn weight(&self, ty: &str) -> f64 {
51        *self.get(ty)
52    }
53}
54
55// ─── Config ────────────────────────────────────────────────────────────────
56
57/// Configuration for resolution thresholds and scoring weights (§10.1).
58#[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            // Thresholds lowered from v1.0 (0.85 / 0.55): n-gram Jaccard gives
72            // lower absolute scores than Jaro-Winkler for the same string pair.
73            // Exact matches still score ≥ 1.0 via w_exact; the thresholds govern
74            // the fuzzy + context zone. Calibrated against Jaccard on 3-gram
75            // shingles — "alice"/"alicia" ≈ 0.36, "alice"/"alise" ≈ 0.40.
76            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            // Embeddings are a secondary signal for names (§10.3): low for
82            // Person/Organization (names are literal), higher for Concept
83            // (paraphrase is normal). The signal itself comes from the caller
84            // and is zero until embeddings are available at resolution time.
85            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// ─── Decision ──────────────────────────────────────────────────────────────
97
98/// The resolution decision for a mention against a set of candidates.
99#[derive(Debug, Clone)]
100pub enum Decision {
101    /// Link to existing entity. Score ≥ tau_high.
102    Link {
103        entity: EntityId,
104        method: ResolutionMethod,
105        score: f64,
106    },
107    /// Create a new entity. Score ≤ tau_low.
108    New {
109        method: ResolutionMethod,
110        score: f64,
111    },
112    /// Create a new entity AND record a merge candidate.
113    /// tau_low < score < tau_high.
114    Candidate {
115        new_entity: EntityId,
116        existing: EntityId,
117        score: f64,
118    },
119}
120
121// ─── Normalize ─────────────────────────────────────────────────────────────
122
123/// Normalize a surface form: NFKC, casefold, collapse whitespace.
124/// Honorifics/suffixes per entity type are registry data (§7.6) and stripped
125/// when configured; normalization is always script-neutral (P11).
126pub 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
137// ─── Score ─────────────────────────────────────────────────────────────────
138
139/// Compute the resolution score for a candidate.
140///
141/// `score = type_gate × (w_exact·is_exact + w_ngram·jaccard₃ + w_graph·ctx + w_emb·emb)`
142///
143/// - `type_gate` = 0.0 if types disagree (hard reject), 1.0 if they match.
144/// - `jaccard₃` is n-gram Jaccard over 3-gram shingles of the normalized
145///   surfaces (§7.7). Order-insensitive and prefix-neutral in every script.
146/// - `ctx` is the graph-context overlap (§10.2) — evidence about the world,
147///   not a spelling heuristic.
148/// - `emb` is the embedding similarity (§10.3) — secondary for names, weighted
149///   per type.
150///
151/// Result is clamped to `[0, 1]`.
152pub 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    // Hard type gate.
161    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    // N-gram Jaccard over 3-gram shingles (§7.7). Prefix-neutral, script-neutral.
172    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
186// ─── Resolve ───────────────────────────────────────────────────────────────
187
188/// Resolve a mention against a list of candidate entity keys.
189///
190/// `candidates` must already be filtered to the same space.
191/// `graph_context` returns the context-overlap score [0, 1] for a candidate
192/// entity (shared-neighbors fraction, §10.2).
193/// `embedding_sim` returns the embedding-similarity score [0, 1] for a
194/// candidate entity (§10.3). Both may return 0.0 when the signal is
195/// unavailable — but the **caller** decides that, not this function.
196///
197/// Returns the decision: Link, New, or Candidate.
198pub 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    // Score all candidates.
207    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    // Sort descending by score, then by entity id for determinism.
217    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            // Between thresholds: new entity + merge candidate.
246            Decision::Candidate {
247                new_entity: String::new(), // caller assigns the new entity id
248                existing: c.entity.clone(),
249                score: best,
250            }
251        }
252    }
253}
254
255// ─── Tests ─────────────────────────────────────────────────────────────────
256
257#[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    // ── N-gram Jaccard behavior (§7.7, D30) ─────────────────────────────
350
351    #[test]
352    fn near_match_without_context_is_candidate() {
353        // "alice" vs "alicia": Jaccard ≈ 0.36, below tau_high (0.75), above
354        // tau_low (0.25) → Candidate, not Link.
355        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        // Same near-match but with strong graph context → Link.
373        let cands = vec![make_key("e1", "alicia", "Person")];
374        let dec = resolve(
375            "alice",
376            &"Person".to_string(),
377            &cands,
378            |_| 1.0, // full context overlap
379            |_| 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        // D30: two Korean names sharing the surname 김 should NOT score as
391        // highly similar. "김민수" vs "김서연" — shared prefix is the surname,
392        // not evidence of identity.
393        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        // Without context, two different given names with a shared surname
403        // should be New or Candidate, never Link.
404        assert!(
405            !matches!(dec, Decision::Link { .. }),
406            "shared surname should not Link without context, got {dec:?}"
407        );
408    }
409
410    // ── PerType ─────────────────────────────────────────────────────────
411
412    #[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); // unmapped → default
419    }
420}