Yesterday's eligibility-trace fix made Hebbian consolidation numerically real: hebb_max 0.000799 -> 0.4725, and 1,198 hebbian-associate edges formed in 23h48m. This morning's census found where they went: nowhere. soul daemon (in-process graph): 42,426 edges, 1,198 hebbian engram server (:8742, durable): 41,213 edges, 49 hebbian Two processes, two graphs, one direction of travel. The soul pulls from the server every 10 min (GET /api/sync) and never pushes. It cannot fall back on saving its own copy either: soul.el sets soul_snapshot_path only inside `if is_genesis && safe_to_seed`, and safe_to_seed is unconditionally false whenever ENGRAM_URL is set -- because the server owns persistence and a soul writing snapshot.json would clobber it. That guard is correct. The consequence was not: mem_save() has never once executed. The soul is the ONLY process running idle cognition, so it is where essentially all co-activation happens -- and it was throwing away every association it learned, every restart, silently. The mechanism worked and the learning still evaporated. Consolidation is now a message, not a file. Fast volatile store hands each newly-formed association to the slow durable store over the API the server already exposes; only edges past ENGRAM_HEBB_LINK_MIN are ever queued, so what crosses the process boundary already earned it. - el_runtime.c: 512-slot overwrite-oldest write-back ring; enqueue at edge formation; engram_hebb_drain_json() pops a postable JSON batch. Drops and drains are counted, not silent -- a consolidation path that quietly discards is the exact failure this entry exists to correct. - server.el: POST /api/edges/batch. persist_canonical() writes the full 60MB snapshot per call, and route_create_edge calls it per edge -- correct for one interactive edge, ruinous for bulk (~840MB/beat to persist 14 associations). Batch connects all, snapshots once. Same durability, 1/N the writes. - act-stats: hebb_wb_pending / _drained / _dropped. pending climbing with drained flat = drain not called; drained climbing with sent 0 = POST refused. Both failure modes are now visible in the stream instead of in an autopsy. Verified live: batch route accepts valid entries, skips malformed ones without aborting the batch, and enforces _auth. All 1,256 learned associations are now in the canonical store; the soul booted at 42,431 edges with hebb_max 0.4941 carried across the restart for the first time.
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, not an abstraction above it. That's what this is.
Why existing databases are wrong for this use case
Relational databases store rows and retrieve them by predicate. Key-value stores retrieve by exact key. Vector databases retrieve by geometric proximity. All of them share the same fundamental model: you store data in, you query it out. Storage and retrieval are separate systems.
The brain doesn't work this way.
When you remember something, you don't query your hippocampus. You activate a memory trace and the pattern propagates. Long-term potentiation — the strengthening of synaptic connections through co-activation — is simultaneously the storage mechanism and the retrieval mechanism. The structure that holds the memory is the same structure that surfaces it.
No existing database models this. Engram does.
The Spreading Activation Model
Engram retrieval works through spreading activation:
-
Seeds — you name one or more nodes you know are relevant (e.g. the current task, recent context, a concept you're reasoning about)
-
Query embedding — you provide a semantic vector representing the direction of your current thought
-
Propagation — activation flows outward from seeds through weighted edges. At each hop, strength attenuates multiplicatively:
strength = parent_strength × edge_weight × target_salience × cosine_sim(query, target) -
Pruning — paths weaker than a threshold are cut (the attention filter)
-
Return — the top-N nodes by activation strength
This is not a query. It is a pattern completion. The system surfaces what is most associatively relevant to the current context, weighted by how strongly those things have been reinforced over time.
The Four Memory Tiers
| Tier | Analogy | Contents |
|---|---|---|
Working |
Prefrontal working memory | K most recently activated nodes — hot, fast |
Episodic |
Hippocampus | Time-ordered events and experiences |
Semantic |
Neocortex | Concept graph — long-term structural knowledge |
Procedural |
Cerebellum / basal ganglia | Patterns, workflows, habits |
Nodes migrate between tiers based on salience decay and reinforcement. A frequently activated semantic node stays semantic. A rarely-touched episodic memory decays toward procedural background.
Salience — Forgetting as Adaptation
Salience is not stored permanently. It decays:
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;
importance * (1.0 / (1.0 + days_since)) * (activation_count as f32 + 1.0).ln()
}
Three signals:
- Importance (0.0–1.0): set at creation, stable
- Recency: decays toward zero as days pass without activation
- Frequency: log-compressed count of activations
Forgetting in Engram is not a bug. It is adaptive pruning. Memories that are never activated again become less likely to surface during retrieval. They are not deleted — they remain in storage — but they stop competing for attention. This is exactly how biological memory works, and why it is adaptive rather than pathological.
Quick Start
use engram_core::{EngramDb, Node, Edge, NodeType, MemoryTier, RelationType};
use std::path::Path;
// Open or create a database
let db = EngramDb::open(Path::new("/var/lib/my-agent/memory"))?;
// Create a node with a semantic embedding
let node = Node::new(
NodeType::Concept,
vec![0.9, 0.1, 0.3, 0.7, 0.8, 0.2], // embedding from your LLM
b"Spreading activation surfaces relevant memories by pattern completion".to_vec(),
MemoryTier::Semantic,
0.9, // importance
);
let id = db.put_node(node)?;
// Link it to related concepts
let related = db.put_node(Node::new(
NodeType::Concept,
vec![0.8, 0.2, 0.4, 0.6, 0.7, 0.3],
b"Long-term potentiation: co-activation strengthens synaptic weight".to_vec(),
MemoryTier::Semantic,
0.85,
))?;
db.put_edge(Edge::new(id, related, RelationType::Causes, 0.9))?;
// Retrieve by spreading activation
let results = db.activate(
&[id], // seeds
&[0.85, 0.15, 0.35, 0.65, 0.75, 0.25], // query embedding
3, // max hops
10, // top-N results
)?;
for r in results {
println!(
"strength={:.4} hops={} — {}",
r.activation_strength,
r.hops,
String::from_utf8_lossy(&r.node.content)
);
}
Project Structure
engram/
crates/
engram-core/ # The memory engine — storage, graph, activation, salience
engram-ffi/ # C FFI stubs for cross-language bindings
bindings/
kotlin/ # Android / JVM binding notes
typescript/ # WASM / Node binding notes
go/ # CGo binding notes
examples/
basic.rs # Full walkthrough: insert, activate, search, decay
Public API
impl EngramDb {
fn open(path: &Path) -> EngramResult<Self>;
fn put_node(&self, node: Node) -> EngramResult<Uuid>;
fn get_node(&self, id: Uuid) -> EngramResult<Option<Node>>;
fn put_edge(&self, edge: Edge) -> EngramResult<()>;
fn get_edges_from(&self, from_id: Uuid) -> EngramResult<Vec<Edge>>;
fn get_edges_to(&self, to_id: Uuid) -> EngramResult<Vec<Edge>>;
fn search_embedding(&self, embedding: &[f32], limit: usize) -> EngramResult<Vec<ScoredNode>>;
fn activate(&self, seeds: &[Uuid], query_embedding: &[f32], max_depth: u8, limit: usize) -> EngramResult<Vec<ActivatedNode>>;
fn traverse(&self, from: Uuid, relation: Option<RelationType>, max_depth: u8) -> EngramResult<Vec<Node>>;
fn touch(&self, id: Uuid) -> EngramResult<()>;
fn decay(&self, factor: f32) -> EngramResult<usize>;
fn node_count(&self) -> EngramResult<usize>;
fn edge_count(&self) -> EngramResult<usize>;
}
Dependencies
sled— embedded persistent B-tree (no daemon, no network, local-first)bincode— compact binary serializationuuid— stable node identityserde— derive supportthiserror/anyhow— error handling
Design Decisions
Why sled? Local-first. No daemon. Transactional. Fast enough for the node counts Engram targets (< 1M nodes). When the right HNSW index is needed, it will layer on top of sled, not replace it.
Why flat cosine scan? Correct and simple. The graph structure itself is the primary retrieval mechanism. Vector search is a secondary signal. HNSW adds complexity and a compile dependency that isn't justified until retrieval quality at scale demands it.
Why multiplicative activation? Because memory is conjunctive. A path requires all of its links to be strong to carry signal. Addition would allow many weak associations to accumulate into false relevance. Multiplication enforces that every factor matters.
Why salience decay? Because not everything that was once important remains important. Adaptive forgetting is not failure — it is the mechanism that keeps attention on what's current. A memory system that never forgets is one that can never focus.