init: Engram v0.1 — native memory substrate for accumulating intelligence

Memory is not stored and retrieved — it is activated and propagated.
Implements the spreading activation model with salience decay, typed edges,
four memory tiers, and flat cosine vector search over a sled embedded store.
This commit is contained in:
Will Anderson
2026-04-27 15:37:42 -05:00
commit 1a609502c8
20 changed files with 2375 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "engram-core"
version = "0.1.0"
edition = "2021"
description = "Engram — native memory substrate for accumulating intelligence"
license = "MIT"
[dependencies]
sled = "0.34"
uuid = { version = "1", features = ["v4", "serde"] }
serde = { version = "1", features = ["derive"] }
bincode = "1"
anyhow = "1"
thiserror = "1"
+240
View File
@@ -0,0 +1,240 @@
/// 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::graph;
use crate::types::{ActivatedNode, Node};
use crate::vector::cosine_similarity;
use sled::Db;
use std::collections::{BinaryHeap, HashMap};
use uuid::Uuid;
/// 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).
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)
}
+170
View File
@@ -0,0 +1,170 @@
/// EngramDb — the top-level database handle.
///
/// All public API methods live here. The internal modules (graph, vector,
/// activation, salience) are implementation details. Callers interact only
/// with EngramDb.
use crate::activation;
use crate::error::{EngramError, EngramResult};
use crate::graph;
use crate::salience;
use crate::storage;
use crate::types::{ActivatedNode, Edge, Node, RelationType, ScoredNode};
use crate::vector;
use sled::Db;
use std::path::Path;
use uuid::Uuid;
pub struct EngramDb {
db: Db,
}
impl EngramDb {
/// Open (or create) an engram database at the given path.
///
/// The path should be a directory. Sled will create it if it doesn't exist.
pub fn open(path: &Path) -> EngramResult<Self> {
let db = sled::open(path)?;
Ok(Self { db })
}
// ── Node operations ───────────────────────────────────────────────────────
/// Persist a node. Returns the node's UUID.
///
/// If a node with the same ID already exists, it is overwritten.
pub fn put_node(&self, node: Node) -> EngramResult<Uuid> {
graph::put_node(&self.db, &node)
}
/// 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`.
///
/// Uses flat cosine scan — O(n), correct for < 100k nodes.
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)
})
}
// ── Spreading activation ──────────────────────────────────────────────────
/// Run spreading activation from a set of seed nodes.
///
/// Activation propagates outward through the graph. At each hop, strength
/// is attenuated by edge weight, target salience, and semantic similarity
/// to `query_embedding`. The top-`limit` nodes by activation strength are returned.
///
/// See `activation.rs` for a full description of the algorithm.
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 specified, only edges of that type are followed.
/// The seed node itself is excluded from the result.
pub fn traverse(
&self,
from: Uuid,
relation: Option<RelationType>,
max_depth: u8,
) -> EngramResult<Vec<Node>> {
graph::traverse(&self.db, from, relation, max_depth)
}
// ── Salience management ───────────────────────────────────────────────────
/// Mark a node as recently activated — update last_activated, increment
/// activation_count, and recompute salience.
///
/// Call this whenever a node is surfaced during retrieval so that
/// frequently-used nodes accumulate higher salience over time.
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,
);
graph::put_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). A factor of 0.95 decays salience by 5%.
/// Returns the number of nodes updated.
///
/// This models the adaptive nature of forgetting: nodes that haven't been
/// activated recently become less salient over time, making room for new
/// associations.
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);
// Always write if salience changed at all (decay always changes it
// unless the node is already at zero)
if new_salience != node.salience {
node.salience = new_salience;
storage::write_salience(&self.db, node.id, new_salience)?;
// Also update the full node record so future reads are consistent
graph::put_node(&self.db, &node)?;
count += 1;
}
}
Ok(count)
}
// ── Statistics ────────────────────────────────────────────────────────────
/// Total number of nodes stored.
pub fn node_count(&self) -> EngramResult<usize> {
graph::node_count(&self.db)
}
/// Total number of edges stored (each directed edge counted once).
pub fn edge_count(&self) -> EngramResult<usize> {
graph::edge_count(&self.db)
}
}
+24
View File
@@ -0,0 +1,24 @@
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("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>;
+90
View File
@@ -0,0 +1,90 @@
/// 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, RelationType};
use sled::Db;
use std::collections::{HashSet, VecDeque};
use uuid::Uuid;
/// Persist a node and its embedding. Overwrites any existing node with the same id.
pub fn put_node(db: &Db, node: &Node) -> EngramResult<Uuid> {
storage::write_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, only edges of that type 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<RelationType>,
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(ref 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:")
}
+46
View File
@@ -0,0 +1,46 @@
/// 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, RelationType};
/// 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 db;
pub mod error;
pub mod graph;
pub mod salience;
pub mod storage;
pub mod types;
pub mod vector;
// Re-export the public surface
pub use db::EngramDb;
pub use error::{EngramError, EngramResult};
pub use types::{
ActivatedNode, Edge, MemoryTier, Node, NodeType, RelationType, ScoredNode, now_ms,
};
+47
View File
@@ -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.
///
/// ```
/// 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)
}
+174
View File
@@ -0,0 +1,174 @@
/// 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
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 ─────────────────────────────────────────────────────────────
pub fn write_node(db: &Db, node: &Node) -> EngramResult<()> {
let key = node_key(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(())
}
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()
}
+193
View File
@@ -0,0 +1,193 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// 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
#[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,
}
/// 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,
}
/// The typed relationship between two nodes.
///
/// Relation types encode causal, temporal, hierarchical, and logical
/// structure into the graph itself — not just into metadata.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationType {
/// This node replaces or obsoletes another
Supersedes,
/// This node is a causal precursor to another
Causes,
/// This node hierarchically contains another
Contains,
/// This node cites or points to another as supporting context
References,
/// This node is in logical tension with another
Contradicts,
/// This node is a concrete instance of a more abstract node
Exemplifies,
/// Co-activation: firing this node tends to fire the other
Activates,
/// Temporal ordering: this node came before the other
TemporallyPrecedes,
}
/// 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,
}
}
}
/// 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.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub id: Uuid,
pub from_id: Uuid,
pub to_id: Uuid,
pub relation: RelationType,
/// 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: RelationType, weight: f32) -> Self {
let now = now_ms();
Self {
id: Uuid::new_v4(),
from_id,
to_id,
relation,
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
}
+82
View File
@@ -0,0 +1,82 @@
/// Vector similarity search over stored node embeddings.
///
/// v0.1 uses a flat cosine scan — O(n) but correct and dependency-free.
/// For < 100k nodes this is adequate. Future versions will layer in HNSW
/// once the graph structure itself is validated.
///
/// Cosine similarity between two vectors A and B:
/// cos(θ) = (A · B) / (|A| × |B|)
///
/// We return 0.0 when either vector has zero norm (degenerate case).
use crate::error::{EngramError, EngramResult};
use crate::storage;
use crate::types::{Node, ScoredNode};
use sled::Db;
use uuid::Uuid;
/// Compute the cosine similarity between two equal-length f32 slices.
///
/// Returns a value in [-1.0, 1.0], where 1.0 means identical direction.
/// For normalized embeddings (unit vectors) the dot product alone is sufficient,
/// but we compute full cosine here to be robust to unnormalized inputs.
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)
}
/// Search all stored embeddings for the `limit` closest nodes to `query`.
///
/// This is a full scan. Every stored vector is loaded and scored.
/// Results are sorted descending by cosine similarity.
pub fn search_embedding(
db: &Db,
query: &[f32],
limit: usize,
// Loader that retrieves a Node by Uuid — avoids a circular dep on graph.rs
node_loader: impl Fn(Uuid) -> EngramResult<Option<Node>>,
) -> EngramResult<Vec<ScoredNode>> {
let vectors = storage::scan_vectors(db)?;
let mut scored: Vec<(Uuid, f32)> = vectors
.iter()
.map(|(id, emb)| {
let sim = cosine_similarity(query, emb);
(*id, sim)
})
.collect();
// Sort descending by score
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)
}
/// Retrieve the stored embedding for a single node by id.
/// Returns an error if the node has no stored vector (shouldn't happen in normal use).
pub fn get_embedding(db: &Db, id: Uuid) -> EngramResult<Vec<f32>> {
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)),
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "engram-ffi"
version = "0.1.0"
edition = "2021"
description = "C FFI bindings for engram-core"
license = "MIT"
[lib]
crate-type = ["cdylib", "staticlib"]
[dependencies]
engram-core = { path = "../engram-core" }
uuid = { version = "1", features = ["v4", "serde"] }
+118
View File
@@ -0,0 +1,118 @@
/// C FFI stubs for engram-core.
///
/// These are minimal stubs for v0.1 — enough to link from Kotlin, TypeScript (via WASM
/// or Node native addon), and Go. Full binding generation will use cbindgen in v0.2.
///
/// All pointers passed across the FFI boundary must remain valid for the duration of
/// the call. Strings are null-terminated UTF-8. The caller owns all returned heap memory
/// and must free it via the corresponding `engram_free_*` function.
///
/// # Safety
/// All functions in this module are `unsafe` because they accept raw pointers.
/// Callers are responsible for ensuring pointer validity and correct lifetimes.
use engram_core::EngramDb;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::path::Path;
/// Opaque handle to an open EngramDb instance.
pub struct EngramHandle {
db: EngramDb,
}
/// Open an engram database at the given path.
///
/// Returns a heap-allocated handle on success, or null on failure.
/// The caller must eventually call `engram_close` to free the handle.
///
/// # Safety
/// `path` must be a valid, null-terminated UTF-8 string.
#[no_mangle]
pub unsafe extern "C" fn engram_open(path: *const c_char) -> *mut EngramHandle {
if path.is_null() {
return std::ptr::null_mut();
}
let path_str = match CStr::from_ptr(path).to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
match EngramDb::open(Path::new(path_str)) {
Ok(db) => Box::into_raw(Box::new(EngramHandle { db })),
Err(_) => std::ptr::null_mut(),
}
}
/// Close and free an engram database handle.
///
/// After this call, `handle` is invalid and must not be used.
///
/// # Safety
/// `handle` must have been returned by `engram_open` and not yet freed.
#[no_mangle]
pub unsafe extern "C" fn engram_close(handle: *mut EngramHandle) {
if !handle.is_null() {
drop(Box::from_raw(handle));
}
}
/// Return the number of nodes in the database.
///
/// Returns -1 on error.
///
/// # Safety
/// `handle` must be a valid, non-null pointer from `engram_open`.
#[no_mangle]
pub unsafe extern "C" fn engram_node_count(handle: *const EngramHandle) -> i64 {
if handle.is_null() {
return -1;
}
match (*handle).db.node_count() {
Ok(n) => n as i64,
Err(_) => -1,
}
}
/// Return the number of edges in the database.
///
/// Returns -1 on error.
///
/// # Safety
/// `handle` must be a valid, non-null pointer from `engram_open`.
#[no_mangle]
pub unsafe extern "C" fn engram_edge_count(handle: *const EngramHandle) -> i64 {
if handle.is_null() {
return -1;
}
match (*handle).db.edge_count() {
Ok(n) => n as i64,
Err(_) => -1,
}
}
/// Apply salience decay across all nodes.
///
/// Returns the number of nodes updated, or -1 on error.
///
/// # Safety
/// `handle` must be a valid, non-null pointer from `engram_open`.
#[no_mangle]
pub unsafe extern "C" fn engram_decay(handle: *mut EngramHandle, factor: f32) -> i64 {
if handle.is_null() {
return -1;
}
match (*handle).db.decay(factor) {
Ok(n) => n as i64,
Err(_) => -1,
}
}
/// Free a C string returned by engram FFI functions.
///
/// # Safety
/// `s` must have been allocated by an engram FFI function, not by the caller.
#[no_mangle]
pub unsafe extern "C" fn engram_free_string(s: *mut c_char) {
if !s.is_null() {
drop(CString::from_raw(s));
}
}