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
+13 -2
View File
@@ -1,14 +1,25 @@
[package]
name = "engram-core"
version = "0.1.0"
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 = "0.34"
sled = { version = "0.34", optional = true }
uuid = { version = "1", features = ["v4", "serde"] }
serde = { version = "1", features = ["derive"] }
bincode = "1"
anyhow = "1"
thiserror = "1"
instant-distance = { version = "0.6", features = ["with-serde"] }
rusqlite = { version = "0.31", optional = true }
[dev-dependencies]
tempfile = "3"
+80 -2
View File
@@ -49,13 +49,19 @@
/// 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;
#[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
@@ -99,6 +105,7 @@ impl Ord for Candidate {
/// # 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],
@@ -238,3 +245,74 @@ pub fn activate(
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,307 @@
/// 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.
node.tier = MemoryTier::Semantic;
graph::put_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::put_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);
}
}
+408 -150
View File
@@ -1,170 +1,428 @@
/// 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;
/// 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
pub struct EngramDb {
db: Db,
}
// ── sled-backed implementation ────────────────────────────────────────────────
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 })
#[cfg(feature = "sled-backend")]
mod sled_impl {
use crate::activation;
use crate::consolidation::{self, ConsolidationConfig, ConsolidationReport};
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 {
pub(crate) db: Db,
}
// ── Node operations ───────────────────────────────────────────────────────
impl EngramDb {
/// Open (or create) an engram database at the given path.
pub fn open(path: &Path) -> EngramResult<Self> {
let db = sled::open(path)?;
Ok(Self { db })
}
/// 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)
}
// ── Node operations ───────────────────────────────────────────────────
/// 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)
}
/// Persist a node. Returns the node's UUID.
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)
}
// ── 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| {
/// 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)
})
}
// ── 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;
// ── 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.
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.
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). 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::put_node(&self.db, &node)?;
count += 1;
}
}
Ok(count)
}
Ok(count)
}
// ── Statistics ────────────────────────────────────────────────────────────
// ── Consolidation ─────────────────────────────────────────────────────
/// Total number of nodes stored.
pub fn node_count(&self) -> EngramResult<usize> {
graph::node_count(&self.db)
}
/// 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)
}
/// Total number of edges stored (each directed edge counted once).
pub fn edge_count(&self) -> EngramResult<usize> {
graph::edge_count(&self.db)
// ── 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)
}
}
}
// ── 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, RelationType, ScoredNode};
use crate::vector;
use std::collections::{BinaryHeap, 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<RelationType>,
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(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) = 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;
+7
View File
@@ -30,13 +30,19 @@
/// }
/// ```
pub mod activation;
pub mod consolidation;
pub mod db;
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;
@@ -44,3 +50,4 @@ pub use error::{EngramError, EngramResult};
pub use types::{
ActivatedNode, Edge, MemoryTier, Node, NodeType, RelationType, ScoredNode, now_ms,
};
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(())
}
}
+420
View File
@@ -0,0 +1,420 @@
/// 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` (type = "supersedes" or "Supersedes") are converted
/// to `Edge { relation: RelationType::Supersedes }`. Other edge types become
/// `RelationType::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, RelationType};
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 = edge_type_to_relation(&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)
}
}
}
/// Convert a Neuron edge type string to an Engram RelationType.
fn edge_type_to_relation(edge_type: &str) -> RelationType {
match edge_type.to_lowercase().as_str() {
"supersedes" | "superseded_by" => RelationType::Supersedes,
"causes" | "caused_by" => RelationType::Causes,
"contains" | "contained_by" => RelationType::Contains,
"references" | "referenced_by" => RelationType::References,
"contradicts" => RelationType::Contradicts,
"exemplifies" | "exemplified_by" => RelationType::Exemplifies,
"activates" => RelationType::Activates,
"temporally_precedes" | "follows" => RelationType::TemporallyPrecedes,
_ => RelationType::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!(edge_type_to_relation("supersedes"), RelationType::Supersedes);
}
#[test]
fn edge_type_unknown_is_references() {
assert_eq!(edge_type_to_relation("foobar"), RelationType::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());
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
/// 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)
/// ```
///
+6
View File
@@ -129,6 +129,12 @@ impl Node {
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
+279 -31
View File
@@ -1,24 +1,58 @@
/// 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.
/// Strategy:
/// - For < HNSW_THRESHOLD indexed nodes: flat O(n) cosine scan (correct, no deps)
/// - For >= HNSW_THRESHOLD nodes: HNSW approximate nearest-neighbour index
///
/// Cosine similarity between two vectors A and B:
/// cos(θ) = (A · B) / (|A| × |B|)
/// 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.
///
/// We return 0.0 when either vector has zero norm (degenerate case).
use crate::error::{EngramError, EngramResult};
/// 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;
use crate::types::{Node, ScoredNode};
#[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.
/// 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;
@@ -32,42 +66,72 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
(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`.
///
/// This is a full scan. Every stored vector is loaded and scored.
/// Results are sorted descending by cosine similarity.
/// 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,
// 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 });
}
if vectors.len() < HNSW_THRESHOLD {
return flat_search(query, limit, &vectors, node_loader);
}
Ok(results)
// 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.
/// Returns an error if the node has no stored vector (shouldn't happen in normal use).
#[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) => {
@@ -80,3 +144,187 @@ pub fn get_embedding(db: &Db, id: Uuid) -> EngramResult<Vec<f32>> {
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);
}
}
+3
View File
@@ -11,3 +11,6 @@ crate-type = ["cdylib", "staticlib"]
[dependencies]
engram-core = { path = "../engram-core" }
uuid = { version = "1", features = ["v4", "serde"] }
[dev-dependencies]
tempfile = "3"
+392 -41
View File
@@ -1,32 +1,40 @@
/// C FFI stubs for engram-core.
/// C FFI 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.
/// These functions form the stable ABI that Go (via CGo), Python (via ctypes),
/// and other native callers use. All pointers must remain valid for the duration
/// of the call. Strings are null-terminated UTF-8. The caller must free any
/// returned heap-allocated C string with `engram_free_string`.
///
/// # 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;
/// Every function in this module accepts raw pointers and is therefore `unsafe`.
/// Callers must ensure:
/// - All handle pointers came from `engram_open` and have not been freed.
/// - All string pointers are valid null-terminated UTF-8.
/// - Returned C strings are freed exactly once via `engram_free_string`.
use engram_core::{
ActivatedNode, EngramDb, MemoryTier, Node, NodeType,
};
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::path::Path;
use uuid::Uuid;
/// Opaque handle to an open EngramDb instance.
// ── Handle type ───────────────────────────────────────────────────────────────
/// Opaque handle wrapping an open `EngramDb`.
pub struct EngramHandle {
db: EngramDb,
}
/// Open an engram database at the given path.
// ── Lifecycle ─────────────────────────────────────────────────────────────────
/// Open or create an engram database at `path`.
///
/// Returns a heap-allocated handle on success, or null on failure.
/// The caller must eventually call `engram_close` to free the handle.
/// Returns a heap-allocated `EngramHandle` on success, null on error.
/// Must be freed with `engram_close`.
///
/// # Safety
/// `path` must be a valid, null-terminated UTF-8 string.
/// `path` must be a valid, non-null, null-terminated UTF-8 string.
#[no_mangle]
pub unsafe extern "C" fn engram_open(path: *const c_char) -> *mut EngramHandle {
if path.is_null() {
@@ -42,9 +50,9 @@ pub unsafe extern "C" fn engram_open(path: *const c_char) -> *mut EngramHandle {
}
}
/// Close and free an engram database handle.
/// Close and free an engram handle.
///
/// After this call, `handle` is invalid and must not be used.
/// After this call `handle` is invalid.
///
/// # Safety
/// `handle` must have been returned by `engram_open` and not yet freed.
@@ -55,64 +63,407 @@ pub unsafe extern "C" fn engram_close(handle: *mut EngramHandle) {
}
}
/// Return the number of nodes in the database.
///
/// Returns -1 on error.
// ── Statistics ────────────────────────────────────────────────────────────────
/// Return the total number of nodes. Returns -1 on error.
///
/// # Safety
/// `handle` must be a valid, non-null pointer from `engram_open`.
/// `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,
}
(*handle).db.node_count().map(|n| n as i64).unwrap_or(-1)
}
/// Return the number of edges in the database.
///
/// Returns -1 on error.
/// Return the total number of edges. Returns -1 on error.
///
/// # Safety
/// `handle` must be a valid, non-null pointer from `engram_open`.
/// `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,
}
(*handle).db.edge_count().map(|n| n as i64).unwrap_or(-1)
}
/// Apply salience decay across all nodes.
// ── Salience management ───────────────────────────────────────────────────────
/// Apply multiplicative decay to all node saliences.
///
/// Returns the number of nodes updated, or -1 on error.
/// `factor` should be in (0.0, 1.0). Returns nodes updated, or -1 on error.
///
/// # Safety
/// `handle` must be a valid, non-null pointer from `engram_open`.
/// `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,
(*handle).db.decay(factor).map(|n| n as i64).unwrap_or(-1)
}
// ── Node operations ───────────────────────────────────────────────────────────
/// Store a node from a JSON representation.
///
/// `json` must be a UTF-8 JSON object with at least:
/// `{ "content": "...", "node_type": "Memory"|"Concept"|..., "tier": "Episodic"|...,
/// "importance": 0.8, "embedding": [f32, ...] }`
///
/// Returns a heap-allocated UUID string on success, null on error.
/// Caller must free with `engram_free_string`.
///
/// # Safety
/// `handle` and `json` must be valid non-null pointers.
#[no_mangle]
pub unsafe extern "C" fn engram_put_node(
handle: *mut EngramHandle,
json: *const c_char,
) -> *mut c_char {
if handle.is_null() || json.is_null() {
return std::ptr::null_mut();
}
let json_str = match CStr::from_ptr(json).to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
let node = match node_from_json(json_str) {
Some(n) => n,
None => return std::ptr::null_mut(),
};
match (*handle).db.put_node(node) {
Ok(id) => match CString::new(id.to_string()) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
},
Err(_) => std::ptr::null_mut(),
}
}
/// Free a C string returned by engram FFI functions.
/// Retrieve a node by UUID and return it as JSON.
///
/// `id` must be a UUID string. Returns heap-allocated JSON on success, null if
/// not found or on error. Caller must free with `engram_free_string`.
///
/// # Safety
/// `s` must have been allocated by an engram FFI function, not by the caller.
/// `handle` and `id` must be valid non-null pointers.
#[no_mangle]
pub unsafe extern "C" fn engram_get_node(
handle: *const EngramHandle,
id: *const c_char,
) -> *mut c_char {
if handle.is_null() || id.is_null() {
return std::ptr::null_mut();
}
let id_str = match CStr::from_ptr(id).to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
let uuid = match id_str.parse::<Uuid>() {
Ok(u) => u,
Err(_) => return std::ptr::null_mut(),
};
match (*handle).db.get_node(uuid) {
Ok(Some(node)) => match CString::new(node_to_json(&node)) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
},
_ => std::ptr::null_mut(),
}
}
// ── Spreading activation ──────────────────────────────────────────────────────
/// Run spreading activation and return results as JSON.
///
/// `req_json` must be:
/// `{ "seeds": ["uuid", ...], "query_embedding": [f32, ...],
/// "max_depth": 3, "limit": 10 }`
///
/// Returns heap-allocated JSON array of `ActivatedNode` objects, or null.
/// Caller must free with `engram_free_string`.
///
/// # Safety
/// `handle` and `req_json` must be valid non-null pointers.
#[no_mangle]
pub unsafe extern "C" fn engram_activate(
handle: *const EngramHandle,
req_json: *const c_char,
) -> *mut c_char {
if handle.is_null() || req_json.is_null() {
return std::ptr::null_mut();
}
let json_str = match CStr::from_ptr(req_json).to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
let (seeds, query_emb, max_depth, limit) = match parse_activate_request(json_str) {
Some(r) => r,
None => return std::ptr::null_mut(),
};
match (*handle).db.activate(&seeds, &query_emb, max_depth, limit) {
Ok(results) => {
let json = activated_nodes_to_json(&results);
match CString::new(json) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
}
Err(_) => std::ptr::null_mut(),
}
}
/// Free a C string returned by any engram FFI function.
///
/// # Safety
/// `s` must have been allocated by an engram FFI function. Do not call twice.
#[no_mangle]
pub unsafe extern "C" fn engram_free_string(s: *mut c_char) {
if !s.is_null() {
drop(CString::from_raw(s));
}
}
// ── JSON helpers ──────────────────────────────────────────────────────────────
// Minimal hand-rolled JSON to avoid adding serde_json as a dependency.
// These are intentionally simple — they handle the subset we need.
fn node_from_json(json: &str) -> Option<Node> {
// Extract fields with simple string scanning.
let content = extract_string(json, "content").unwrap_or_default();
let node_type_str = extract_string(json, "node_type").unwrap_or_else(|| "Memory".into());
let tier_str = extract_string(json, "tier").unwrap_or_else(|| "Episodic".into());
let importance: f32 = extract_number(json, "importance").unwrap_or(0.5);
let embedding = extract_float_array(json, "embedding").unwrap_or_default();
let node_type = match node_type_str.as_str() {
"Concept" => NodeType::Concept,
"Event" => NodeType::Event,
"Entity" => NodeType::Entity,
"Process" => NodeType::Process,
"InternalState" => NodeType::InternalState,
_ => NodeType::Memory,
};
let tier = match tier_str.as_str() {
"Working" => MemoryTier::Working,
"Semantic" => MemoryTier::Semantic,
"Procedural" => MemoryTier::Procedural,
_ => MemoryTier::Episodic,
};
Some(Node::new(node_type, embedding, content.into_bytes(), tier, importance))
}
fn node_to_json(node: &Node) -> String {
let content = String::from_utf8_lossy(&node.content);
let node_type = format!("{:?}", node.node_type);
let tier = format!("{:?}", node.tier);
let emb_str = node
.embedding
.iter()
.map(|f| format!("{:.6}", f))
.collect::<Vec<_>>()
.join(",");
format!(
r#"{{"id":"{}","node_type":"{}","tier":"{}","content":"{}","salience":{:.6},"importance":{:.6},"activation_count":{},"embedding":[{}]}}"#,
node.id,
node_type,
tier,
content.replace('"', "\\\""),
node.salience,
node.importance,
node.activation_count,
emb_str,
)
}
fn activated_nodes_to_json(nodes: &[ActivatedNode]) -> String {
let items: Vec<String> = nodes
.iter()
.map(|a| {
format!(
r#"{{"node":{},"activation_strength":{:.6},"hops":{}}}"#,
node_to_json(&a.node),
a.activation_strength,
a.hops,
)
})
.collect();
format!("[{}]", items.join(","))
}
fn parse_activate_request(json: &str) -> Option<(Vec<Uuid>, Vec<f32>, u8, usize)> {
let seeds_raw = extract_string_array(json, "seeds")?;
let seeds: Vec<Uuid> = seeds_raw
.iter()
.filter_map(|s| s.parse::<Uuid>().ok())
.collect();
let query_emb = extract_float_array(json, "query_embedding")?;
let max_depth = extract_number(json, "max_depth").unwrap_or(3.0) as u8;
let limit = extract_number(json, "limit").unwrap_or(10.0) as usize;
Some((seeds, query_emb, max_depth, limit))
}
// ── Tiny JSON field extractors ────────────────────────────────────────────────
fn extract_string(json: &str, key: &str) -> Option<String> {
let needle = format!("\"{}\":", key);
let start = json.find(&needle)? + needle.len();
let rest = json[start..].trim_start();
if !rest.starts_with('"') {
return None;
}
let inner = &rest[1..];
let end = inner.find('"')?;
Some(inner[..end].to_string())
}
fn extract_number(json: &str, key: &str) -> Option<f32> {
let needle = format!("\"{}\":", key);
let start = json.find(&needle)? + needle.len();
let rest = json[start..].trim_start();
let end = rest
.find(|c: char| c == ',' || c == '}' || c == ']')
.unwrap_or(rest.len());
rest[..end].trim().parse::<f32>().ok()
}
fn extract_float_array(json: &str, key: &str) -> Option<Vec<f32>> {
let needle = format!("\"{}\":", key);
let start = json.find(&needle)? + needle.len();
let rest = json[start..].trim_start();
if !rest.starts_with('[') {
return None;
}
let end = rest.find(']')?;
let inner = &rest[1..end];
let floats: Vec<f32> = inner
.split(',')
.filter_map(|s| s.trim().parse::<f32>().ok())
.collect();
Some(floats)
}
fn extract_string_array(json: &str, key: &str) -> Option<Vec<String>> {
let needle = format!("\"{}\":", key);
let start = json.find(&needle)? + needle.len();
let rest = json[start..].trim_start();
if !rest.starts_with('[') {
return None;
}
let end = rest.find(']')?;
let inner = &rest[1..end];
let strings: Vec<String> = inner
.split(',')
.filter_map(|s| {
let s = s.trim();
if s.starts_with('"') && s.ends_with('"') {
Some(s[1..s.len() - 1].to_string())
} else {
None
}
})
.collect();
Some(strings)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::CString;
#[test]
fn open_and_close() {
let dir = tempfile::tempdir().unwrap();
let path = CString::new(dir.path().to_str().unwrap()).unwrap();
unsafe {
let handle = engram_open(path.as_ptr());
assert!(!handle.is_null());
engram_close(handle);
}
}
#[test]
fn null_path_returns_null() {
unsafe {
let handle = engram_open(std::ptr::null());
assert!(handle.is_null());
}
}
#[test]
fn put_and_get_node_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = CString::new(dir.path().to_str().unwrap()).unwrap();
let json = CString::new(
r#"{"content":"hello","node_type":"Memory","tier":"Episodic","importance":0.8,"embedding":[0.1,0.2,0.3]}"#,
)
.unwrap();
unsafe {
let handle = engram_open(path.as_ptr());
assert!(!handle.is_null());
let uuid_ptr = engram_put_node(handle, json.as_ptr());
assert!(!uuid_ptr.is_null());
let uuid_str = CStr::from_ptr(uuid_ptr).to_str().unwrap().to_string();
engram_free_string(uuid_ptr);
// Now get the node back.
let id_cstr = CString::new(uuid_str).unwrap();
let node_json_ptr = engram_get_node(handle, id_cstr.as_ptr());
assert!(!node_json_ptr.is_null());
let node_json = CStr::from_ptr(node_json_ptr).to_str().unwrap().to_string();
assert!(node_json.contains("hello"));
engram_free_string(node_json_ptr);
assert_eq!(engram_node_count(handle), 1);
engram_close(handle);
}
}
#[test]
fn node_count_and_edge_count() {
let dir = tempfile::tempdir().unwrap();
let path = CString::new(dir.path().to_str().unwrap()).unwrap();
unsafe {
let handle = engram_open(path.as_ptr());
assert_eq!(engram_node_count(handle), 0);
assert_eq!(engram_edge_count(handle), 0);
engram_close(handle);
}
}
#[test]
fn extract_string_works() {
let json = r#"{"content":"hello world","importance":0.5}"#;
assert_eq!(extract_string(json, "content"), Some("hello world".into()));
}
#[test]
fn extract_number_works() {
let json = r#"{"importance":0.75,"other":1}"#;
let v = extract_number(json, "importance").unwrap();
assert!((v - 0.75).abs() < 1e-4);
}
#[test]
fn extract_float_array_works() {
let json = r#"{"embedding":[0.1,0.2,0.3]}"#;
let arr = extract_float_array(json, "embedding").unwrap();
assert_eq!(arr.len(), 3);
assert!((arr[0] - 0.1).abs() < 1e-4);
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "engram-jni"
version = "0.1.0"
edition = "2021"
description = "JNI bindings for engram-core (Kotlin/Android)"
license = "MIT"
[lib]
name = "engram_jni"
crate-type = ["cdylib"]
[dependencies]
engram-core = { path = "../engram-core" }
jni = "0.21"
uuid = { version = "1", features = ["v4", "serde"] }
serde_json = "1"
[dev-dependencies]
tempfile = "3"
+496
View File
@@ -0,0 +1,496 @@
/// JNI bindings for engram-core.
///
/// These functions expose the Engram API to Kotlin/JVM callers via Java Native Interface.
/// The convention is:
///
/// Java_<package>_<class>_<method>
/// → Java_ai_neuron_engram_EngramDb_<method>
///
/// The `EngramDb` handle is stored as a Java `long` (native pointer). The Kotlin
/// wrapper class casts it to/from `Long` and keeps it private.
///
/// # Memory model
/// - `open` allocates an `EngramHandle` on the Rust heap and returns its address as `jlong`.
/// - `close` takes that `jlong`, reconstructs the Box, and drops it.
/// - All other methods borrow the handle via `&*ptr`.
///
/// # JSON wire format
/// Nodes and results are passed as JSON strings to avoid bespoke JNI object marshalling.
/// The Kotlin layer converts between the data classes and JSON.
use engram_core::{ActivatedNode, EngramDb, MemoryTier, Node, NodeType, ScoredNode};
use jni::objects::{JClass, JString};
use jni::sys::{jfloatArray, jint, jlong, jstring};
use jni::JNIEnv;
use std::path::Path;
use uuid::Uuid;
// ── Handle ────────────────────────────────────────────────────────────────────
struct EngramHandle {
db: EngramDb,
}
// ── Helper macros ─────────────────────────────────────────────────────────────
macro_rules! handle_ref {
($handle:expr) => {
unsafe { &*($handle as *const EngramHandle) }
};
}
// ── JNI methods: lifecycle ────────────────────────────────────────────────────
/// Open an engram database and return a native handle as jlong.
///
/// Kotlin: `external fun open(path: String): Long`
#[no_mangle]
pub extern "system" fn Java_ai_neuron_engram_EngramDb_open(
mut env: JNIEnv,
_class: JClass,
path: JString,
) -> jlong {
let path_str: String = match env.get_string(&path) {
Ok(s) => s.into(),
Err(_) => return 0,
};
match EngramDb::open(Path::new(&path_str)) {
Ok(db) => {
let handle = Box::new(EngramHandle { db });
Box::into_raw(handle) as jlong
}
Err(e) => {
let _ = env.throw_new("java/lang/RuntimeException", e.to_string());
0
}
}
}
/// Close and free a database handle.
///
/// Kotlin: `external fun close(handle: Long)`
#[no_mangle]
pub extern "system" fn Java_ai_neuron_engram_EngramDb_close(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) {
if handle != 0 {
unsafe {
drop(Box::from_raw(handle as *mut EngramHandle));
}
}
}
// ── JNI methods: statistics ───────────────────────────────────────────────────
/// Kotlin: `external fun nodeCount(handle: Long): Long`
#[no_mangle]
pub extern "system" fn Java_ai_neuron_engram_EngramDb_nodeCount(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
) -> jlong {
let h = handle_ref!(handle);
match h.db.node_count() {
Ok(n) => n as jlong,
Err(e) => {
let _ = env.throw_new("java/lang/RuntimeException", e.to_string());
-1
}
}
}
/// Kotlin: `external fun edgeCount(handle: Long): Long`
#[no_mangle]
pub extern "system" fn Java_ai_neuron_engram_EngramDb_edgeCount(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
) -> jlong {
let h = handle_ref!(handle);
match h.db.edge_count() {
Ok(n) => n as jlong,
Err(e) => {
let _ = env.throw_new("java/lang/RuntimeException", e.to_string());
-1
}
}
}
// ── JNI methods: nodes ────────────────────────────────────────────────────────
/// Store a node from JSON and return the assigned UUID string.
///
/// Kotlin: `external fun putNode(handle: Long, nodeJson: String): String`
#[no_mangle]
pub extern "system" fn Java_ai_neuron_engram_EngramDb_putNode(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
node_json: JString,
) -> jstring {
let json: String = match env.get_string(&node_json) {
Ok(s) => s.into(),
Err(_) => return std::ptr::null_mut(),
};
let node = match node_from_json(&json) {
Some(n) => n,
None => {
let _ = env.throw_new("java/lang/IllegalArgumentException", "Invalid node JSON");
return std::ptr::null_mut();
}
};
let h = handle_ref!(handle);
match h.db.put_node(node) {
Ok(id) => {
let id_str = id.to_string();
env.new_string(&id_str)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
Err(e) => {
let _ = env.throw_new("java/lang/RuntimeException", e.to_string());
std::ptr::null_mut()
}
}
}
/// Retrieve a node by UUID, returned as JSON, or null if not found.
///
/// Kotlin: `external fun getNode(handle: Long, id: String): String?`
#[no_mangle]
pub extern "system" fn Java_ai_neuron_engram_EngramDb_getNode(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
id: JString,
) -> jstring {
let id_str: String = match env.get_string(&id) {
Ok(s) => s.into(),
Err(_) => return std::ptr::null_mut(),
};
let uuid = match id_str.parse::<Uuid>() {
Ok(u) => u,
Err(_) => return std::ptr::null_mut(),
};
let h = handle_ref!(handle);
match h.db.get_node(uuid) {
Ok(Some(node)) => {
let json = node_to_json(&node);
env.new_string(&json)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
Ok(None) => std::ptr::null_mut(),
Err(e) => {
let _ = env.throw_new("java/lang/RuntimeException", e.to_string());
std::ptr::null_mut()
}
}
}
// ── JNI methods: search ───────────────────────────────────────────────────────
/// Search for similar nodes by embedding vector.
/// Returns a JSON array of scored nodes.
///
/// Kotlin: `external fun searchEmbedding(handle: Long, embedding: FloatArray, limit: Int): String`
#[no_mangle]
pub extern "system" fn Java_ai_neuron_engram_EngramDb_searchEmbedding(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
embedding: jfloatArray,
limit: jint,
) -> jstring {
let emb = match float_array_from_jni(&mut env, embedding) {
Some(v) => v,
None => return std::ptr::null_mut(),
};
let h = handle_ref!(handle);
match h.db.search_embedding(&emb, limit as usize) {
Ok(results) => {
let json = scored_nodes_to_json(&results);
env.new_string(&json)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
Err(e) => {
let _ = env.throw_new("java/lang/RuntimeException", e.to_string());
std::ptr::null_mut()
}
}
}
/// Run spreading activation.
/// `seeds_json` is a JSON array of UUID strings.
/// Returns a JSON array of activated nodes.
///
/// Kotlin: `external fun activate(handle: Long, seedsJson: String, queryEmbedding: FloatArray, maxDepth: Int, limit: Int): String`
#[no_mangle]
pub extern "system" fn Java_ai_neuron_engram_EngramDb_activate(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
seeds_json: JString,
query_embedding: jfloatArray,
max_depth: jint,
limit: jint,
) -> jstring {
let seeds_str: String = match env.get_string(&seeds_json) {
Ok(s) => s.into(),
Err(_) => return std::ptr::null_mut(),
};
let seeds: Vec<Uuid> = parse_uuid_array(&seeds_str);
let query_emb = match float_array_from_jni(&mut env, query_embedding) {
Some(v) => v,
None => return std::ptr::null_mut(),
};
let h = handle_ref!(handle);
match h.db.activate(&seeds, &query_emb, max_depth as u8, limit as usize) {
Ok(results) => {
let json = activated_nodes_to_json(&results);
env.new_string(&json)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
Err(e) => {
let _ = env.throw_new("java/lang/RuntimeException", e.to_string());
std::ptr::null_mut()
}
}
}
// ── JNI methods: salience ─────────────────────────────────────────────────────
/// Touch a node (increment activation count and update salience).
///
/// Kotlin: `external fun touch(handle: Long, id: String)`
#[no_mangle]
pub extern "system" fn Java_ai_neuron_engram_EngramDb_touch(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
id: JString,
) {
let id_str: String = match env.get_string(&id) {
Ok(s) => s.into(),
Err(_) => return,
};
let uuid = match id_str.parse::<Uuid>() {
Ok(u) => u,
Err(_) => return,
};
let h = handle_ref!(handle);
if let Err(e) = h.db.touch(uuid) {
let _ = env.throw_new("java/lang/RuntimeException", e.to_string());
}
}
/// Apply salience decay. Returns the number of nodes updated.
///
/// Kotlin: `external fun decay(handle: Long, factor: Float): Int`
#[no_mangle]
pub extern "system" fn Java_ai_neuron_engram_EngramDb_decay(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
factor: f32,
) -> jint {
let h = handle_ref!(handle);
match h.db.decay(factor) {
Ok(n) => n as jint,
Err(e) => {
let _ = env.throw_new("java/lang/RuntimeException", e.to_string());
-1
}
}
}
// ── JNI helpers ───────────────────────────────────────────────────────────────
fn float_array_from_jni(env: &mut JNIEnv, arr: jfloatArray) -> Option<Vec<f32>> {
if arr.is_null() {
return None;
}
let arr_obj = unsafe { jni::objects::JFloatArray::from_raw(arr) };
let len = env.get_array_length(&arr_obj).ok()? as usize;
let mut buf = vec![0f32; len];
env.get_float_array_region(&arr_obj, 0, &mut buf).ok()?;
Some(buf)
}
fn parse_uuid_array(json: &str) -> Vec<Uuid> {
// Minimal parser: `["uuid1","uuid2",...]`
json.trim_matches(|c| c == '[' || c == ']')
.split(',')
.filter_map(|s| {
let s = s.trim().trim_matches('"');
s.parse::<Uuid>().ok()
})
.collect()
}
// ── JSON helpers ──────────────────────────────────────────────────────────────
fn node_from_json(json: &str) -> Option<Node> {
let content = extract_string_field(json, "content").unwrap_or_default();
let node_type_str =
extract_string_field(json, "node_type").unwrap_or_else(|| "Memory".into());
let tier_str = extract_string_field(json, "tier").unwrap_or_else(|| "Episodic".into());
let importance: f32 = extract_f32_field(json, "importance").unwrap_or(0.5);
let embedding = extract_f32_array(json, "embedding").unwrap_or_default();
let node_type = match node_type_str.as_str() {
"Concept" => NodeType::Concept,
"Event" => NodeType::Event,
"Entity" => NodeType::Entity,
"Process" => NodeType::Process,
"InternalState" => NodeType::InternalState,
_ => NodeType::Memory,
};
let tier = match tier_str.as_str() {
"Working" => MemoryTier::Working,
"Semantic" => MemoryTier::Semantic,
"Procedural" => MemoryTier::Procedural,
_ => MemoryTier::Episodic,
};
Some(Node::new(node_type, embedding, content.into_bytes(), tier, importance))
}
fn node_to_json(node: &Node) -> String {
let content = String::from_utf8_lossy(&node.content)
.replace('\\', "\\\\")
.replace('"', "\\\"");
let emb_str = node
.embedding
.iter()
.map(|f| format!("{:.6}", f))
.collect::<Vec<_>>()
.join(",");
format!(
r#"{{"id":"{}","node_type":"{:?}","tier":"{:?}","content":"{}","salience":{:.6},"importance":{:.6},"activation_count":{},"embedding":[{}]}}"#,
node.id, node.node_type, node.tier, content, node.salience, node.importance,
node.activation_count, emb_str,
)
}
fn scored_nodes_to_json(nodes: &[ScoredNode]) -> String {
let items: Vec<String> = nodes
.iter()
.map(|s| format!(r#"{{"node":{},"score":{:.6}}}"#, node_to_json(&s.node), s.score))
.collect();
format!("[{}]", items.join(","))
}
fn activated_nodes_to_json(nodes: &[ActivatedNode]) -> String {
let items: Vec<String> = nodes
.iter()
.map(|a| {
format!(
r#"{{"node":{},"activation_strength":{:.6},"hops":{}}}"#,
node_to_json(&a.node), a.activation_strength, a.hops,
)
})
.collect();
format!("[{}]", items.join(","))
}
fn extract_string_field(json: &str, key: &str) -> Option<String> {
let needle = format!("\"{}\":", key);
let start = json.find(&needle)? + needle.len();
let rest = json[start..].trim_start();
if !rest.starts_with('"') {
return None;
}
let inner = &rest[1..];
let end = inner.find('"')?;
Some(inner[..end].to_string())
}
fn extract_f32_field(json: &str, key: &str) -> Option<f32> {
let needle = format!("\"{}\":", key);
let start = json.find(&needle)? + needle.len();
let rest = json[start..].trim_start();
let end = rest
.find(|c: char| c == ',' || c == '}')
.unwrap_or(rest.len());
rest[..end].trim().parse::<f32>().ok()
}
fn extract_f32_array(json: &str, key: &str) -> Option<Vec<f32>> {
let needle = format!("\"{}\":", key);
let start = json.find(&needle)? + needle.len();
let rest = json[start..].trim_start();
if !rest.starts_with('[') {
return None;
}
let end = rest.find(']')?;
let inner = &rest[1..end];
Some(
inner
.split(',')
.filter_map(|s| s.trim().parse::<f32>().ok())
.collect(),
)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn node_json_roundtrip() {
let node = Node::new(
NodeType::Memory,
vec![0.1, 0.2, 0.3],
b"test content".to_vec(),
MemoryTier::Episodic,
0.8,
);
let json = node_to_json(&node);
assert!(json.contains("test content"));
assert!(json.contains("Memory"));
assert!(json.contains("Episodic"));
}
#[test]
fn parse_uuid_array_valid() {
let uuids = parse_uuid_array(r#"["550e8400-e29b-41d4-a716-446655440000"]"#);
assert_eq!(uuids.len(), 1);
}
#[test]
fn parse_uuid_array_empty() {
let uuids = parse_uuid_array("[]");
assert_eq!(uuids.len(), 0);
}
#[test]
fn extract_string_field_works() {
let json = r#"{"content":"hello","type":"Memory"}"#;
assert_eq!(extract_string_field(json, "content"), Some("hello".into()));
assert_eq!(extract_string_field(json, "type"), Some("Memory".into()));
}
#[test]
fn extract_f32_array_works() {
let json = r#"{"embedding":[0.1,0.2,0.3]}"#;
let arr = extract_f32_array(json, "embedding").unwrap();
assert_eq!(arr.len(), 3);
}
#[test]
fn activated_nodes_json_is_array() {
let json = activated_nodes_to_json(&[]);
assert_eq!(json, "[]");
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "engram-migrate"
version = "0.1.0"
edition = "2021"
description = "CLI tool: migrate a Neuron SQLite database into an Engram sled store"
license = "MIT"
[[bin]]
name = "engram-migrate"
path = "src/main.rs"
[dependencies]
engram-core = { path = "../engram-core", features = ["sled-backend", "migration"] }
+105
View File
@@ -0,0 +1,105 @@
/// engram-migrate — import a Neuron SQLite database into an Engram sled store.
///
/// Usage:
/// engram-migrate --sqlite ~/.neuron/neuron.db --output ~/.engram/neuron
///
/// The tool reads memory_nodes, knowledge_entries, and graph_edges from the
/// Neuron SQLite database and writes them to a new Engram sled store.
///
/// Embeddings are placeholder random unit vectors (dimension 384 by default).
/// Re-run with a real embedding model once the ONNX engine is available.
use engram_core::migration::{migrate_from_neuron, MigrationConfig};
use std::path::PathBuf;
use std::process;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 5 {
eprintln!("Usage: engram-migrate --sqlite <path> --output <path>");
eprintln!(" --sqlite Path to the Neuron SQLite database (e.g. ~/.neuron/neuron.db)");
eprintln!(" --output Path for the new Engram sled store (e.g. ~/.engram/neuron)");
process::exit(1);
}
let mut sqlite_path: Option<PathBuf> = None;
let mut output_path: Option<PathBuf> = None;
let mut embedding_dim: usize = 384;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--sqlite" => {
i += 1;
sqlite_path = Some(PathBuf::from(&args[i]));
}
"--output" => {
i += 1;
output_path = Some(PathBuf::from(&args[i]));
}
"--embedding-dim" => {
i += 1;
embedding_dim = args[i].parse().unwrap_or(384);
}
_ => {
eprintln!("Unknown argument: {}", args[i]);
process::exit(1);
}
}
i += 1;
}
let sqlite_path = match sqlite_path {
Some(p) => p,
None => {
eprintln!("Missing --sqlite argument");
process::exit(1);
}
};
let output_path = match output_path {
Some(p) => p,
None => {
eprintln!("Missing --output argument");
process::exit(1);
}
};
if !sqlite_path.exists() {
eprintln!("SQLite file not found: {}", sqlite_path.display());
process::exit(1);
}
println!("Migrating Neuron database...");
println!(" Source: {}", sqlite_path.display());
println!(" Output: {}", output_path.display());
println!(" Embedding dim: {}", embedding_dim);
println!();
let config = MigrationConfig {
sqlite_path,
engram_path: output_path,
embedding_dim,
};
match migrate_from_neuron(&config) {
Ok(report) => {
println!("Migration complete.");
println!(" Memories migrated: {}", report.memories_migrated);
println!(" Knowledge migrated: {}", report.knowledge_migrated);
println!(" Edges created: {}", report.edges_created);
if !report.errors.is_empty() {
println!();
println!("Non-fatal errors ({}):", report.errors.len());
for e in &report.errors {
println!(" - {}", e);
}
}
}
Err(e) => {
eprintln!("Migration failed: {}", e);
process::exit(1);
}
}
}