Files
bigmerge 3fcc36c2f1
El SDK CI - dev / build-and-test (pull_request) Failing after 14m58s
runtime: transduction is a language concern, so move it into the language
#141 let signal enter as geometry and it worked, but it was placed at the
CONSUMER and said so in its own commit message. This is the correction.

Three defects, all of them placement:

1. It sat in the engram. Ingest is a LANGUAGE concern — every el program
   touching any modality needs it, and the engram is merely one el program
   that happens to hold a graph. The geometry surface is now defined in
   el_runtime.c immediately ABOVE the engram section and depends on nothing
   inside it. Delete the entire engram and geometry still enters el.

2. It marshalled the vector as a hex STRING, because el had no first-class
   geometry value — which reintroduced text as the TRANSPORT medium one layer
   below the problem being fixed. Geometry is now an el value: a magic-tagged
   heap object carried in el_val_t, same discipline as List/Map. Hex survives
   only as an adapter at the edge, which is all an encoding should ever be.

3. It needed an arbitrary `dim <= 8192` bound purely to size an allocation
   from a caller's CLAIM about a string's length. A value carries its own
   width, so the width is derived and never asserted. The bound is gone, not
   raised — there is nothing left to validate.

Language surface, none of it engram-prefixed: geometry_new / _dim / _is /
_get / _set / _norm / _free, geometry_from_f32le_hex + geometry_to_f32le_hex
as the wire adapters, realizer_register(modality, fn_name), realizer_has, and
transduce(signal, modality) -> Geometry.

REALIZERS ARE DECLARABLE IN EL. This is the part that makes the move real
rather than nominal: registration resolves a name with dlsym against the
running binary, the identical mechanism http_set_handler already relies on,
because every el `fn name(...)` compiles to a global C symbol with that exact
name. So an ordinary el function IS a realizer and a new modality needs no
runtime patch. Verified end to end in lang/examples/transduce.el: an el-defined
tone_realizer is registered by name, transduce dispatches to it, and the
signal demonstrably reaches it (distinct signals produce distinct geometry).

A modality with no realizer transduces to NOTHING. There is deliberately no
built-in realizer, not even for text — silently embedding a description of a
signal and calling that perception is the exact defect this ends.

engram/src/server.el is migrated: POST /api/nodes decodes "emb" hex exactly
once, at the edge, into a Geometry, and everything below that line moves
geometry. The wire is unchanged because production clients speak it. "dim" is
now an ASSERTION about the vector, not the source of its width; disagreement
is a rejected ingest, not a silent reinterpretation.

#141's engram_node_set_emb becomes a DEPRECATED WRAPPER over
geometry_from_f32le_hex + node_attach_geometry — kept only because the runtime
ships as an SDK asset and a downstream binary may link the symbol. Its exact
contract, negative cases included, is preserved and re-verified.

ingest.el's `fn transduce` is renamed transduce_manifold. Mechanically it had
to yield the name (duplicate C symbol, a hard compile error, measured). But it
was never signal->geometry: it chunks already-extracted content into a node+edge
manifold, one layer up, and had taken the name belonging to the primitive
underneath it. Behaviour unchanged.

PROPERTIES FROM #141 PRESERVED, each re-measured on a scratch engram (:8971,
never prod :8742):
  - off-dimension vectors stored but NOT indexed — the HNSW build loop still
    filters on n->emb_dim == dim at four sites, so a 64-dim voice vector is
    durable and addressable without perturbing the 768-dim canonical index
  - geometry makes a node ineligible for embed_backfill: after backfill the
    64-dim voice node was still 64-dim while the text control acquired 768
  - the create response reports whether geometry landed, and the node document
    always emits emb_dim and embedded

Read-back with control and negatives, all verified against a PID-confirmed
fresh binary: geometry node emb_dim=64 embedded=true / emb_set=1; text-only
control emb_dim=0 embedded=false / emb_set=0; malformed hex, ragged length,
and dim-disagreement each emb_set=0.

Two compiler landmines found by reading the generated C rather than trusting a
successful build, both documented at their sites: elc lowers `a == b` to
str_eq unless both operand NAMES are in the per-function int-name set (which
does NOT propagate into nested if-expression blocks — the first cut would have
strcmp'd two integers as pointers on the first geometry-bearing request), and
`+` lowers to string concat when either operand is a user-defined call.
2026-08-16 11:37:27 -05:00
..
2026-04-30 13:49:28 -05:00

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:

  1. Seeds — you name one or more nodes you know are relevant (e.g. the current task, recent context, a concept you're reasoning about)

  2. Query embedding — you provide a semantic vector representing the direction of your current thought

  3. 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)
    
  4. Pruning — paths weaker than a threshold are cut (the attention filter)

  5. 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.01.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 serialization
  • uuid — stable node identity
  • serde — derive support
  • thiserror / 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.