Files
el/engram/engrams/engram-core/src/activation.rs
T
Will Anderson 909c1577f1 rename crates/ to engrams/, bindings/ to receptors/
- crates/ → engrams/ (Rust engrams live here)
- bindings/ → receptors/ (cross-language access points into the graph)
- Cargo.toml workspace paths updated
2026-04-29 03:27:33 -05:00

319 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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 24)
/// * `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)
}