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:
ClawHDF5 Coding Agent
2026-08-17 00:34:01 +00:00
parent 4051d5c16e
commit 1efd82c841
+87 -8
View File
@@ -155,6 +155,55 @@ fn levenshtein(a: &str, b: &str) -> usize {
prev[nb] 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 // KnowledgeCache
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -348,6 +397,7 @@ impl KnowledgeCache {
/// together with their discovered depth. The seed entity itself is NOT /// together with their discovered depth. The seed entity itself is NOT
/// included. Traversal follows both outgoing and incoming relation edges. /// included. Traversal follows both outgoing and incoming relation edges.
pub fn bfs_neighbors(&self, entity_id: u64, max_depth: usize) -> Vec<(Entity, usize)> { 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 visited: HashSet<u64> = HashSet::new();
let mut queue: VecDeque<(u64, usize)> = VecDeque::new(); let mut queue: VecDeque<(u64, usize)> = VecDeque::new();
let mut results: Vec<(Entity, usize)> = Vec::new(); let mut results: Vec<(Entity, usize)> = Vec::new();
@@ -360,11 +410,13 @@ impl KnowledgeCache {
continue; continue;
} }
// Collect neighbour IDs from outgoing and incoming edges. // Collect neighbour IDs from outgoing and incoming edges touching
let neighbours: Vec<u64> = self // this node only, instead of scanning every relation in the graph.
.relations let neighbours: Vec<u64> = idx
.relations_touching(current_id)
.iter() .iter()
.filter_map(|r| { .filter_map(|&i| {
let r = &self.relations[i];
if r.src == current_id { if r.src == current_id {
Some(r.tgt) Some(r.tgt)
} else if r.tgt == current_id { } else if r.tgt == current_id {
@@ -377,9 +429,9 @@ impl KnowledgeCache {
for neighbour_id in neighbours { for neighbour_id in neighbours {
if visited.insert(neighbour_id) 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)); queue.push_back((neighbour_id, depth + 1));
} }
} }
@@ -450,6 +502,7 @@ impl KnowledgeCache {
min_activation: f32, min_activation: f32,
max_steps: usize, max_steps: usize,
) -> Vec<(u64, f32)> { ) -> Vec<(u64, f32)> {
let idx = AdjacencyIndex::build(&self.entities, &self.relations);
let mut activation: HashMap<u64, f32> = HashMap::new(); let mut activation: HashMap<u64, f32> = HashMap::new();
// Initialise seeds with activation 1.0. // Initialise seeds with activation 1.0.
@@ -472,8 +525,10 @@ impl KnowledgeCache {
let mut any_spread = false; let mut any_spread = false;
for (source_id, source_score) in current { for (source_id, source_score) in current {
// Spread to all neighbours via outgoing and incoming edges. // Spread only to edges touching this node, instead of
for rel in &self.relations { // 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 { let neighbour_id = if rel.src == source_id {
rel.tgt rel.tgt
} else if rel.tgt == source_id { } else if rel.tgt == source_id {
@@ -1059,6 +1114,30 @@ mod tests {
assert!(b_score.unwrap() > 0.0); 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] #[test]
fn test_spreading_activation_decay_reduces_signal() { fn test_spreading_activation_decay_reduces_signal() {
let mut cache = KnowledgeCache::new(); let mut cache = KnowledgeCache::new();