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:
Will Anderson
2026-04-29 03:27:33 -05:00
parent 61a4632163
commit 909c1577f1
89 changed files with 2114 additions and 452 deletions
@@ -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 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)
}