feat: HNSW index, consolidation engine, Kotlin/TS/Go bindings, SQLite migration connector

- vector.rs: replace flat O(n) scan with instant-distance HNSW for stores
  >= 100 nodes; flat scan retained as fallback for small graphs; dirty-flag
  persistence in sled triggers index rebuild only when nodes are added

- consolidation.rs: Episodic → Semantic promotion based on activation_count
  and salience_floor thresholds; global decay pass after each cycle;
  ConsolidationConfig + ConsolidationReport types; 8 tests

- migration.rs: reads Neuron SQLite (memory_nodes, knowledge_entries,
  graph_edges) and writes to Engram sled; placeholder unit-vector embeddings
  with TODO for ONNX; 5 tests including full in-memory DB roundtrip

- crates/engram-migrate: CLI binary (engram-migrate --sqlite / --output)

- crates/engram-jni: JNI cdylib exposing open/close/put_node/get_node/
  activate/search_embedding/touch/decay/node_count/edge_count via
  Java_ai_neuron_engram_EngramDb_* entry points; 6 tests

- bindings/kotlin: EngramDb.kt (AutoCloseable JNI wrapper), EngramNode,
  EngramEdge, ActivatedNode, EngramTypes; build.gradle.kts; settings.gradle.kts

- bindings/typescript: engram-wasm crate (wasm-bindgen, serde-wasm-bindgen);
  WasmEngramDb with in-memory backend (sled not available in WASM);
  TypeScript wrapper (index.ts, types.ts, package.json, tsconfig.json)

- bindings/go: engram.go (CGo wrapper), engram.h (C header), engram_test.go
  (4 tests covering open/close/put_node/get_node/node_count/decay); go.mod

- engram-core: wasm feature gate for in-memory backend; mem_storage.rs;
  activation.activate_mem for WASM path; Node::with_id helper;
  salience.rs doctest fixed (text block)

- examples/basic.rs: consolidation section added
- examples/migrate.rs: migration API demonstration

