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
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
/// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user