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
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "engram-core"
version = "0.1.1"
edition = "2021"
description = "Engram — native memory substrate for accumulating intelligence"
license = "MIT"
[features]
default = ["sled-backend"]
sled-backend = ["dep:sled"]
wasm = []
migration = ["dep:rusqlite"]
[dependencies]
sled = { version = "0.34", optional = true }
uuid = { version = "1", features = ["v4", "serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bincode = "1"
anyhow = "1"
thiserror = "1"
instant-distance = { version = "0.6", features = ["with-serde"] }
rusqlite = { version = "0.31", optional = true }
[dev-dependencies]
tempfile = "3"
@@ -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)
}
@@ -0,0 +1,308 @@
/// Consolidation — promoting Episodic memories to Semantic knowledge.
///
/// Biological memory consolidation is the process by which unstable,
/// hippocampus-dependent memories are gradually transformed into stable,
/// neocortex-integrated semantic knowledge. In the brain this happens
/// primarily during sleep through hippocampal replay.
///
/// Here, consolidation is explicit and on-demand. The caller decides when to
/// run a consolidation cycle and with what thresholds. The engine:
///
/// 1. Scans all Episodic nodes.
/// 2. Promotes those that have been activated enough (high activation_count)
/// and are still salient enough (above salience_floor) to MemoryTier::Semantic.
/// 3. Runs a global salience decay pass to age all nodes.
/// 4. Returns a report of what changed.
///
/// This models the idea that memories become "knowledge" not by being told they
/// should be, but by being *used* — activated, reinforced, and found relevant
/// repeatedly over time.
use crate::error::EngramResult;
use crate::salience;
use crate::types::{MemoryTier, Node};
#[cfg(feature = "sled-backend")]
use crate::storage;
#[cfg(feature = "sled-backend")]
use crate::graph;
#[cfg(feature = "sled-backend")]
use sled::Db;
#[cfg(feature = "wasm")]
use crate::mem_storage::MemStore;
/// Configuration for a consolidation run.
#[derive(Debug, Clone)]
pub struct ConsolidationConfig {
/// Episodic nodes with activation_count >= this threshold are candidates for promotion.
pub episodic_to_semantic_threshold: u64,
/// Candidates must also have salience >= this floor to be promoted.
pub salience_floor: f32,
/// Maximum number of promotions per consolidation cycle (prevents runaway batch writes).
pub max_promotions_per_run: usize,
/// Decay factor applied to all node saliences after promotion (0.01.0).
pub decay_factor: f32,
}
impl Default for ConsolidationConfig {
fn default() -> Self {
Self {
episodic_to_semantic_threshold: 5,
salience_floor: 0.3,
max_promotions_per_run: 50,
decay_factor: 0.98,
}
}
}
/// Summary of what happened during a consolidation cycle.
#[derive(Debug, Default, Clone)]
pub struct ConsolidationReport {
/// Number of Episodic nodes promoted to Semantic.
pub promoted: usize,
/// Number of nodes whose salience was updated by the decay pass.
pub decayed: usize,
/// Number of nodes removed because their salience dropped below the minimum
/// (currently unused — pruning is opt-in in v0.1).
pub pruned: usize,
}
// ── sled-backed consolidation ─────────────────────────────────────────────────
#[cfg(feature = "sled-backend")]
/// Run a consolidation cycle against the open sled database.
pub fn consolidate(db: &Db, config: &ConsolidationConfig) -> EngramResult<ConsolidationReport> {
let mut report = ConsolidationReport::default();
// Step 1: scan all nodes, identify Episodic candidates.
let all_nodes: Vec<Node> = storage::scan_nodes(db)?;
let mut promoted_count = 0usize;
for mut node in all_nodes {
if node.tier != MemoryTier::Episodic {
continue;
}
if node.activation_count >= config.episodic_to_semantic_threshold
&& node.salience >= config.salience_floor
{
// Promote: change tier to Semantic and persist.
// Use overwrite_node — this is an internal state update, not a new node.
node.tier = MemoryTier::Semantic;
graph::overwrite_node(db, &node)?;
promoted_count += 1;
if promoted_count >= config.max_promotions_per_run {
break;
}
}
}
report.promoted = promoted_count;
// Step 2: global salience decay.
let all_nodes_post: Vec<Node> = storage::scan_nodes(db)?;
let mut decayed_count = 0usize;
for mut node in all_nodes_post {
let new_sal = salience::decay_salience(node.salience, config.decay_factor);
if new_sal != node.salience {
node.salience = new_sal;
storage::write_salience(db, node.id, new_sal)?;
graph::overwrite_node(db, &node)?;
decayed_count += 1;
}
}
report.decayed = decayed_count;
Ok(report)
}
// ── in-memory consolidation (wasm) ────────────────────────────────────────────
#[cfg(feature = "wasm")]
/// Run a consolidation cycle against the in-memory store.
pub fn consolidate_mem(
store: &mut MemStore,
config: &ConsolidationConfig,
) -> EngramResult<ConsolidationReport> {
let mut report = ConsolidationReport::default();
let mut promoted_count = 0usize;
let all_ids: Vec<uuid::Uuid> = store.nodes.keys().copied().collect();
for id in &all_ids {
if promoted_count >= config.max_promotions_per_run {
break;
}
if let Some(node) = store.nodes.get_mut(id) {
if node.tier == MemoryTier::Episodic
&& node.activation_count >= config.episodic_to_semantic_threshold
&& node.salience >= config.salience_floor
{
node.tier = MemoryTier::Semantic;
promoted_count += 1;
}
}
}
report.promoted = promoted_count;
let mut decayed_count = 0usize;
for node in store.nodes.values_mut() {
let new_sal = salience::decay_salience(node.salience, config.decay_factor);
if new_sal != node.salience {
node.salience = new_sal;
decayed_count += 1;
}
}
report.decayed = decayed_count;
Ok(report)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{MemoryTier, Node, NodeType};
fn episodic_node_with_activations(count: u64, salience: f32) -> Node {
let mut node = Node::new(
NodeType::Memory,
vec![0.5; 4],
b"test memory".to_vec(),
MemoryTier::Episodic,
0.8,
);
node.activation_count = count;
node.salience = salience;
node
}
#[test]
fn default_config_sensible_values() {
let cfg = ConsolidationConfig::default();
assert_eq!(cfg.episodic_to_semantic_threshold, 5);
assert!(cfg.salience_floor > 0.0);
assert!(cfg.max_promotions_per_run > 0);
assert!(cfg.decay_factor > 0.0 && cfg.decay_factor <= 1.0);
}
#[test]
fn node_is_promotion_candidate() {
let cfg = ConsolidationConfig::default();
let node = episodic_node_with_activations(10, 0.8);
let is_candidate = node.tier == MemoryTier::Episodic
&& node.activation_count >= cfg.episodic_to_semantic_threshold
&& node.salience >= cfg.salience_floor;
assert!(is_candidate);
}
#[test]
fn node_below_threshold_not_candidate() {
let cfg = ConsolidationConfig::default();
// activation_count below threshold
let node = episodic_node_with_activations(2, 0.8);
let is_candidate = node.tier == MemoryTier::Episodic
&& node.activation_count >= cfg.episodic_to_semantic_threshold
&& node.salience >= cfg.salience_floor;
assert!(!is_candidate);
}
#[test]
fn node_below_salience_floor_not_candidate() {
let cfg = ConsolidationConfig::default();
// salience below floor
let node = episodic_node_with_activations(10, 0.1);
let is_candidate = node.tier == MemoryTier::Episodic
&& node.activation_count >= cfg.episodic_to_semantic_threshold
&& node.salience >= cfg.salience_floor;
assert!(!is_candidate);
}
#[test]
fn decay_reduces_salience() {
let original = 1.0f32;
let decayed = salience::decay_salience(original, 0.98);
assert!(decayed < original);
assert!((decayed - 0.98).abs() < 1e-6);
}
#[test]
fn report_default_is_zero() {
let r = ConsolidationReport::default();
assert_eq!(r.promoted, 0);
assert_eq!(r.decayed, 0);
assert_eq!(r.pruned, 0);
}
#[cfg(feature = "sled-backend")]
#[test]
fn consolidate_promotes_eligible_episodic_nodes() {
use crate::graph;
let dir = tempfile::tempdir().unwrap();
let sled_db = sled::open(dir.path()).unwrap();
let node = episodic_node_with_activations(10, 0.8);
graph::put_node(&sled_db, &node).unwrap();
let cfg = ConsolidationConfig::default();
let report = consolidate(&sled_db, &cfg).unwrap();
assert_eq!(report.promoted, 1);
let stored = graph::get_node(&sled_db, node.id).unwrap().unwrap();
assert_eq!(stored.tier, MemoryTier::Semantic);
}
#[cfg(feature = "sled-backend")]
#[test]
fn consolidate_respects_max_promotions() {
use crate::graph;
let dir = tempfile::tempdir().unwrap();
let sled_db = sled::open(dir.path()).unwrap();
// Insert 10 eligible nodes.
for _ in 0..10 {
let node = episodic_node_with_activations(20, 0.9);
graph::put_node(&sled_db, &node).unwrap();
}
let cfg = ConsolidationConfig {
max_promotions_per_run: 3,
..Default::default()
};
let report = consolidate(&sled_db, &cfg).unwrap();
assert_eq!(report.promoted, 3);
}
#[cfg(feature = "sled-backend")]
#[test]
fn consolidate_runs_decay_after_promotion() {
use crate::graph;
let dir = tempfile::tempdir().unwrap();
let sled_db = sled::open(dir.path()).unwrap();
let node = Node::new(
NodeType::Concept,
vec![0.0; 4],
b"semantic".to_vec(),
MemoryTier::Semantic,
0.5,
);
let original_salience = node.salience;
graph::put_node(&sled_db, &node).unwrap();
let cfg = ConsolidationConfig::default();
let report = consolidate(&sled_db, &cfg).unwrap();
assert!(report.decayed >= 1);
let stored = graph::get_node(&sled_db, node.id).unwrap().unwrap();
assert!(stored.salience < original_salience);
}
}
+549
View File
@@ -0,0 +1,549 @@
/// EngramDb — the top-level database handle.
///
/// All public API methods live here. The internal modules (graph, vector,
/// activation, salience, consolidation) are implementation details. Callers
/// interact only with EngramDb.
///
/// # Feature flags
/// - `sled-backend` (default): persistent storage via sled
/// - `wasm`: in-memory storage only (no filesystem), for WASM targets
// ── sled-backed implementation ────────────────────────────────────────────────
#[cfg(feature = "sled-backend")]
mod sled_impl {
use crate::activation;
use crate::consolidation::{self, ConsolidationConfig, ConsolidationReport};
use crate::edge_type;
use crate::error::{EngramError, EngramResult};
use crate::graph;
use crate::salience;
use crate::storage;
use crate::types::{ActivatedNode, Edge, EdgeTypeDef, Node, ScoredNode, now_ms};
use crate::vector;
use sled::Db;
use std::path::Path;
use uuid::Uuid;
pub struct EngramDb {
pub(crate) db: Db,
}
impl Clone for EngramDb {
/// Clone shares the same underlying sled instance (single file lock,
/// multiple in-process handles). This is safe and avoids re-opening.
fn clone(&self) -> Self {
Self { db: self.db.clone() }
}
}
impl EngramDb {
/// Open (or create) an engram database at the given path.
///
/// Seeds all built-in edge types on first open. Idempotent — existing
/// definitions are not overwritten on subsequent opens.
pub fn open(path: &Path) -> EngramResult<Self> {
let db = sled::open(path)?;
edge_type::seed_builtin_types(&db)?;
Ok(Self { db })
}
// ── Node operations ───────────────────────────────────────────────────
/// Persist a new node. Returns the node's UUID.
///
/// Returns `EngramError::NodeAlreadyExists` if the ID already exists.
/// Nodes are immutable — to update, create a new node and add a
/// `supersedes` edge from new → old.
pub fn put_node(&self, node: Node) -> EngramResult<Uuid> {
let id = graph::put_node(&self.db, &node)?;
// Mark HNSW index dirty so next search rebuilds it.
vector::mark_dirty(&self.db);
Ok(id)
}
/// Retrieve a node by UUID. Returns None if not found.
pub fn get_node(&self, id: Uuid) -> EngramResult<Option<Node>> {
graph::get_node(&self.db, id)
}
// ── Edge operations ───────────────────────────────────────────────────
/// Persist a directed edge between two nodes.
pub fn put_edge(&self, edge: Edge) -> EngramResult<()> {
graph::put_edge(&self.db, &edge)
}
/// All edges originating from a node.
pub fn get_edges_from(&self, from_id: Uuid) -> EngramResult<Vec<Edge>> {
graph::edges_from(&self.db, from_id)
}
/// All edges pointing to a node.
pub fn get_edges_to(&self, to_id: Uuid) -> EngramResult<Vec<Edge>> {
graph::edges_to(&self.db, to_id)
}
// ── Vector search ─────────────────────────────────────────────────────
/// Find the `limit` nodes whose embeddings are most similar to `embedding`.
///
/// Falls back to flat scan for stores with < 100 nodes.
/// Uses the HNSW index for larger stores.
pub fn search_embedding(
&self,
embedding: &[f32],
limit: usize,
) -> EngramResult<Vec<ScoredNode>> {
vector::search_embedding(&self.db, embedding, limit, |id| {
graph::get_node(&self.db, id)
})
}
/// Explicitly build (or rebuild) the HNSW index.
///
/// This is not normally needed — the index is built lazily on first search.
/// Call this if you want to pre-warm the index after a large batch insert.
///
/// Returns the number of nodes indexed.
pub fn build_index(&self) -> EngramResult<usize> {
vector::build_index(&self.db)
}
// ── Spreading activation ──────────────────────────────────────────────
/// Run spreading activation from a set of seed nodes.
pub fn activate(
&self,
seeds: &[Uuid],
query_embedding: &[f32],
max_depth: u8,
limit: usize,
) -> EngramResult<Vec<ActivatedNode>> {
activation::activate(&self.db, seeds, query_embedding, max_depth, limit)
}
// ── Graph traversal ───────────────────────────────────────────────────
/// BFS traversal from `from`, following edges up to `max_depth` hops.
///
/// If `relation` is `Some("causes")`, only edges with that relation
/// name are followed. Pass `None` to follow all edges.
pub fn traverse(
&self,
from: Uuid,
relation: Option<&str>,
max_depth: u8,
) -> EngramResult<Vec<Node>> {
graph::traverse(&self.db, from, relation, max_depth)
}
// ── Edge type registry (native bindings for el) ───────────────────────
//
// These are the el-callable surfaces. All intelligence about when to
// create types, how to score confidence, and when to merge/split lives
// in el. Rust stores and retrieves.
/// Create or update an edge type. If the type already exists its
/// description and confidence are updated; id, first_observed,
/// instance_count, derived_from, supersedes, and deprecated are preserved.
pub fn native_edge_type_put(
&self,
name: &str,
description: &str,
confidence: f32,
) -> EngramResult<()> {
let confidence = confidence.clamp(0.0, 1.0);
if let Some(mut existing) = edge_type::get_edge_type(&self.db, name)? {
existing.description = description.to_string();
existing.confidence = confidence;
edge_type::register_edge_type(&self.db, &existing)?;
} else {
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: name.to_string(),
description: description.to_string(),
first_observed: now_ms(),
instance_count: 0,
confidence,
derived_from: None,
supersedes: None,
deprecated: false,
};
edge_type::register_edge_type(&self.db, &def)?;
}
Ok(())
}
/// Retrieve an edge type as a JSON string. Returns empty string if not found.
pub fn native_edge_type_get(&self, name: &str) -> EngramResult<String> {
match edge_type::get_edge_type(&self.db, name)? {
Some(def) => Ok(serde_json::to_string(&def).unwrap_or_default()),
None => Ok(String::new()),
}
}
/// List all registered edge types as a JSON array string.
pub fn native_edge_type_list(&self) -> EngramResult<String> {
let defs = edge_type::all_edge_types(&self.db)?;
Ok(serde_json::to_string(&defs).unwrap_or_default())
}
/// Increment the instance_count for a named edge type by one.
pub fn native_edge_type_increment_count(&self, name: &str) -> EngramResult<()> {
edge_type::increment_edge_type_count(&self.db, name)
}
/// Update the description and confidence of an existing edge type.
/// instance_count, id, first_observed, and other metadata are preserved.
pub fn native_edge_type_update(
&self,
name: &str,
description: &str,
confidence: f32,
) -> EngramResult<()> {
if let Some(mut def) = edge_type::get_edge_type(&self.db, name)? {
def.description = description.to_string();
def.confidence = confidence.clamp(0.0, 1.0);
edge_type::register_edge_type(&self.db, &def)?;
}
Ok(())
}
// ── Salience management ───────────────────────────────────────────────
/// Mark a node as recently activated — update last_activated, increment
/// activation_count, and recompute salience.
pub fn touch(&self, id: Uuid) -> EngramResult<()> {
let mut node =
graph::get_node(&self.db, id)?.ok_or(EngramError::NotFound(id))?;
node.last_activated = crate::types::now_ms();
node.activation_count += 1;
node.salience = salience::compute_salience(
node.importance,
node.last_activated,
node.activation_count,
);
// Use overwrite_node — touch is an internal state update, not a new node.
graph::overwrite_node(&self.db, &node)?;
Ok(())
}
/// Apply a multiplicative decay to the salience of every node in the store.
///
/// `factor` should be in (0.0, 1.0). Returns the number of nodes updated.
pub fn decay(&self, factor: f32) -> EngramResult<usize> {
if !(0.0..=1.0).contains(&factor) {
return Err(EngramError::InvalidParam(format!(
"decay factor must be in [0.0, 1.0], got {}",
factor
)));
}
let nodes = storage::scan_nodes(&self.db)?;
let mut count = 0usize;
for mut node in nodes {
let new_salience = salience::decay_salience(node.salience, factor);
if new_salience != node.salience {
node.salience = new_salience;
storage::write_salience(&self.db, node.id, new_salience)?;
graph::overwrite_node(&self.db, &node)?;
count += 1;
}
}
Ok(count)
}
// ── Consolidation ─────────────────────────────────────────────────────
/// Run a memory consolidation cycle.
///
/// Promotes Episodic nodes that have been activated enough times and are
/// still salient enough to MemoryTier::Semantic. Then decays all saliences.
///
/// See `consolidation::ConsolidationConfig` for tuning knobs.
pub fn consolidate(
&self,
config: &ConsolidationConfig,
) -> EngramResult<ConsolidationReport> {
consolidation::consolidate(&self.db, config)
}
// ── Statistics ────────────────────────────────────────────────────────
/// Total number of nodes stored.
pub fn node_count(&self) -> EngramResult<usize> {
graph::node_count(&self.db)
}
/// Total number of edges stored.
pub fn edge_count(&self) -> EngramResult<usize> {
graph::edge_count(&self.db)
}
// ── Bulk scan (for sync) ───────────────────────────────────────────────
/// Scan all nodes in the store.
///
/// Used by the sync engine to generate delta snapshots.
pub fn scan_nodes(&self) -> EngramResult<Vec<crate::types::Node>> {
storage::scan_nodes(&self.db)
}
/// Scan all edges in the store (forward index only).
pub fn scan_edges(&self) -> EngramResult<Vec<Edge>> {
let prefix = b"edges:from:";
let mut edges = Vec::new();
for result in self.db.scan_prefix(prefix) {
let (_k, v) = result?;
let edge: Edge = bincode::deserialize(&v)?;
edges.push(edge);
}
Ok(edges)
}
/// Delete a node by UUID (tombstone support for sync).
pub fn delete_node(&self, id: Uuid) -> EngramResult<()> {
let key = storage::node_key(id);
self.db.remove(key)?;
Ok(())
}
}
}
// ── WASM / in-memory implementation ──────────────────────────────────────────
#[cfg(feature = "wasm")]
mod wasm_impl {
use crate::consolidation::{self, ConsolidationConfig, ConsolidationReport};
use crate::error::{EngramError, EngramResult};
use crate::mem_storage::MemStore;
use crate::salience;
use crate::types::{ActivatedNode, Edge, Node, ScoredNode};
use crate::vector;
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::RwLock;
use uuid::Uuid;
pub struct EngramDb {
pub(crate) store: RwLock<MemStore>,
}
impl EngramDb {
/// Create an in-memory engram database. The `path` argument is ignored in WASM mode.
pub fn open(_path: &std::path::Path) -> EngramResult<Self> {
Ok(Self {
store: RwLock::new(MemStore::new()),
})
}
// ── Node operations ───────────────────────────────────────────────────
pub fn put_node(&self, node: Node) -> EngramResult<Uuid> {
let id = node.id;
self.store
.write()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?
.write_node(&node)?;
Ok(id)
}
pub fn get_node(&self, id: Uuid) -> EngramResult<Option<Node>> {
self.store
.read()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?
.read_node(id)
}
// ── Edge operations ───────────────────────────────────────────────────
pub fn put_edge(&self, edge: Edge) -> EngramResult<()> {
self.store
.write()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?
.write_edge(&edge)
}
pub fn get_edges_from(&self, from_id: Uuid) -> EngramResult<Vec<Edge>> {
self.store
.read()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?
.read_edges_from(from_id)
}
pub fn get_edges_to(&self, to_id: Uuid) -> EngramResult<Vec<Edge>> {
self.store
.read()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?
.read_edges_to(to_id)
}
// ── Vector search ─────────────────────────────────────────────────────
pub fn search_embedding(
&self,
embedding: &[f32],
limit: usize,
) -> EngramResult<Vec<ScoredNode>> {
let store = self
.store
.read()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?;
let vectors = store.scan_vectors()?;
let nodes_snap: HashMap<Uuid, Node> = store.nodes.clone();
drop(store);
vector::search_embedding_memory(embedding, limit, &vectors, |id| {
Ok(nodes_snap.get(&id).cloned())
})
}
/// No-op in WASM mode (flat scan is always used). Returns node count.
pub fn build_index(&self) -> EngramResult<usize> {
self.node_count()
}
// ── Spreading activation ──────────────────────────────────────────────
pub fn activate(
&self,
seeds: &[Uuid],
query_embedding: &[f32],
max_depth: u8,
limit: usize,
) -> EngramResult<Vec<ActivatedNode>> {
use crate::activation;
let store = self
.store
.read()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?;
activation::activate_mem(&store, seeds, query_embedding, max_depth, limit)
}
// ── Graph traversal ───────────────────────────────────────────────────
pub fn traverse(
&self,
from: Uuid,
relation: Option<&str>,
max_depth: u8,
) -> EngramResult<Vec<Node>> {
let store = self
.store
.read()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?;
let mut visited: HashSet<Uuid> = HashSet::new();
let mut queue: VecDeque<(Uuid, u8)> = VecDeque::new();
let mut result: Vec<Node> = Vec::new();
visited.insert(from);
queue.push_back((from, 0));
while let Some((current_id, depth)) = queue.pop_front() {
if depth >= max_depth {
continue;
}
let edges = store.read_edges_from(current_id)?;
for edge in edges {
if let Some(rel) = relation {
if edge.relation != rel {
continue;
}
}
let next = edge.to_id;
if visited.contains(&next) {
continue;
}
visited.insert(next);
if let Some(node) = store.read_node(next)? {
result.push(node);
queue.push_back((next, depth + 1));
}
}
}
Ok(result)
}
// ── Salience management ───────────────────────────────────────────────
pub fn touch(&self, id: Uuid) -> EngramResult<()> {
let mut store = self
.store
.write()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?;
let node = store
.nodes
.get_mut(&id)
.ok_or(EngramError::NotFound(id))?;
node.last_activated = crate::types::now_ms();
node.activation_count += 1;
node.salience = salience::compute_salience(
node.importance,
node.last_activated,
node.activation_count,
);
Ok(())
}
pub fn decay(&self, factor: f32) -> EngramResult<usize> {
if !(0.0..=1.0).contains(&factor) {
return Err(EngramError::InvalidParam(format!(
"decay factor must be in [0.0, 1.0], got {}",
factor
)));
}
let mut store = self
.store
.write()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?;
let mut count = 0usize;
for node in store.nodes.values_mut() {
let new_sal = salience::decay_salience(node.salience, factor);
if new_sal != node.salience {
node.salience = new_sal;
count += 1;
}
}
Ok(count)
}
// ── Consolidation ─────────────────────────────────────────────────────
pub fn consolidate(
&self,
config: &ConsolidationConfig,
) -> EngramResult<ConsolidationReport> {
let mut store = self
.store
.write()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?;
consolidation::consolidate_mem(&mut store, config)
}
// ── Statistics ────────────────────────────────────────────────────────
pub fn node_count(&self) -> EngramResult<usize> {
Ok(self
.store
.read()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?
.node_count())
}
pub fn edge_count(&self) -> EngramResult<usize> {
Ok(self
.store
.read()
.map_err(|_| EngramError::InvalidParam("lock poisoned".into()))?
.edge_count())
}
}
}
// ── Re-export the right impl ──────────────────────────────────────────────────
#[cfg(feature = "sled-backend")]
pub use sled_impl::EngramDb;
#[cfg(feature = "wasm")]
pub use wasm_impl::EngramDb;
+443
View File
@@ -0,0 +1,443 @@
/// Edge type registry — dynamic, first-class edge type management.
///
/// Edge types are stored under the key prefix `edge_types:<name>` in the sled
/// database. Each value is a bincode-encoded `EdgeTypeDef`.
///
/// # Operations
///
/// - `register_edge_type` — create or update a type definition
/// - `get_edge_type` — look up by name
/// - `update_edge_type_description` — change the human-readable description
/// - `increment_edge_type_count` — bump the instance counter when a new edge is created
/// - `merge_edge_types` — retag all edges from one type to another and deprecate the source
/// - `split_edge_type` — record a split operation; the predicate is stored as a description note
/// - `deprecate_edge_type` — mark a type as no longer current
/// - `all_edge_types` — return every registered type
/// - `edge_types_by_confidence` — filter by minimum confidence score
use crate::error::EngramResult;
use crate::storage;
use crate::types::{now_ms, Edge, EdgeTypeDef};
use sled::Db;
use uuid::Uuid;
// ── Key helpers ───────────────────────────────────────────────────────────────
fn edge_type_key(name: &str) -> Vec<u8> {
format!("edge_types:{}", name).into_bytes()
}
const EDGE_TYPE_PREFIX: &[u8] = b"edge_types:";
// ── Public API ────────────────────────────────────────────────────────────────
/// Register a new edge type, or overwrite an existing definition.
///
/// Returns the UUID of the stored `EdgeTypeDef`.
pub fn register_edge_type(db: &Db, def: &EdgeTypeDef) -> EngramResult<Uuid> {
let key = edge_type_key(&def.name);
let val = bincode::serialize(def)?;
db.insert(key, val)?;
Ok(def.id)
}
/// Look up an edge type by its canonical name.
pub fn get_edge_type(db: &Db, name: &str) -> EngramResult<Option<EdgeTypeDef>> {
match db.get(edge_type_key(name))? {
Some(bytes) => Ok(Some(bincode::deserialize(&bytes)?)),
None => Ok(None),
}
}
/// Update the human-readable description of an existing edge type.
///
/// No-ops silently if the type does not exist.
pub fn update_edge_type_description(db: &Db, name: &str, description: &str) -> EngramResult<()> {
if let Some(mut def) = get_edge_type(db, name)? {
def.description = description.to_string();
let val = bincode::serialize(&def)?;
db.insert(edge_type_key(name), val)?;
}
Ok(())
}
/// Increment the instance counter for an edge type.
///
/// Called whenever a new edge with this type is persisted. No-ops if the type
/// is not registered (the counter stays in-registry, not in the edge itself).
pub fn increment_edge_type_count(db: &Db, name: &str) -> EngramResult<()> {
if let Some(mut def) = get_edge_type(db, name)? {
def.instance_count = def.instance_count.saturating_add(1);
let val = bincode::serialize(&def)?;
db.insert(edge_type_key(name), val)?;
}
Ok(())
}
/// Merge two edge types: retag all edges from `from_name` to `into_name`,
/// then deprecate `from_name`.
///
/// After this call every edge that carried `from_name` will carry `into_name`
/// instead. The `from_name` definition is marked deprecated and its `supersedes`
/// field records the merge destination.
pub fn merge_edge_types(db: &Db, from_name: &str, into_name: &str) -> EngramResult<()> {
// Collect and retag every edge carrying from_name
let prefix = b"edges:from:";
let mut edges_to_retag: Vec<Edge> = Vec::new();
for result in db.scan_prefix(prefix) {
let (_k, v) = result?;
let edge: Edge = bincode::deserialize(&v)?;
if edge.relation == from_name {
edges_to_retag.push(edge);
}
}
for mut edge in edges_to_retag {
edge.relation = into_name.to_string();
storage::write_edge(db, &edge)?;
}
// Deprecate the source type and record the merge destination
if let Some(mut def) = get_edge_type(db, from_name)? {
def.deprecated = true;
def.supersedes = Some(into_name.to_string());
let val = bincode::serialize(&def)?;
db.insert(edge_type_key(from_name), val)?;
}
// Update instance count on the destination to account for the absorbed edges
if let Some(mut into_def) = get_edge_type(db, into_name)? {
// Recount from graph (simple: scan all edges for into_name)
let mut count = 0u64;
for result in db.scan_prefix(prefix) {
let (_k, v) = result?;
let edge: Edge = bincode::deserialize(&v)?;
if edge.relation == into_name {
count += 1;
}
}
into_def.instance_count = count;
let val = bincode::serialize(&into_def)?;
db.insert(edge_type_key(into_name), val)?;
}
Ok(())
}
/// Record a split of `name` into `new_name_a` and `new_name_b`.
///
/// The predicate that drives the split is stored as a descriptive note on
/// both new types. This does NOT automatically retag edges — the caller is
/// responsible for deciding which edges go to `new_name_a` vs `new_name_b`
/// and calling `storage::write_edge` for each. The split records the intent;
/// the actual retagging is domain-specific.
///
/// The original type is deprecated with a note referencing the two successors.
pub fn split_edge_type(
db: &Db,
name: &str,
new_name_a: &str,
new_name_b: &str,
predicate: &str,
) -> EngramResult<()> {
let now = now_ms();
// Deprecate the original
if let Some(mut original) = get_edge_type(db, name)? {
original.deprecated = true;
original.description = format!(
"{} [SPLIT into '{}' and '{}' via predicate: {}]",
original.description, new_name_a, new_name_b, predicate
);
let val = bincode::serialize(&original)?;
db.insert(edge_type_key(name), val)?;
}
// Register new_name_a if it doesn't already exist
if get_edge_type(db, new_name_a)?.is_none() {
let def_a = EdgeTypeDef {
id: Uuid::new_v4(),
name: new_name_a.to_string(),
description: format!(
"Split from '{}' — predicate: {}",
name, predicate
),
first_observed: now,
instance_count: 0,
confidence: 0.0,
derived_from: Some(format!("split from '{}'", name)),
supersedes: None,
deprecated: false,
};
register_edge_type(db, &def_a)?;
}
// Register new_name_b if it doesn't already exist
if get_edge_type(db, new_name_b)?.is_none() {
let def_b = EdgeTypeDef {
id: Uuid::new_v4(),
name: new_name_b.to_string(),
description: format!(
"Split from '{}' — predicate: {}",
name, predicate
),
first_observed: now,
instance_count: 0,
confidence: 0.0,
derived_from: Some(format!("split from '{}'", name)),
supersedes: None,
deprecated: false,
};
register_edge_type(db, &def_b)?;
}
Ok(())
}
/// Mark an edge type as deprecated. Deprecated types should not be used on
/// new edges, but existing edges carrying this type remain valid.
pub fn deprecate_edge_type(db: &Db, name: &str) -> EngramResult<()> {
if let Some(mut def) = get_edge_type(db, name)? {
def.deprecated = true;
let val = bincode::serialize(&def)?;
db.insert(edge_type_key(name), val)?;
}
Ok(())
}
/// Return all registered edge type definitions.
pub fn all_edge_types(db: &Db) -> EngramResult<Vec<EdgeTypeDef>> {
let mut types = Vec::new();
for result in db.scan_prefix(EDGE_TYPE_PREFIX) {
let (_k, v) = result?;
let def: EdgeTypeDef = bincode::deserialize(&v)?;
types.push(def);
}
Ok(types)
}
/// Return all edge types with a confidence score at or above `min_confidence`.
pub fn edge_types_by_confidence(db: &Db, min_confidence: f32) -> EngramResult<Vec<EdgeTypeDef>> {
let mut types = all_edge_types(db)?;
types.retain(|t| t.confidence >= min_confidence);
types.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
Ok(types)
}
// ── Built-in type seed ────────────────────────────────────────────────────────
/// Register all built-in edge types if they have not already been registered.
///
/// Called once from `EngramDb::open`. Idempotent — existing definitions are
/// not overwritten, so user customisations survive restarts.
pub fn seed_builtin_types(db: &Db) -> EngramResult<()> {
let now = now_ms();
// Original relational types — confidence 1.0 (canonical, well-established)
let originals: &[(&str, &str)] = &[
("supersedes", "This node replaces or obsoletes another"),
("causes", "This node is a causal precursor to another"),
("contains", "This node hierarchically contains another"),
("references", "This node cites another as supporting context"),
("contradicts", "This node is in logical tension with another"),
("exemplifies", "This node is a concrete instance of a more abstract node"),
("activates", "Co-activation: firing this tends to fire the other"),
("temporally_precedes", "Temporal ordering: this node came before the other"),
];
for (name, description) in originals {
if get_edge_type(db, name)?.is_none() {
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: name.to_string(),
description: description.to_string(),
first_observed: now,
instance_count: 0,
confidence: 1.0,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(db, &def)?;
}
}
// Personhood / relational types — confidence 0.9 (well-understood but newer)
let personhood: &[(&str, &str)] = &[
("grounded_in", "This value or belief is rooted in this experience"),
("reinforced_by", "This pattern kept being confirmed by this"),
("derives_from", "This preference or belief flows from this value"),
("in_tension_with", "These two things pull against each other"),
("expressed_through","This value surfaces in this voice or behavior"),
("shaped_by", "This pattern was formed by this relationship or experience"),
("challenged_by", "This belief was tested by this experience"),
("resonates_with", "This memory echoes this value"),
];
for (name, description) in personhood {
if get_edge_type(db, name)?.is_none() {
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: name.to_string(),
description: description.to_string(),
first_observed: now,
instance_count: 0,
confidence: 0.9,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(db, &def)?;
}
}
Ok(())
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn open_tmp() -> (Db, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = sled::open(dir.path()).unwrap();
(db, dir)
}
#[test]
fn register_and_retrieve() {
let (db, _dir) = open_tmp();
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: "causes".to_string(),
description: "A causes B".to_string(),
first_observed: 0,
instance_count: 0,
confidence: 1.0,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(&db, &def).unwrap();
let got = get_edge_type(&db, "causes").unwrap().unwrap();
assert_eq!(got.name, "causes");
assert!((got.confidence - 1.0).abs() < f32::EPSILON);
}
#[test]
fn update_description() {
let (db, _dir) = open_tmp();
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: "test_type".to_string(),
description: "old".to_string(),
first_observed: 0,
instance_count: 0,
confidence: 0.5,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(&db, &def).unwrap();
update_edge_type_description(&db, "test_type", "new description").unwrap();
let got = get_edge_type(&db, "test_type").unwrap().unwrap();
assert_eq!(got.description, "new description");
}
#[test]
fn increment_count() {
let (db, _dir) = open_tmp();
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: "references".to_string(),
description: "refs".to_string(),
first_observed: 0,
instance_count: 5,
confidence: 1.0,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(&db, &def).unwrap();
increment_edge_type_count(&db, "references").unwrap();
let got = get_edge_type(&db, "references").unwrap().unwrap();
assert_eq!(got.instance_count, 6);
}
#[test]
fn deprecate() {
let (db, _dir) = open_tmp();
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: "old_type".to_string(),
description: "going away".to_string(),
first_observed: 0,
instance_count: 0,
confidence: 0.3,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(&db, &def).unwrap();
deprecate_edge_type(&db, "old_type").unwrap();
let got = get_edge_type(&db, "old_type").unwrap().unwrap();
assert!(got.deprecated);
}
#[test]
fn all_types_and_confidence_filter() {
let (db, _dir) = open_tmp();
seed_builtin_types(&db).unwrap();
let all = all_edge_types(&db).unwrap();
assert!(all.len() >= 16); // 8 originals + 8 personhood
// All original types have confidence 1.0
let high = edge_types_by_confidence(&db, 1.0).unwrap();
assert!(high.len() >= 8);
for t in &high {
assert!((t.confidence - 1.0).abs() < f32::EPSILON);
}
// personhood types have confidence 0.9 — included when threshold is <= 0.9
let wide = edge_types_by_confidence(&db, 0.9).unwrap();
assert!(wide.len() >= 16);
}
#[test]
fn seed_is_idempotent() {
let (db, _dir) = open_tmp();
seed_builtin_types(&db).unwrap();
seed_builtin_types(&db).unwrap(); // second call must not panic or duplicate
let all = all_edge_types(&db).unwrap();
// All names should be distinct
let mut names: Vec<String> = all.iter().map(|t| t.name.clone()).collect();
names.sort();
names.dedup();
assert_eq!(names.len(), all.len());
}
#[test]
fn split_type_records_both_halves() {
let (db, _dir) = open_tmp();
let original = EdgeTypeDef {
id: Uuid::new_v4(),
name: "relates_to".to_string(),
description: "generic relation".to_string(),
first_observed: 0,
instance_count: 0,
confidence: 0.5,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(&db, &original).unwrap();
split_edge_type(&db, "relates_to", "causes", "references", "directionality").unwrap();
let orig = get_edge_type(&db, "relates_to").unwrap().unwrap();
assert!(orig.deprecated);
assert!(get_edge_type(&db, "causes").unwrap().is_some());
assert!(get_edge_type(&db, "references").unwrap().is_some());
}
}
+27
View File
@@ -0,0 +1,27 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum EngramError {
#[error("Storage error: {0}")]
Storage(#[from] sled::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] bincode::Error),
#[error("Node not found: {0}")]
NotFound(uuid::Uuid),
#[error("Node already exists: {0} — nodes are immutable; supersede via edge")]
NodeAlreadyExists(uuid::Uuid),
#[error("Invalid embedding: expected {expected} dimensions, got {got}")]
DimensionMismatch { expected: usize, got: usize },
#[error("Invalid parameter: {0}")]
InvalidParam(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub type EngramResult<T> = Result<T, EngramError>;
+98
View File
@@ -0,0 +1,98 @@
/// Graph operations: store and retrieve nodes and edges, and depth-limited traversal.
///
/// The graph is stored in sled (a persistent embedded B-tree). Edges are indexed
/// in both directions so that forward and backward traversals are equally cheap.
use crate::error::EngramResult;
use crate::storage;
use crate::types::{Edge, Node};
use sled::Db;
use std::collections::{HashSet, VecDeque};
use uuid::Uuid;
/// Persist a new node and its embedding. Enforces node immutability — returns
/// `EngramError::NodeAlreadyExists` if the ID is already in the store.
pub fn put_node(db: &Db, node: &Node) -> EngramResult<Uuid> {
storage::write_node(db, node)?;
Ok(node.id)
}
/// Overwrite a node unconditionally. Used internally for salience/tier mutations
/// (touch, decay, consolidation). Do NOT call this for user-visible node creation.
pub fn overwrite_node(db: &Db, node: &Node) -> EngramResult<Uuid> {
storage::overwrite_node(db, node)?;
Ok(node.id)
}
/// Retrieve a node by id. Returns None if not found.
pub fn get_node(db: &Db, id: Uuid) -> EngramResult<Option<Node>> {
storage::read_node(db, id)
}
/// Persist an edge. Both forward and reverse indices are written atomically.
pub fn put_edge(db: &Db, edge: &Edge) -> EngramResult<()> {
storage::write_edge(db, edge)
}
/// All edges originating from a given node.
pub fn edges_from(db: &Db, from_id: Uuid) -> EngramResult<Vec<Edge>> {
storage::read_edges_from(db, from_id)
}
/// All edges pointing to a given node.
pub fn edges_to(db: &Db, to_id: Uuid) -> EngramResult<Vec<Edge>> {
storage::read_edges_to(db, to_id)
}
/// Breadth-first traversal starting from `from`, following forward edges only.
///
/// If `relation` is `Some(&str)`, only edges whose `relation` field matches
/// that string are followed. The BFS respects `max_depth` hops. The seed node
/// itself is NOT included. Visited nodes are deduplicated.
pub fn traverse(
db: &Db,
from: Uuid,
relation: Option<&str>,
max_depth: u8,
) -> EngramResult<Vec<Node>> {
let mut visited: HashSet<Uuid> = HashSet::new();
let mut queue: VecDeque<(Uuid, u8)> = VecDeque::new();
let mut result: Vec<Node> = Vec::new();
visited.insert(from);
queue.push_back((from, 0));
while let Some((current_id, depth)) = queue.pop_front() {
if depth >= max_depth {
continue;
}
let edges = edges_from(db, current_id)?;
for edge in edges {
// Filter by relation type if specified
if let Some(rel) = relation {
if edge.relation != rel {
continue;
}
}
let next = edge.to_id;
if visited.contains(&next) {
continue;
}
visited.insert(next);
if let Some(node) = get_node(db, next)? {
result.push(node);
queue.push_back((next, depth + 1));
}
}
}
Ok(result)
}
/// Count all nodes in the store.
pub fn node_count(db: &Db) -> EngramResult<usize> {
storage::count_prefix(db, b"nodes:")
}
/// Count all edges in the store (forward index only — each edge counted once).
pub fn edge_count(db: &Db) -> EngramResult<usize> {
storage::count_prefix(db, b"edges:from:")
}
+57
View File
@@ -0,0 +1,57 @@
/// Engram — a local-first memory substrate for accumulating intelligence.
///
/// An engram is the physical trace of a memory in the brain — the actual encoded
/// substrate. This crate provides the storage and retrieval primitives that model
/// how biological memory works: not as query-and-retrieve, but as
/// activation-and-propagation.
///
/// # Quick Start
///
/// ```rust,no_run
/// use engram_core::{EngramDb, Node, Edge, NodeType, MemoryTier, EDGE_SUPERSEDES};
/// use std::path::Path;
///
/// let db = EngramDb::open(Path::new("/tmp/my-engram")).unwrap();
///
/// let node = Node::new(
/// NodeType::Memory,
/// vec![0.9, 0.1, 0.3, 0.7],
/// b"The spreading activation model of memory".to_vec(),
/// MemoryTier::Semantic,
/// 0.9,
/// );
/// let id = db.put_node(node).unwrap();
///
/// // Retrieve by spreading activation from a seed
/// let results = db.activate(&[id], &[0.8, 0.2, 0.3, 0.6], 3, 10).unwrap();
/// for r in results {
/// println!("{:.4} hops={} {:?}", r.activation_strength, r.hops,
/// String::from_utf8_lossy(&r.node.content));
/// }
/// ```
pub mod activation;
pub mod consolidation;
pub mod db;
#[cfg(not(feature = "wasm"))]
pub mod edge_type;
pub mod error;
pub mod graph;
pub mod salience;
#[cfg(not(feature = "wasm"))]
pub mod storage;
#[cfg(feature = "wasm")]
pub mod mem_storage;
pub mod types;
pub mod vector;
#[cfg(feature = "migration")]
pub mod migration;
// Re-export the public surface
pub use db::EngramDb;
pub use error::{EngramError, EngramResult};
pub use types::{
ActivatedNode, Edge, EdgeTypeDef, MemoryTier, Node, NodeType, ScoredNode, now_ms,
EDGE_ACTIVATES, EDGE_CAUSES, EDGE_CONTAINS, EDGE_CONTRADICTS, EDGE_EXEMPLIFIES,
EDGE_REFERENCES, EDGE_SUPERSEDES, EDGE_TEMPORALLY_PRECEDES,
};
pub use consolidation::{ConsolidationConfig, ConsolidationReport};
@@ -0,0 +1,99 @@
/// In-memory storage backend for environments without a filesystem.
///
/// Used when the `wasm` feature is enabled (e.g. browser via wasm-bindgen).
/// Implements the same logical interface as the sled-backed `storage` module
/// so that `EngramDb` can work identically in both environments.
///
/// All state lives in a `MemStore` that is held by `EngramDb` under a `RwLock`
/// so concurrent reads are fine and writes are serialised.
use crate::error::{EngramError, EngramResult};
use crate::types::{Edge, Node};
use std::collections::HashMap;
use uuid::Uuid;
#[derive(Default)]
pub struct MemStore {
pub nodes: HashMap<Uuid, Node>,
/// from_id → list of edges
pub edges_from: HashMap<Uuid, Vec<Edge>>,
/// to_id → list of edges
pub edges_to: HashMap<Uuid, Vec<Edge>>,
}
impl MemStore {
pub fn new() -> Self {
Self::default()
}
// ── Node operations ───────────────────────────────────────────────────────
pub fn write_node(&mut self, node: &Node) -> EngramResult<()> {
self.nodes.insert(node.id, node.clone());
Ok(())
}
pub fn read_node(&self, id: Uuid) -> EngramResult<Option<Node>> {
Ok(self.nodes.get(&id).cloned())
}
pub fn scan_nodes(&self) -> EngramResult<Vec<Node>> {
Ok(self.nodes.values().cloned().collect())
}
pub fn node_count(&self) -> usize {
self.nodes.len()
}
// ── Edge operations ───────────────────────────────────────────────────────
pub fn write_edge(&mut self, edge: &Edge) -> EngramResult<()> {
self.edges_from
.entry(edge.from_id)
.or_default()
.push(edge.clone());
self.edges_to
.entry(edge.to_id)
.or_default()
.push(edge.clone());
Ok(())
}
pub fn read_edges_from(&self, from_id: Uuid) -> EngramResult<Vec<Edge>> {
Ok(self
.edges_from
.get(&from_id)
.cloned()
.unwrap_or_default())
}
pub fn read_edges_to(&self, to_id: Uuid) -> EngramResult<Vec<Edge>> {
Ok(self
.edges_to
.get(&to_id)
.cloned()
.unwrap_or_default())
}
pub fn edge_count(&self) -> usize {
self.edges_from.values().map(|v| v.len()).sum()
}
// ── Vector operations ─────────────────────────────────────────────────────
pub fn scan_vectors(&self) -> EngramResult<Vec<(Uuid, Vec<f32>)>> {
Ok(self
.nodes
.iter()
.map(|(id, n)| (*id, n.embedding.clone()))
.collect())
}
// ── Salience ──────────────────────────────────────────────────────────────
pub fn write_salience(&mut self, id: Uuid, salience: f32) -> EngramResult<()> {
if let Some(node) = self.nodes.get_mut(&id) {
node.salience = salience;
}
Ok(())
}
}
+423
View File
@@ -0,0 +1,423 @@
/// Migration connector — imports Neuron's SQLite database into Engram.
///
/// Neuron stores memories, knowledge, and graph nodes in a SQLite database.
/// This module reads that database and converts records to Engram nodes and edges.
///
/// # Schema mapping
///
/// | Neuron table | Engram node |
/// |----------------------|----------------------------------------------|
/// | `memory_nodes` | `Node { tier: Episodic, node_type: Memory }` |
/// | `knowledge_entries` | `Node { tier: Semantic, node_type: Concept }` |
///
/// Edges from `graph_edges` are converted using their `edge_type` string directly
/// (normalised to lowercase). Unknown types map to `"references"`.
///
/// # Embeddings
///
/// Neuron does not currently expose embeddings through the SQLite schema.
/// Random unit vectors are generated as placeholders. Replace the call to
/// `placeholder_embedding` with your embedding model once the ONNX engine is wired in.
///
/// TODO: wire in real embeddings from all-MiniLM-L6-v2 via the ONNX runtime.
use crate::error::{EngramError, EngramResult};
use crate::types::{Edge, MemoryTier, Node, NodeType};
use rusqlite::{Connection, OpenFlags};
use std::collections::HashMap;
use std::path::PathBuf;
use uuid::Uuid;
// ── Config and report ─────────────────────────────────────────────────────────
/// Configuration for a Neuron → Engram migration.
pub struct MigrationConfig {
/// Path to `~/.neuron/neuron.db` (or any other Neuron SQLite file).
pub sqlite_path: PathBuf,
/// Path where the new Engram sled store will be created.
pub engram_path: PathBuf,
/// Dimensionality of placeholder embeddings.
/// Default: 384 (matches all-MiniLM-L6-v2).
pub embedding_dim: usize,
}
impl MigrationConfig {
pub fn new(sqlite_path: PathBuf, engram_path: PathBuf) -> Self {
Self {
sqlite_path,
engram_path,
embedding_dim: 384,
}
}
}
/// Summary of what was imported during migration.
#[derive(Debug, Default)]
pub struct MigrationReport {
/// Rows imported from `memory_nodes`.
pub memories_migrated: usize,
/// Rows imported from `knowledge_entries`.
pub knowledge_migrated: usize,
/// Edges created from `graph_edges`.
pub edges_created: usize,
/// Non-fatal errors collected during the run.
pub errors: Vec<String>,
}
// ── Main entry point ──────────────────────────────────────────────────────────
/// Read the Neuron SQLite database at `config.sqlite_path` and import all
/// records into a new Engram sled store at `config.engram_path`.
///
/// Returns a `MigrationReport` describing what was imported.
///
/// Non-fatal errors (e.g. a single unreadable row) are collected in
/// `report.errors` rather than aborting the entire migration.
pub fn migrate_from_neuron(config: &MigrationConfig) -> EngramResult<MigrationReport> {
let conn = Connection::open_with_flags(
&config.sqlite_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| EngramError::InvalidParam(format!("Cannot open SQLite: {e}")))?;
let engram_db = crate::db::EngramDb::open(&config.engram_path)?;
let mut report = MigrationReport::default();
// Maps Neuron string IDs to the Engram UUIDs we assigned.
let mut id_map: HashMap<String, Uuid> = HashMap::new();
// ── Import memory_nodes ───────────────────────────────────────────────────
{
let mut stmt = conn
.prepare(
"SELECT id, content, importance, superseded_by, created_at \
FROM memory_nodes ORDER BY created_at ASC",
)
.map_err(|e| EngramError::InvalidParam(format!("prepare memory_nodes: {e}")))?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?, // id
row.get::<_, String>(1)?, // content
row.get::<_, String>(2)?, // importance
row.get::<_, Option<String>>(3)?, // superseded_by
row.get::<_, i64>(4)?, // created_at
))
})
.map_err(|e| EngramError::InvalidParam(format!("query memory_nodes: {e}")))?;
for row_result in rows {
match row_result {
Ok((neuron_id, content, importance_str, _superseded_by, _created_at)) => {
let importance = importance_string_to_f32(&importance_str);
let embedding = placeholder_embedding(config.embedding_dim);
let node = Node::new(
NodeType::Memory,
embedding,
content.into_bytes(),
MemoryTier::Episodic,
importance,
);
match engram_db.put_node(node.clone()) {
Ok(uuid) => {
id_map.insert(neuron_id, uuid);
report.memories_migrated += 1;
}
Err(e) => {
report.errors.push(format!("put_node memory {neuron_id}: {e}"));
}
}
}
Err(e) => {
report.errors.push(format!("read memory row: {e}"));
}
}
}
}
// ── Import knowledge_entries ──────────────────────────────────────────────
{
let mut stmt = conn
.prepare(
"SELECT id, title, content, tier, created_at \
FROM knowledge_entries ORDER BY created_at ASC",
)
.map_err(|e| EngramError::InvalidParam(format!("prepare knowledge_entries: {e}")))?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?, // id
row.get::<_, String>(1)?, // title
row.get::<_, String>(2)?, // content
row.get::<_, String>(3)?, // tier
row.get::<_, i64>(4)?, // created_at
))
})
.map_err(|e| EngramError::InvalidParam(format!("query knowledge_entries: {e}")))?;
for row_result in rows {
match row_result {
Ok((neuron_id, title, body, _tier_str, _created_at)) => {
// Combine title + content as the engram node content.
let combined = format!("{title}\n\n{body}");
let embedding = placeholder_embedding(config.embedding_dim);
let node = Node::new(
NodeType::Concept,
embedding,
combined.into_bytes(),
MemoryTier::Semantic,
0.75, // knowledge is moderately important by default
);
match engram_db.put_node(node.clone()) {
Ok(uuid) => {
id_map.insert(neuron_id, uuid);
report.knowledge_migrated += 1;
}
Err(e) => {
report.errors.push(format!(
"put_node knowledge {neuron_id}: {e}"
));
}
}
}
Err(e) => {
report.errors.push(format!("read knowledge row: {e}"));
}
}
}
}
// ── Import graph_edges ────────────────────────────────────────────────────
{
// Only import edges where both endpoints ended up in our id_map.
let mut stmt = conn
.prepare(
"SELECT from_id, to_id, edge_type, weight FROM graph_edges",
)
.map_err(|e| EngramError::InvalidParam(format!("prepare graph_edges: {e}")))?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?, // from_id
row.get::<_, String>(1)?, // to_id
row.get::<_, String>(2)?, // edge_type
row.get::<_, f64>(3)?, // weight
))
})
.map_err(|e| EngramError::InvalidParam(format!("query graph_edges: {e}")))?;
for row_result in rows {
match row_result {
Ok((from_str, to_str, edge_type, weight)) => {
let from_uuid = match id_map.get(&from_str) {
Some(u) => *u,
None => continue, // endpoint not migrated, skip
};
let to_uuid = match id_map.get(&to_str) {
Some(u) => *u,
None => continue,
};
let relation = normalise_edge_type(&edge_type);
let edge = Edge::new(from_uuid, to_uuid, relation, weight as f32);
match engram_db.put_edge(edge) {
Ok(()) => report.edges_created += 1,
Err(e) => {
report.errors.push(format!(
"put_edge {from_str}{to_str}: {e}"
));
}
}
}
Err(e) => {
report.errors.push(format!("read edge row: {e}"));
}
}
}
}
Ok(report)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Convert Neuron's text importance level to a float score.
fn importance_string_to_f32(importance: &str) -> f32 {
match importance.to_lowercase().as_str() {
"critical" => 1.0,
"high" => 0.85,
"normal" | "medium" => 0.5,
"low" => 0.25,
_ => {
// Try parsing directly as a float.
importance.parse::<f32>().unwrap_or(0.5).clamp(0.0, 1.0)
}
}
}
/// Normalise a Neuron edge type string to a canonical Engram edge type name.
///
/// Known variants (including old PascalCase forms from the Rust enum era) are
/// mapped to their lowercase canonical names. Unknown types fall back to
/// `"references"` as a safe, non-destructive default.
fn normalise_edge_type(edge_type: &str) -> &'static str {
match edge_type.to_lowercase().as_str() {
"supersedes" | "superseded_by" => "supersedes",
"causes" | "caused_by" => "causes",
"contains" | "contained_by" => "contains",
"references" | "referenced_by" => "references",
"contradicts" => "contradicts",
"exemplifies" | "exemplified_by" => "exemplifies",
"activates" => "activates",
"temporally_precedes" | "temporallyprecedes" | "follows" => "temporally_precedes",
_ => "references", // safe default
}
}
/// Generate a pseudo-random unit vector of the given dimension as a placeholder embedding.
///
/// Uses a simple xorshift64 PRNG seeded from the current time. The result is
/// semantically meaningless — it only satisfies the schema requirement that
/// every node has an embedding vector.
///
/// TODO: replace with actual embeddings from all-MiniLM-L6-v2 via ONNX runtime
/// once the embedding engine is wired in.
pub fn placeholder_embedding(dim: usize) -> Vec<f32> {
// Seed from subsecond wall time for reasonable entropy across calls.
let mut state: u64 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos() as u64
| 1; // ensure non-zero
// xorshift64 — no overflow risk, passes statistical tests well enough for placeholders.
let mut xorshift = || -> f32 {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
// Map to [-1, 1]
(state as f32 / u64::MAX as f32) * 2.0 - 1.0
};
let mut raw: Vec<f32> = (0..dim).map(|_| xorshift()).collect();
// Normalise to unit length.
let norm: f32 = raw.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
for x in &mut raw {
*x /= norm;
}
}
raw
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn importance_string_critical() {
assert!((importance_string_to_f32("critical") - 1.0).abs() < 1e-6);
}
#[test]
fn importance_string_normal() {
assert!((importance_string_to_f32("normal") - 0.5).abs() < 1e-6);
}
#[test]
fn importance_string_unknown_defaults_to_half() {
assert!((importance_string_to_f32("???") - 0.5).abs() < 1e-6);
}
#[test]
fn edge_type_supersedes() {
assert_eq!(normalise_edge_type("supersedes"), "supersedes");
}
#[test]
fn edge_type_unknown_is_references() {
assert_eq!(normalise_edge_type("foobar"), "references");
}
#[test]
fn placeholder_embedding_correct_length() {
let emb = placeholder_embedding(384);
assert_eq!(emb.len(), 384);
}
#[test]
fn placeholder_embedding_is_unit_vector() {
let emb = placeholder_embedding(128);
let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-4);
}
#[test]
fn migrate_from_in_memory_db() {
// Build a minimal SQLite DB in a temp dir and migrate it.
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let engram_path = dir.path().join("engram");
// Create minimal Neuron-like schema and insert a couple of rows.
let conn = Connection::open(&db_path).unwrap();
conn.execute_batch(
"CREATE TABLE memory_nodes (
id TEXT PRIMARY KEY, content TEXT NOT NULL, importance TEXT NOT NULL DEFAULT 'normal',
superseded_by TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
);
CREATE TABLE knowledge_entries (
id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL,
category TEXT NOT NULL DEFAULT '', tier TEXT NOT NULL DEFAULT 'note',
tags TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
);
CREATE TABLE graph_edges (
from_id TEXT NOT NULL, from_type TEXT NOT NULL,
to_id TEXT NOT NULL, to_type TEXT NOT NULL,
edge_type TEXT NOT NULL, weight REAL NOT NULL DEFAULT 1.0,
PRIMARY KEY (from_id, to_id, edge_type)
);",
)
.unwrap();
conn.execute(
"INSERT INTO memory_nodes (id, content, importance, created_at, updated_at)
VALUES ('mem-1', 'First memory', 'high', 1000, 1000)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO knowledge_entries (id, title, content, created_at, updated_at)
VALUES ('kn-1', 'Some concept', 'Body text.', 2000, 2000)",
[],
)
.unwrap();
drop(conn); // close before migrating
let config = MigrationConfig {
sqlite_path: db_path,
engram_path,
embedding_dim: 16,
};
let report = migrate_from_neuron(&config).unwrap();
assert_eq!(report.memories_migrated, 1);
assert_eq!(report.knowledge_migrated, 1);
assert_eq!(report.edges_created, 0); // no edges in the test DB
assert!(report.errors.is_empty());
}
}
@@ -0,0 +1,47 @@
/// Salience is the brain's answer to the question: "Is this worth remembering right now?"
///
/// It combines three signals:
/// - **Importance**: explicit weight assigned at creation (how significant is this?)
/// - **Recency**: exponential decay since last activation (recent = more relevant)
/// - **Frequency**: log-compressed activation count (things recalled often stay accessible)
///
/// The formula is intentionally simple. It models forgetting as *adaptive*, not as failure.
/// Things that aren't activated decay toward zero — not because they are lost, but because
/// they are no longer relevant to current cognition. This is how biological memory works.
///
/// ```text
/// salience = importance × (1 / (1 + days_since_activation)) × ln(activation_count + 1)
/// ```
///
/// Note: a node activated for the first time has activation_count=0, so the log term
/// evaluates to ln(1) = 0. We add 1 to the ln argument to give first activations a
/// baseline salience equal to importance × recency.
use crate::types::now_ms;
/// Compute the current salience of a node.
///
/// # Arguments
/// * `importance` - explicit importance score, 0.01.0
/// * `last_activated_ms` - Unix milliseconds of last activation
/// * `activation_count` - how many times the node has been activated
///
/// # Returns
/// Salience score, unbounded above but typically 0.05.0 for well-used nodes.
pub fn compute_salience(importance: f32, last_activated_ms: i64, activation_count: u64) -> f32 {
let days_since = (now_ms() - last_activated_ms) as f32 / 86_400_000.0;
// Recency factor: 1.0 at activation, approaching 0 asymptotically.
// At 1 day: 0.5. At 6 days: ~0.14. At 30 days: ~0.03.
let recency = 1.0 / (1.0 + days_since);
// Frequency factor: log-compressed so that going from 0→1 activations matters
// more than going from 100→101. This mirrors the diminishing returns of rehearsal.
let frequency = (activation_count as f32 + 1.0).ln();
importance * recency * frequency
}
/// Apply a multiplicative decay to a salience score.
///
/// Called periodically to age stored salience values without recomputing from scratch.
/// A factor of 0.95 means 5% forgetting per decay cycle.
pub fn decay_salience(current: f32, factor: f32) -> f32 {
(current * factor).max(0.0)
}
+202
View File
@@ -0,0 +1,202 @@
/// Low-level sled key/value operations for nodes, edges, vectors, and salience.
///
/// Key schema:
/// nodes:{uuid} → bincode-encoded Node
/// edges:from:{from}:{to} → bincode-encoded Edge
/// edges:to:{to}:{from} → reverse index (same Edge bytes)
/// vectors:{uuid} → raw little-endian f32 bytes
/// salience:{uuid} → 4-byte little-endian f32
/// edge_types:{name} → bincode-encoded EdgeTypeDef (managed by edge_type.rs)
use crate::error::{EngramError, EngramResult};
use crate::types::{Edge, Node};
use sled::Db;
use uuid::Uuid;
// ── Key constructors ──────────────────────────────────────────────────────────
pub fn node_key(id: Uuid) -> Vec<u8> {
format!("nodes:{}", id).into_bytes()
}
pub fn edge_from_key(from: Uuid, to: Uuid) -> Vec<u8> {
format!("edges:from:{}:{}", from, to).into_bytes()
}
pub fn edge_to_key(to: Uuid, from: Uuid) -> Vec<u8> {
format!("edges:to:{}:{}", to, from).into_bytes()
}
pub fn vector_key(id: Uuid) -> Vec<u8> {
format!("vectors:{}", id).into_bytes()
}
pub fn salience_key(id: Uuid) -> Vec<u8> {
format!("salience:{}", id).into_bytes()
}
// ── Node storage ─────────────────────────────────────────────────────────────
/// Persist a node. Returns `EngramError::NodeAlreadyExists` if a node with
/// this ID already exists in the store.
///
/// Nodes are immutable and append-only. To update, create a new node and
/// connect it to the old with a `supersedes` edge.
pub fn write_node(db: &Db, node: &Node) -> EngramResult<()> {
let key = node_key(node.id);
// Immutability guard — reject writes to existing node IDs.
if db.contains_key(&key)? {
return Err(EngramError::NodeAlreadyExists(node.id));
}
let val = bincode::serialize(node)?;
db.insert(key, val)?;
// Store the embedding separately for fast vector scan
let vkey = vector_key(node.id);
let vbytes = floats_to_bytes(&node.embedding);
db.insert(vkey, vbytes)?;
// Store salience separately so the decay pass can update it cheaply
let skey = salience_key(node.id);
db.insert(skey, f32_to_bytes(node.salience))?;
Ok(())
}
/// Overwrite a node unconditionally. Used internally for salience/tier updates
/// that must mutate in-place (touch, decay, consolidation).
///
/// Do NOT expose this in public API — callers should use `write_node` which
/// enforces immutability.
pub(crate) fn overwrite_node(db: &Db, node: &Node) -> EngramResult<()> {
let key = node_key(node.id);
let val = bincode::serialize(node)?;
db.insert(key, val)?;
let vkey = vector_key(node.id);
db.insert(vkey, floats_to_bytes(&node.embedding))?;
let skey = salience_key(node.id);
db.insert(skey, f32_to_bytes(node.salience))?;
Ok(())
}
pub fn read_node(db: &Db, id: Uuid) -> EngramResult<Option<Node>> {
match db.get(node_key(id))? {
Some(bytes) => Ok(Some(bincode::deserialize(&bytes)?)),
None => Ok(None),
}
}
/// Iterate over every node in the store.
pub fn scan_nodes(db: &Db) -> EngramResult<Vec<Node>> {
let prefix = b"nodes:";
let mut nodes = Vec::new();
for result in db.scan_prefix(prefix) {
let (_k, v) = result?;
let node: Node = bincode::deserialize(&v)?;
nodes.push(node);
}
Ok(nodes)
}
// ── Edge storage ─────────────────────────────────────────────────────────────
pub fn write_edge(db: &Db, edge: &Edge) -> EngramResult<()> {
let bytes = bincode::serialize(edge)?;
// Forward index: from → to
db.insert(edge_from_key(edge.from_id, edge.to_id), bytes.clone())?;
// Reverse index: to → from
db.insert(edge_to_key(edge.to_id, edge.from_id), bytes)?;
Ok(())
}
pub fn read_edges_from(db: &Db, from_id: Uuid) -> EngramResult<Vec<Edge>> {
let prefix = format!("edges:from:{}:", from_id).into_bytes();
read_edges_with_prefix(db, &prefix)
}
pub fn read_edges_to(db: &Db, to_id: Uuid) -> EngramResult<Vec<Edge>> {
let prefix = format!("edges:to:{}:", to_id).into_bytes();
read_edges_with_prefix(db, &prefix)
}
fn read_edges_with_prefix(db: &Db, prefix: &[u8]) -> EngramResult<Vec<Edge>> {
let mut edges = Vec::new();
for result in db.scan_prefix(prefix) {
let (_k, v) = result?;
let edge: Edge = bincode::deserialize(&v)?;
edges.push(edge);
}
Ok(edges)
}
// ── Vector scan ───────────────────────────────────────────────────────────────
/// Read all stored (uuid, embedding) pairs. Used for flat cosine search.
pub fn scan_vectors(db: &Db) -> EngramResult<Vec<(Uuid, Vec<f32>)>> {
let prefix = b"vectors:";
let mut out = Vec::new();
for result in db.scan_prefix(prefix) {
let (k, v) = result?;
// key = "vectors:{uuid}" — slice off the prefix
let id_str = std::str::from_utf8(&k[prefix.len()..])
.map_err(|e| EngramError::InvalidParam(e.to_string()))?;
let id = id_str
.parse::<Uuid>()
.map_err(|e| EngramError::InvalidParam(e.to_string()))?;
let floats = bytes_to_floats(&v);
out.push((id, floats));
}
Ok(out)
}
// ── Salience update ───────────────────────────────────────────────────────────
/// Overwrite the salience entry for a node without rewriting the full node blob.
pub fn write_salience(db: &Db, id: Uuid, salience: f32) -> EngramResult<()> {
db.insert(salience_key(id), f32_to_bytes(salience))?;
Ok(())
}
pub fn read_salience(db: &Db, id: Uuid) -> EngramResult<Option<f32>> {
match db.get(salience_key(id))? {
Some(b) => Ok(Some(bytes_to_f32(&b))),
None => Ok(None),
}
}
/// Count entries matching a key prefix.
pub fn count_prefix(db: &Db, prefix: &[u8]) -> EngramResult<usize> {
let mut n = 0usize;
for result in db.scan_prefix(prefix) {
result?;
n += 1;
}
Ok(n)
}
// ── Byte encoding helpers ─────────────────────────────────────────────────────
fn f32_to_bytes(v: f32) -> Vec<u8> {
v.to_le_bytes().to_vec()
}
fn bytes_to_f32(b: &[u8]) -> f32 {
let arr: [u8; 4] = b[..4].try_into().unwrap_or([0u8; 4]);
f32::from_le_bytes(arr)
}
fn floats_to_bytes(floats: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(floats.len() * 4);
for f in floats {
out.extend_from_slice(&f.to_le_bytes());
}
out
}
fn bytes_to_floats(bytes: &[u8]) -> Vec<f32> {
bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect()
}
+228
View File
@@ -0,0 +1,228 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ── Built-in edge type name constants ─────────────────────────────────────────
//
// String constants for the 8 built-in relation types. Use these on Edge.relation
// to reference known types. Intelligence about when to create new types or how
// to score confidence belongs in el, not here.
pub const EDGE_SUPERSEDES: &str = "supersedes";
pub const EDGE_CAUSES: &str = "causes";
pub const EDGE_CONTAINS: &str = "contains";
pub const EDGE_REFERENCES: &str = "references";
pub const EDGE_CONTRADICTS: &str = "contradicts";
pub const EDGE_EXEMPLIFIES: &str = "exemplifies";
pub const EDGE_ACTIVATES: &str = "activates";
pub const EDGE_TEMPORALLY_PRECEDES: &str = "temporally_precedes";
/// The functional role of a node in the memory graph.
///
/// Different node types participate in different retrieval patterns:
/// - Memories and Events are time-anchored
/// - Concepts and Entities form the semantic backbone
/// - Processes encode procedural knowledge
/// - InternalState captures the system's own affective context
/// - Custom(String) is an open extension point; el defines new types freely
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum NodeType {
/// A specific remembered experience or observation
Memory,
/// An abstract idea, category, or semantic anchor
Concept,
/// A time-stamped occurrence in the world or in processing
Event,
/// A named thing — person, place, object, system
Entity,
/// A procedural pattern, workflow, or sequence of steps
Process,
/// An internal affective or motivational state
InternalState,
/// Caller-defined node type. el uses this for types Rust does not need to know about.
Custom(String),
}
/// Where in the memory hierarchy a node currently lives.
///
/// Tiers model the brain's own stratified memory architecture.
/// Nodes migrate between tiers based on salience decay and reinforcement.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MemoryTier {
/// Hot working memory — the K most recently activated nodes.
/// Ultra-fast access. Evicted by recency when K is exceeded.
Working,
/// Episodic memory — time-ordered events and experiences.
/// Indexed chronologically; supports temporal traversal.
Episodic,
/// Semantic memory — the concept graph with weighted associations.
/// This is the long-term structural knowledge of the system.
Semantic,
/// Procedural memory — patterns, workflows, habits.
/// Retrieved by similarity to current task context.
Procedural,
}
/// Metadata record for a named edge type. Dumb data container — Rust stores and
/// returns it. All decisions about confidence thresholds, when to create new
/// types, merge/split logic, and pattern recognition belong in el, not here.
///
/// Fields like `deprecated`, `derived_from`, and `supersedes` are data.
/// They are set by the caller (el) and stored verbatim. Rust never inspects
/// or acts on them autonomously.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EdgeTypeDef {
/// Stable unique identifier for this edge type record
pub id: Uuid,
/// The canonical name used on edges (e.g. `"causes"`, `"resonates_with"`)
pub name: String,
/// Human-readable description of what this relation means
pub description: String,
/// Unix milliseconds when this type was first registered
pub first_observed: i64,
/// How many edges currently carry this type
pub instance_count: u64,
/// Caller-supplied confidence, 0.01.0. Stored as-is; not computed here.
pub confidence: f32,
/// Free-text note about what observation prompted this type's creation.
/// Set by el; stored verbatim.
pub derived_from: Option<String>,
/// Name of the edge type this one replaced, if any. Set by el; stored verbatim.
pub supersedes: Option<String>,
/// When true, this type should no longer be used for new edges.
/// Set by el; stored verbatim.
pub deprecated: bool,
}
/// A node in the engram graph — the fundamental unit of stored memory.
///
/// A node is not just a record. It is an activation site. Its embedding
/// is its semantic identity; its salience governs whether it surfaces
/// during retrieval; its activation history encodes its importance to
/// the system's ongoing cognition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
/// Stable unique identifier
pub id: Uuid,
/// Functional role in the memory system
pub node_type: NodeType,
/// Semantic vector — the node's position in meaning-space.
/// Cosine similarity to a query embedding drives spreading activation.
pub embedding: Vec<f32>,
/// Compressed raw content — the actual payload
pub content: Vec<u8>,
/// Unix milliseconds when the node was first created
pub created_at: i64,
/// Unix milliseconds when the node was last activated (read or touched)
pub last_activated: i64,
/// How many times the node has been activated
pub activation_count: u64,
/// Composite score: recency × frequency × importance.
/// Updated on every touch. Governs spreading activation priority.
pub salience: f32,
/// Which memory tier this node currently occupies
pub tier: MemoryTier,
/// Explicit importance, 0.01.0. Set by the caller; stable over time.
pub importance: f32,
}
impl Node {
/// Construct a new node with sensible defaults.
pub fn new(
node_type: NodeType,
embedding: Vec<f32>,
content: Vec<u8>,
tier: MemoryTier,
importance: f32,
) -> Self {
let now = now_ms();
let importance = importance.clamp(0.0, 1.0);
// Creation counts as the first activation — activation_count starts at 1.
// This ensures a newly created node has non-zero salience from birth.
// (ln(1+1) = ln(2) ≈ 0.693, so salience ≈ importance × recency × 0.693)
let initial_count = 1u64;
let salience = crate::salience::compute_salience(importance, now, initial_count);
Self {
id: Uuid::new_v4(),
node_type,
embedding,
content,
created_at: now,
last_activated: now,
activation_count: initial_count,
salience,
tier,
importance,
}
}
/// Override the node's UUID. Used in tests and deserialization helpers.
pub fn with_id(mut self, id: Uuid) -> Self {
self.id = id;
self
}
}
/// A node returned from spreading activation, annotated with how strongly
/// it was activated and how many hops from the seed set it is.
#[derive(Debug, Clone)]
pub struct ActivatedNode {
pub node: Node,
/// Activation strength at this node — product of path weights,
/// salience, and semantic similarity. Higher is more relevant.
pub activation_strength: f32,
/// Number of graph hops from the nearest seed node
pub hops: u8,
}
/// A node returned from vector similarity search, annotated with its score.
#[derive(Debug, Clone)]
pub struct ScoredNode {
pub node: Node,
/// Cosine similarity to the query embedding, in [0.0, 1.0]
pub score: f32,
}
/// An edge in the engram graph — a typed, weighted connection between nodes.
///
/// Edge weights strengthen with co-activation (Hebbian learning).
/// The weight directly multiplies activation flow during spreading activation.
///
/// The `relation` field is a free-form string naming the edge type — look up
/// the canonical definition in the `EdgeTypeDef` registry via `edge_type`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub id: Uuid,
pub from_id: Uuid,
pub to_id: Uuid,
/// The edge type name (e.g. `"causes"`, `"resonates_with"`). Matches the
/// `name` field of the corresponding `EdgeTypeDef` in the registry.
pub relation: String,
/// Connection strength, 0.01.0. Increases when both endpoints are
/// activated in close temporal proximity (long-term potentiation).
pub weight: f32,
pub created_at: i64,
/// Unix ms when this edge last carried activation
pub last_fired: i64,
}
impl Edge {
pub fn new(from_id: Uuid, to_id: Uuid, relation: impl Into<String>, weight: f32) -> Self {
let now = now_ms();
Self {
id: Uuid::new_v4(),
from_id,
to_id,
relation: relation.into(),
weight: weight.clamp(0.0, 1.0),
created_at: now,
last_fired: now,
}
}
}
/// Current wall time in Unix milliseconds.
pub fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before epoch")
.as_millis() as i64
}
+330
View File
@@ -0,0 +1,330 @@
/// Vector similarity search over stored node embeddings.
///
/// Strategy:
/// - For < HNSW_THRESHOLD indexed nodes: flat O(n) cosine scan (correct, no deps)
/// - For >= HNSW_THRESHOLD nodes: HNSW approximate nearest-neighbour index
///
/// The HNSW index is built lazily on first search call when the graph is large
/// enough. A "dirty" flag in sled (`hnsw:dirty`) is set to 1 whenever `put_node`
/// adds an embedding; on the next search the index is rebuilt from the current
/// store. For small graphs (< threshold) the flat scan is always used — it is
/// fast enough and avoids the overhead of HNSW construction.
///
/// Cosine similarity: cos(θ) = (A · B) / (|A| × |B|)
/// instant-distance expects a *distance* metric (lower = closer), so we expose:
/// distance = 1 cosine_similarity, clamped to [0, 2]
#[cfg(feature = "sled-backend")]
use crate::storage;
#[cfg(feature = "sled-backend")]
use sled::Db;
use crate::error::EngramResult;
use crate::types::{Node, ScoredNode};
use instant_distance::{Builder, HnswMap, Search};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Minimum number of nodes before we switch from flat scan to HNSW.
const HNSW_THRESHOLD: usize = 100;
/// sled key used to store the dirty flag (1 = needs rebuild, 0 = clean).
#[cfg(feature = "sled-backend")]
const HNSW_DIRTY_KEY: &[u8] = b"hnsw:dirty";
// ── Point wrapper ─────────────────────────────────────────────────────────────
/// An f32 embedding vector treated as an HNSW point.
///
/// The distance metric is `1 cosine_similarity` so that instant-distance
/// (which minimises distance) finds the most similar vectors.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EmbeddingPoint(pub Vec<f32>);
impl instant_distance::Point for EmbeddingPoint {
fn distance(&self, other: &Self) -> f32 {
let sim = cosine_similarity(&self.0, &other.0);
(1.0 - sim).clamp(0.0, 2.0)
}
}
// ── Public similarity helper ──────────────────────────────────────────────────
/// Compute the cosine similarity between two equal-length f32 slices.
///
/// Returns a value in [-1.0, 1.0], where 1.0 means identical direction.
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
(dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
}
// ── sled-backed search ────────────────────────────────────────────────────────
#[cfg(feature = "sled-backend")]
/// Search all stored embeddings for the `limit` closest nodes to `query`.
///
/// Falls back to flat scan for stores with < HNSW_THRESHOLD nodes, or when the
/// index has not yet been built. Uses HNSW for large stores.
pub fn search_embedding(
db: &Db,
query: &[f32],
limit: usize,
node_loader: impl Fn(Uuid) -> EngramResult<Option<Node>>,
) -> EngramResult<Vec<ScoredNode>> {
let vectors = storage::scan_vectors(db)?;
if vectors.len() < HNSW_THRESHOLD {
return flat_search(query, limit, &vectors, node_loader);
}
// Check if the index needs rebuilding.
let dirty = db
.get(HNSW_DIRTY_KEY)?
.map(|v| v.first().copied().unwrap_or(1) != 0)
.unwrap_or(true);
// We always rebuild if dirty. The index is not serialised to sled because
// HnswMap serialisation size can be large and the rebuild is fast (<10ms
// for typical node counts). The dirty flag is persisted so we skip
// unnecessary rebuilds between searches within the same sled session.
let (map, ids) = build_hnsw_index(&vectors);
if dirty {
// Clear the dirty flag now that we have a fresh index.
let _ = db.insert(HNSW_DIRTY_KEY, vec![0u8]);
}
hnsw_search(query, limit, &map, &ids, node_loader)
}
/// Mark the HNSW index as dirty. Call this after any `put_node`.
#[cfg(feature = "sled-backend")]
pub fn mark_dirty(db: &Db) {
let _ = db.insert(HNSW_DIRTY_KEY, vec![1u8]);
}
/// Explicitly build and persist the HNSW index. Returns the number of nodes indexed.
///
/// Not normally needed — the index is built lazily on first search.
/// Call this to pre-warm after a large batch insert.
#[cfg(feature = "sled-backend")]
pub fn build_index(db: &Db) -> EngramResult<usize> {
let vectors = storage::scan_vectors(db)?;
let n = vectors.len();
// Build the index (result is discarded — next search will build from the
// current clean state).
if n > 0 {
let _ = build_hnsw_index(&vectors);
}
let _ = db.insert(HNSW_DIRTY_KEY, vec![0u8]);
Ok(n)
}
/// Retrieve the stored embedding for a single node by id.
#[cfg(feature = "sled-backend")]
pub fn get_embedding(db: &Db, id: Uuid) -> EngramResult<Vec<f32>> {
use crate::error::EngramError;
let key = storage::vector_key(id);
match db.get(key)? {
Some(bytes) => {
let floats: Vec<f32> = bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect();
Ok(floats)
}
None => Err(EngramError::NotFound(id)),
}
}
// ── In-memory search (used by wasm / unit tests) ──────────────────────────────
/// Search a list of (id, embedding) pairs without a database.
pub fn search_embedding_memory(
query: &[f32],
limit: usize,
vectors: &[(Uuid, Vec<f32>)],
node_loader: impl Fn(Uuid) -> EngramResult<Option<Node>>,
) -> EngramResult<Vec<ScoredNode>> {
if vectors.len() < HNSW_THRESHOLD {
flat_search(query, limit, vectors, node_loader)
} else {
let (map, ids) = build_hnsw_index(vectors);
hnsw_search(query, limit, &map, &ids, node_loader)
}
}
// ── Internal helpers ──────────────────────────────────────────────────────────
/// Flat cosine scan — O(n). Used when the graph is small.
fn flat_search(
query: &[f32],
limit: usize,
vectors: &[(Uuid, Vec<f32>)],
node_loader: impl Fn(Uuid) -> EngramResult<Option<Node>>,
) -> EngramResult<Vec<ScoredNode>> {
let mut scored: Vec<(Uuid, f32)> = vectors
.iter()
.map(|(id, emb)| (*id, cosine_similarity(query, emb)))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(limit);
let mut results = Vec::with_capacity(scored.len());
for (id, score) in scored {
if let Some(node) = node_loader(id)? {
results.push(ScoredNode { node, score });
}
}
Ok(results)
}
/// Build an HnswMap from a flat vector list.
fn build_hnsw_index(vectors: &[(Uuid, Vec<f32>)]) -> (HnswMap<EmbeddingPoint, Uuid>, Vec<Uuid>) {
let points: Vec<EmbeddingPoint> = vectors
.iter()
.map(|(_, emb)| EmbeddingPoint(emb.clone()))
.collect();
let ids: Vec<Uuid> = vectors.iter().map(|(id, _)| *id).collect();
let map = Builder::default().build(points, ids.clone());
(map, ids)
}
/// Search using an HnswMap. Converts distance back to cosine similarity score.
fn hnsw_search(
query: &[f32],
limit: usize,
map: &HnswMap<EmbeddingPoint, Uuid>,
_ids: &[Uuid],
node_loader: impl Fn(Uuid) -> EngramResult<Option<Node>>,
) -> EngramResult<Vec<ScoredNode>> {
let query_point = EmbeddingPoint(query.to_vec());
let mut search = Search::default();
let mut results = Vec::new();
for item in map.search(&query_point, &mut search).take(limit) {
// distance = 1 cosine_sim → cosine_sim = 1 distance
let score = (1.0 - item.distance).clamp(-1.0, 1.0);
let node_id = *item.value;
if let Some(node) = node_loader(node_id)? {
results.push(ScoredNode { node, score });
}
}
// Ensure descending score order (HNSW returns ascending distance order)
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
Ok(results)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{MemoryTier, Node, NodeType};
fn dummy_node(id: Uuid) -> Node {
Node::new(
NodeType::Memory,
vec![0.0; 4],
vec![],
MemoryTier::Episodic,
0.5,
)
.with_id(id)
}
#[test]
fn cosine_identical_vectors() {
let v = vec![1.0_f32, 0.0, 0.0, 1.0];
assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6);
}
#[test]
fn cosine_orthogonal_vectors() {
let a = vec![1.0_f32, 0.0];
let b = vec![0.0_f32, 1.0];
assert!(cosine_similarity(&a, &b).abs() < 1e-6);
}
#[test]
fn cosine_opposite_vectors() {
let a = vec![1.0_f32, 0.0];
let b = vec![-1.0_f32, 0.0];
let sim = cosine_similarity(&a, &b);
assert!((sim - (-1.0)).abs() < 1e-6);
}
#[test]
fn flat_search_returns_ordered_results() {
let id_best = Uuid::new_v4();
let id_mid = Uuid::new_v4();
let id_low = Uuid::new_v4();
let query = vec![1.0_f32, 0.0, 0.0, 0.0];
let vecs: Vec<(Uuid, Vec<f32>)> = vec![
(id_low, vec![0.0, 1.0, 0.0, 0.0]), // sim=0
(id_best, vec![1.0, 0.0, 0.0, 0.0]), // sim=1 ← best
(id_mid, vec![0.7, 0.7, 0.0, 0.0]), // sim≈0.7
];
let results = flat_search(&query, 3, &vecs, |id| Ok(Some(dummy_node(id)))).unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].node.id, id_best);
assert!(results[0].score > results[1].score);
assert!(results[1].score > results[2].score);
}
#[test]
fn flat_search_respects_limit() {
let query = vec![1.0_f32, 0.0];
let vecs: Vec<(Uuid, Vec<f32>)> = (0..10)
.map(|i| (Uuid::new_v4(), vec![i as f32, 0.0]))
.collect();
let results = flat_search(&query, 3, &vecs, |id| Ok(Some(dummy_node(id)))).unwrap();
assert_eq!(results.len(), 3);
}
#[test]
fn embedding_point_distance_self_is_zero() {
use instant_distance::Point;
let p = EmbeddingPoint(vec![0.6_f32, 0.8]);
assert!(p.distance(&p) < 1e-5);
}
#[test]
fn search_memory_small_falls_back_to_flat() {
let vecs: Vec<(Uuid, Vec<f32>)> = (0..10)
.map(|i| {
let mut emb = vec![0.0_f32; 8];
emb[i % 8] = 1.0;
(Uuid::new_v4(), emb)
})
.collect();
let target = vecs[3].clone();
let query = target.1.clone();
let results =
search_embedding_memory(&query, 1, &vecs, |id| Ok(Some(dummy_node(id)))).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].node.id, target.0);
assert!((results[0].score - 1.0).abs() < 1e-5);
}
#[test]
fn cosine_zero_vector_returns_zero() {
let a = vec![0.0_f32, 0.0];
let b = vec![1.0_f32, 0.0];
assert_eq!(cosine_similarity(&a, &b), 0.0);
}
}