perf(agent): cache lowercased entity names and early-exit in resolve_or_create
resolve_or_create allocated a fresh lowercased String for every entity on every call (this runs per extracted mention during entity/relation extraction) and never short-circuited on an exact dist == 0 match, scoring every remaining entity regardless. Add Entity::name_lower, computed once at construction (add_entity, and schema.rs's direct-push load path), and break out of the scan as soon as an exact match is found. INT-12
This commit is contained in:
@@ -50,6 +50,9 @@ impl RelationType {
|
||||
pub struct Entity {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// Lowercased `name`, cached at construction time to avoid re-allocating
|
||||
/// and re-lowercasing on every entity-resolution scan.
|
||||
pub name_lower: String,
|
||||
pub entity_type: String,
|
||||
/// Index into the memory embeddings array, or -1 if none.
|
||||
pub embedding_idx: i64,
|
||||
@@ -69,6 +72,7 @@ impl Default for Entity {
|
||||
Self {
|
||||
id: 0,
|
||||
name: String::new(),
|
||||
name_lower: String::new(),
|
||||
entity_type: String::new(),
|
||||
embedding_idx: -1,
|
||||
properties: HashMap::new(),
|
||||
@@ -198,6 +202,7 @@ impl KnowledgeCache {
|
||||
self.entities.push(Entity {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
name_lower: name.to_lowercase(),
|
||||
entity_type: entity_type.to_owned(),
|
||||
embedding_idx,
|
||||
properties: HashMap::new(),
|
||||
@@ -310,16 +315,22 @@ impl KnowledgeCache {
|
||||
) -> (u64, bool) {
|
||||
let lower_name = name.to_lowercase();
|
||||
|
||||
// Search for the closest existing entity.
|
||||
let best = self
|
||||
.entities
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let dist = levenshtein(&lower_name, &e.name.to_lowercase());
|
||||
(e.id, dist)
|
||||
})
|
||||
.filter(|&(_, dist)| dist <= max_distance)
|
||||
.min_by_key(|&(_, dist)| dist);
|
||||
// Search for the closest existing entity, short-circuiting on an
|
||||
// exact match since no closer candidate can exist.
|
||||
let mut best: Option<(u64, usize)> = None;
|
||||
for e in &self.entities {
|
||||
let dist = levenshtein(&lower_name, &e.name_lower);
|
||||
if dist > max_distance {
|
||||
continue;
|
||||
}
|
||||
if dist == 0 {
|
||||
best = Some((e.id, dist));
|
||||
break;
|
||||
}
|
||||
if best.is_none_or(|(_, best_dist)| dist < best_dist) {
|
||||
best = Some((e.id, dist));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((id, _)) = best {
|
||||
return (id, false);
|
||||
@@ -855,6 +866,19 @@ mod tests {
|
||||
assert_eq!(id, orig_id);
|
||||
}
|
||||
|
||||
/// An exact match must win even when a near-match with a smaller Levenshtein
|
||||
/// distance-to-zero gap was scanned first — the early exit on dist == 0
|
||||
/// must not skip past a later exact match.
|
||||
#[test]
|
||||
fn test_resolve_or_create_exact_match_beats_earlier_fuzzy_candidate() {
|
||||
let mut cache = KnowledgeCache::new();
|
||||
cache.add_entity("Alyce", "person", -1); // dist 1 from "Alice"
|
||||
let exact_id = cache.add_entity("Alice", "person", -1); // dist 0
|
||||
let (id, created) = cache.resolve_or_create("Alice", "person", -1, 2);
|
||||
assert!(!created);
|
||||
assert_eq!(id, exact_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_or_create_no_match_beyond_threshold() {
|
||||
let mut cache = KnowledgeCache::new();
|
||||
|
||||
Reference in New Issue
Block a user