909c1577f1
- crates/ → engrams/ (Rust engrams live here) - bindings/ → receptors/ (cross-language access points into the graph) - Cargo.toml workspace paths updated
319 lines
13 KiB
Rust
319 lines
13 KiB
Rust
/// Spreading Activation — the core retrieval mechanism of Engram.
|
||
///
|
||
/// # The Central Insight
|
||
///
|
||
/// Conventional databases separate storage from retrieval. You put data in,
|
||
/// you query it out. The storage structure (B-tree, LSM, etc.) and the retrieval
|
||
/// mechanism (SQL planner, index scan) are fundamentally different things.
|
||
///
|
||
/// The brain doesn't work this way. Memory is not stored and retrieved —
|
||
/// it is **activated and propagated**. When you remember something, you don't
|
||
/// "query" your hippocampus. You activate a node and the pattern spreads through
|
||
/// weighted connections to neighboring nodes. Long-term potentiation IS the storage
|
||
/// structure AND the retrieval mechanism simultaneously.
|
||
///
|
||
/// This module implements that model directly.
|
||
///
|
||
/// # How It Works
|
||
///
|
||
/// 1. **Seeds**: Start with one or more known node UUIDs (e.g., the most recent
|
||
/// context, the current task, recent observations).
|
||
///
|
||
/// 2. **Query embedding**: The semantic vector representing what you're looking
|
||
/// for. This is the "direction of thought" — activation flows more strongly
|
||
/// toward nodes that are semantically similar to the current context.
|
||
///
|
||
/// 3. **BFS propagation**: Activation spreads outward from seeds through edges.
|
||
/// At each hop, the strength attenuates based on:
|
||
/// - `edge.weight`: how strongly these two nodes are associated
|
||
/// - `target.salience`: how salient (recently activated, frequent, important) the target is
|
||
/// - `cosine_sim(query, target)`: how semantically relevant the target is to what we want
|
||
///
|
||
/// 4. **Pruning**: Paths with activation strength below `PRUNE_THRESHOLD` are cut.
|
||
/// This prevents exponential blowup and models the brain's attention filter.
|
||
///
|
||
/// 5. **Return**: The top-N nodes by activation strength, with their hop distance.
|
||
///
|
||
/// # Activation Formula (per hop)
|
||
///
|
||
/// strength = parent_strength × edge_weight × target_salience × cosine_sim(query, target)
|
||
///
|
||
/// This is multiplicative: a weak edge, a dormant node, or a semantically irrelevant
|
||
/// target all suppress activation. All four factors must be non-trivial for a path
|
||
/// to propagate successfully. This is exactly how associative memory works.
|
||
///
|
||
/// # Why Multiplication, Not Addition
|
||
///
|
||
/// Addition would allow many weak signals to accumulate into false relevance.
|
||
/// The brain's associative memory is conjunctive: an activated path requires
|
||
/// ALL of its links to be strong enough to carry the signal. Multiplication
|
||
/// enforces this. If any factor is near zero, the path dies.
|
||
use crate::error::EngramResult;
|
||
use crate::types::{ActivatedNode, Node};
|
||
use crate::vector::cosine_similarity;
|
||
use std::collections::{BinaryHeap, HashMap};
|
||
use uuid::Uuid;
|
||
|
||
#[cfg(feature = "sled-backend")]
|
||
use crate::graph;
|
||
#[cfg(feature = "sled-backend")]
|
||
use sled::Db;
|
||
|
||
#[cfg(feature = "wasm")]
|
||
use crate::mem_storage::MemStore;
|
||
|
||
/// Activation strengths below this threshold are pruned from the BFS frontier.
|
||
/// 0.01 is deliberately small — we want to allow long indirect chains when
|
||
/// the intermediate edges are strong. Raise this to focus retrieval, lower to
|
||
/// allow more associative drift.
|
||
const PRUNE_THRESHOLD: f32 = 0.01;
|
||
|
||
// We need Ord on (f32, Uuid) for the priority queue. Use a wrapper.
|
||
#[derive(PartialEq)]
|
||
struct Candidate {
|
||
strength: f32,
|
||
hops: u8,
|
||
id: Uuid,
|
||
}
|
||
|
||
impl Eq for Candidate {}
|
||
|
||
impl PartialOrd for Candidate {
|
||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||
Some(self.cmp(other))
|
||
}
|
||
}
|
||
|
||
impl Ord for Candidate {
|
||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||
// BinaryHeap is a max-heap; we want highest-strength first
|
||
self.strength
|
||
.partial_cmp(&other.strength)
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
}
|
||
}
|
||
|
||
/// Run spreading activation from a set of seed nodes.
|
||
///
|
||
/// # Arguments
|
||
/// * `db` — the open engram database
|
||
/// * `seeds` — starting node IDs (the current "active set")
|
||
/// * `query_embedding` — semantic vector representing what we're looking for
|
||
/// * `max_depth` — maximum number of hops to traverse (typically 2–4)
|
||
/// * `limit` — return only the top-N results
|
||
///
|
||
/// # Returns
|
||
/// Up to `limit` nodes, sorted by activation strength descending.
|
||
/// Seed nodes themselves are excluded from the result (they're already known).
|
||
#[cfg(feature = "sled-backend")]
|
||
pub fn activate(
|
||
db: &Db,
|
||
seeds: &[Uuid],
|
||
query_embedding: &[f32],
|
||
max_depth: u8,
|
||
limit: usize,
|
||
) -> EngramResult<Vec<ActivatedNode>> {
|
||
// best_strength[id] = highest activation strength seen so far for this node.
|
||
// We use this to handle cases where multiple paths lead to the same node —
|
||
// the strongest path wins (like the brain's winner-take-most competition).
|
||
let mut best_strength: HashMap<Uuid, (f32, u8)> = HashMap::new();
|
||
|
||
// Priority queue: process highest-strength candidates first.
|
||
// This is a best-first BFS — we explore the most promising paths before
|
||
// weaker ones, which means pruning cuts off genuinely unimportant branches.
|
||
let mut queue: BinaryHeap<Candidate> = BinaryHeap::new();
|
||
|
||
// Initialize: seed nodes start with full strength (1.0).
|
||
// They represent our current context — fully activated, zero hops away.
|
||
for &seed in seeds {
|
||
// Seeds are tracked with strength 1.0 but NOT added to best_strength yet;
|
||
// we want to allow other paths to reach them if they form a cycle.
|
||
// However, we must visit their neighbors. We add seeds directly.
|
||
queue.push(Candidate {
|
||
strength: 1.0,
|
||
hops: 0,
|
||
id: seed,
|
||
});
|
||
// Mark seeds so we don't re-process them as results, but allow
|
||
// re-traversal from them if another path arrives stronger.
|
||
best_strength.insert(seed, (1.0, 0));
|
||
}
|
||
|
||
// BFS / best-first traversal
|
||
while let Some(Candidate { strength, hops, id }) = queue.pop() {
|
||
// Depth limit: don't propagate beyond max_depth
|
||
if hops >= max_depth {
|
||
continue;
|
||
}
|
||
|
||
// Retrieve outgoing edges from the current node
|
||
let edges = graph::edges_from(db, id)?;
|
||
|
||
for edge in &edges {
|
||
let target_id = edge.to_id;
|
||
|
||
// Load the target node. If it doesn't exist (dangling edge), skip.
|
||
let target: Node = match graph::get_node(db, target_id)? {
|
||
Some(n) => n,
|
||
None => continue,
|
||
};
|
||
|
||
// ── Activation strength computation ──────────────────────────
|
||
//
|
||
// Each factor models a distinct aspect of associative memory:
|
||
//
|
||
// 1. parent_strength: how strongly was the parent activated?
|
||
// Activation attenuates with each hop — deep chains carry less signal.
|
||
//
|
||
// 2. edge.weight: how strong is the association between these nodes?
|
||
// High-weight edges are like well-worn neural pathways — low resistance.
|
||
// Low-weight edges are new or rarely traversed — they carry little signal.
|
||
//
|
||
// 3. target.salience: how salient is the target node right now?
|
||
// Dormant nodes (low salience) resist activation.
|
||
// Frequently-used, recently-touched nodes activate easily.
|
||
// This is how recency and frequency bias retrieval, as in human memory.
|
||
//
|
||
// 4. cosine_sim(query, target): semantic relevance.
|
||
// If the target's embedding is far from what we're looking for,
|
||
// the activation doesn't flow there. This is the "direction of thought"
|
||
// filtering — the query steers the spread toward relevant regions.
|
||
//
|
||
// The product of all four is the activation strength at the target.
|
||
// All factors are in [0, 1] so the product is also in [0, 1].
|
||
// (Salience can exceed 1 for very active nodes, which is fine —
|
||
// it means those nodes are hyper-salient, like obsessive thoughts.)
|
||
|
||
let semantic_sim = cosine_similarity(query_embedding, &target.embedding);
|
||
// We clamp semantic_sim to [0, 1] so that anti-correlated embeddings
|
||
// don't produce negative activation (which would invert the signal).
|
||
let semantic_sim = semantic_sim.max(0.0);
|
||
|
||
let new_strength = strength * edge.weight * target.salience.max(0.0) * semantic_sim;
|
||
|
||
// Prune: if this path is too weak to matter, stop here.
|
||
// This is the attention filter — irrelevant associations fade away.
|
||
if new_strength < PRUNE_THRESHOLD {
|
||
continue;
|
||
}
|
||
|
||
let next_hops = hops + 1;
|
||
|
||
// Winner-take-most: only propagate from this node if this is the
|
||
// strongest path we've seen to it so far. This prevents exponential
|
||
// blowup when the graph has many parallel paths to the same node.
|
||
let is_stronger = match best_strength.get(&target_id) {
|
||
Some(&(prev, _)) => new_strength > prev,
|
||
None => true,
|
||
};
|
||
|
||
if is_stronger {
|
||
best_strength.insert(target_id, (new_strength, next_hops));
|
||
queue.push(Candidate {
|
||
strength: new_strength,
|
||
hops: next_hops,
|
||
id: target_id,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Collect results: exclude seed nodes, load full Node structs, sort by strength
|
||
let seed_set: std::collections::HashSet<Uuid> = seeds.iter().copied().collect();
|
||
|
||
let mut results: Vec<ActivatedNode> = Vec::new();
|
||
for (id, (strength, hops)) in &best_strength {
|
||
if seed_set.contains(id) {
|
||
continue;
|
||
}
|
||
if let Some(node) = graph::get_node(db, *id)? {
|
||
results.push(ActivatedNode {
|
||
node,
|
||
activation_strength: *strength,
|
||
hops: *hops,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sort by activation strength descending, take top N
|
||
results.sort_by(|a, b| {
|
||
b.activation_strength
|
||
.partial_cmp(&a.activation_strength)
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
});
|
||
results.truncate(limit);
|
||
|
||
Ok(results)
|
||
}
|
||
|
||
/// In-memory spreading activation for the WASM backend.
|
||
///
|
||
/// Identical algorithm to `activate` but reads from a `MemStore` instead of sled.
|
||
#[cfg(feature = "wasm")]
|
||
pub fn activate_mem(
|
||
store: &MemStore,
|
||
seeds: &[Uuid],
|
||
query_embedding: &[f32],
|
||
max_depth: u8,
|
||
limit: usize,
|
||
) -> EngramResult<Vec<ActivatedNode>> {
|
||
let mut best_strength: HashMap<Uuid, (f32, u8)> = HashMap::new();
|
||
let mut queue: BinaryHeap<Candidate> = BinaryHeap::new();
|
||
|
||
for &seed in seeds {
|
||
queue.push(Candidate { strength: 1.0, hops: 0, id: seed });
|
||
best_strength.insert(seed, (1.0, 0));
|
||
}
|
||
|
||
while let Some(Candidate { strength, hops, id }) = queue.pop() {
|
||
if hops >= max_depth {
|
||
continue;
|
||
}
|
||
let edges = store.read_edges_from(id)?;
|
||
for edge in &edges {
|
||
let target_id = edge.to_id;
|
||
let target: Node = match store.read_node(target_id)? {
|
||
Some(n) => n,
|
||
None => continue,
|
||
};
|
||
let semantic_sim = cosine_similarity(query_embedding, &target.embedding).max(0.0);
|
||
let new_strength = strength * edge.weight * target.salience.max(0.0) * semantic_sim;
|
||
if new_strength < PRUNE_THRESHOLD {
|
||
continue;
|
||
}
|
||
let next_hops = hops + 1;
|
||
let is_stronger = match best_strength.get(&target_id) {
|
||
Some(&(prev, _)) => new_strength > prev,
|
||
None => true,
|
||
};
|
||
if is_stronger {
|
||
best_strength.insert(target_id, (new_strength, next_hops));
|
||
queue.push(Candidate { strength: new_strength, hops: next_hops, id: target_id });
|
||
}
|
||
}
|
||
}
|
||
|
||
let seed_set: std::collections::HashSet<Uuid> = seeds.iter().copied().collect();
|
||
let mut results: Vec<ActivatedNode> = Vec::new();
|
||
for (id, (strength, hops)) in &best_strength {
|
||
if seed_set.contains(id) {
|
||
continue;
|
||
}
|
||
if let Some(node) = store.read_node(*id)? {
|
||
results.push(ActivatedNode {
|
||
node,
|
||
activation_strength: *strength,
|
||
hops: *hops,
|
||
});
|
||
}
|
||
}
|
||
|
||
results.sort_by(|a, b| {
|
||
b.activation_strength
|
||
.partial_cmp(&a.activation_strength)
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
});
|
||
results.truncate(limit);
|
||
Ok(results)
|
||
}
|