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:
ClawHDF5 Coding Agent
2026-08-17 00:32:20 +00:00
parent 934d053f92
commit 4051d5c16e
2 changed files with 35 additions and 10 deletions
+34 -10
View File
@@ -50,6 +50,9 @@ impl RelationType {
pub struct Entity { pub struct Entity {
pub id: u64, pub id: u64,
pub name: String, 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, pub entity_type: String,
/// Index into the memory embeddings array, or -1 if none. /// Index into the memory embeddings array, or -1 if none.
pub embedding_idx: i64, pub embedding_idx: i64,
@@ -69,6 +72,7 @@ impl Default for Entity {
Self { Self {
id: 0, id: 0,
name: String::new(), name: String::new(),
name_lower: String::new(),
entity_type: String::new(), entity_type: String::new(),
embedding_idx: -1, embedding_idx: -1,
properties: HashMap::new(), properties: HashMap::new(),
@@ -198,6 +202,7 @@ impl KnowledgeCache {
self.entities.push(Entity { self.entities.push(Entity {
id, id,
name: name.to_owned(), name: name.to_owned(),
name_lower: name.to_lowercase(),
entity_type: entity_type.to_owned(), entity_type: entity_type.to_owned(),
embedding_idx, embedding_idx,
properties: HashMap::new(), properties: HashMap::new(),
@@ -310,16 +315,22 @@ impl KnowledgeCache {
) -> (u64, bool) { ) -> (u64, bool) {
let lower_name = name.to_lowercase(); let lower_name = name.to_lowercase();
// Search for the closest existing entity. // Search for the closest existing entity, short-circuiting on an
let best = self // exact match since no closer candidate can exist.
.entities let mut best: Option<(u64, usize)> = None;
.iter() for e in &self.entities {
.map(|e| { let dist = levenshtein(&lower_name, &e.name_lower);
let dist = levenshtein(&lower_name, &e.name.to_lowercase()); if dist > max_distance {
(e.id, dist) continue;
}) }
.filter(|&(_, dist)| dist <= max_distance) if dist == 0 {
.min_by_key(|&(_, dist)| dist); 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 { if let Some((id, _)) = best {
return (id, false); return (id, false);
@@ -855,6 +866,19 @@ mod tests {
assert_eq!(id, orig_id); 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] #[test]
fn test_resolve_or_create_no_match_beyond_threshold() { fn test_resolve_or_create_no_match_beyond_threshold() {
let mut cache = KnowledgeCache::new(); let mut cache = KnowledgeCache::new();
+1
View File
@@ -480,6 +480,7 @@ fn load_knowledge_group(file: &clawhdf5::File) -> Result<KnowledgeCache, MemoryE
cache.entities.push(crate::knowledge::Entity { cache.entities.push(crate::knowledge::Entity {
id: entity_ids[i] as u64, id: entity_ids[i] as u64,
name: entity_names[i].clone(), name: entity_names[i].clone(),
name_lower: entity_names[i].to_lowercase(),
entity_type: entity_types[i].clone(), entity_type: entity_types[i].clone(),
embedding_idx: emb_idxs[i], embedding_idx: emb_idxs[i],
..Default::default() ..Default::default()