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]
|
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user