/// 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 { 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> { // 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 = 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 = 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 = seeds.iter().copied().collect(); let mut results: Vec = 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> { let mut best_strength: HashMap = HashMap::new(); let mut queue: BinaryHeap = 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 = seeds.iter().copied().collect(); let mut results: Vec = 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) }