Build: cargo build --workspace -- zero warnings, zero errors
Tests: 38 pass (25 engram-core + 7 engram-ffi + 6 engram-jni)
This commit is contained in:
Will Anderson
2026-04-27 16:00:47 -05:00
parent 1a609502c8
commit 2454c83e82
37 changed files with 4573 additions and 237 deletions
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "engram-wasm"
version = "0.1.0"
edition = "2021"
description = "WASM/TypeScript bindings for engram-core via wasm-bindgen"
license = "MIT"
[lib]
crate-type = ["cdylib"]
[dependencies]
engram-core = { path = "../../crates/engram-core", features = ["wasm"], default-features = false }
wasm-bindgen = "0.2"
serde-wasm-bindgen = "0.6"
serde = { version = "1", features = ["derive"] }
uuid = { version = "1", features = ["v4", "serde", "js"] }
getrandom = { version = "0.2", features = ["js"] }
console_error_panic_hook = { version = "0.1", optional = true }
[features]
default = ["console_error_panic_hook"]
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@neuron/engram",
"version": "0.1.0",
"description": "Engram memory substrate — TypeScript/WASM bindings",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"pkg"
],
"scripts": {
"build:wasm": "wasm-pack build . --target web --out-dir pkg",
"build:ts": "tsc",
"build": "npm run build:wasm && npm run build:ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
+125
View File
@@ -0,0 +1,125 @@
/**
* TypeScript wrapper around the engram WASM module.
*
* Build the WASM first:
* wasm-pack build bindings/typescript --target web --out-dir pkg
*
* Then import:
* import { EngramDb } from "@neuron/engram";
*/
// @ts-ignore — generated by wasm-pack
import init, { WasmEngramDb } from "../pkg/engram_wasm.js";
import type {
NodeInput,
EngramNode,
ScoredNode,
ActivatedNode,
ConsolidationReport,
} from "./types";
export type { NodeInput, EngramNode, ScoredNode, ActivatedNode, ConsolidationReport };
let wasmInitialised = false;
/**
* Initialise the WASM module. Must be called once before creating any EngramDb.
*/
export async function initEngram(): Promise<void> {
if (!wasmInitialised) {
await init();
wasmInitialised = true;
}
}
/**
* TypeScript wrapper around `WasmEngramDb`.
*
* All state is in-memory (WASM has no filesystem access). The path argument
* is accepted for API symmetry but is ignored.
*
* ```ts
* await initEngram();
* const db = new EngramDb();
*
* const id = await db.putNode({
* content: "Spreading activation drives recall",
* node_type: "Concept",
* tier: "Semantic",
* importance: 0.9,
* embedding: Array.from({ length: 384 }, () => Math.random()),
* });
*
* const results = await db.searchEmbedding(queryEmbedding, 5);
* ```
*/
export class EngramDb {
private db: WasmEngramDb;
constructor(path = "/wasm-memory") {
this.db = new WasmEngramDb(path);
}
/** Store a node and return its UUID. */
putNode(node: NodeInput): string {
return this.db.put_node(node);
}
/** Retrieve a node by UUID, or null if not found. */
getNode(id: string): EngramNode | null {
return this.db.get_node(id);
}
/** Find the `limit` most similar nodes by embedding vector. */
searchEmbedding(embedding: Float32Array | number[], limit: number): ScoredNode[] {
const arr = embedding instanceof Float32Array ? embedding : new Float32Array(embedding);
return this.db.search_embedding(arr, limit);
}
/**
* Run spreading activation from seed nodes.
*
* @param seeds Array of UUID strings (the "active context")
* @param queryEmbedding Semantic vector for the current query
* @param maxDepth Maximum BFS hops (typically 24)
* @param limit Number of results to return
*/
activate(
seeds: string[],
queryEmbedding: Float32Array | number[],
maxDepth = 3,
limit = 10
): ActivatedNode[] {
const arr =
queryEmbedding instanceof Float32Array
? queryEmbedding
: new Float32Array(queryEmbedding);
return this.db.activate(seeds, arr, maxDepth, limit);
}
/** Mark a node as recently accessed (increments activation count). */
touch(id: string): void {
this.db.touch(id);
}
/** Apply multiplicative salience decay. Returns the number of nodes updated. */
decay(factor: number): number {
return this.db.decay(factor);
}
/** Run a memory consolidation cycle. */
consolidate(): ConsolidationReport {
return this.db.consolidate();
}
/** Total number of nodes stored. */
nodeCount(): number {
return this.db.node_count();
}
/** Total number of edges stored. */
edgeCount(): number {
return this.db.edge_count();
}
}
+311
View File
@@ -0,0 +1,311 @@
/// WASM/TypeScript bindings for engram-core via wasm-bindgen.
///
/// This crate compiles to a WebAssembly module that can be loaded by the
/// TypeScript wrapper in `src/index.ts`. All types are passed as JSON strings
/// across the WASM boundary to avoid bespoke serialisation code.
///
/// # Storage
/// sled is not available in WASM (no filesystem). When compiled with the `wasm`
/// feature, engram-core switches to an in-memory HashMap backend. All state is
/// therefore lost on page reload — persistence requires sending nodes to a
/// server-side store and re-loading them on startup.
///
/// # Build
/// ```
/// wasm-pack build bindings/typescript --target web
/// ```
use engram_core::{
ActivatedNode, ConsolidationConfig, EngramDb, MemoryTier, Node, NodeType, ScoredNode,
};
use serde::{Deserialize, Serialize};
use std::path::Path;
use uuid::Uuid;
use wasm_bindgen::prelude::*;
// ── Panic hook ────────────────────────────────────────────────────────────────
#[wasm_bindgen(start)]
pub fn main() {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
}
// ── WasmEngramDb ─────────────────────────────────────────────────────────────
/// The main entry point for WASM callers.
///
/// TypeScript:
/// ```ts
/// const db = new WasmEngramDb("ignored-path");
/// const id = db.putNode(JSON.stringify({ content: "...", node_type: "Memory", ... }));
/// ```
#[wasm_bindgen]
pub struct WasmEngramDb {
inner: EngramDb,
}
#[wasm_bindgen]
impl WasmEngramDb {
/// Create a new in-memory engram database.
///
/// The `path` argument is accepted for API symmetry with the sled backend
/// but is ignored — all storage is in-memory.
#[wasm_bindgen(constructor)]
pub fn open(_path: &str) -> Result<WasmEngramDb, JsValue> {
let db = EngramDb::open(Path::new("/wasm-memory"))
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(WasmEngramDb { inner: db })
}
// ── Node operations ───────────────────────────────────────────────────────
/// Store a node. Accepts a JSON object with fields:
/// `{ content, node_type, tier, importance, embedding }`
/// Returns the assigned UUID string.
pub fn put_node(&self, node: JsValue) -> Result<String, JsValue> {
let n = js_value_to_node(node)?;
self.inner
.put_node(n)
.map(|id| id.to_string())
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Retrieve a node by UUID. Returns a JSON object or null.
pub fn get_node(&self, id: &str) -> Result<JsValue, JsValue> {
let uuid = id
.parse::<Uuid>()
.map_err(|e| JsValue::from_str(&e.to_string()))?;
match self
.inner
.get_node(uuid)
.map_err(|e| JsValue::from_str(&e.to_string()))?
{
Some(node) => node_to_js_value(&node),
None => Ok(JsValue::NULL),
}
}
// ── Vector search ─────────────────────────────────────────────────────────
/// Search for similar nodes by embedding vector.
///
/// `embedding` is a JS Float32Array. Returns a JSON array of
/// `{ node, score }` objects.
pub fn search_embedding(
&self,
embedding: &[f32],
limit: usize,
) -> Result<JsValue, JsValue> {
let results = self
.inner
.search_embedding(embedding, limit)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
scored_nodes_to_js(&results)
}
// ── Spreading activation ──────────────────────────────────────────────────
/// Run spreading activation.
///
/// `seeds` is a JS array of UUID strings.
/// `query_embedding` is a Float32Array.
/// Returns a JSON array of `{ node, activation_strength, hops }`.
pub fn activate(
&self,
seeds: JsValue,
query_embedding: &[f32],
max_depth: u8,
limit: usize,
) -> Result<JsValue, JsValue> {
let seed_strs: Vec<String> = serde_wasm_bindgen::from_value(seeds)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let seeds: Vec<Uuid> = seed_strs
.iter()
.filter_map(|s| s.parse::<Uuid>().ok())
.collect();
let results = self
.inner
.activate(&seeds, query_embedding, max_depth, limit)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
activated_nodes_to_js(&results)
}
// ── Salience management ───────────────────────────────────────────────────
/// Touch a node (increment activation count and update salience).
pub fn touch(&self, id: &str) -> Result<(), JsValue> {
let uuid = id
.parse::<Uuid>()
.map_err(|e| JsValue::from_str(&e.to_string()))?;
self.inner
.touch(uuid)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Apply multiplicative salience decay. Returns the number of nodes updated.
pub fn decay(&self, factor: f32) -> Result<u32, JsValue> {
self.inner
.decay(factor)
.map(|n| n as u32)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
// ── Consolidation ─────────────────────────────────────────────────────────
/// Run a memory consolidation cycle.
/// Returns `{ promoted, decayed, pruned }`.
pub fn consolidate(&self) -> Result<JsValue, JsValue> {
let config = ConsolidationConfig::default();
let report = self
.inner
.consolidate(&config)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
#[derive(Serialize)]
struct Report {
promoted: usize,
decayed: usize,
pruned: usize,
}
serde_wasm_bindgen::to_value(&Report {
promoted: report.promoted,
decayed: report.decayed,
pruned: report.pruned,
})
.map_err(|e| JsValue::from_str(&e.to_string()))
}
// ── Statistics ────────────────────────────────────────────────────────────
/// Return the total number of nodes.
pub fn node_count(&self) -> Result<u32, JsValue> {
self.inner
.node_count()
.map(|n| n as u32)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Return the total number of edges.
pub fn edge_count(&self) -> Result<u32, JsValue> {
self.inner
.edge_count()
.map(|n| n as u32)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
}
// ── Serialisation helpers ─────────────────────────────────────────────────────
#[derive(Deserialize)]
struct NodeInput {
content: String,
node_type: Option<String>,
tier: Option<String>,
importance: Option<f32>,
embedding: Option<Vec<f32>>,
}
fn js_value_to_node(val: JsValue) -> Result<Node, JsValue> {
let input: NodeInput = serde_wasm_bindgen::from_value(val)
.map_err(|e| JsValue::from_str(&format!("Invalid node: {e}")))?;
let node_type = match input.node_type.as_deref().unwrap_or("Memory") {
"Concept" => NodeType::Concept,
"Event" => NodeType::Event,
"Entity" => NodeType::Entity,
"Process" => NodeType::Process,
"InternalState" => NodeType::InternalState,
_ => NodeType::Memory,
};
let tier = match input.tier.as_deref().unwrap_or("Episodic") {
"Working" => MemoryTier::Working,
"Semantic" => MemoryTier::Semantic,
"Procedural" => MemoryTier::Procedural,
_ => MemoryTier::Episodic,
};
let embedding = input.embedding.unwrap_or_default();
let importance = input.importance.unwrap_or(0.5);
Ok(Node::new(node_type, embedding, input.content.into_bytes(), tier, importance))
}
#[derive(Serialize)]
struct NodeOutput {
id: String,
content: String,
node_type: String,
tier: String,
salience: f32,
importance: f32,
activation_count: u64,
embedding: Vec<f32>,
}
fn node_to_js_value(node: &Node) -> Result<JsValue, JsValue> {
let out = NodeOutput {
id: node.id.to_string(),
content: String::from_utf8_lossy(&node.content).into_owned(),
node_type: format!("{:?}", node.node_type),
tier: format!("{:?}", node.tier),
salience: node.salience,
importance: node.importance,
activation_count: node.activation_count,
embedding: node.embedding.clone(),
};
serde_wasm_bindgen::to_value(&out).map_err(|e| JsValue::from_str(&e.to_string()))
}
#[derive(Serialize)]
struct ScoredNodeOutput {
node: NodeOutput,
score: f32,
}
fn scored_nodes_to_js(nodes: &[ScoredNode]) -> Result<JsValue, JsValue> {
let out: Vec<ScoredNodeOutput> = nodes
.iter()
.map(|s| ScoredNodeOutput {
node: NodeOutput {
id: s.node.id.to_string(),
content: String::from_utf8_lossy(&s.node.content).into_owned(),
node_type: format!("{:?}", s.node.node_type),
tier: format!("{:?}", s.node.tier),
salience: s.node.salience,
importance: s.node.importance,
activation_count: s.node.activation_count,
embedding: s.node.embedding.clone(),
},
score: s.score,
})
.collect();
serde_wasm_bindgen::to_value(&out).map_err(|e| JsValue::from_str(&e.to_string()))
}
#[derive(Serialize)]
struct ActivatedNodeOutput {
node: NodeOutput,
activation_strength: f32,
hops: u8,
}
fn activated_nodes_to_js(nodes: &[ActivatedNode]) -> Result<JsValue, JsValue> {
let out: Vec<ActivatedNodeOutput> = nodes
.iter()
.map(|a| ActivatedNodeOutput {
node: NodeOutput {
id: a.node.id.to_string(),
content: String::from_utf8_lossy(&a.node.content).into_owned(),
node_type: format!("{:?}", a.node.node_type),
tier: format!("{:?}", a.node.tier),
salience: a.node.salience,
importance: a.node.importance,
activation_count: a.node.activation_count,
embedding: a.node.embedding.clone(),
},
activation_strength: a.activation_strength,
hops: a.hops,
})
.collect();
serde_wasm_bindgen::to_value(&out).map_err(|e| JsValue::from_str(&e.to_string()))
}
+49
View File
@@ -0,0 +1,49 @@
/**
* TypeScript types mirroring the Rust structs in engram-core.
*/
export type NodeType =
| "Memory"
| "Concept"
| "Event"
| "Entity"
| "Process"
| "InternalState";
export type MemoryTier = "Working" | "Episodic" | "Semantic" | "Procedural";
export interface EngramNode {
id: string;
content: string;
node_type: NodeType;
tier: MemoryTier;
salience: number;
importance: number;
activation_count: number;
embedding: number[];
}
export interface NodeInput {
content: string;
node_type?: NodeType;
tier?: MemoryTier;
importance?: number;
embedding?: number[];
}
export interface ScoredNode {
node: EngramNode;
score: number;
}
export interface ActivatedNode {
node: EngramNode;
activation_strength: number;
hops: number;
}
export interface ConsolidationReport {
promoted: number;
decayed: number;
pruned: number;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowJs": false,
"rootDir": "src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "pkg"]
}