perf(agent): add adjacency index for knowledge graph traversal
bfs_neighbors scanned the entire relations list per queue-popped node (O(V·E) instead of O(V+E)) and did an O(n) linear find over entities per discovered neighbor; spreading_activation scanned the entire relations list per active node per step (O(max_steps·active·E)). Add a per-call AdjacencyIndex (entity-id -> entities-index map, entity-id -> touching-relation-indices map) built once in O(V+E) and shared by both traversal loops, replacing the linear scans with O(degree) / O(1) lookups. Built fresh per call rather than cached on KnowledgeCache: entities and relations are plain pub Vecs pushed to directly by schema.rs's load path (bypassing add_entity/add_relation), so a persisted index would need extra staleness bookkeeping. get_relations_from/ get_relations_to are left as plain O(E) filters — they're single-node lookups already optimal for a standalone call; wrapping them in an O(V+E) index build would be a regression, not a fix, and nothing in the codebase currently calls them in a per-node loop. Added a self-loop regression test: the index must visit a src==tgt relation exactly once, matching the original flat-iteration behavior. INT-13
This commit is contained in:
@@ -155,6 +155,55 @@ fn levenshtein(a: &str, b: &str) -> usize {
|
||||
prev[nb]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdjacencyIndex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Adjacency index over a snapshot of `entities`/`relations`: an entity-id ->
|
||||
/// entities-slice-index map, and an entity-id -> relation-indices map (edges
|
||||
/// touching that entity as either source or target).
|
||||
///
|
||||
/// Built fresh per traversal call rather than cached on `KnowledgeCache`:
|
||||
/// entities/relations are plain `pub` `Vec`s that get pushed to directly
|
||||
/// (e.g. `schema.rs`'s load path bypasses `add_entity`/`add_relation`), so a
|
||||
/// persistent index would need extra bookkeeping to avoid drifting stale. A
|
||||
/// one-off O(V+E) build per call is still a large win over the O(V·E) (BFS)
|
||||
/// / O(steps·active·E) (spreading activation) scans it replaces.
|
||||
struct AdjacencyIndex {
|
||||
entity_index: HashMap<u64, usize>,
|
||||
by_entity: HashMap<u64, Vec<usize>>,
|
||||
}
|
||||
|
||||
impl AdjacencyIndex {
|
||||
fn build(entities: &[Entity], relations: &[Relation]) -> Self {
|
||||
let mut entity_index = HashMap::with_capacity(entities.len());
|
||||
for (i, e) in entities.iter().enumerate() {
|
||||
entity_index.insert(e.id, i);
|
||||
}
|
||||
|
||||
let mut by_entity: HashMap<u64, Vec<usize>> = HashMap::new();
|
||||
for (i, r) in relations.iter().enumerate() {
|
||||
by_entity.entry(r.src).or_default().push(i);
|
||||
if r.tgt != r.src {
|
||||
by_entity.entry(r.tgt).or_default().push(i);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
entity_index,
|
||||
by_entity,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indices into `relations` of every edge touching `entity_id`.
|
||||
fn relations_touching(&self, entity_id: u64) -> &[usize] {
|
||||
self.by_entity
|
||||
.get(&entity_id)
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KnowledgeCache
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -348,6 +397,7 @@ impl KnowledgeCache {
|
||||
/// together with their discovered depth. The seed entity itself is NOT
|
||||
/// included. Traversal follows both outgoing and incoming relation edges.
|
||||
pub fn bfs_neighbors(&self, entity_id: u64, max_depth: usize) -> Vec<(Entity, usize)> {
|
||||
let idx = AdjacencyIndex::build(&self.entities, &self.relations);
|
||||
let mut visited: HashSet<u64> = HashSet::new();
|
||||
let mut queue: VecDeque<(u64, usize)> = VecDeque::new();
|
||||
let mut results: Vec<(Entity, usize)> = Vec::new();
|
||||
@@ -360,11 +410,13 @@ impl KnowledgeCache {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect neighbour IDs from outgoing and incoming edges.
|
||||
let neighbours: Vec<u64> = self
|
||||
.relations
|
||||
// Collect neighbour IDs from outgoing and incoming edges touching
|
||||
// this node only, instead of scanning every relation in the graph.
|
||||
let neighbours: Vec<u64> = idx
|
||||
.relations_touching(current_id)
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
.filter_map(|&i| {
|
||||
let r = &self.relations[i];
|
||||
if r.src == current_id {
|
||||
Some(r.tgt)
|
||||
} else if r.tgt == current_id {
|
||||
@@ -377,9 +429,9 @@ impl KnowledgeCache {
|
||||
|
||||
for neighbour_id in neighbours {
|
||||
if visited.insert(neighbour_id)
|
||||
&& let Some(entity) = self.get_entity(neighbour_id)
|
||||
&& let Some(&entity_idx) = idx.entity_index.get(&neighbour_id)
|
||||
{
|
||||
results.push((entity.clone(), depth + 1));
|
||||
results.push((self.entities[entity_idx].clone(), depth + 1));
|
||||
queue.push_back((neighbour_id, depth + 1));
|
||||
}
|
||||
}
|
||||
@@ -450,6 +502,7 @@ impl KnowledgeCache {
|
||||
min_activation: f32,
|
||||
max_steps: usize,
|
||||
) -> Vec<(u64, f32)> {
|
||||
let idx = AdjacencyIndex::build(&self.entities, &self.relations);
|
||||
let mut activation: HashMap<u64, f32> = HashMap::new();
|
||||
|
||||
// Initialise seeds with activation 1.0.
|
||||
@@ -472,8 +525,10 @@ impl KnowledgeCache {
|
||||
let mut any_spread = false;
|
||||
|
||||
for (source_id, source_score) in current {
|
||||
// Spread to all neighbours via outgoing and incoming edges.
|
||||
for rel in &self.relations {
|
||||
// Spread only to edges touching this node, instead of
|
||||
// scanning every relation in the graph per active node.
|
||||
for &rel_idx in idx.relations_touching(source_id) {
|
||||
let rel = &self.relations[rel_idx];
|
||||
let neighbour_id = if rel.src == source_id {
|
||||
rel.tgt
|
||||
} else if rel.tgt == source_id {
|
||||
@@ -1059,6 +1114,30 @@ mod tests {
|
||||
assert!(b_score.unwrap() > 0.0);
|
||||
}
|
||||
|
||||
/// A self-loop relation (src == tgt) must be visited exactly once by the
|
||||
/// adjacency index, matching the pre-index behavior of iterating
|
||||
/// `self.relations` directly (each relation processed once regardless of
|
||||
/// how many of its endpoints match the current node).
|
||||
#[test]
|
||||
fn test_spreading_activation_self_loop_not_double_counted() {
|
||||
let mut cache = KnowledgeCache::new();
|
||||
let a = cache.add_entity("A", "node", -1);
|
||||
cache.add_relation(a, a, "self", 1.0);
|
||||
|
||||
let result = cache.spreading_activation(&[a], 0.5, 0.0001, 1);
|
||||
let a_score = result
|
||||
.iter()
|
||||
.find(|&&(id, _)| id == a)
|
||||
.map(|&(_, s)| s)
|
||||
.unwrap();
|
||||
// Seed activation (1.0) plus exactly one spread contribution
|
||||
// (1.0 * weight 1.0 * decay 0.5), not two.
|
||||
assert!(
|
||||
(a_score - 1.5).abs() < 1e-5,
|
||||
"expected 1.5 (one self-loop contribution), got {a_score}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spreading_activation_decay_reduces_signal() {
|
||||
let mut cache = KnowledgeCache::new();
|
||||
|
||||
Reference in New Issue
Block a user