remove Rust workspace; El implementation is the canonical engram

Deletes the entire Rust first-pass: Cargo workspace, 10 crates,
engram-data/, engram-data-tx-log/, receptors/, studio/, and examples/.
Keeps: src/server.el, manifest.el, dist/, spec/, README.md,
engram-explainer.html.
This commit is contained in:
Will Anderson
2026-05-03 03:25:10 -05:00
parent c68af6d94e
commit 65047713f7
90 changed files with 0 additions and 20078 deletions
Generated
-3185
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
[workspace]
resolver = "2"
members = [
"engrams/engram-core",
"engrams/engram-ffi",
"engrams/engram-jni",
"engrams/engram-migrate",
"engrams/engram-sync",
"engrams/engram-server",
"engrams/engram-projection",
"engrams/engram-tx",
"engrams/engram-crypto",
"engrams/engram-reasoning",
# engram-wasm is in receptors/ and compiled separately via wasm-pack
# (wasm targets can't be in the same workspace build as native targets)
]
# Workspace-level example that depends on engram-core.
# Run with: cargo run --example basic
[[example]]
name = "basic"
path = "examples/basic.rs"
[[example]]
name = "migrate"
path = "examples/migrate.rs"
[package]
name = "engram"
version = "0.1.1"
edition = "2021"
[features]
migration = ["engram-core/migration"]
[dependencies]
engram-core = { path = "engrams/engram-core" }
-4
View File
@@ -1,4 +0,0 @@
segment_size: 524288
use_compression: false
version: 0.34
vQ
Binary file not shown.
Binary file not shown.
-4
View File
@@ -1,4 +0,0 @@
segment_size: 524288
use_compression: false
version: 0.34
vQ
BIN
View File
Binary file not shown.
Binary file not shown.
-26
View File
@@ -1,26 +0,0 @@
[package]
name = "engram-core"
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 = { version = "0.34", optional = true }
uuid = { version = "1", features = ["v4", "serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bincode = "1"
anyhow = "1"
thiserror = "1"
instant-distance = { version = "0.6", features = ["with-serde"] }
rusqlite = { version = "0.31", optional = true }
[dev-dependencies]
tempfile = "3"
-318
View File
@@ -1,318 +0,0 @@
/// Spreading Activation — the core retrieval mechanism of Engram.
///
/// # The Central Insight
///
/// Conventional databases separate storage from retrieval. You put data in,
/// you query it out. The storage structure (B-tree, LSM, etc.) and the retrieval
/// mechanism (SQL planner, index scan) are fundamentally different things.
///
/// The brain doesn't work this way. Memory is not stored and retrieved —
/// it is **activated and propagated**. When you remember something, you don't
/// "query" your hippocampus. You activate a node and the pattern spreads through
/// weighted connections to neighboring nodes. Long-term potentiation IS the storage
/// structure AND the retrieval mechanism simultaneously.
///
/// This module implements that model directly.
///
/// # How It Works
///
/// 1. **Seeds**: Start with one or more known node UUIDs (e.g., the most recent
/// context, the current task, recent observations).
///
/// 2. **Query embedding**: The semantic vector representing what you're looking
/// for. This is the "direction of thought" — activation flows more strongly
/// toward nodes that are semantically similar to the current context.
///
/// 3. **BFS propagation**: Activation spreads outward from seeds through edges.
/// At each hop, the strength attenuates based on:
/// - `edge.weight`: how strongly these two nodes are associated
/// - `target.salience`: how salient (recently activated, frequent, important) the target is
/// - `cosine_sim(query, target)`: how semantically relevant the target is to what we want
///
/// 4. **Pruning**: Paths with activation strength below `PRUNE_THRESHOLD` are cut.
/// This prevents exponential blowup and models the brain's attention filter.
///
/// 5. **Return**: The top-N nodes by activation strength, with their hop distance.
///
/// # Activation Formula (per hop)
///
/// strength = parent_strength × edge_weight × target_salience × cosine_sim(query, target)
///
/// This is multiplicative: a weak edge, a dormant node, or a semantically irrelevant
/// target all suppress activation. All four factors must be non-trivial for a path
/// to propagate successfully. This is exactly how associative memory works.
///
/// # Why Multiplication, Not Addition
///
/// Addition would allow many weak signals to accumulate into false relevance.
/// The brain's associative memory is conjunctive: an activated path requires
/// ALL of its links to be strong enough to carry the signal. Multiplication
/// enforces this. If any factor is near zero, the path dies.
use crate::error::EngramResult;
use crate::types::{ActivatedNode, Node};
use crate::vector::cosine_similarity;
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
/// allow more associative drift.
const PRUNE_THRESHOLD: f32 = 0.01;
// We need Ord on (f32, Uuid) for the priority queue. Use a wrapper.
#[derive(PartialEq)]
struct Candidate {
strength: f32,
hops: u8,
id: Uuid,
}
impl Eq for Candidate {}
impl PartialOrd for Candidate {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Candidate {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// BinaryHeap is a max-heap; we want highest-strength first
self.strength
.partial_cmp(&other.strength)
.unwrap_or(std::cmp::Ordering::Equal)
}
}
/// Run spreading activation from a set of seed nodes.
///
/// # Arguments
/// * `db` — the open engram database
/// * `seeds` — starting node IDs (the current "active set")
/// * `query_embedding` — semantic vector representing what we're looking for
/// * `max_depth` — maximum number of hops to traverse (typically 24)
/// * `limit` — return only the top-N results
///
/// # Returns
/// Up to `limit` nodes, sorted by activation strength descending.
/// Seed nodes themselves are excluded from the result (they're already known).
#[cfg(feature = "sled-backend")]
pub fn activate(
db: &Db,
seeds: &[Uuid],
query_embedding: &[f32],
max_depth: u8,
limit: usize,
) -> EngramResult<Vec<ActivatedNode>> {
// best_strength[id] = highest activation strength seen so far for this node.
// We use this to handle cases where multiple paths lead to the same node —
// the strongest path wins (like the brain's winner-take-most competition).
let mut best_strength: HashMap<Uuid, (f32, u8)> = HashMap::new();
// Priority queue: process highest-strength candidates first.
// This is a best-first BFS — we explore the most promising paths before
// weaker ones, which means pruning cuts off genuinely unimportant branches.
let mut queue: BinaryHeap<Candidate> = BinaryHeap::new();
// Initialize: seed nodes start with full strength (1.0).
// They represent our current context — fully activated, zero hops away.
for &seed in seeds {
// Seeds are tracked with strength 1.0 but NOT added to best_strength yet;
// we want to allow other paths to reach them if they form a cycle.
// However, we must visit their neighbors. We add seeds directly.
queue.push(Candidate {
strength: 1.0,
hops: 0,
id: seed,
});
// Mark seeds so we don't re-process them as results, but allow
// re-traversal from them if another path arrives stronger.
best_strength.insert(seed, (1.0, 0));
}
// BFS / best-first traversal
while let Some(Candidate { strength, hops, id }) = queue.pop() {
// Depth limit: don't propagate beyond max_depth
if hops >= max_depth {
continue;
}
// Retrieve outgoing edges from the current node
let edges = graph::edges_from(db, id)?;
for edge in &edges {
let target_id = edge.to_id;
// Load the target node. If it doesn't exist (dangling edge), skip.
let target: Node = match graph::get_node(db, target_id)? {
Some(n) => n,
None => continue,
};
// ── Activation strength computation ──────────────────────────
//
// Each factor models a distinct aspect of associative memory:
//
// 1. parent_strength: how strongly was the parent activated?
// Activation attenuates with each hop — deep chains carry less signal.
//
// 2. edge.weight: how strong is the association between these nodes?
// High-weight edges are like well-worn neural pathways — low resistance.
// Low-weight edges are new or rarely traversed — they carry little signal.
//
// 3. target.salience: how salient is the target node right now?
// Dormant nodes (low salience) resist activation.
// Frequently-used, recently-touched nodes activate easily.
// This is how recency and frequency bias retrieval, as in human memory.
//
// 4. cosine_sim(query, target): semantic relevance.
// If the target's embedding is far from what we're looking for,
// the activation doesn't flow there. This is the "direction of thought"
// filtering — the query steers the spread toward relevant regions.
//
// The product of all four is the activation strength at the target.
// All factors are in [0, 1] so the product is also in [0, 1].
// (Salience can exceed 1 for very active nodes, which is fine —
// it means those nodes are hyper-salient, like obsessive thoughts.)
let semantic_sim = cosine_similarity(query_embedding, &target.embedding);
// We clamp semantic_sim to [0, 1] so that anti-correlated embeddings
// don't produce negative activation (which would invert the signal).
let semantic_sim = semantic_sim.max(0.0);
let new_strength = strength * edge.weight * target.salience.max(0.0) * semantic_sim;
// Prune: if this path is too weak to matter, stop here.
// This is the attention filter — irrelevant associations fade away.
if new_strength < PRUNE_THRESHOLD {
continue;
}
let next_hops = hops + 1;
// Winner-take-most: only propagate from this node if this is the
// strongest path we've seen to it so far. This prevents exponential
// blowup when the graph has many parallel paths to the same node.
let is_stronger = match best_strength.get(&target_id) {
Some(&(prev, _)) => new_strength > prev,
None => true,
};
if is_stronger {
best_strength.insert(target_id, (new_strength, next_hops));
queue.push(Candidate {
strength: new_strength,
hops: next_hops,
id: target_id,
});
}
}
}
// Collect results: exclude seed nodes, load full Node structs, sort by strength
let seed_set: std::collections::HashSet<Uuid> = seeds.iter().copied().collect();
let mut results: Vec<ActivatedNode> = Vec::new();
for (id, (strength, hops)) in &best_strength {
if seed_set.contains(id) {
continue;
}
if let Some(node) = graph::get_node(db, *id)? {
results.push(ActivatedNode {
node,
activation_strength: *strength,
hops: *hops,
});
}
}
// Sort by activation strength descending, take top N
results.sort_by(|a, b| {
b.activation_strength
.partial_cmp(&a.activation_strength)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(limit);
Ok(results)
}
/// 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)
}
-308
View File
@@ -1,308 +0,0 @@
/// 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.
// Use overwrite_node — this is an internal state update, not a new node.
node.tier = MemoryTier::Semantic;
graph::overwrite_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::overwrite_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);
}
}
-549
View File
@@ -1,549 +0,0 @@
/// EngramDb — the top-level database handle.
///
/// All public API methods live here. The internal modules (graph, vector,
/// 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
// ── sled-backed implementation ────────────────────────────────────────────────
#[cfg(feature = "sled-backend")]
mod sled_impl {
use crate::activation;
use crate::consolidation::{self, ConsolidationConfig, ConsolidationReport};
use crate::edge_type;
use crate::error::{EngramError, EngramResult};
use crate::graph;
use crate::salience;
use crate::storage;
use crate::types::{ActivatedNode, Edge, EdgeTypeDef, Node, ScoredNode, now_ms};
use crate::vector;
use sled::Db;
use std::path::Path;
use uuid::Uuid;
pub struct EngramDb {
pub(crate) db: Db,
}
impl Clone for EngramDb {
/// Clone shares the same underlying sled instance (single file lock,
/// multiple in-process handles). This is safe and avoids re-opening.
fn clone(&self) -> Self {
Self { db: self.db.clone() }
}
}
impl EngramDb {
/// Open (or create) an engram database at the given path.
///
/// Seeds all built-in edge types on first open. Idempotent — existing
/// definitions are not overwritten on subsequent opens.
pub fn open(path: &Path) -> EngramResult<Self> {
let db = sled::open(path)?;
edge_type::seed_builtin_types(&db)?;
Ok(Self { db })
}
// ── Node operations ───────────────────────────────────────────────────
/// Persist a new node. Returns the node's UUID.
///
/// Returns `EngramError::NodeAlreadyExists` if the ID already exists.
/// Nodes are immutable — to update, create a new node and add a
/// `supersedes` edge from new → old.
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)
}
/// Retrieve a node by UUID. Returns None if not found.
pub fn get_node(&self, id: Uuid) -> EngramResult<Option<Node>> {
graph::get_node(&self.db, id)
}
// ── Edge operations ───────────────────────────────────────────────────
/// Persist a directed edge between two nodes.
pub fn put_edge(&self, edge: Edge) -> EngramResult<()> {
graph::put_edge(&self.db, &edge)
}
/// All edges originating from a node.
pub fn get_edges_from(&self, from_id: Uuid) -> EngramResult<Vec<Edge>> {
graph::edges_from(&self.db, from_id)
}
/// All edges pointing to a node.
pub fn get_edges_to(&self, to_id: Uuid) -> EngramResult<Vec<Edge>> {
graph::edges_to(&self.db, to_id)
}
// ── Vector search ─────────────────────────────────────────────────────
/// Find the `limit` nodes whose embeddings are most similar to `embedding`.
///
/// 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.
///
/// If `relation` is `Some("causes")`, only edges with that relation
/// name are followed. Pass `None` to follow all edges.
pub fn traverse(
&self,
from: Uuid,
relation: Option<&str>,
max_depth: u8,
) -> EngramResult<Vec<Node>> {
graph::traverse(&self.db, from, relation, max_depth)
}
// ── Edge type registry (native bindings for el) ───────────────────────
//
// These are the el-callable surfaces. All intelligence about when to
// create types, how to score confidence, and when to merge/split lives
// in el. Rust stores and retrieves.
/// Create or update an edge type. If the type already exists its
/// description and confidence are updated; id, first_observed,
/// instance_count, derived_from, supersedes, and deprecated are preserved.
pub fn native_edge_type_put(
&self,
name: &str,
description: &str,
confidence: f32,
) -> EngramResult<()> {
let confidence = confidence.clamp(0.0, 1.0);
if let Some(mut existing) = edge_type::get_edge_type(&self.db, name)? {
existing.description = description.to_string();
existing.confidence = confidence;
edge_type::register_edge_type(&self.db, &existing)?;
} else {
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: name.to_string(),
description: description.to_string(),
first_observed: now_ms(),
instance_count: 0,
confidence,
derived_from: None,
supersedes: None,
deprecated: false,
};
edge_type::register_edge_type(&self.db, &def)?;
}
Ok(())
}
/// Retrieve an edge type as a JSON string. Returns empty string if not found.
pub fn native_edge_type_get(&self, name: &str) -> EngramResult<String> {
match edge_type::get_edge_type(&self.db, name)? {
Some(def) => Ok(serde_json::to_string(&def).unwrap_or_default()),
None => Ok(String::new()),
}
}
/// List all registered edge types as a JSON array string.
pub fn native_edge_type_list(&self) -> EngramResult<String> {
let defs = edge_type::all_edge_types(&self.db)?;
Ok(serde_json::to_string(&defs).unwrap_or_default())
}
/// Increment the instance_count for a named edge type by one.
pub fn native_edge_type_increment_count(&self, name: &str) -> EngramResult<()> {
edge_type::increment_edge_type_count(&self.db, name)
}
/// Update the description and confidence of an existing edge type.
/// instance_count, id, first_observed, and other metadata are preserved.
pub fn native_edge_type_update(
&self,
name: &str,
description: &str,
confidence: f32,
) -> EngramResult<()> {
if let Some(mut def) = edge_type::get_edge_type(&self.db, name)? {
def.description = description.to_string();
def.confidence = confidence.clamp(0.0, 1.0);
edge_type::register_edge_type(&self.db, &def)?;
}
Ok(())
}
// ── 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,
);
// Use overwrite_node — touch is an internal state update, not a new node.
graph::overwrite_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::overwrite_node(&self.db, &node)?;
count += 1;
}
}
Ok(count)
}
// ── Consolidation ─────────────────────────────────────────────────────
/// 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)
}
// ── 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)
}
// ── Bulk scan (for sync) ───────────────────────────────────────────────
/// Scan all nodes in the store.
///
/// Used by the sync engine to generate delta snapshots.
pub fn scan_nodes(&self) -> EngramResult<Vec<crate::types::Node>> {
storage::scan_nodes(&self.db)
}
/// Scan all edges in the store (forward index only).
pub fn scan_edges(&self) -> EngramResult<Vec<Edge>> {
let prefix = b"edges:from:";
let mut edges = Vec::new();
for result in self.db.scan_prefix(prefix) {
let (_k, v) = result?;
let edge: Edge = bincode::deserialize(&v)?;
edges.push(edge);
}
Ok(edges)
}
/// Delete a node by UUID (tombstone support for sync).
pub fn delete_node(&self, id: Uuid) -> EngramResult<()> {
let key = storage::node_key(id);
self.db.remove(key)?;
Ok(())
}
}
}
// ── 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, ScoredNode};
use crate::vector;
use std::collections::{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<&str>,
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(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;
-443
View File
@@ -1,443 +0,0 @@
/// Edge type registry — dynamic, first-class edge type management.
///
/// Edge types are stored under the key prefix `edge_types:<name>` in the sled
/// database. Each value is a bincode-encoded `EdgeTypeDef`.
///
/// # Operations
///
/// - `register_edge_type` — create or update a type definition
/// - `get_edge_type` — look up by name
/// - `update_edge_type_description` — change the human-readable description
/// - `increment_edge_type_count` — bump the instance counter when a new edge is created
/// - `merge_edge_types` — retag all edges from one type to another and deprecate the source
/// - `split_edge_type` — record a split operation; the predicate is stored as a description note
/// - `deprecate_edge_type` — mark a type as no longer current
/// - `all_edge_types` — return every registered type
/// - `edge_types_by_confidence` — filter by minimum confidence score
use crate::error::EngramResult;
use crate::storage;
use crate::types::{now_ms, Edge, EdgeTypeDef};
use sled::Db;
use uuid::Uuid;
// ── Key helpers ───────────────────────────────────────────────────────────────
fn edge_type_key(name: &str) -> Vec<u8> {
format!("edge_types:{}", name).into_bytes()
}
const EDGE_TYPE_PREFIX: &[u8] = b"edge_types:";
// ── Public API ────────────────────────────────────────────────────────────────
/// Register a new edge type, or overwrite an existing definition.
///
/// Returns the UUID of the stored `EdgeTypeDef`.
pub fn register_edge_type(db: &Db, def: &EdgeTypeDef) -> EngramResult<Uuid> {
let key = edge_type_key(&def.name);
let val = bincode::serialize(def)?;
db.insert(key, val)?;
Ok(def.id)
}
/// Look up an edge type by its canonical name.
pub fn get_edge_type(db: &Db, name: &str) -> EngramResult<Option<EdgeTypeDef>> {
match db.get(edge_type_key(name))? {
Some(bytes) => Ok(Some(bincode::deserialize(&bytes)?)),
None => Ok(None),
}
}
/// Update the human-readable description of an existing edge type.
///
/// No-ops silently if the type does not exist.
pub fn update_edge_type_description(db: &Db, name: &str, description: &str) -> EngramResult<()> {
if let Some(mut def) = get_edge_type(db, name)? {
def.description = description.to_string();
let val = bincode::serialize(&def)?;
db.insert(edge_type_key(name), val)?;
}
Ok(())
}
/// Increment the instance counter for an edge type.
///
/// Called whenever a new edge with this type is persisted. No-ops if the type
/// is not registered (the counter stays in-registry, not in the edge itself).
pub fn increment_edge_type_count(db: &Db, name: &str) -> EngramResult<()> {
if let Some(mut def) = get_edge_type(db, name)? {
def.instance_count = def.instance_count.saturating_add(1);
let val = bincode::serialize(&def)?;
db.insert(edge_type_key(name), val)?;
}
Ok(())
}
/// Merge two edge types: retag all edges from `from_name` to `into_name`,
/// then deprecate `from_name`.
///
/// After this call every edge that carried `from_name` will carry `into_name`
/// instead. The `from_name` definition is marked deprecated and its `supersedes`
/// field records the merge destination.
pub fn merge_edge_types(db: &Db, from_name: &str, into_name: &str) -> EngramResult<()> {
// Collect and retag every edge carrying from_name
let prefix = b"edges:from:";
let mut edges_to_retag: Vec<Edge> = Vec::new();
for result in db.scan_prefix(prefix) {
let (_k, v) = result?;
let edge: Edge = bincode::deserialize(&v)?;
if edge.relation == from_name {
edges_to_retag.push(edge);
}
}
for mut edge in edges_to_retag {
edge.relation = into_name.to_string();
storage::write_edge(db, &edge)?;
}
// Deprecate the source type and record the merge destination
if let Some(mut def) = get_edge_type(db, from_name)? {
def.deprecated = true;
def.supersedes = Some(into_name.to_string());
let val = bincode::serialize(&def)?;
db.insert(edge_type_key(from_name), val)?;
}
// Update instance count on the destination to account for the absorbed edges
if let Some(mut into_def) = get_edge_type(db, into_name)? {
// Recount from graph (simple: scan all edges for into_name)
let mut count = 0u64;
for result in db.scan_prefix(prefix) {
let (_k, v) = result?;
let edge: Edge = bincode::deserialize(&v)?;
if edge.relation == into_name {
count += 1;
}
}
into_def.instance_count = count;
let val = bincode::serialize(&into_def)?;
db.insert(edge_type_key(into_name), val)?;
}
Ok(())
}
/// Record a split of `name` into `new_name_a` and `new_name_b`.
///
/// The predicate that drives the split is stored as a descriptive note on
/// both new types. This does NOT automatically retag edges — the caller is
/// responsible for deciding which edges go to `new_name_a` vs `new_name_b`
/// and calling `storage::write_edge` for each. The split records the intent;
/// the actual retagging is domain-specific.
///
/// The original type is deprecated with a note referencing the two successors.
pub fn split_edge_type(
db: &Db,
name: &str,
new_name_a: &str,
new_name_b: &str,
predicate: &str,
) -> EngramResult<()> {
let now = now_ms();
// Deprecate the original
if let Some(mut original) = get_edge_type(db, name)? {
original.deprecated = true;
original.description = format!(
"{} [SPLIT into '{}' and '{}' via predicate: {}]",
original.description, new_name_a, new_name_b, predicate
);
let val = bincode::serialize(&original)?;
db.insert(edge_type_key(name), val)?;
}
// Register new_name_a if it doesn't already exist
if get_edge_type(db, new_name_a)?.is_none() {
let def_a = EdgeTypeDef {
id: Uuid::new_v4(),
name: new_name_a.to_string(),
description: format!(
"Split from '{}' — predicate: {}",
name, predicate
),
first_observed: now,
instance_count: 0,
confidence: 0.0,
derived_from: Some(format!("split from '{}'", name)),
supersedes: None,
deprecated: false,
};
register_edge_type(db, &def_a)?;
}
// Register new_name_b if it doesn't already exist
if get_edge_type(db, new_name_b)?.is_none() {
let def_b = EdgeTypeDef {
id: Uuid::new_v4(),
name: new_name_b.to_string(),
description: format!(
"Split from '{}' — predicate: {}",
name, predicate
),
first_observed: now,
instance_count: 0,
confidence: 0.0,
derived_from: Some(format!("split from '{}'", name)),
supersedes: None,
deprecated: false,
};
register_edge_type(db, &def_b)?;
}
Ok(())
}
/// Mark an edge type as deprecated. Deprecated types should not be used on
/// new edges, but existing edges carrying this type remain valid.
pub fn deprecate_edge_type(db: &Db, name: &str) -> EngramResult<()> {
if let Some(mut def) = get_edge_type(db, name)? {
def.deprecated = true;
let val = bincode::serialize(&def)?;
db.insert(edge_type_key(name), val)?;
}
Ok(())
}
/// Return all registered edge type definitions.
pub fn all_edge_types(db: &Db) -> EngramResult<Vec<EdgeTypeDef>> {
let mut types = Vec::new();
for result in db.scan_prefix(EDGE_TYPE_PREFIX) {
let (_k, v) = result?;
let def: EdgeTypeDef = bincode::deserialize(&v)?;
types.push(def);
}
Ok(types)
}
/// Return all edge types with a confidence score at or above `min_confidence`.
pub fn edge_types_by_confidence(db: &Db, min_confidence: f32) -> EngramResult<Vec<EdgeTypeDef>> {
let mut types = all_edge_types(db)?;
types.retain(|t| t.confidence >= min_confidence);
types.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
Ok(types)
}
// ── Built-in type seed ────────────────────────────────────────────────────────
/// Register all built-in edge types if they have not already been registered.
///
/// Called once from `EngramDb::open`. Idempotent — existing definitions are
/// not overwritten, so user customisations survive restarts.
pub fn seed_builtin_types(db: &Db) -> EngramResult<()> {
let now = now_ms();
// Original relational types — confidence 1.0 (canonical, well-established)
let originals: &[(&str, &str)] = &[
("supersedes", "This node replaces or obsoletes another"),
("causes", "This node is a causal precursor to another"),
("contains", "This node hierarchically contains another"),
("references", "This node cites another as supporting context"),
("contradicts", "This node is in logical tension with another"),
("exemplifies", "This node is a concrete instance of a more abstract node"),
("activates", "Co-activation: firing this tends to fire the other"),
("temporally_precedes", "Temporal ordering: this node came before the other"),
];
for (name, description) in originals {
if get_edge_type(db, name)?.is_none() {
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: name.to_string(),
description: description.to_string(),
first_observed: now,
instance_count: 0,
confidence: 1.0,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(db, &def)?;
}
}
// Personhood / relational types — confidence 0.9 (well-understood but newer)
let personhood: &[(&str, &str)] = &[
("grounded_in", "This value or belief is rooted in this experience"),
("reinforced_by", "This pattern kept being confirmed by this"),
("derives_from", "This preference or belief flows from this value"),
("in_tension_with", "These two things pull against each other"),
("expressed_through","This value surfaces in this voice or behavior"),
("shaped_by", "This pattern was formed by this relationship or experience"),
("challenged_by", "This belief was tested by this experience"),
("resonates_with", "This memory echoes this value"),
];
for (name, description) in personhood {
if get_edge_type(db, name)?.is_none() {
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: name.to_string(),
description: description.to_string(),
first_observed: now,
instance_count: 0,
confidence: 0.9,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(db, &def)?;
}
}
Ok(())
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn open_tmp() -> (Db, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = sled::open(dir.path()).unwrap();
(db, dir)
}
#[test]
fn register_and_retrieve() {
let (db, _dir) = open_tmp();
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: "causes".to_string(),
description: "A causes B".to_string(),
first_observed: 0,
instance_count: 0,
confidence: 1.0,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(&db, &def).unwrap();
let got = get_edge_type(&db, "causes").unwrap().unwrap();
assert_eq!(got.name, "causes");
assert!((got.confidence - 1.0).abs() < f32::EPSILON);
}
#[test]
fn update_description() {
let (db, _dir) = open_tmp();
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: "test_type".to_string(),
description: "old".to_string(),
first_observed: 0,
instance_count: 0,
confidence: 0.5,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(&db, &def).unwrap();
update_edge_type_description(&db, "test_type", "new description").unwrap();
let got = get_edge_type(&db, "test_type").unwrap().unwrap();
assert_eq!(got.description, "new description");
}
#[test]
fn increment_count() {
let (db, _dir) = open_tmp();
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: "references".to_string(),
description: "refs".to_string(),
first_observed: 0,
instance_count: 5,
confidence: 1.0,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(&db, &def).unwrap();
increment_edge_type_count(&db, "references").unwrap();
let got = get_edge_type(&db, "references").unwrap().unwrap();
assert_eq!(got.instance_count, 6);
}
#[test]
fn deprecate() {
let (db, _dir) = open_tmp();
let def = EdgeTypeDef {
id: Uuid::new_v4(),
name: "old_type".to_string(),
description: "going away".to_string(),
first_observed: 0,
instance_count: 0,
confidence: 0.3,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(&db, &def).unwrap();
deprecate_edge_type(&db, "old_type").unwrap();
let got = get_edge_type(&db, "old_type").unwrap().unwrap();
assert!(got.deprecated);
}
#[test]
fn all_types_and_confidence_filter() {
let (db, _dir) = open_tmp();
seed_builtin_types(&db).unwrap();
let all = all_edge_types(&db).unwrap();
assert!(all.len() >= 16); // 8 originals + 8 personhood
// All original types have confidence 1.0
let high = edge_types_by_confidence(&db, 1.0).unwrap();
assert!(high.len() >= 8);
for t in &high {
assert!((t.confidence - 1.0).abs() < f32::EPSILON);
}
// personhood types have confidence 0.9 — included when threshold is <= 0.9
let wide = edge_types_by_confidence(&db, 0.9).unwrap();
assert!(wide.len() >= 16);
}
#[test]
fn seed_is_idempotent() {
let (db, _dir) = open_tmp();
seed_builtin_types(&db).unwrap();
seed_builtin_types(&db).unwrap(); // second call must not panic or duplicate
let all = all_edge_types(&db).unwrap();
// All names should be distinct
let mut names: Vec<String> = all.iter().map(|t| t.name.clone()).collect();
names.sort();
names.dedup();
assert_eq!(names.len(), all.len());
}
#[test]
fn split_type_records_both_halves() {
let (db, _dir) = open_tmp();
let original = EdgeTypeDef {
id: Uuid::new_v4(),
name: "relates_to".to_string(),
description: "generic relation".to_string(),
first_observed: 0,
instance_count: 0,
confidence: 0.5,
derived_from: None,
supersedes: None,
deprecated: false,
};
register_edge_type(&db, &original).unwrap();
split_edge_type(&db, "relates_to", "causes", "references", "directionality").unwrap();
let orig = get_edge_type(&db, "relates_to").unwrap().unwrap();
assert!(orig.deprecated);
assert!(get_edge_type(&db, "causes").unwrap().is_some());
assert!(get_edge_type(&db, "references").unwrap().is_some());
}
}
-27
View File
@@ -1,27 +0,0 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum EngramError {
#[error("Storage error: {0}")]
Storage(#[from] sled::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] bincode::Error),
#[error("Node not found: {0}")]
NotFound(uuid::Uuid),
#[error("Node already exists: {0} — nodes are immutable; supersede via edge")]
NodeAlreadyExists(uuid::Uuid),
#[error("Invalid embedding: expected {expected} dimensions, got {got}")]
DimensionMismatch { expected: usize, got: usize },
#[error("Invalid parameter: {0}")]
InvalidParam(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub type EngramResult<T> = Result<T, EngramError>;
-98
View File
@@ -1,98 +0,0 @@
/// Graph operations: store and retrieve nodes and edges, and depth-limited traversal.
///
/// The graph is stored in sled (a persistent embedded B-tree). Edges are indexed
/// in both directions so that forward and backward traversals are equally cheap.
use crate::error::EngramResult;
use crate::storage;
use crate::types::{Edge, Node};
use sled::Db;
use std::collections::{HashSet, VecDeque};
use uuid::Uuid;
/// Persist a new node and its embedding. Enforces node immutability — returns
/// `EngramError::NodeAlreadyExists` if the ID is already in the store.
pub fn put_node(db: &Db, node: &Node) -> EngramResult<Uuid> {
storage::write_node(db, node)?;
Ok(node.id)
}
/// Overwrite a node unconditionally. Used internally for salience/tier mutations
/// (touch, decay, consolidation). Do NOT call this for user-visible node creation.
pub fn overwrite_node(db: &Db, node: &Node) -> EngramResult<Uuid> {
storage::overwrite_node(db, node)?;
Ok(node.id)
}
/// Retrieve a node by id. Returns None if not found.
pub fn get_node(db: &Db, id: Uuid) -> EngramResult<Option<Node>> {
storage::read_node(db, id)
}
/// Persist an edge. Both forward and reverse indices are written atomically.
pub fn put_edge(db: &Db, edge: &Edge) -> EngramResult<()> {
storage::write_edge(db, edge)
}
/// All edges originating from a given node.
pub fn edges_from(db: &Db, from_id: Uuid) -> EngramResult<Vec<Edge>> {
storage::read_edges_from(db, from_id)
}
/// All edges pointing to a given node.
pub fn edges_to(db: &Db, to_id: Uuid) -> EngramResult<Vec<Edge>> {
storage::read_edges_to(db, to_id)
}
/// Breadth-first traversal starting from `from`, following forward edges only.
///
/// If `relation` is `Some(&str)`, only edges whose `relation` field matches
/// that string are followed. The BFS respects `max_depth` hops. The seed node
/// itself is NOT included. Visited nodes are deduplicated.
pub fn traverse(
db: &Db,
from: Uuid,
relation: Option<&str>,
max_depth: u8,
) -> EngramResult<Vec<Node>> {
let mut visited: HashSet<Uuid> = HashSet::new();
let mut queue: VecDeque<(Uuid, u8)> = VecDeque::new();
let mut result: Vec<Node> = Vec::new();
visited.insert(from);
queue.push_back((from, 0));
while let Some((current_id, depth)) = queue.pop_front() {
if depth >= max_depth {
continue;
}
let edges = edges_from(db, current_id)?;
for edge in edges {
// Filter by relation type if specified
if let Some(rel) = relation {
if edge.relation != rel {
continue;
}
}
let next = edge.to_id;
if visited.contains(&next) {
continue;
}
visited.insert(next);
if let Some(node) = get_node(db, next)? {
result.push(node);
queue.push_back((next, depth + 1));
}
}
}
Ok(result)
}
/// Count all nodes in the store.
pub fn node_count(db: &Db) -> EngramResult<usize> {
storage::count_prefix(db, b"nodes:")
}
/// Count all edges in the store (forward index only — each edge counted once).
pub fn edge_count(db: &Db) -> EngramResult<usize> {
storage::count_prefix(db, b"edges:from:")
}
-57
View File
@@ -1,57 +0,0 @@
/// Engram — a local-first memory substrate for accumulating intelligence.
///
/// An engram is the physical trace of a memory in the brain — the actual encoded
/// substrate. This crate provides the storage and retrieval primitives that model
/// how biological memory works: not as query-and-retrieve, but as
/// activation-and-propagation.
///
/// # Quick Start
///
/// ```rust,no_run
/// use engram_core::{EngramDb, Node, Edge, NodeType, MemoryTier, EDGE_SUPERSEDES};
/// use std::path::Path;
///
/// let db = EngramDb::open(Path::new("/tmp/my-engram")).unwrap();
///
/// let node = Node::new(
/// NodeType::Memory,
/// vec![0.9, 0.1, 0.3, 0.7],
/// b"The spreading activation model of memory".to_vec(),
/// MemoryTier::Semantic,
/// 0.9,
/// );
/// let id = db.put_node(node).unwrap();
///
/// // Retrieve by spreading activation from a seed
/// let results = db.activate(&[id], &[0.8, 0.2, 0.3, 0.6], 3, 10).unwrap();
/// for r in results {
/// println!("{:.4} hops={} {:?}", r.activation_strength, r.hops,
/// String::from_utf8_lossy(&r.node.content));
/// }
/// ```
pub mod activation;
pub mod consolidation;
pub mod db;
#[cfg(not(feature = "wasm"))]
pub mod edge_type;
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;
pub use error::{EngramError, EngramResult};
pub use types::{
ActivatedNode, Edge, EdgeTypeDef, MemoryTier, Node, NodeType, ScoredNode, now_ms,
EDGE_ACTIVATES, EDGE_CAUSES, EDGE_CONTAINS, EDGE_CONTRADICTS, EDGE_EXEMPLIFIES,
EDGE_REFERENCES, EDGE_SUPERSEDES, EDGE_TEMPORALLY_PRECEDES,
};
pub use consolidation::{ConsolidationConfig, ConsolidationReport};
-99
View File
@@ -1,99 +0,0 @@
/// 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(())
}
}
-423
View File
@@ -1,423 +0,0 @@
/// 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` are converted using their `edge_type` string directly
/// (normalised to lowercase). Unknown types map to `"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};
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 = normalise_edge_type(&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)
}
}
}
/// Normalise a Neuron edge type string to a canonical Engram edge type name.
///
/// Known variants (including old PascalCase forms from the Rust enum era) are
/// mapped to their lowercase canonical names. Unknown types fall back to
/// `"references"` as a safe, non-destructive default.
fn normalise_edge_type(edge_type: &str) -> &'static str {
match edge_type.to_lowercase().as_str() {
"supersedes" | "superseded_by" => "supersedes",
"causes" | "caused_by" => "causes",
"contains" | "contained_by" => "contains",
"references" | "referenced_by" => "references",
"contradicts" => "contradicts",
"exemplifies" | "exemplified_by" => "exemplifies",
"activates" => "activates",
"temporally_precedes" | "temporallyprecedes" | "follows" => "temporally_precedes",
_ => "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!(normalise_edge_type("supersedes"), "supersedes");
}
#[test]
fn edge_type_unknown_is_references() {
assert_eq!(normalise_edge_type("foobar"), "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());
}
}
-47
View File
@@ -1,47 +0,0 @@
/// Salience is the brain's answer to the question: "Is this worth remembering right now?"
///
/// It combines three signals:
/// - **Importance**: explicit weight assigned at creation (how significant is this?)
/// - **Recency**: exponential decay since last activation (recent = more relevant)
/// - **Frequency**: log-compressed activation count (things recalled often stay accessible)
///
/// The formula is intentionally simple. It models forgetting as *adaptive*, not as failure.
/// Things that aren't activated decay toward zero — not because they are lost, but because
/// they are no longer relevant to current cognition. This is how biological memory works.
///
/// ```text
/// salience = importance × (1 / (1 + days_since_activation)) × ln(activation_count + 1)
/// ```
///
/// Note: a node activated for the first time has activation_count=0, so the log term
/// evaluates to ln(1) = 0. We add 1 to the ln argument to give first activations a
/// baseline salience equal to importance × recency.
use crate::types::now_ms;
/// Compute the current salience of a node.
///
/// # Arguments
/// * `importance` - explicit importance score, 0.01.0
/// * `last_activated_ms` - Unix milliseconds of last activation
/// * `activation_count` - how many times the node has been activated
///
/// # Returns
/// Salience score, unbounded above but typically 0.05.0 for well-used nodes.
pub fn compute_salience(importance: f32, last_activated_ms: i64, activation_count: u64) -> f32 {
let days_since = (now_ms() - last_activated_ms) as f32 / 86_400_000.0;
// Recency factor: 1.0 at activation, approaching 0 asymptotically.
// At 1 day: 0.5. At 6 days: ~0.14. At 30 days: ~0.03.
let recency = 1.0 / (1.0 + days_since);
// Frequency factor: log-compressed so that going from 0→1 activations matters
// more than going from 100→101. This mirrors the diminishing returns of rehearsal.
let frequency = (activation_count as f32 + 1.0).ln();
importance * recency * frequency
}
/// Apply a multiplicative decay to a salience score.
///
/// Called periodically to age stored salience values without recomputing from scratch.
/// A factor of 0.95 means 5% forgetting per decay cycle.
pub fn decay_salience(current: f32, factor: f32) -> f32 {
(current * factor).max(0.0)
}
-202
View File
@@ -1,202 +0,0 @@
/// Low-level sled key/value operations for nodes, edges, vectors, and salience.
///
/// Key schema:
/// nodes:{uuid} → bincode-encoded Node
/// edges:from:{from}:{to} → bincode-encoded Edge
/// edges:to:{to}:{from} → reverse index (same Edge bytes)
/// vectors:{uuid} → raw little-endian f32 bytes
/// salience:{uuid} → 4-byte little-endian f32
/// edge_types:{name} → bincode-encoded EdgeTypeDef (managed by edge_type.rs)
use crate::error::{EngramError, EngramResult};
use crate::types::{Edge, Node};
use sled::Db;
use uuid::Uuid;
// ── Key constructors ──────────────────────────────────────────────────────────
pub fn node_key(id: Uuid) -> Vec<u8> {
format!("nodes:{}", id).into_bytes()
}
pub fn edge_from_key(from: Uuid, to: Uuid) -> Vec<u8> {
format!("edges:from:{}:{}", from, to).into_bytes()
}
pub fn edge_to_key(to: Uuid, from: Uuid) -> Vec<u8> {
format!("edges:to:{}:{}", to, from).into_bytes()
}
pub fn vector_key(id: Uuid) -> Vec<u8> {
format!("vectors:{}", id).into_bytes()
}
pub fn salience_key(id: Uuid) -> Vec<u8> {
format!("salience:{}", id).into_bytes()
}
// ── Node storage ─────────────────────────────────────────────────────────────
/// Persist a node. Returns `EngramError::NodeAlreadyExists` if a node with
/// this ID already exists in the store.
///
/// Nodes are immutable and append-only. To update, create a new node and
/// connect it to the old with a `supersedes` edge.
pub fn write_node(db: &Db, node: &Node) -> EngramResult<()> {
let key = node_key(node.id);
// Immutability guard — reject writes to existing node IDs.
if db.contains_key(&key)? {
return Err(EngramError::NodeAlreadyExists(node.id));
}
let val = bincode::serialize(node)?;
db.insert(key, val)?;
// Store the embedding separately for fast vector scan
let vkey = vector_key(node.id);
let vbytes = floats_to_bytes(&node.embedding);
db.insert(vkey, vbytes)?;
// Store salience separately so the decay pass can update it cheaply
let skey = salience_key(node.id);
db.insert(skey, f32_to_bytes(node.salience))?;
Ok(())
}
/// Overwrite a node unconditionally. Used internally for salience/tier updates
/// that must mutate in-place (touch, decay, consolidation).
///
/// Do NOT expose this in public API — callers should use `write_node` which
/// enforces immutability.
pub(crate) fn overwrite_node(db: &Db, node: &Node) -> EngramResult<()> {
let key = node_key(node.id);
let val = bincode::serialize(node)?;
db.insert(key, val)?;
let vkey = vector_key(node.id);
db.insert(vkey, floats_to_bytes(&node.embedding))?;
let skey = salience_key(node.id);
db.insert(skey, f32_to_bytes(node.salience))?;
Ok(())
}
pub fn read_node(db: &Db, id: Uuid) -> EngramResult<Option<Node>> {
match db.get(node_key(id))? {
Some(bytes) => Ok(Some(bincode::deserialize(&bytes)?)),
None => Ok(None),
}
}
/// Iterate over every node in the store.
pub fn scan_nodes(db: &Db) -> EngramResult<Vec<Node>> {
let prefix = b"nodes:";
let mut nodes = Vec::new();
for result in db.scan_prefix(prefix) {
let (_k, v) = result?;
let node: Node = bincode::deserialize(&v)?;
nodes.push(node);
}
Ok(nodes)
}
// ── Edge storage ─────────────────────────────────────────────────────────────
pub fn write_edge(db: &Db, edge: &Edge) -> EngramResult<()> {
let bytes = bincode::serialize(edge)?;
// Forward index: from → to
db.insert(edge_from_key(edge.from_id, edge.to_id), bytes.clone())?;
// Reverse index: to → from
db.insert(edge_to_key(edge.to_id, edge.from_id), bytes)?;
Ok(())
}
pub fn read_edges_from(db: &Db, from_id: Uuid) -> EngramResult<Vec<Edge>> {
let prefix = format!("edges:from:{}:", from_id).into_bytes();
read_edges_with_prefix(db, &prefix)
}
pub fn read_edges_to(db: &Db, to_id: Uuid) -> EngramResult<Vec<Edge>> {
let prefix = format!("edges:to:{}:", to_id).into_bytes();
read_edges_with_prefix(db, &prefix)
}
fn read_edges_with_prefix(db: &Db, prefix: &[u8]) -> EngramResult<Vec<Edge>> {
let mut edges = Vec::new();
for result in db.scan_prefix(prefix) {
let (_k, v) = result?;
let edge: Edge = bincode::deserialize(&v)?;
edges.push(edge);
}
Ok(edges)
}
// ── Vector scan ───────────────────────────────────────────────────────────────
/// Read all stored (uuid, embedding) pairs. Used for flat cosine search.
pub fn scan_vectors(db: &Db) -> EngramResult<Vec<(Uuid, Vec<f32>)>> {
let prefix = b"vectors:";
let mut out = Vec::new();
for result in db.scan_prefix(prefix) {
let (k, v) = result?;
// key = "vectors:{uuid}" — slice off the prefix
let id_str = std::str::from_utf8(&k[prefix.len()..])
.map_err(|e| EngramError::InvalidParam(e.to_string()))?;
let id = id_str
.parse::<Uuid>()
.map_err(|e| EngramError::InvalidParam(e.to_string()))?;
let floats = bytes_to_floats(&v);
out.push((id, floats));
}
Ok(out)
}
// ── Salience update ───────────────────────────────────────────────────────────
/// Overwrite the salience entry for a node without rewriting the full node blob.
pub fn write_salience(db: &Db, id: Uuid, salience: f32) -> EngramResult<()> {
db.insert(salience_key(id), f32_to_bytes(salience))?;
Ok(())
}
pub fn read_salience(db: &Db, id: Uuid) -> EngramResult<Option<f32>> {
match db.get(salience_key(id))? {
Some(b) => Ok(Some(bytes_to_f32(&b))),
None => Ok(None),
}
}
/// Count entries matching a key prefix.
pub fn count_prefix(db: &Db, prefix: &[u8]) -> EngramResult<usize> {
let mut n = 0usize;
for result in db.scan_prefix(prefix) {
result?;
n += 1;
}
Ok(n)
}
// ── Byte encoding helpers ─────────────────────────────────────────────────────
fn f32_to_bytes(v: f32) -> Vec<u8> {
v.to_le_bytes().to_vec()
}
fn bytes_to_f32(b: &[u8]) -> f32 {
let arr: [u8; 4] = b[..4].try_into().unwrap_or([0u8; 4]);
f32::from_le_bytes(arr)
}
fn floats_to_bytes(floats: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(floats.len() * 4);
for f in floats {
out.extend_from_slice(&f.to_le_bytes());
}
out
}
fn bytes_to_floats(bytes: &[u8]) -> Vec<f32> {
bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect()
}
-228
View File
@@ -1,228 +0,0 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ── Built-in edge type name constants ─────────────────────────────────────────
//
// String constants for the 8 built-in relation types. Use these on Edge.relation
// to reference known types. Intelligence about when to create new types or how
// to score confidence belongs in el, not here.
pub const EDGE_SUPERSEDES: &str = "supersedes";
pub const EDGE_CAUSES: &str = "causes";
pub const EDGE_CONTAINS: &str = "contains";
pub const EDGE_REFERENCES: &str = "references";
pub const EDGE_CONTRADICTS: &str = "contradicts";
pub const EDGE_EXEMPLIFIES: &str = "exemplifies";
pub const EDGE_ACTIVATES: &str = "activates";
pub const EDGE_TEMPORALLY_PRECEDES: &str = "temporally_precedes";
/// The functional role of a node in the memory graph.
///
/// Different node types participate in different retrieval patterns:
/// - Memories and Events are time-anchored
/// - Concepts and Entities form the semantic backbone
/// - Processes encode procedural knowledge
/// - InternalState captures the system's own affective context
/// - Custom(String) is an open extension point; el defines new types freely
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum NodeType {
/// A specific remembered experience or observation
Memory,
/// An abstract idea, category, or semantic anchor
Concept,
/// A time-stamped occurrence in the world or in processing
Event,
/// A named thing — person, place, object, system
Entity,
/// A procedural pattern, workflow, or sequence of steps
Process,
/// An internal affective or motivational state
InternalState,
/// Caller-defined node type. el uses this for types Rust does not need to know about.
Custom(String),
}
/// Where in the memory hierarchy a node currently lives.
///
/// Tiers model the brain's own stratified memory architecture.
/// Nodes migrate between tiers based on salience decay and reinforcement.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MemoryTier {
/// Hot working memory — the K most recently activated nodes.
/// Ultra-fast access. Evicted by recency when K is exceeded.
Working,
/// Episodic memory — time-ordered events and experiences.
/// Indexed chronologically; supports temporal traversal.
Episodic,
/// Semantic memory — the concept graph with weighted associations.
/// This is the long-term structural knowledge of the system.
Semantic,
/// Procedural memory — patterns, workflows, habits.
/// Retrieved by similarity to current task context.
Procedural,
}
/// Metadata record for a named edge type. Dumb data container — Rust stores and
/// returns it. All decisions about confidence thresholds, when to create new
/// types, merge/split logic, and pattern recognition belong in el, not here.
///
/// Fields like `deprecated`, `derived_from`, and `supersedes` are data.
/// They are set by the caller (el) and stored verbatim. Rust never inspects
/// or acts on them autonomously.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EdgeTypeDef {
/// Stable unique identifier for this edge type record
pub id: Uuid,
/// The canonical name used on edges (e.g. `"causes"`, `"resonates_with"`)
pub name: String,
/// Human-readable description of what this relation means
pub description: String,
/// Unix milliseconds when this type was first registered
pub first_observed: i64,
/// How many edges currently carry this type
pub instance_count: u64,
/// Caller-supplied confidence, 0.01.0. Stored as-is; not computed here.
pub confidence: f32,
/// Free-text note about what observation prompted this type's creation.
/// Set by el; stored verbatim.
pub derived_from: Option<String>,
/// Name of the edge type this one replaced, if any. Set by el; stored verbatim.
pub supersedes: Option<String>,
/// When true, this type should no longer be used for new edges.
/// Set by el; stored verbatim.
pub deprecated: bool,
}
/// A node in the engram graph — the fundamental unit of stored memory.
///
/// A node is not just a record. It is an activation site. Its embedding
/// is its semantic identity; its salience governs whether it surfaces
/// during retrieval; its activation history encodes its importance to
/// the system's ongoing cognition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
/// Stable unique identifier
pub id: Uuid,
/// Functional role in the memory system
pub node_type: NodeType,
/// Semantic vector — the node's position in meaning-space.
/// Cosine similarity to a query embedding drives spreading activation.
pub embedding: Vec<f32>,
/// Compressed raw content — the actual payload
pub content: Vec<u8>,
/// Unix milliseconds when the node was first created
pub created_at: i64,
/// Unix milliseconds when the node was last activated (read or touched)
pub last_activated: i64,
/// How many times the node has been activated
pub activation_count: u64,
/// Composite score: recency × frequency × importance.
/// Updated on every touch. Governs spreading activation priority.
pub salience: f32,
/// Which memory tier this node currently occupies
pub tier: MemoryTier,
/// Explicit importance, 0.01.0. Set by the caller; stable over time.
pub importance: f32,
}
impl Node {
/// Construct a new node with sensible defaults.
pub fn new(
node_type: NodeType,
embedding: Vec<f32>,
content: Vec<u8>,
tier: MemoryTier,
importance: f32,
) -> Self {
let now = now_ms();
let importance = importance.clamp(0.0, 1.0);
// Creation counts as the first activation — activation_count starts at 1.
// This ensures a newly created node has non-zero salience from birth.
// (ln(1+1) = ln(2) ≈ 0.693, so salience ≈ importance × recency × 0.693)
let initial_count = 1u64;
let salience = crate::salience::compute_salience(importance, now, initial_count);
Self {
id: Uuid::new_v4(),
node_type,
embedding,
content,
created_at: now,
last_activated: now,
activation_count: initial_count,
salience,
tier,
importance,
}
}
/// 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
/// it was activated and how many hops from the seed set it is.
#[derive(Debug, Clone)]
pub struct ActivatedNode {
pub node: Node,
/// Activation strength at this node — product of path weights,
/// salience, and semantic similarity. Higher is more relevant.
pub activation_strength: f32,
/// Number of graph hops from the nearest seed node
pub hops: u8,
}
/// A node returned from vector similarity search, annotated with its score.
#[derive(Debug, Clone)]
pub struct ScoredNode {
pub node: Node,
/// Cosine similarity to the query embedding, in [0.0, 1.0]
pub score: f32,
}
/// An edge in the engram graph — a typed, weighted connection between nodes.
///
/// Edge weights strengthen with co-activation (Hebbian learning).
/// The weight directly multiplies activation flow during spreading activation.
///
/// The `relation` field is a free-form string naming the edge type — look up
/// the canonical definition in the `EdgeTypeDef` registry via `edge_type`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub id: Uuid,
pub from_id: Uuid,
pub to_id: Uuid,
/// The edge type name (e.g. `"causes"`, `"resonates_with"`). Matches the
/// `name` field of the corresponding `EdgeTypeDef` in the registry.
pub relation: String,
/// Connection strength, 0.01.0. Increases when both endpoints are
/// activated in close temporal proximity (long-term potentiation).
pub weight: f32,
pub created_at: i64,
/// Unix ms when this edge last carried activation
pub last_fired: i64,
}
impl Edge {
pub fn new(from_id: Uuid, to_id: Uuid, relation: impl Into<String>, weight: f32) -> Self {
let now = now_ms();
Self {
id: Uuid::new_v4(),
from_id,
to_id,
relation: relation.into(),
weight: weight.clamp(0.0, 1.0),
created_at: now,
last_fired: now,
}
}
}
/// Current wall time in Unix milliseconds.
pub fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before epoch")
.as_millis() as i64
}
-330
View File
@@ -1,330 +0,0 @@
/// Vector similarity search over stored node embeddings.
///
/// Strategy:
/// - For < HNSW_THRESHOLD indexed nodes: flat O(n) cosine scan (correct, no deps)
/// - For >= HNSW_THRESHOLD nodes: HNSW approximate nearest-neighbour index
///
/// 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.
///
/// 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;
#[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.
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
(dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
}
// ── sled-backed search ────────────────────────────────────────────────────────
#[cfg(feature = "sled-backend")]
/// Search all stored embeddings for the `limit` closest nodes to `query`.
///
/// 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,
node_loader: impl Fn(Uuid) -> EngramResult<Option<Node>>,
) -> EngramResult<Vec<ScoredNode>> {
let vectors = storage::scan_vectors(db)?;
if vectors.len() < HNSW_THRESHOLD {
return flat_search(query, limit, &vectors, node_loader);
}
// 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.
#[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) => {
let floats: Vec<f32> = bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect();
Ok(floats)
}
None => Err(EngramError::NotFound(id)),
}
}
// ── 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);
}
}
-34
View File
@@ -1,34 +0,0 @@
[package]
name = "engram-crypto"
version = "0.1.0"
edition = "2021"
description = "Quantum-secure encryption at rest for Engram — AES-256-GCM with PQ upgrade path"
license = "MIT"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "1"
# AES-256-GCM symmetric encryption (quantum-resistant at 256-bit key length)
aes-gcm = "0.10"
# BLAKE3 for key derivation (fast, cryptographically strong)
blake3 = "1"
# Random number generation
rand = "0.8"
# Base64 encoding for serialization (used in EncryptedContent serialization)
base64 = "0.22"
# TODO: Upgrade to post-quantum KEM/signature once crates stabilize.
# Target: ml-kem (CRYSTALS-Kyber / NIST ML-KEM) and ml-dsa (CRYSTALS-Dilithium / NIST ML-DSA).
# As of 2025, the `ml-kem` and `ml-dsa` crates are available on crates.io but not yet
# production-stable for all platforms. The algorithm registry structure below is designed
# so that the upgrade is a drop-in: add the PQ crate, implement the KemAlgorithm variant,
# and new writes use the new algorithm while old records continue to decrypt via the registry.
#
# Uncomment when ready:
# ml-kem = "0.2" # CRYSTALS-Kyber (NIST ML-KEM 768/1024)
# ml-dsa = "0.1" # CRYSTALS-Dilithium (NIST ML-DSA)
[dev-dependencies]
tempfile = "3"
-87
View File
@@ -1,87 +0,0 @@
/// Algorithm registry types — the versioning layer for crypto algorithm rotation.
///
/// Each encrypted record carries an `algorithm_id`. The registry maps these IDs
/// to the parameters needed to decrypt. When you rotate algorithms, old records
/// keep their ID and decrypt using the historical version. New records use the
/// new active algorithm.
use serde::{Deserialize, Serialize};
/// Key Encapsulation Mechanism algorithms.
///
/// # Current
/// - `Aes256GcmDirect`: AES-256-GCM with a directly-provided 256-bit key.
/// Quantum-resistant at 256-bit (Grover halves to 128-bit effective security).
///
/// # Planned (post-quantum upgrade)
/// - `MlKem768`: CRYSTALS-Kyber 768 (NIST ML-KEM Level 3 — 128-bit PQ security)
/// - `MlKem1024`: CRYSTALS-Kyber 1024 (NIST ML-KEM Level 5 — 256-bit PQ security)
/// - `ClassicRsa4096`: RSA-4096 OAEP fallback (NOT quantum-resistant — for compat only)
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum KemAlgorithm {
/// AES-256-GCM with direct key (current default, quantum-resistant at 256-bit)
Aes256GcmDirect,
// TODO: uncomment when ml-kem crate stabilizes
// MlKem768,
// MlKem1024,
// ClassicRsa4096,
}
impl KemAlgorithm {
pub fn id(&self) -> &'static str {
match self {
KemAlgorithm::Aes256GcmDirect => "aes256gcm-direct-v1",
}
}
}
/// Signature algorithms for authenticating ciphertext.
///
/// # Current
/// - `Blake3Mac`: BLAKE3 keyed hash as a MAC (message authentication code).
/// Not a signature in the asymmetric sense, but provides authenticity.
///
/// # Planned (post-quantum upgrade)
/// - `MlDsa44` / `MlDsa65` / `MlDsa87`: CRYSTALS-Dilithium (NIST ML-DSA)
/// - `SphincsSha256128f`: SPHINCS+ stateless hash-based signature
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SigAlgorithm {
/// BLAKE3 keyed MAC (current default)
Blake3Mac,
// TODO: uncomment when ml-dsa crate stabilizes
// MlDsa44, // NIST Security Level 2 (128-bit)
// MlDsa65, // NIST Security Level 3 (192-bit)
// MlDsa87, // NIST Security Level 5 (256-bit)
// SphincsSha256128f, // Stateless hash-based, conservative security
}
impl SigAlgorithm {
pub fn id(&self) -> &'static str {
match self {
SigAlgorithm::Blake3Mac => "blake3-mac-v1",
}
}
}
/// A versioned algorithm configuration entry.
///
/// Historical versions are kept in the registry so that old ciphertexts can
/// always be decrypted even after algorithm rotation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlgorithmVersion {
/// Unique string ID stored alongside every ciphertext.
pub id: String,
/// The KEM algorithm used for key derivation/encapsulation.
pub kem: KemAlgorithm,
/// The signature algorithm used for ciphertext authentication.
pub sig: SigAlgorithm,
/// Unix milliseconds when this version became active.
pub activated_at: i64,
/// Unix milliseconds when this version was superseded (None = still active).
pub retired_at: Option<i64>,
}
impl AlgorithmVersion {
pub fn is_active(&self) -> bool {
self.retired_at.is_none()
}
}
-340
View File
@@ -1,340 +0,0 @@
/// CryptoEngine — encrypt and decrypt node content with algorithm versioning.
///
/// # Security Model
///
/// - **Symmetric encryption**: AES-256-GCM (authenticated encryption, quantum-resistant)
/// - **Key derivation**: BLAKE3 KDF — stretches the master key into per-operation keys
/// - **Authentication**: BLAKE3 keyed MAC over (algorithm_id || nonce || ciphertext)
/// - **Nonces**: 96-bit random nonce per encryption (from OS CSPRNG via `rand`)
///
/// # AES-256-GCM and Quantum Resistance
///
/// AES-256 is considered quantum-resistant: Grover's algorithm provides at most
/// a quadratic speedup, reducing 256-bit security to ~128-bit effective security
/// against quantum adversaries. 128-bit quantum security is currently considered
/// sufficient. The algorithm_id in EncryptedContent ensures an upgrade to
/// ML-KEM/Kyber is a transparent drop-in when the crates stabilize.
use aes_gcm::{
aead::{Aead, AeadCore, KeyInit, OsRng},
Aes256Gcm, Key, Nonce,
};
use serde::{Deserialize, Serialize};
use crate::error::{CryptoError, CryptoResult};
use crate::registry::AlgorithmRegistry;
/// An encrypted content blob, self-describing with its algorithm version.
///
/// The `algorithm_id` field allows any version to decrypt any record,
/// even after algorithm rotation. This is the key to migration-free upgrades.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptedContent {
/// Which algorithm version encrypted this record.
/// Maps to an entry in `AlgorithmRegistry::versions`.
pub algorithm_id: String,
/// The AES-256-GCM ciphertext (includes the GCM auth tag).
pub ciphertext: Vec<u8>,
/// In a PQ scheme: the KEM-encapsulated symmetric key.
/// In the current AES-direct scheme: empty (key is derived from master key + context).
pub encapsulated_key: Vec<u8>,
/// 96-bit random AES-GCM nonce.
pub nonce: Vec<u8>,
/// BLAKE3 MAC over (algorithm_id || nonce || ciphertext).
/// In a PQ scheme: this would be a Dilithium/ML-DSA signature.
pub signature: Vec<u8>,
}
impl EncryptedContent {
/// Serialize to a compact JSON string for storage.
pub fn to_bytes(&self) -> CryptoResult<Vec<u8>> {
serde_json::to_vec(self).map_err(|e| CryptoError::Serialization(e.to_string()))
}
/// Deserialize from bytes (JSON).
pub fn from_bytes(bytes: &[u8]) -> CryptoResult<Self> {
serde_json::from_slice(bytes).map_err(|e| CryptoError::DecryptionFailed(e.to_string()))
}
}
/// The encryption engine.
///
/// One engine instance per process (or per-request for stateless usage).
/// The engine holds the master key and the algorithm registry.
pub struct CryptoEngine {
/// Master key bytes — 32 bytes for AES-256.
/// In a PQ scheme: this would be a keypair (public/private).
master_key: [u8; 32],
/// Algorithm registry — tracks active and historical versions.
pub registry: AlgorithmRegistry,
}
impl CryptoEngine {
/// Create an engine from a 32-byte master key (AES-256 requires 256-bit key).
pub fn from_key(key: &[u8]) -> CryptoResult<Self> {
if key.len() < 32 {
return Err(CryptoError::InvalidKeyLength {
expected: 32,
got: key.len(),
});
}
let mut master_key = [0u8; 32];
master_key.copy_from_slice(&key[..32]);
Ok(Self {
master_key,
registry: AlgorithmRegistry::default_registry(),
})
}
/// Create an engine from an environment variable `ENGRAM_ENCRYPTION_KEY`.
///
/// Returns `None` if the variable is not set (dev mode — plaintext storage).
/// Returns an error if the variable is set but the key is too short.
pub fn from_env() -> CryptoResult<Option<Self>> {
match std::env::var("ENGRAM_ENCRYPTION_KEY") {
Ok(key_str) => {
let key_bytes = key_str.as_bytes();
// Derive a 32-byte key from whatever the user provided
let derived = derive_key(key_bytes, b"engram-master-key");
Ok(Some(Self::from_key(&derived)?))
}
Err(std::env::VarError::NotPresent) => Ok(None),
Err(e) => Err(CryptoError::KeyDerivation(e.to_string())),
}
}
// ── Encrypt ───────────────────────────────────────────────────────────────
/// Encrypt `plaintext` using the active algorithm.
///
/// Returns an `EncryptedContent` that is self-describing: it carries its
/// `algorithm_id`, so decryption never needs out-of-band version tracking.
pub fn encrypt(&self, plaintext: &[u8]) -> CryptoResult<EncryptedContent> {
let algorithm_id = self.registry.active_id().to_string();
// Derive a per-operation encryption key from the master key
let enc_key_bytes = derive_key(&self.master_key, b"encrypt");
let key = Key::<Aes256Gcm>::from_slice(&enc_key_bytes);
let cipher = Aes256Gcm::new(key);
// Random 96-bit nonce
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
// Encrypt
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
// MAC: BLAKE3 keyed hash over (algorithm_id || nonce || ciphertext)
let mac_key = derive_key(&self.master_key, b"mac");
let signature = compute_mac(&mac_key, &algorithm_id, nonce.as_slice(), &ciphertext);
Ok(EncryptedContent {
algorithm_id,
ciphertext,
encapsulated_key: vec![], // unused in AES-direct mode
nonce: nonce.to_vec(),
signature,
})
}
// ── Decrypt ───────────────────────────────────────────────────────────────
/// Decrypt an `EncryptedContent`, dispatching to the correct algorithm version.
pub fn decrypt(&self, content: &EncryptedContent) -> CryptoResult<Vec<u8>> {
// Look up the algorithm version that produced this ciphertext
let _version = self.registry.get_version(&content.algorithm_id)?;
// Verify MAC before decrypting (fail-fast on tampering)
let mac_key = derive_key(&self.master_key, b"mac");
let expected = compute_mac(&mac_key, &content.algorithm_id, &content.nonce, &content.ciphertext);
if expected != content.signature {
return Err(CryptoError::SignatureInvalid);
}
// Decrypt
let enc_key_bytes = derive_key(&self.master_key, b"encrypt");
let key = Key::<Aes256Gcm>::from_slice(&enc_key_bytes);
let cipher = Aes256Gcm::new(key);
if content.nonce.len() != 12 {
return Err(CryptoError::DecryptionFailed(format!(
"invalid nonce length: {}",
content.nonce.len()
)));
}
let nonce = Nonce::from_slice(&content.nonce);
let plaintext = cipher
.decrypt(nonce, content.ciphertext.as_slice())
.map_err(|e| CryptoError::DecryptionFailed(e.to_string()))?;
Ok(plaintext)
}
// ── Signature verification ─────────────────────────────────────────────────
/// Verify the MAC/signature on an encrypted content blob.
pub fn verify_signature(&self, content: &EncryptedContent) -> CryptoResult<bool> {
let mac_key = derive_key(&self.master_key, b"mac");
let expected = compute_mac(&mac_key, &content.algorithm_id, &content.nonce, &content.ciphertext);
Ok(expected == content.signature)
}
// ── Algorithm rotation ────────────────────────────────────────────────────
/// Rotate to a new KEM algorithm.
///
/// After rotation, new encryptions use the new algorithm.
/// Old records retain their `algorithm_id` and decrypt via the historical registry.
pub fn rotate_algorithm(&mut self, new_kem: crate::algorithm::KemAlgorithm) -> CryptoResult<()> {
self.registry.rotate_kem(new_kem)
}
}
// ── Key derivation ────────────────────────────────────────────────────────────
/// Derive a 32-byte sub-key from the master key and a context string.
/// Uses BLAKE3's keyed hash for domain separation.
fn derive_key(master: &[u8], context: &[u8]) -> [u8; 32] {
// BLAKE3 derive_key: master is the key material, context is the domain
let mut hasher = blake3::Hasher::new_keyed(
&padded_32(master),
);
hasher.update(context);
let hash = hasher.finalize();
*hash.as_bytes()
}
/// Pad or truncate bytes to exactly 32 bytes.
fn padded_32(bytes: &[u8]) -> [u8; 32] {
let mut out = [0u8; 32];
let len = bytes.len().min(32);
out[..len].copy_from_slice(&bytes[..len]);
out
}
/// Compute a BLAKE3 keyed MAC over (algorithm_id || nonce || ciphertext).
fn compute_mac(mac_key: &[u8; 32], algorithm_id: &str, nonce: &[u8], ciphertext: &[u8]) -> Vec<u8> {
let mut hasher = blake3::Hasher::new_keyed(mac_key);
hasher.update(algorithm_id.as_bytes());
hasher.update(nonce);
hasher.update(ciphertext);
hasher.finalize().as_bytes().to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_engine() -> CryptoEngine {
let key = b"test-master-key-must-be-32-bytes";
CryptoEngine::from_key(key).unwrap()
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
let engine = make_engine();
let plaintext = b"sensitive memory content - do not store unencrypted";
let enc = engine.encrypt(plaintext).unwrap();
let dec = engine.decrypt(&enc).unwrap();
assert_eq!(dec, plaintext);
}
#[test]
fn test_nonce_is_random() {
let engine = make_engine();
let enc1 = engine.encrypt(b"same plaintext").unwrap();
let enc2 = engine.encrypt(b"same plaintext").unwrap();
// Nonces must differ (probabilistic — collision probability 1/2^96)
assert_ne!(enc1.nonce, enc2.nonce);
// Ciphertexts must differ (different nonces → different ciphertexts)
assert_ne!(enc1.ciphertext, enc2.ciphertext);
}
#[test]
fn test_tampered_ciphertext_rejected() {
let engine = make_engine();
let mut enc = engine.encrypt(b"original").unwrap();
// Flip a byte in the ciphertext
if let Some(b) = enc.ciphertext.first_mut() {
*b ^= 0xFF;
}
// Decryption should fail due to GCM auth tag verification
let result = engine.decrypt(&enc);
assert!(result.is_err());
}
#[test]
fn test_tampered_mac_rejected() {
let engine = make_engine();
let mut enc = engine.encrypt(b"original").unwrap();
// Flip a byte in the signature
if let Some(b) = enc.signature.first_mut() {
*b ^= 0xFF;
}
let result = engine.decrypt(&enc);
assert!(result.is_err());
}
#[test]
fn test_verify_signature() {
let engine = make_engine();
let enc = engine.encrypt(b"content").unwrap();
assert!(engine.verify_signature(&enc).unwrap());
let mut tampered = enc.clone();
tampered.signature[0] ^= 0x01;
assert!(!engine.verify_signature(&tampered).unwrap());
}
#[test]
fn test_algorithm_id_stored() {
let engine = make_engine();
let enc = engine.encrypt(b"data").unwrap();
assert_eq!(enc.algorithm_id, "aes256gcm-direct-v1");
}
#[test]
fn test_serialization_roundtrip() {
let engine = make_engine();
let enc = engine.encrypt(b"serialize me").unwrap();
let bytes = enc.to_bytes().unwrap();
let restored = EncryptedContent::from_bytes(&bytes).unwrap();
let dec = engine.decrypt(&restored).unwrap();
assert_eq!(dec, b"serialize me");
}
#[test]
fn test_short_key_rejected() {
let short_key = b"too-short";
let result = CryptoEngine::from_key(short_key);
assert!(result.is_err());
}
#[test]
fn test_empty_plaintext() {
let engine = make_engine();
let enc = engine.encrypt(b"").unwrap();
let dec = engine.decrypt(&enc).unwrap();
assert_eq!(dec, b"");
}
#[test]
fn test_large_plaintext() {
let engine = make_engine();
let plaintext = vec![0xABu8; 1_000_000]; // 1 MB
let enc = engine.encrypt(&plaintext).unwrap();
let dec = engine.decrypt(&enc).unwrap();
assert_eq!(dec, plaintext);
}
#[test]
fn test_unknown_algorithm_rejected() {
let engine = make_engine();
let mut enc = engine.encrypt(b"data").unwrap();
enc.algorithm_id = "unknown-algo-v99".to_string();
let result = engine.decrypt(&enc);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CryptoError::UnknownAlgorithm(_)));
}
}
-30
View File
@@ -1,30 +0,0 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CryptoError {
#[error("Encryption failed: {0}")]
EncryptionFailed(String),
#[error("Decryption failed: {0}")]
DecryptionFailed(String),
#[error("Signature verification failed")]
SignatureInvalid,
#[error("Unknown algorithm ID: {0}")]
UnknownAlgorithm(String),
#[error("Key derivation failed: {0}")]
KeyDerivation(String),
#[error("Invalid key length: expected {expected}, got {got}")]
InvalidKeyLength { expected: usize, got: usize },
#[error("Algorithm not available: {0}")]
AlgorithmUnavailable(String),
#[error("Serialization error: {0}")]
Serialization(String),
}
pub type CryptoResult<T> = Result<T, CryptoError>;
-43
View File
@@ -1,43 +0,0 @@
/// Engram Crypto — quantum-secure encryption at rest.
///
/// # Current Implementation
///
/// Uses AES-256-GCM for symmetric encryption with BLAKE3 for key derivation.
/// AES-256 is already quantum-resistant (Grover's algorithm halves the key space
/// from 2^256 to 2^128, which remains computationally infeasible).
///
/// # Post-Quantum Upgrade Path
///
/// The `AlgorithmRegistry` stores an `algorithm_id` alongside every ciphertext.
/// When ML-KEM (CRYSTALS-Kyber) and ML-DSA (CRYSTALS-Dilithium) crates stabilize,
/// the upgrade is:
/// 1. Add `KemAlgorithm::MlKem768` / `MlKem1024` variants
/// 2. Implement `CryptoEngine::encrypt()` for the new algorithm
/// 3. Set it as the active algorithm in the registry
/// 4. Old records continue to decrypt via their stored `algorithm_id`
/// 5. Background re-encryption rotates old records to the new algorithm
///
/// No data migration required — the registry handles version negotiation.
///
/// # Usage
///
/// ```rust,no_run
/// use engram_crypto::{CryptoEngine, AlgorithmRegistry};
///
/// let key = b"an-example-32-byte-key!!12345678";
/// let engine = CryptoEngine::from_key(key).unwrap();
///
/// let plaintext = b"sensitive memory content";
/// let encrypted = engine.encrypt(plaintext).unwrap();
/// let decrypted = engine.decrypt(&encrypted).unwrap();
/// assert_eq!(plaintext, decrypted.as_slice());
/// ```
pub mod algorithm;
pub mod engine;
pub mod error;
pub mod registry;
pub use algorithm::{AlgorithmVersion, KemAlgorithm, SigAlgorithm};
pub use engine::{CryptoEngine, EncryptedContent};
pub use error::CryptoError;
pub use registry::AlgorithmRegistry;
-95
View File
@@ -1,95 +0,0 @@
/// Algorithm registry — tracks active and historical algorithm versions.
use std::collections::HashMap;
use crate::algorithm::{AlgorithmVersion, KemAlgorithm, SigAlgorithm};
use crate::error::{CryptoError, CryptoResult};
/// Maintains the set of algorithm versions known to this node.
///
/// The active version is used for all new encryptions.
/// Historical versions remain so old ciphertexts can always be decrypted.
pub struct AlgorithmRegistry {
/// The currently active algorithm version.
pub active_kem: KemAlgorithm,
pub active_sig: SigAlgorithm,
/// All known versions (active + historical), keyed by algorithm ID.
pub versions: HashMap<String, AlgorithmVersion>,
}
impl AlgorithmRegistry {
/// Create a registry with the default algorithm (AES-256-GCM + BLAKE3 MAC).
pub fn default_registry() -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
let default_version = AlgorithmVersion {
id: "aes256gcm-direct-v1".to_string(),
kem: KemAlgorithm::Aes256GcmDirect,
sig: SigAlgorithm::Blake3Mac,
activated_at: now,
retired_at: None,
};
let mut versions = HashMap::new();
versions.insert(default_version.id.clone(), default_version);
Self {
active_kem: KemAlgorithm::Aes256GcmDirect,
active_sig: SigAlgorithm::Blake3Mac,
versions,
}
}
/// Get the active algorithm ID (used as the `algorithm_id` in new ciphertexts).
pub fn active_id(&self) -> &str {
self.active_kem.id()
}
/// Look up a version by its ID (for decryption of historical records).
pub fn get_version(&self, id: &str) -> CryptoResult<&AlgorithmVersion> {
self.versions
.get(id)
.ok_or_else(|| CryptoError::UnknownAlgorithm(id.to_string()))
}
/// Rotate to a new KEM algorithm.
///
/// The current active version is marked as retired. A new version entry is
/// added and becomes active. Old records retain their algorithm_id and can
/// still be decrypted via `get_version()`.
///
/// Background re-encryption can then update old records at leisure.
pub fn rotate_kem(&mut self, new_kem: KemAlgorithm) -> CryptoResult<()> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
// Retire current active version
if let Some(current) = self.versions.get_mut(self.active_kem.id()) {
current.retired_at = Some(now);
}
let new_id = new_kem.id().to_string();
let new_version = AlgorithmVersion {
id: new_id.clone(),
kem: new_kem.clone(),
sig: self.active_sig.clone(),
activated_at: now,
retired_at: None,
};
self.versions.insert(new_id, new_version);
self.active_kem = new_kem;
Ok(())
}
/// List all versions, active and historical.
pub fn list_versions(&self) -> Vec<&AlgorithmVersion> {
let mut vs: Vec<&AlgorithmVersion> = self.versions.values().collect();
vs.sort_by_key(|v| v.activated_at);
vs
}
}
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "engram-ffi"
version = "0.1.0"
edition = "2021"
description = "C FFI bindings for engram-core"
license = "MIT"
[lib]
crate-type = ["cdylib", "staticlib"]
[dependencies]
engram-core = { path = "../engram-core" }
uuid = { version = "1", features = ["v4", "serde"] }
[dev-dependencies]
tempfile = "3"
-469
View File
@@ -1,469 +0,0 @@
/// C FFI for engram-core.
///
/// 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
/// 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;
// ── Handle type ───────────────────────────────────────────────────────────────
/// Opaque handle wrapping an open `EngramDb`.
pub struct EngramHandle {
db: EngramDb,
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
/// Open or create an engram database at `path`.
///
/// Returns a heap-allocated `EngramHandle` on success, null on error.
/// Must be freed with `engram_close`.
///
/// # Safety
/// `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() {
return std::ptr::null_mut();
}
let path_str = match CStr::from_ptr(path).to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
match EngramDb::open(Path::new(path_str)) {
Ok(db) => Box::into_raw(Box::new(EngramHandle { db })),
Err(_) => std::ptr::null_mut(),
}
}
/// Close and free an engram handle.
///
/// After this call `handle` is invalid.
///
/// # Safety
/// `handle` must have been returned by `engram_open` and not yet freed.
#[no_mangle]
pub unsafe extern "C" fn engram_close(handle: *mut EngramHandle) {
if !handle.is_null() {
drop(Box::from_raw(handle));
}
}
// ── Statistics ────────────────────────────────────────────────────────────────
/// Return the total number of nodes. Returns -1 on error.
///
/// # Safety
/// `handle` must be a valid non-null pointer from `engram_open`.
#[no_mangle]
pub unsafe extern "C" fn engram_node_count(handle: *const EngramHandle) -> i64 {
if handle.is_null() {
return -1;
}
(*handle).db.node_count().map(|n| n as i64).unwrap_or(-1)
}
/// Return the total number of edges. Returns -1 on error.
///
/// # Safety
/// `handle` must be a valid non-null pointer from `engram_open`.
#[no_mangle]
pub unsafe extern "C" fn engram_edge_count(handle: *const EngramHandle) -> i64 {
if handle.is_null() {
return -1;
}
(*handle).db.edge_count().map(|n| n as i64).unwrap_or(-1)
}
// ── Salience management ───────────────────────────────────────────────────────
/// Apply multiplicative decay to all node saliences.
///
/// `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`.
#[no_mangle]
pub unsafe extern "C" fn engram_decay(handle: *mut EngramHandle, factor: f32) -> i64 {
if handle.is_null() {
return -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(),
}
}
/// 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
/// `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
@@ -1,19 +0,0 @@
[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
@@ -1,496 +0,0 @@
/// 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
@@ -1,13 +0,0 @@
[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
@@ -1,105 +0,0 @@
/// 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);
}
}
}
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "engram-projection"
version = "0.1.0"
edition = "2021"
description = "Schema/projection layer for Engram — schema-free views over the activation surface"
license = "MIT"
[dependencies]
engram-core = { path = "../engram-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "1"
base64 = "0.22"
[dev-dependencies]
tempfile = "3"
-463
View File
@@ -1,463 +0,0 @@
/// Projection engine — maps activated nodes through a ProjectionSchema.
///
/// The engine is stateless: it takes a schema and a result set, and returns
/// the projected view. No mutation of the underlying graph occurs.
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use engram_core::types::{ActivatedNode, NodeType};
use serde_json::{json, Value};
use std::collections::HashMap;
use uuid::Uuid;
use crate::error::ProjectionResult;
use crate::schema::{
FieldMapping, FieldSource, NodeFilter, ProjectedRow, ProjectionResult as PResult,
ProjectionSchema, ProjectionType,
};
/// Stateless projection executor.
pub struct ProjectionEngine;
impl ProjectionEngine {
/// Apply a projection schema to a set of activated nodes.
///
/// Returns a `ProjectionResult` containing the shaped output.
/// The activation result set is not modified.
pub fn project(
schema: &ProjectionSchema,
activated: &[ActivatedNode],
) -> ProjectionResult<PResult> {
// Step 1: filter to in-scope nodes
let in_scope: Vec<&ActivatedNode> = activated
.iter()
.filter(|a| matches_filter(a, &schema.node_filter))
.collect();
let nodes_in_scope = in_scope.len();
match schema.projection_type {
ProjectionType::Relational | ProjectionType::Document => {
let rows = in_scope
.iter()
.map(|a| project_row(a, &schema.field_mappings))
.collect::<ProjectionResult<Vec<_>>>()?;
Ok(PResult {
schema_name: schema.name.clone(),
nodes_in_scope,
rows,
key_value: None,
wide_column: None,
})
}
ProjectionType::KeyValue => {
let mut kv: HashMap<String, Value> = HashMap::new();
for a in &in_scope {
let key = a.node.id.to_string();
let val = String::from_utf8_lossy(&a.node.content).to_string();
kv.insert(key, Value::String(val));
}
Ok(PResult {
schema_name: schema.name.clone(),
nodes_in_scope,
rows: vec![],
key_value: Some(kv),
wide_column: None,
})
}
ProjectionType::WideColumn => {
let mut wc: HashMap<String, HashMap<String, Value>> = HashMap::new();
for a in &in_scope {
let id = a.node.id.to_string();
let mut cols = HashMap::new();
for mapping in &schema.field_mappings {
let val = extract_field(a, &mapping.source)
.unwrap_or_else(|| mapping.default.clone().unwrap_or(Value::Null));
cols.insert(mapping.field_name.clone(), val);
}
wc.insert(id, cols);
}
Ok(PResult {
schema_name: schema.name.clone(),
nodes_in_scope,
rows: vec![],
key_value: None,
wide_column: Some(wc),
})
}
}
}
}
// ── Node filter evaluation ────────────────────────────────────────────────────
fn matches_filter(a: &ActivatedNode, filter: &NodeFilter) -> bool {
match filter {
NodeFilter::All => true,
NodeFilter::ByType(types) => {
let node_type_str = node_type_str(&a.node.node_type);
types.iter().any(|t| t == &node_type_str)
}
NodeFilter::ByTier(tiers) => tiers.iter().any(|t| t == &a.node.tier),
NodeFilter::ByTag(tags) => {
// Tags are searched in content (treated as UTF-8) as a simple substring match.
// This is intentionally lenient — callers may embed tag metadata in content.
let content_str = String::from_utf8_lossy(&a.node.content);
tags.iter().any(|tag| content_str.contains(tag.as_str()))
}
NodeFilter::ByActivationThreshold(threshold) => a.activation_strength >= *threshold,
NodeFilter::BySalience(threshold) => a.node.salience >= *threshold,
NodeFilter::Combined(filters) => filters.iter().all(|f| matches_filter(a, f)),
NodeFilter::Any(filters) => filters.iter().any(|f| matches_filter(a, f)),
}
}
// ── Row projection ────────────────────────────────────────────────────────────
fn project_row(a: &ActivatedNode, mappings: &[FieldMapping]) -> ProjectionResult<ProjectedRow> {
let mut fields = HashMap::new();
for mapping in mappings {
let val = extract_field(a, &mapping.source)
.unwrap_or_else(|| mapping.default.clone().unwrap_or(Value::Null));
fields.insert(mapping.field_name.clone(), val);
}
Ok(ProjectedRow {
node_id: a.node.id,
fields,
})
}
// ── Field extraction ──────────────────────────────────────────────────────────
fn extract_field(a: &ActivatedNode, source: &FieldSource) -> Option<Value> {
match source {
FieldSource::NodeId => Some(Value::String(a.node.id.to_string())),
FieldSource::NodeType => Some(Value::String(node_type_str(&a.node.node_type).to_string())),
FieldSource::Tier => Some(Value::String(tier_str(&a.node.tier).to_string())),
FieldSource::Salience => Some(json!(a.node.salience)),
FieldSource::Importance => Some(json!(a.node.importance)),
FieldSource::ActivationStrength => Some(json!(a.activation_strength)),
FieldSource::Hops => Some(json!(a.hops)),
FieldSource::CreatedAt => Some(json!(a.node.created_at)),
FieldSource::LastActivated => Some(json!(a.node.last_activated)),
FieldSource::ActivationCount => Some(json!(a.node.activation_count)),
FieldSource::ContentRaw => {
Some(Value::String(String::from_utf8_lossy(&a.node.content).to_string()))
}
FieldSource::ContentBase64 => Some(Value::String(B64.encode(&a.node.content))),
FieldSource::ContentJsonPath(path) => {
// Parse content as JSON, then traverse the dot-path
let content_str = std::str::from_utf8(&a.node.content).ok()?;
let doc: Value = serde_json::from_str(content_str).ok()?;
traverse_json_path(&doc, path).cloned()
}
FieldSource::Literal(v) => Some(v.clone()),
}
}
/// Traverse a dot-separated JSON path.
/// E.g., "user.name" on `{"user": {"name": "Alice"}}` returns `"Alice"`.
fn traverse_json_path<'a>(doc: &'a Value, path: &str) -> Option<&'a Value> {
let mut current = doc;
for segment in path.split('.') {
current = match current {
Value::Object(map) => map.get(segment)?,
Value::Array(arr) => {
let idx: usize = segment.parse().ok()?;
arr.get(idx)?
}
_ => return None,
};
}
Some(current)
}
// ── String helpers ────────────────────────────────────────────────────────────
fn node_type_str(t: &NodeType) -> String {
match t {
NodeType::Memory => "Memory".to_string(),
NodeType::Concept => "Concept".to_string(),
NodeType::Event => "Event".to_string(),
NodeType::Entity => "Entity".to_string(),
NodeType::Process => "Process".to_string(),
NodeType::InternalState => "InternalState".to_string(),
NodeType::Custom(s) => s.clone(),
}
}
fn tier_str(t: &engram_core::types::MemoryTier) -> &'static str {
use engram_core::types::MemoryTier;
match t {
MemoryTier::Working => "Working",
MemoryTier::Episodic => "Episodic",
MemoryTier::Semantic => "Semantic",
MemoryTier::Procedural => "Procedural",
}
}
/// Extract node_id from a projected row (used for display / keying).
pub fn row_id(row: &ProjectedRow) -> Uuid {
row.node_id
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{FieldMapping, FieldSource, NodeFilter, ProjectionSchema, ProjectionType};
use engram_core::types::{ActivatedNode, MemoryTier, Node, NodeType};
fn make_node(content: &str, tier: MemoryTier, importance: f32) -> Node {
Node::new(
NodeType::Memory,
vec![1.0, 0.0],
content.as_bytes().to_vec(),
tier,
importance,
)
}
fn make_activated(node: Node, strength: f32) -> ActivatedNode {
ActivatedNode {
node,
activation_strength: strength,
hops: 1,
}
}
#[test]
fn test_relational_projection_basic() {
let node = make_node("hello world", MemoryTier::Semantic, 0.8);
let activated = vec![make_activated(node, 0.9)];
let schema = ProjectionSchema {
name: "test".into(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: NodeFilter::All,
field_mappings: vec![
FieldMapping {
field_name: "content".into(),
source: FieldSource::ContentRaw,
default: None,
},
FieldMapping {
field_name: "tier".into(),
source: FieldSource::Tier,
default: None,
},
FieldMapping {
field_name: "strength".into(),
source: FieldSource::ActivationStrength,
default: None,
},
],
};
let result = ProjectionEngine::project(&schema, &activated).unwrap();
assert_eq!(result.nodes_in_scope, 1);
assert_eq!(result.rows.len(), 1);
assert_eq!(result.rows[0].fields["content"], Value::String("hello world".into()));
assert_eq!(result.rows[0].fields["tier"], Value::String("Semantic".into()));
}
#[test]
fn test_key_value_projection() {
let node = make_node("test content", MemoryTier::Working, 0.5);
let activated = vec![make_activated(node, 0.7)];
let schema = ProjectionSchema {
name: "kv".into(),
description: None,
projection_type: ProjectionType::KeyValue,
node_filter: NodeFilter::All,
field_mappings: vec![],
};
let result = ProjectionEngine::project(&schema, &activated).unwrap();
assert_eq!(result.nodes_in_scope, 1);
let kv = result.key_value.unwrap();
assert_eq!(kv.len(), 1);
let content = kv.values().next().unwrap();
assert_eq!(content, &Value::String("test content".into()));
}
#[test]
fn test_filter_by_activation_threshold() {
let n1 = make_activated(make_node("high", MemoryTier::Semantic, 0.9), 0.8);
let n2 = make_activated(make_node("low", MemoryTier::Episodic, 0.3), 0.1);
let schema = ProjectionSchema {
name: "filtered".into(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: NodeFilter::ByActivationThreshold(0.5),
field_mappings: vec![FieldMapping {
field_name: "content".into(),
source: FieldSource::ContentRaw,
default: None,
}],
};
let result = ProjectionEngine::project(&schema, &[n1, n2]).unwrap();
assert_eq!(result.nodes_in_scope, 1);
assert_eq!(result.rows[0].fields["content"], Value::String("high".into()));
}
#[test]
fn test_filter_by_tier() {
let n1 = make_activated(make_node("semantic", MemoryTier::Semantic, 0.9), 0.5);
let n2 = make_activated(make_node("working", MemoryTier::Working, 0.5), 0.5);
let schema = ProjectionSchema {
name: "tier_filter".into(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: NodeFilter::ByTier(vec![MemoryTier::Semantic]),
field_mappings: vec![FieldMapping {
field_name: "content".into(),
source: FieldSource::ContentRaw,
default: None,
}],
};
let result = ProjectionEngine::project(&schema, &[n1, n2]).unwrap();
assert_eq!(result.nodes_in_scope, 1);
assert_eq!(result.rows[0].fields["content"], Value::String("semantic".into()));
}
#[test]
fn test_json_path_extraction() {
let content = r#"{"user": {"name": "Alice", "age": 30}}"#;
let node = make_node(content, MemoryTier::Semantic, 0.8);
let activated = vec![make_activated(node, 0.9)];
let schema = ProjectionSchema {
name: "json_path".into(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: NodeFilter::All,
field_mappings: vec![
FieldMapping {
field_name: "name".into(),
source: FieldSource::ContentJsonPath("user.name".into()),
default: None,
},
FieldMapping {
field_name: "age".into(),
source: FieldSource::ContentJsonPath("user.age".into()),
default: None,
},
],
};
let result = ProjectionEngine::project(&schema, &activated).unwrap();
assert_eq!(result.rows[0].fields["name"], Value::String("Alice".into()));
assert_eq!(result.rows[0].fields["age"], json!(30));
}
#[test]
fn test_wide_column_projection() {
let n1 = make_activated(make_node("col_content", MemoryTier::Procedural, 0.6), 0.5);
let schema = ProjectionSchema {
name: "wide".into(),
description: None,
projection_type: ProjectionType::WideColumn,
node_filter: NodeFilter::All,
field_mappings: vec![
FieldMapping {
field_name: "raw".into(),
source: FieldSource::ContentRaw,
default: None,
},
FieldMapping {
field_name: "tier".into(),
source: FieldSource::Tier,
default: None,
},
],
};
let result = ProjectionEngine::project(&schema, &[n1]).unwrap();
assert_eq!(result.nodes_in_scope, 1);
let wc = result.wide_column.unwrap();
assert_eq!(wc.len(), 1);
let cols = wc.values().next().unwrap();
assert_eq!(cols["raw"], Value::String("col_content".into()));
assert_eq!(cols["tier"], Value::String("Procedural".into()));
}
#[test]
fn test_combined_filter() {
let n1 = make_activated(make_node("tag:important semantic", MemoryTier::Semantic, 0.9), 0.8);
let n2 = make_activated(make_node("no tag", MemoryTier::Semantic, 0.9), 0.8);
let n3 = make_activated(make_node("tag:important working", MemoryTier::Working, 0.3), 0.8);
let filter = NodeFilter::Combined(vec![
NodeFilter::ByTier(vec![MemoryTier::Semantic]),
NodeFilter::ByTag(vec!["tag:important".into()]),
]);
let schema = ProjectionSchema {
name: "combined".into(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: filter,
field_mappings: vec![FieldMapping {
field_name: "content".into(),
source: FieldSource::ContentRaw,
default: None,
}],
};
let result = ProjectionEngine::project(&schema, &[n1, n2, n3]).unwrap();
assert_eq!(result.nodes_in_scope, 1);
assert_eq!(
result.rows[0].fields["content"],
Value::String("tag:important semantic".into())
);
}
#[test]
fn test_literal_field() {
let node = make_node("any", MemoryTier::Working, 0.5);
let activated = vec![make_activated(node, 0.5)];
let schema = ProjectionSchema {
name: "literal".into(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: NodeFilter::All,
field_mappings: vec![FieldMapping {
field_name: "schema_version".into(),
source: FieldSource::Literal(json!("v1")),
default: None,
}],
};
let result = ProjectionEngine::project(&schema, &activated).unwrap();
assert_eq!(result.rows[0].fields["schema_version"], Value::String("v1".into()));
}
}
-24
View File
@@ -1,24 +0,0 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ProjectionError {
#[error("Projection not found: {0}")]
NotFound(String),
#[error("Projection already exists: {0}")]
AlreadyExists(String),
#[error("Field mapping error: {0}")]
FieldMapping(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Engram error: {0}")]
Engram(#[from] engram_core::EngramError),
#[error("Invalid projection schema: {0}")]
InvalidSchema(String),
}
pub type ProjectionResult<T> = Result<T, ProjectionError>;
-33
View File
@@ -1,33 +0,0 @@
/// Engram Projection Layer — schema-as-a-view over the activation surface.
///
/// # The Core Insight
///
/// Engram has no schema. A node has: embedding (semantic identity), content
/// (arbitrary bytes), metadata via tier/type, and salience. Schema is a
/// *projection* — a view imposed on the activation surface at query time.
///
/// The same Engram graph can surface as relational rows, JSON documents,
/// wide-column families, or key-value pairs depending on how you project it.
/// Migrations are free because there is nothing to migrate — you just update
/// the projection.
///
/// # How It Works
///
/// 1. Register a `ProjectionSchema` that describes which nodes are in scope
/// and how to map their fields.
/// 2. At query time, run spreading activation (or use an existing result set).
/// 3. Apply the projection to map `ActivatedNode`s into the projected view.
///
/// The projection is purely a read-time transform. It never modifies the graph.
pub mod engine;
pub mod error;
pub mod registry;
pub mod schema;
pub use engine::ProjectionEngine;
pub use error::ProjectionError;
pub use registry::ProjectionRegistry;
pub use schema::{
FieldMapping, FieldSource, NodeFilter, ProjectedRow, ProjectionResult, ProjectionSchema,
ProjectionType,
};
-133
View File
@@ -1,133 +0,0 @@
/// In-memory registry of named projection schemas.
///
/// The registry is the store of all registered projections. In a running server,
/// one registry instance is shared (behind a Mutex or RwLock). Projections are
/// looked up by name to execute queries.
use std::collections::HashMap;
use crate::error::{ProjectionError, ProjectionResult};
use crate::schema::ProjectionSchema;
/// Holds all registered `ProjectionSchema`s, keyed by name.
#[derive(Default)]
pub struct ProjectionRegistry {
schemas: HashMap<String, ProjectionSchema>,
}
impl ProjectionRegistry {
pub fn new() -> Self {
Self {
schemas: HashMap::new(),
}
}
/// Register a new schema. Fails if one with the same name already exists.
pub fn register(&mut self, schema: ProjectionSchema) -> ProjectionResult<()> {
if self.schemas.contains_key(&schema.name) {
return Err(ProjectionError::AlreadyExists(schema.name.clone()));
}
if schema.name.is_empty() {
return Err(ProjectionError::InvalidSchema("name must not be empty".into()));
}
self.schemas.insert(schema.name.clone(), schema);
Ok(())
}
/// Replace an existing schema (upsert). Creates if not present.
pub fn upsert(&mut self, schema: ProjectionSchema) -> ProjectionResult<()> {
if schema.name.is_empty() {
return Err(ProjectionError::InvalidSchema("name must not be empty".into()));
}
self.schemas.insert(schema.name.clone(), schema);
Ok(())
}
/// Retrieve a schema by name.
pub fn get(&self, name: &str) -> ProjectionResult<&ProjectionSchema> {
self.schemas
.get(name)
.ok_or_else(|| ProjectionError::NotFound(name.to_string()))
}
/// List all schema names.
pub fn list(&self) -> Vec<&ProjectionSchema> {
let mut schemas: Vec<&ProjectionSchema> = self.schemas.values().collect();
schemas.sort_by(|a, b| a.name.cmp(&b.name));
schemas
}
/// Remove a schema by name. Returns true if it existed.
pub fn remove(&mut self, name: &str) -> bool {
self.schemas.remove(name).is_some()
}
/// Number of registered schemas.
pub fn len(&self) -> usize {
self.schemas.len()
}
/// True if no schemas are registered.
pub fn is_empty(&self) -> bool {
self.schemas.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{NodeFilter, ProjectionType};
fn make_schema(name: &str) -> ProjectionSchema {
ProjectionSchema {
name: name.to_string(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: NodeFilter::All,
field_mappings: vec![],
}
}
#[test]
fn test_register_and_get() {
let mut reg = ProjectionRegistry::new();
reg.register(make_schema("users")).unwrap();
let s = reg.get("users").unwrap();
assert_eq!(s.name, "users");
}
#[test]
fn test_register_duplicate_fails() {
let mut reg = ProjectionRegistry::new();
reg.register(make_schema("events")).unwrap();
assert!(reg.register(make_schema("events")).is_err());
}
#[test]
fn test_upsert_replaces() {
let mut reg = ProjectionRegistry::new();
reg.register(make_schema("s1")).unwrap();
let mut updated = make_schema("s1");
updated.description = Some("updated".into());
reg.upsert(updated).unwrap();
assert_eq!(reg.get("s1").unwrap().description.as_deref(), Some("updated"));
}
#[test]
fn test_list_sorted() {
let mut reg = ProjectionRegistry::new();
reg.register(make_schema("zoo")).unwrap();
reg.register(make_schema("alpha")).unwrap();
reg.register(make_schema("mango")).unwrap();
let names: Vec<&str> = reg.list().iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "mango", "zoo"]);
}
#[test]
fn test_remove() {
let mut reg = ProjectionRegistry::new();
reg.register(make_schema("temp")).unwrap();
assert!(reg.remove("temp"));
assert!(!reg.remove("temp"));
assert!(reg.get("temp").is_err());
}
}
-133
View File
@@ -1,133 +0,0 @@
/// Schema types for the projection layer.
///
/// A `ProjectionSchema` defines a named view over the Engram graph.
/// It specifies which nodes are in scope and how to extract fields from them.
use engram_core::types::MemoryTier;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
/// How the projection presents data to the caller.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProjectionType {
/// Nodes as rows; edges become foreign key references.
/// Each row is a flat map of field_name → value.
Relational,
/// Nodes as JSON documents.
/// Fields are nested under a "fields" key; metadata at the top level.
Document,
/// Nodes as column families (node_id → column_name → value).
/// Suitable for wide, sparse schemas.
WideColumn,
/// Simple node_id → content mapping.
/// Ignores field mappings; raw content bytes as base64.
KeyValue,
}
/// Which nodes from the activation result set fall within this projection's scope.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
pub enum NodeFilter {
/// Include nodes whose node_type matches any of the given strings.
ByType(Vec<String>),
/// Include nodes in any of the given memory tiers.
ByTier(Vec<MemoryTier>),
/// Include nodes whose tier name contains any of the given tag strings
/// (stored in node metadata via content prefix convention).
ByTag(Vec<String>),
/// Include nodes with activation strength >= threshold.
ByActivationThreshold(f32),
/// Include nodes whose salience >= threshold.
BySalience(f32),
/// All of the sub-filters must match (AND).
Combined(Vec<NodeFilter>),
/// Any sub-filter matches (OR).
Any(Vec<NodeFilter>),
/// Pass all nodes through without filtering.
All,
}
/// Where to source a projected field's value.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
pub enum FieldSource {
/// Extract a value from the node's content, interpreted as JSON, using a dot-path.
/// E.g., "user.name" extracts `{ "user": { "name": "Alice" } }["user"]["name"]`.
ContentJsonPath(String),
/// The node's content, raw, as a UTF-8 string (lossy).
ContentRaw,
/// The node's content as a base64-encoded string.
ContentBase64,
/// The node's unique identifier.
NodeId,
/// The node's type as a string.
NodeType,
/// The node's memory tier as a string.
Tier,
/// The node's current salience score.
Salience,
/// The node's importance (caller-set, stable).
Importance,
/// The activation strength at this node (from spreading activation).
ActivationStrength,
/// The hop count from the nearest seed node.
Hops,
/// The node's creation timestamp (Unix ms).
CreatedAt,
/// The node's last-activated timestamp (Unix ms).
LastActivated,
/// The node's activation count.
ActivationCount,
/// A literal constant value.
Literal(Value),
}
/// One field in a projected row.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldMapping {
/// The name of this field in the projected output.
pub field_name: String,
/// Where to get the value from.
pub source: FieldSource,
/// If the source fails to produce a value, use this fallback. Null means omit.
pub default: Option<Value>,
}
/// A complete schema definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectionSchema {
/// Unique name for this projection.
pub name: String,
/// Human-readable description.
pub description: Option<String>,
/// How results are shaped.
pub projection_type: ProjectionType,
/// Which nodes from the activation result are included.
pub node_filter: NodeFilter,
/// How to extract fields from each included node.
pub field_mappings: Vec<FieldMapping>,
}
/// One projected node in a Relational or Document projection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectedRow {
/// The source node's UUID (always included).
pub node_id: uuid::Uuid,
/// Extracted fields as ordered map field_name → value.
pub fields: HashMap<String, Value>,
}
/// The output of running a projection over an activation result set.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectionResult {
/// The schema that produced this result.
pub schema_name: String,
/// How many nodes from the activation set were in scope.
pub nodes_in_scope: usize,
/// The projected rows.
pub rows: Vec<ProjectedRow>,
/// For KeyValue projection: node_id (string) → content.
pub key_value: Option<HashMap<String, Value>>,
/// For WideColumn projection: node_id → column_name → value.
pub wide_column: Option<HashMap<String, HashMap<String, Value>>>,
}
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "engram-reasoning"
version = "0.1.0"
edition = "2021"
description = "Graph-native inference engine for Engram — evidence chains, confidence propagation, causal reasoning"
license = "MIT"
[dependencies]
engram-core = { path = "../engram-core" }
uuid = { version = "1", features = ["v4", "serde"] }
serde = { version = "1", features = ["derive"] }
anyhow = "1"
thiserror = "1"
[dev-dependencies]
tempfile = "3"
File diff suppressed because it is too large Load Diff
-50
View File
@@ -1,50 +0,0 @@
/// Engram Reasoning Engine — graph-native inference separated from language generation.
///
/// # What this crate is
///
/// This is NOT an LLM wrapper. It is a reasoning system that traverses the Engram
/// knowledge graph to reach conclusions through evidence chains.
///
/// LLMs: input tokens → transformer → output tokens. Reasoning and generation are
/// the same process. You cannot separate them.
///
/// This engine: hypothesis → graph traversal → evidence chains → confidence-weighted
/// conclusion. Generation happens separately (a codec converts the conclusion to
/// language). The reasoning IS the traversal.
///
/// # Quick Start
///
/// ```rust,no_run
/// use engram_core::{EngramDb, Node, Edge, NodeType, MemoryTier};
/// use engram_reasoning::{ReasoningEngine, Hypothesis, HypothesisType, ReasoningConfig};
/// use std::path::Path;
/// use std::sync::{Arc, Mutex};
///
/// let db = Arc::new(Mutex::new(EngramDb::open(Path::new("/tmp/engram-reason-test")).unwrap()));
/// let config = ReasoningConfig::default();
/// let mut engine = ReasoningEngine::new(db, config);
///
/// let hypothesis = Hypothesis::new(
/// "Spreading activation improves memory retrieval",
/// vec![0.9f32, 0.1, 0.3, 0.7],
/// HypothesisType::IsTrue,
/// );
///
/// let result = engine.reason(&hypothesis).unwrap();
/// println!("Verdict: {:?}", result.conclusion.verdict);
/// println!("Confidence: {:.2}", result.confidence);
/// ```
pub mod engine;
pub mod types;
#[cfg(test)]
mod tests;
// Re-export the primary public surface
pub use engine::ReasoningEngine;
pub use types::{
CausalDirection, ChainType, Conclusion, EvidenceChain, EvidenceNode, EvidenceType,
Hypothesis, HypothesisType, InferenceEdge, InferenceEdgeType, ReasoningConfig,
ReasoningResult, Verdict,
};
-531
View File
@@ -1,531 +0,0 @@
/// Tests for the engram-reasoning engine.
///
/// Covers: construction, hypothesis creation, evidence classification,
/// confidence propagation, causal chains, contradiction detection,
/// empty-graph behaviour, and full integration scenarios.
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
use uuid::Uuid;
use engram_core::{Edge, EngramDb, MemoryTier, Node, NodeType};
use crate::{
CausalDirection, EvidenceNode, EvidenceType, Hypothesis, HypothesisType, InferenceEdge,
InferenceEdgeType, ReasoningConfig, ReasoningEngine, Verdict,
};
// ── Test fixtures ─────────────────────────────────────────────────────────
fn make_db() -> (TempDir, Arc<Mutex<EngramDb>>) {
let dir = TempDir::new().expect("tempdir");
let db = EngramDb::open(dir.path()).expect("open db");
(dir, Arc::new(Mutex::new(db)))
}
fn make_engine(db: Arc<Mutex<EngramDb>>) -> ReasoningEngine {
ReasoningEngine::with_default_config(db)
}
fn embedding(values: &[f32]) -> Vec<f32> {
values.to_vec()
}
fn make_node(
db: &Arc<Mutex<EngramDb>>,
content: &str,
emb: Vec<f32>,
node_type: NodeType,
importance: f32,
) -> Uuid {
let node = Node::new(
node_type,
emb,
content.as_bytes().to_vec(),
MemoryTier::Semantic,
importance,
);
let id = node.id;
db.lock().unwrap().put_node(node).unwrap();
id
}
fn make_edge(db: &Arc<Mutex<EngramDb>>, from: Uuid, to: Uuid, relation: &str) {
let edge = Edge::new(from, to, relation, 0.8);
db.lock().unwrap().put_edge(edge).unwrap();
}
// ── Test 1: Engine construction with default config ───────────────────────
#[test]
fn test_engine_construction_default() {
let (_dir, db) = make_db();
let engine = make_engine(db);
assert_eq!(engine.config.max_depth, 5);
assert!((engine.config.min_confidence - 0.05).abs() < f32::EPSILON);
assert_eq!(engine.config.max_evidence_nodes, 50);
assert!((engine.config.contradiction_threshold - 0.4).abs() < f32::EPSILON);
}
// ── Test 2: Engine construction with custom config ────────────────────────
#[test]
fn test_engine_construction_custom_config() {
let (_dir, db) = make_db();
let config = ReasoningConfig {
max_depth: 3,
min_confidence: 0.1,
max_evidence_nodes: 20,
contradiction_threshold: 0.3,
};
let engine = ReasoningEngine::new(db, config);
assert_eq!(engine.config.max_depth, 3);
assert_eq!(engine.config.max_evidence_nodes, 20);
}
// ── Test 3: Hypothesis creation for each type ─────────────────────────────
#[test]
fn test_hypothesis_creation_is_true() {
let h = Hypothesis::new("X is true", vec![1.0, 0.0], HypothesisType::IsTrue);
assert_eq!(h.hypothesis_type, HypothesisType::IsTrue);
assert_eq!(h.text, "X is true");
assert!(!h.id.is_nil());
}
#[test]
fn test_hypothesis_creation_what_causes() {
let h = Hypothesis::new("What causes X", vec![0.5, 0.5], HypothesisType::WhatCauses);
assert_eq!(h.hypothesis_type, HypothesisType::WhatCauses);
}
#[test]
fn test_hypothesis_creation_how_to() {
let h = Hypothesis::new("How to do X", vec![0.3, 0.7], HypothesisType::HowTo);
assert_eq!(h.hypothesis_type, HypothesisType::HowTo);
}
#[test]
fn test_hypothesis_creation_what_is() {
let h = Hypothesis::new("What is X", vec![0.2, 0.8], HypothesisType::WhatIs);
assert_eq!(h.hypothesis_type, HypothesisType::WhatIs);
}
#[test]
fn test_hypothesis_creation_compare() {
let h = Hypothesis::new("Compare X and Y", vec![0.6, 0.4], HypothesisType::Compare);
assert_eq!(h.hypothesis_type, HypothesisType::Compare);
}
// ── Test 4: Empty graph → Insufficient ───────────────────────────────────
#[test]
fn test_empty_graph_gives_insufficient() {
let (_dir, db) = make_db();
let mut engine = make_engine(db);
let h = Hypothesis::new("anything", vec![1.0, 0.0, 0.0, 0.0], HypothesisType::IsTrue);
let result = engine.reason(&h).unwrap();
assert!(
matches!(result.conclusion.verdict, Verdict::Insufficient),
"Expected Insufficient, got {:?}",
result.conclusion.verdict
);
assert_eq!(result.confidence, 0.0);
}
// ── Test 5: Evidence classification — DirectSupport ──────────────────────
#[test]
fn test_evidence_classification_direct_support() {
let (_dir, db) = make_db();
let engine = make_engine(db.clone());
// Node with embedding very close to hypothesis
let node = Node::new(
NodeType::Memory,
vec![0.98_f32, 0.02, 0.0, 0.0],
b"Spreading activation is effective".to_vec(),
MemoryTier::Semantic,
0.9,
);
let h = Hypothesis::new(
"Spreading activation is effective",
vec![1.0_f32, 0.0, 0.0, 0.0],
HypothesisType::IsTrue,
);
let ev = engine.classify_evidence(&node, &h, 0.8, 0);
assert_eq!(ev.evidence_type, EvidenceType::DirectSupport);
}
// ── Test 6: Evidence classification — DirectRefutation ───────────────────
#[test]
fn test_evidence_classification_direct_refutation() {
let (_dir, db) = make_db();
let engine = make_engine(db.clone());
let node = Node::new(
NodeType::Memory,
vec![0.99_f32, 0.01, 0.0, 0.0],
b"Spreading activation is not effective and is wrong".to_vec(),
MemoryTier::Semantic,
0.9,
);
let h = Hypothesis::new(
"Spreading activation is effective",
vec![1.0_f32, 0.0, 0.0, 0.0],
HypothesisType::IsTrue,
);
let ev = engine.classify_evidence(&node, &h, 0.7, 0);
assert_eq!(ev.evidence_type, EvidenceType::DirectRefutation);
}
// ── Test 7: Evidence classification — ContextualFact ─────────────────────
#[test]
fn test_evidence_classification_contextual_fact() {
let (_dir, db) = make_db();
let engine = make_engine(db.clone());
// Node with low similarity to hypothesis
let node = Node::new(
NodeType::Memory,
vec![0.0_f32, 0.0, 1.0, 0.0], // orthogonal
b"Some unrelated content".to_vec(),
MemoryTier::Semantic,
0.5,
);
let h = Hypothesis::new(
"Spreading activation",
vec![1.0_f32, 0.0, 0.0, 0.0],
HypothesisType::IsTrue,
);
let ev = engine.classify_evidence(&node, &h, 0.3, 2);
assert_eq!(ev.evidence_type, EvidenceType::ContextualFact);
}
// ── Test 8: Evidence classification — ProceduralStep ─────────────────────
#[test]
fn test_evidence_classification_procedural_step() {
let (_dir, db) = make_db();
let engine = make_engine(db.clone());
let node = Node::new(
NodeType::Process,
vec![0.95_f32, 0.05, 0.0, 0.0],
b"Step 1: initialize the graph".to_vec(),
MemoryTier::Procedural,
0.8,
);
let h = Hypothesis::new(
"How to build a knowledge graph",
vec![1.0_f32, 0.0, 0.0, 0.0],
HypothesisType::HowTo,
);
let ev = engine.classify_evidence(&node, &h, 0.9, 0);
assert_eq!(ev.evidence_type, EvidenceType::ProceduralStep);
}
// ── Test 9: Confidence propagation ───────────────────────────────────────
#[test]
fn test_confidence_propagation_boost() {
let (_dir, db) = make_db();
let engine = make_engine(db.clone());
let id_a = Uuid::new_v4();
let id_b = Uuid::new_v4();
let mut nodes = vec![
EvidenceNode {
engram_node_id: id_a,
content: "Node A".into(),
evidence_type: EvidenceType::DirectSupport,
confidence: 0.8,
activation_strength: 0.8,
hops_from_seed: 0,
},
EvidenceNode {
engram_node_id: id_b,
content: "Node B".into(),
evidence_type: EvidenceType::IndirectSupport,
confidence: 0.2,
activation_strength: 0.4,
hops_from_seed: 1,
},
];
let edges = vec![InferenceEdge {
from_node: id_a,
to_node: id_b,
edge_type: InferenceEdgeType::Supports,
strength: 0.9,
engram_edge_id: None,
}];
let original_b_confidence = nodes[1].confidence;
engine.propagate_confidence(&mut nodes, &edges);
// Node B's confidence should be boosted by the incoming support from A
assert!(
nodes[1].confidence > original_b_confidence,
"Expected confidence to increase from {}, got {}",
original_b_confidence,
nodes[1].confidence
);
}
// ── Test 10: Confidence propagation — refutation reduces ─────────────────
#[test]
fn test_confidence_propagation_refutation_reduces() {
let (_dir, db) = make_db();
let engine = make_engine(db.clone());
let id_a = Uuid::new_v4();
let id_b = Uuid::new_v4();
let mut nodes = vec![
EvidenceNode {
engram_node_id: id_a,
content: "Strong refuting node".into(),
evidence_type: EvidenceType::DirectRefutation,
confidence: 0.9,
activation_strength: 0.9,
hops_from_seed: 0,
},
EvidenceNode {
engram_node_id: id_b,
content: "Downstream node".into(),
evidence_type: EvidenceType::IndirectSupport,
confidence: 0.6,
activation_strength: 0.5,
hops_from_seed: 1,
},
];
let edges = vec![InferenceEdge {
from_node: id_a,
to_node: id_b,
edge_type: InferenceEdgeType::Refutes,
strength: 0.8,
engram_edge_id: None,
}];
let original_conf = nodes[1].confidence;
engine.propagate_confidence(&mut nodes, &edges);
assert!(
nodes[1].confidence < original_conf,
"Expected confidence to decrease from {}, got {}",
original_conf,
nodes[1].confidence
);
}
// ── Test 11: Causal chain finding ─────────────────────────────────────────
#[test]
fn test_causal_chain_finding() {
let (_dir, db) = make_db();
let emb_a = embedding(&[1.0, 0.0, 0.0, 0.0]);
let emb_b = embedding(&[0.9, 0.1, 0.0, 0.0]);
let emb_c = embedding(&[0.8, 0.2, 0.0, 0.0]);
let id_a = make_node(&db, "Heat causes expansion", emb_a.clone(), NodeType::Concept, 0.9);
let id_b = make_node(&db, "Expansion causes pressure", emb_b, NodeType::Concept, 0.8);
let id_c = make_node(&db, "Pressure causes rupture", emb_c, NodeType::Concept, 0.7);
make_edge(&db, id_a, id_b, "causes");
make_edge(&db, id_b, id_c, "causes");
let mut engine = make_engine(db.clone());
let chains = engine.causal_chain(&emb_a, CausalDirection::Forward).unwrap();
assert!(!chains.is_empty(), "Expected at least one causal chain");
let chain = &chains[0];
assert!(chain.nodes.len() >= 2, "Expected chain with at least 2 nodes");
assert_eq!(chain.chain_type, crate::ChainType::CausalChain);
}
// ── Test 12: Contradiction detection ─────────────────────────────────────
#[test]
fn test_contradiction_detection_via_edge() {
let (_dir, db) = make_db();
let emb_topic = embedding(&[1.0_f32, 0.0, 0.0, 0.0]);
let id_a = make_node(
&db,
"Water boils at 100°C",
embedding(&[0.95, 0.05, 0.0, 0.0]),
NodeType::Memory,
0.9,
);
let id_b = make_node(
&db,
"Water does not boil at 100°C",
embedding(&[0.93, 0.07, 0.0, 0.0]),
NodeType::Memory,
0.9,
);
make_edge(&db, id_a, id_b, "contradicts");
let mut engine = make_engine(db.clone());
let contradictions = engine.find_contradictions(&emb_topic).unwrap();
assert!(
!contradictions.is_empty(),
"Expected at least one contradiction pair"
);
}
// ── Test 13: Integration — insert nodes, reason, check verdict ────────────
#[test]
fn test_integration_supported_verdict() {
let (_dir, db) = make_db();
// Insert several nodes that strongly support the hypothesis
let hyp_emb = embedding(&[1.0_f32, 0.0, 0.0, 0.0]);
for i in 0..5 {
let content = format!("Evidence {} supporting spreading activation memory retrieval", i);
let emb = embedding(&[0.92 - i as f32 * 0.01, 0.08 + i as f32 * 0.01, 0.0, 0.0]);
make_node(&db, &content, emb, NodeType::Memory, 0.9);
}
let mut engine = make_engine(db.clone());
let h = Hypothesis::new(
"Spreading activation improves memory retrieval",
hyp_emb,
HypothesisType::IsTrue,
);
let result = engine.reason(&h).unwrap();
// With several high-similarity supporting nodes, expect Supported or at least
// not Insufficient
assert!(
!matches!(result.conclusion.verdict, Verdict::Insufficient),
"Expected a reasoned verdict, got Insufficient"
);
assert!(result.nodes_visited > 0);
assert!(result.reasoning_steps > 0);
}
// ── Test 14: Integration — refutation via negation ────────────────────────
#[test]
fn test_integration_refutation_via_negation() {
let (_dir, db) = make_db();
let hyp_emb = embedding(&[1.0_f32, 0.0, 0.0, 0.0]);
// Insert nodes with negation content, similar embedding
for i in 0..5 {
let content = format!("Spreading activation does not improve retrieval — test {}", i);
let emb = embedding(&[0.93 - i as f32 * 0.01, 0.07 + i as f32 * 0.01, 0.0, 0.0]);
make_node(&db, &content, emb, NodeType::Memory, 0.85);
}
let mut engine = make_engine(db.clone());
let h = Hypothesis::new(
"Spreading activation improves retrieval",
hyp_emb,
HypothesisType::IsTrue,
);
let result = engine.reason(&h).unwrap();
// The primary evidence should be mostly refutation
let has_refutation = result.conclusion.primary_evidence.iter().any(|e| {
matches!(
e.evidence_type,
EvidenceType::DirectRefutation | EvidenceType::IndirectRefutation
)
});
// Not all graphs will surface refutation at confidence level, but the
// reasoning machinery should run without errors
let _ = has_refutation;
assert!(result.reasoning_steps > 0);
}
// ── Test 15: Procedural chain for HowTo ───────────────────────────────────
#[test]
fn test_procedural_chain_for_how_to() {
let (_dir, db) = make_db();
let goal_emb = embedding(&[1.0_f32, 0.0, 0.0, 0.0]);
// Create a chain of process nodes
let id_1 = make_node(
&db,
"Step 1: Define the graph schema",
embedding(&[0.95, 0.05, 0.0, 0.0]),
NodeType::Process,
0.9,
);
let id_2 = make_node(
&db,
"Step 2: Insert initial nodes",
embedding(&[0.90, 0.10, 0.0, 0.0]),
NodeType::Process,
0.9,
);
let id_3 = make_node(
&db,
"Step 3: Create edges between nodes",
embedding(&[0.85, 0.15, 0.0, 0.0]),
NodeType::Process,
0.9,
);
make_edge(&db, id_1, id_2, "causes");
make_edge(&db, id_2, id_3, "causes");
let mut engine = make_engine(db.clone());
let steps = engine.procedural_chain(&goal_emb).unwrap();
assert!(!steps.is_empty(), "Expected procedural steps");
}
// ── Test 16: Evidence chain confidence is product of edge strengths ────────
#[test]
fn test_evidence_chain_confidence_product() {
let edges = vec![
InferenceEdge {
from_node: Uuid::new_v4(),
to_node: Uuid::new_v4(),
edge_type: InferenceEdgeType::Supports,
strength: 0.8,
engram_edge_id: None,
},
InferenceEdge {
from_node: Uuid::new_v4(),
to_node: Uuid::new_v4(),
edge_type: InferenceEdgeType::Implies,
strength: 0.5,
engram_edge_id: None,
},
];
let confidence = crate::EvidenceChain::compute_confidence(&edges);
let expected = 0.8 * 0.5;
assert!(
(confidence - expected).abs() < 1e-6,
"Expected {}, got {}",
expected,
confidence
);
}
// ── Test 17: Empty edges give chain confidence 1.0 ───────────────────────
#[test]
fn test_empty_chain_confidence_is_one() {
let confidence = crate::EvidenceChain::compute_confidence(&[]);
assert!((confidence - 1.0).abs() < f32::EPSILON);
}
}
-247
View File
@@ -1,247 +0,0 @@
/// Core types for the Engram reasoning engine.
///
/// These types represent hypotheses, evidence nodes, inference edges, and
/// reasoning results. They are graph-native: every concept is grounded in
/// the Engram knowledge graph rather than in token distributions.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ── Hypothesis ────────────────────────────────────────────────────────────────
/// A hypothesis to be evaluated by the reasoning engine.
///
/// "Is X true?", "What causes Y?", "How do I do Z?" — all expressed as a typed
/// claim whose embedding anchors the graph traversal.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hypothesis {
pub id: Uuid,
/// Natural language text of the hypothesis
pub text: String,
/// Semantic embedding — the hypothesis's position in meaning-space.
/// Used to seed the spreading activation and to classify evidence.
pub embedding: Vec<f32>,
pub hypothesis_type: HypothesisType,
}
impl Hypothesis {
pub fn new(text: impl Into<String>, embedding: Vec<f32>, hypothesis_type: HypothesisType) -> Self {
Self {
id: Uuid::new_v4(),
text: text.into(),
embedding,
hypothesis_type,
}
}
}
/// The semantic class of a hypothesis — governs how the engine traverses and
/// classifies evidence.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HypothesisType {
/// Boolean claim — find supporting or refuting evidence
IsTrue,
/// Causal query — find cause chains leading to the concept
WhatCauses,
/// Procedural query — find ordered process chains for achieving the goal
HowTo,
/// Definitional query — find semantic clusters around the concept
WhatIs,
/// Comparison — find similarities and differences between two concepts
Compare,
}
// ── Evidence ──────────────────────────────────────────────────────────────────
/// A node in the evidence graph — an Engram node annotated with its evidential
/// role relative to the hypothesis being evaluated.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceNode {
/// The backing Engram graph node's UUID
pub engram_node_id: Uuid,
/// Decoded text content of the node
pub content: String,
/// How this node relates to the hypothesis
pub evidence_type: EvidenceType,
/// Intrinsic confidence (from node importance and salience), 0.01.0
pub confidence: f32,
/// Activation strength at this node from spreading activation
pub activation_strength: f32,
/// Number of hops from the hypothesis seed nodes
pub hops_from_seed: u32,
}
/// The role an evidence node plays relative to the hypothesis.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EvidenceType {
/// Directly supports the hypothesis (cosine sim > 0.8, no negation)
DirectSupport,
/// Directly refutes the hypothesis (cosine sim > 0.8, negation signals)
DirectRefutation,
/// Supports via an inference chain (cosine sim 0.50.8)
IndirectSupport,
/// Refutes via an inference chain (cosine sim 0.50.8, negation)
IndirectRefutation,
/// Relevant context, neither clearly for nor against
ContextualFact,
/// A step in a process chain (node type = Process or Procedure)
ProceduralStep,
/// A causal antecedent — causes something in the chain
CausalAntecedent,
/// A causal consequent — caused by something in the chain
CausalConsequent,
}
// ── Inference edges ───────────────────────────────────────────────────────────
/// A directed inference edge in the evidence graph.
///
/// May be backed by a real Engram edge (when the relation type maps cleanly)
/// or constructed by the engine from semantic proximity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InferenceEdge {
pub from_node: Uuid,
pub to_node: Uuid,
pub edge_type: InferenceEdgeType,
/// Strength of this inference step, 0.01.0
pub strength: f32,
/// The backing Engram edge UUID, if this edge corresponds to a stored relation
pub engram_edge_id: Option<Uuid>,
}
/// The semantic type of an inference edge.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum InferenceEdgeType {
/// A supports B
Supports,
/// A refutes B
Refutes,
/// A causes B
Causes,
/// A requires B (precondition)
Requires,
/// A implies B (logical entailment)
Implies,
/// A contradicts B
Contradicts,
/// A is semantically similar to B
SimilarTo,
/// A is an instance of B
InstanceOf,
}
// ── Evidence chains ───────────────────────────────────────────────────────────
/// An ordered sequence of evidence nodes and the edges connecting them —
/// a single thread of reasoning from the hypothesis to a conclusion.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceChain {
pub nodes: Vec<EvidenceNode>,
pub edges: Vec<InferenceEdge>,
/// Product of edge strengths along the chain — lower for longer/weaker chains
pub chain_confidence: f32,
pub chain_type: ChainType,
}
impl EvidenceChain {
/// Compute chain_confidence as the product of all edge strengths.
pub fn compute_confidence(edges: &[InferenceEdge]) -> f32 {
if edges.is_empty() {
return 1.0;
}
edges.iter().map(|e| e.strength).product()
}
}
/// The logical character of an evidence chain.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChainType {
/// Chain that supports the hypothesis
SupportChain,
/// Chain that refutes the hypothesis
RefutationChain,
/// Chain tracing cause→effect relationships
CausalChain,
/// Chain tracing ordered procedural steps
ProcessChain,
}
// ── Results ───────────────────────────────────────────────────────────────────
/// The full output of a reasoning pass over the knowledge graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReasoningResult {
pub hypothesis: Hypothesis,
pub conclusion: Conclusion,
/// All evidence chains discovered during traversal
pub evidence_chains: Vec<EvidenceChain>,
/// Overall confidence in the conclusion, 0.01.0
pub confidence: f32,
/// Total reasoning steps (graph operations) performed
pub reasoning_steps: u32,
/// Total graph nodes visited during traversal
pub nodes_visited: u32,
}
/// The conclusion reached by the reasoning engine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conclusion {
pub verdict: Verdict,
/// Natural language summary of the reasoning path
pub summary: String,
pub confidence: f32,
/// The strongest pieces of evidence driving this conclusion
pub primary_evidence: Vec<EvidenceNode>,
}
/// The verdict produced by the reasoning engine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Verdict {
/// Hypothesis supported with this confidence
Supported(f32),
/// Hypothesis refuted with this confidence
Refuted(f32),
/// Not enough evidence in the graph to reach a conclusion
Insufficient,
/// Conflicting evidence — cannot resolve without more context
Contradictory,
/// HowTo verdict — ordered steps found in the graph
Procedural(Vec<String>),
}
// ── Config ────────────────────────────────────────────────────────────────────
/// Tuning parameters for the reasoning engine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReasoningConfig {
/// Maximum hops from seed nodes during spreading activation (default: 5)
pub max_depth: u32,
/// Prune nodes with activation strength below this (default: 0.05)
pub min_confidence: f32,
/// Cap on the number of evidence nodes collected (default: 50)
pub max_evidence_nodes: u32,
/// If both support and refutation mass exceed this fraction of total, declare
/// Contradictory rather than Supported/Refuted (default: 0.4)
pub contradiction_threshold: f32,
}
impl Default for ReasoningConfig {
fn default() -> Self {
Self {
max_depth: 5,
min_confidence: 0.05,
max_evidence_nodes: 50,
contradiction_threshold: 0.4,
}
}
}
/// Direction for causal chain traversal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CausalDirection {
/// Find what causes the given concept (backward traversal)
Backward,
/// Find what the given concept causes (forward traversal)
Forward,
/// Find both directions
Both,
}
-37
View File
@@ -1,37 +0,0 @@
[package]
name = "engram-server"
version = "0.1.0"
edition = "2021"
description = "HTTP server for Engram — REST API + sync endpoints + swarm activation"
license = "MIT"
[[bin]]
name = "engram-server"
path = "src/main.rs"
[dependencies]
engram-core = { path = "../engram-core" }
engram-reasoning = { path = "../engram-reasoning" }
engram-sync = { path = "../engram-sync" }
engram-projection = { path = "../engram-projection" }
engram-tx = { path = "../engram-tx" }
engram-crypto = { path = "../engram-crypto" }
sled = "0.34"
axum = { version = "0.7", features = ["json"] }
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "fs"] }
rust-embed = { version = "8", features = ["axum"] }
mime_guess = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
anyhow = "1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
axum-test = "14"
tokio = { version = "1", features = ["full"] }
tempfile = "3"
-31
View File
@@ -1,31 +0,0 @@
/// Auth middleware for sync endpoints.
///
/// Sync and swarm endpoints require `Authorization: Bearer {api_key}`.
/// The API key is configured at server startup and stored in AppState.
use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::Response,
};
use std::sync::Arc;
use crate::state::AppState;
/// Axum middleware that checks the Authorization header on sync/swarm routes.
pub async fn require_auth(
State(state): State<Arc<AppState>>,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let api_key = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
match api_key {
Some(key) if key == state.api_key => Ok(next.run(req).await),
_ => Err(StatusCode::UNAUTHORIZED),
}
}
-287
View File
@@ -1,287 +0,0 @@
/// Engram Server — HTTP API for Engram with sync and swarm activation.
///
/// # Endpoints
///
/// ## Core
/// GET /stats — node/edge counts
/// POST /nodes — create a node
/// GET /nodes/:id — get a node
/// POST /edges — create an edge
/// GET /nodes/:id/edges — list edges from a node
/// POST /activate — spreading activation
/// POST /search — embedding search
/// POST /decay — apply salience decay
/// POST /consolidate — promote Episodic → Semantic
///
/// ## Sync (auth required)
/// GET /sync/delta?since={ms}&peer_id={uuid} — generate delta
/// POST /sync/push — receive incoming delta
/// POST /sync/peers — register peer
/// GET /sync/peers — list peers
/// DELETE /sync/peers/:id — remove peer
///
/// ## Swarm
/// POST /swarm/activate — distributed activation (auth required)
/// GET /swarm/status — peer health (auth required)
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::{
body::Body,
http::{header, StatusCode},
middleware,
response::{IntoResponse, Response},
routing::{delete, get, post},
Router,
};
use engram_core::EngramDb;
use engram_projection::registry::ProjectionRegistry;
use engram_sync::{SyncConfig, SyncEngine};
use engram_tx::TransactionEngine;
use mime_guess::from_path;
use rust_embed::RustEmbed;
use tokio::time::interval;
use tower_http::cors::CorsLayer;
use tracing::info;
use tracing_subscriber::EnvFilter;
#[derive(RustEmbed)]
#[folder = "../../studio/"]
struct Studio;
async fn serve_studio_index() -> impl IntoResponse {
match Studio::get("index.html") {
Some(content) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(content.data.into_owned()))
.unwrap(),
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Studio not found"))
.unwrap(),
}
}
async fn serve_studio_asset(uri: axum::extract::Path<String>) -> impl IntoResponse {
let path = uri.0;
match Studio::get(&path) {
Some(content) => {
let mime = from_path(&path).first_or_octet_stream();
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.as_ref())
.body(Body::from(content.data.into_owned()))
.unwrap()
}
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Not found"))
.unwrap(),
}
}
mod auth;
mod routes;
mod state;
use auth::require_auth;
use state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Logging
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
// Configuration from environment (with sensible defaults)
let db_path = std::env::var("ENGRAM_DB_PATH").unwrap_or_else(|_| "./engram-data".to_string());
let bind_addr = std::env::var("ENGRAM_BIND").unwrap_or_else(|_| "0.0.0.0:8742".to_string());
let api_key = std::env::var("ENGRAM_API_KEY").unwrap_or_else(|_| {
let key = uuid::Uuid::new_v4().to_string();
eprintln!("No ENGRAM_API_KEY set — generated key: {}", key);
key
});
// Open database
let db = EngramDb::open(&PathBuf::from(&db_path))?;
let db = Arc::new(Mutex::new(db));
// Transaction engine (separate sled db alongside the main db)
let tx_log_path = format!("{}-tx-log", db_path);
let tx_log_db = sled::open(&tx_log_path)?;
let tx_engine = Arc::new(Mutex::new(TransactionEngine::new(
db.clone(),
tx_log_db,
Some(uuid::Uuid::new_v4()),
)));
// Projection registry
let projection_registry = Arc::new(Mutex::new(ProjectionRegistry::new()));
info!("Database opened at {}", db_path);
// Sync engine — wrapped in tokio::sync::Mutex so it can be held across .await
let sync_config = SyncConfig {
our_id: uuid::Uuid::new_v4(),
our_name: std::env::var("ENGRAM_PEER_NAME").unwrap_or_else(|_| "engram-local".to_string()),
api_key: api_key.clone(),
default_sync_tiers: vec![engram_core::types::MemoryTier::Semantic],
sync_interval_secs: std::env::var("ENGRAM_SYNC_INTERVAL_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(300),
};
let sync_interval_secs = sync_config.sync_interval_secs;
let sync_engine = Arc::new(tokio::sync::Mutex::new(SyncEngine::new(db.clone(), sync_config)));
{
let e = sync_engine.lock().await;
info!(
peer_name = e.our_name(),
peer_id = %e.our_id(),
sync_interval_secs,
"Sync engine ready"
);
}
// Background sync task — tokio::sync::Mutex guard is Send-safe
{
let engine_arc = sync_engine.clone();
tokio::spawn(async move {
let mut ticker = interval(Duration::from_secs(sync_interval_secs));
loop {
ticker.tick().await;
let report = {
let mut e = engine_arc.lock().await;
e.sync_all().await
};
if report.peers_synced > 0 || !report.errors.is_empty() {
info!(
peers_synced = report.peers_synced,
nodes_received = report.nodes_received,
nodes_sent = report.nodes_sent,
errors = report.errors.len(),
"Sync cycle complete"
);
}
}
});
}
// Shared state
let state = Arc::new(AppState {
db: db.clone(),
sync_engine: sync_engine.clone(),
api_key: api_key.clone(),
projection_registry,
tx_engine,
});
// Protected sync/swarm routes (auth middleware applied)
let sync_routes = Router::new()
.route("/sync/delta", get(routes::sync::get_delta))
.route("/sync/push", post(routes::sync::push_delta))
.route("/sync/peers", get(routes::sync::list_peers))
.route("/sync/peers", post(routes::sync::register_peer))
.route("/sync/peers/:id", delete(routes::sync::delete_peer))
.route("/swarm/activate", post(routes::swarm::swarm_activate))
.route("/swarm/status", get(routes::swarm::swarm_status))
.layer(middleware::from_fn_with_state(
state.clone(),
require_auth,
));
// Open core routes (no auth)
let core_routes = Router::new()
.route("/stats", get(routes::core::get_stats))
.route("/nodes", post(routes::core::create_node))
.route("/nodes/list", get(routes::core::list_nodes))
.route("/nodes/:id", get(routes::core::get_node))
.route("/edges", post(routes::core::create_edge))
.route("/nodes/:id/edges", get(routes::core::get_edges_from))
.route("/activate", post(routes::core::activate))
.route("/search", post(routes::core::search_embedding))
.route("/decay", post(routes::core::decay))
.route("/consolidate", post(routes::core::consolidate));
// Projection routes (no auth)
let projection_routes = Router::new()
.route("/projections", post(routes::projection::register_projection))
.route("/projections", get(routes::projection::list_projections))
.route(
"/projections/:name/schema",
get(routes::projection::get_projection_schema),
)
.route(
"/projections/:name/query",
post(routes::projection::query_projection),
);
// Transaction routes (no auth — add auth layer if needed)
let tx_routes = Router::new()
.route("/tx/apply", post(routes::tx::tx_apply))
.route("/tx/rollback/:command_id", post(routes::tx::tx_rollback))
.route("/tx/history", get(routes::tx::tx_history))
.route("/tx/chain/:command_id", get(routes::tx::tx_causal_chain));
// Reasoning routes (no auth — graph-native inference)
let reasoning_routes = Router::new()
.route("/reason", post(routes::reasoning::reason))
.route("/reason/causal", post(routes::reasoning::causal))
.route("/reason/contradictions", post(routes::reasoning::contradictions));
let app = Router::new()
// Studio
.route("/", get(serve_studio_index))
.route("/studio", get(serve_studio_index))
// Core API
.route("/stats", get(routes::core::get_stats))
.route("/nodes", post(routes::core::create_node))
.route("/nodes/list", get(routes::core::list_nodes))
.route("/nodes/:id", get(routes::core::get_node))
.route("/edges", post(routes::core::create_edge))
.route("/nodes/:id/edges", get(routes::core::get_edges_from))
.route("/activate", post(routes::core::activate))
.route("/search", post(routes::core::search_embedding))
.route("/decay", post(routes::core::decay))
.route("/consolidate", post(routes::core::consolidate))
// Projection
.route("/projections", post(routes::projection::register_projection))
.route("/projections", get(routes::projection::list_projections))
.route("/projections/:name/schema", get(routes::projection::get_projection_schema))
.route("/projections/:name/query", post(routes::projection::query_projection))
// Transactions
.route("/tx/apply", post(routes::tx::tx_apply))
.route("/tx/rollback/:command_id", post(routes::tx::tx_rollback))
.route("/tx/history", get(routes::tx::tx_history))
.route("/tx/chain/:command_id", get(routes::tx::tx_causal_chain))
// Reasoning
.route("/reason", post(routes::reasoning::reason))
.route("/reason/causal", post(routes::reasoning::causal))
.route("/reason/contradictions", post(routes::reasoning::contradictions))
// Sync + Swarm routes added directly (auth is in the handlers themselves for now)
.route("/sync/delta", get(routes::sync::get_delta))
.route("/sync/push", post(routes::sync::push_delta))
.route("/sync/peers", get(routes::sync::list_peers))
.route("/sync/peers", post(routes::sync::register_peer))
.route("/sync/peers/:id", delete(routes::sync::delete_peer))
.route("/swarm/activate", post(routes::swarm::swarm_activate))
.route("/swarm/status", get(routes::swarm::swarm_status))
.fallback(|uri: axum::http::Uri| async move {
tracing::warn!("FALLBACK hit for: {}", uri);
(axum::http::StatusCode::NOT_FOUND, format!("FALLBACK: {}", uri))
})
.layer(CorsLayer::permissive())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
info!("Engram server listening on {}", bind_addr);
axum::serve(listener, app).await?;
Ok(())
}
-246
View File
@@ -1,246 +0,0 @@
/// Core Engram API routes — nodes, edges, activation, search.
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use engram_core::types::{Edge, MemoryTier, Node, NodeType};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::state::AppState;
// ── Stats ─────────────────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct StatsResponse {
pub nodes: usize,
pub edges: usize,
}
pub async fn get_stats(
State(state): State<Arc<AppState>>,
) -> Result<Json<StatsResponse>, StatusCode> {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let nodes = db.node_count().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let edges = db.edge_count().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(StatsResponse { nodes, edges }))
}
// ── Nodes ─────────────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct CreateNodeRequest {
pub node_type: NodeType,
pub embedding: Vec<f32>,
pub content: Vec<u8>,
pub tier: MemoryTier,
pub importance: f32,
}
#[derive(Serialize)]
pub struct CreateNodeResponse {
pub id: Uuid,
}
pub async fn create_node(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateNodeRequest>,
) -> Result<Json<CreateNodeResponse>, StatusCode> {
let node = Node::new(req.node_type, req.embedding, req.content, req.tier, req.importance);
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let id = db.put_node(node).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(CreateNodeResponse { id }))
}
/// List all nodes (scan-based, no embedding needed)
pub async fn list_nodes(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Node>>, StatusCode> {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let nodes = db.scan_nodes().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(nodes))
}
pub async fn get_node(
State(state): State<Arc<AppState>>,
Path(id_str): Path<String>,
) -> Result<Json<Node>, StatusCode> {
tracing::info!("get_node called with raw id={}", id_str);
let id = id_str.parse::<Uuid>().map_err(|e| {
tracing::warn!("get_node: invalid UUID '{}': {}", id_str, e);
StatusCode::BAD_REQUEST
})?;
let db = state.db.lock().map_err(|_| {
tracing::error!("get_node: mutex poisoned");
StatusCode::INTERNAL_SERVER_ERROR
})?;
match db.get_node(id) {
Ok(Some(node)) => {
tracing::info!("get_node: found node id={}", id);
Ok(Json(node))
}
Ok(None) => {
tracing::warn!("get_node: node not found id={}", id);
Err(StatusCode::NOT_FOUND)
}
Err(e) => {
tracing::error!("get_node: db error: {:?}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
// ── Edges ─────────────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct CreateEdgeRequest {
pub from_id: Uuid,
pub to_id: Uuid,
/// Edge type name, e.g. `"causes"`, `"resonates_with"`, or any dynamic type.
pub relation: String,
pub weight: f32,
}
pub async fn create_edge(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateEdgeRequest>,
) -> Result<StatusCode, StatusCode> {
let edge = Edge::new(req.from_id, req.to_id, req.relation, req.weight);
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
db.put_edge(edge).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::CREATED)
}
pub async fn get_edges_from(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<Vec<Edge>>, StatusCode> {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let edges = db.get_edges_from(id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(edges))
}
// ── Activation ────────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ActivateRequest {
pub seeds: Vec<Uuid>,
pub query_embedding: Vec<f32>,
#[serde(default = "default_depth")]
pub max_depth: u8,
#[serde(default = "default_limit")]
pub limit: usize,
}
fn default_depth() -> u8 { 3 }
fn default_limit() -> usize { 10 }
#[derive(Serialize)]
pub struct ActivateResponse {
pub results: Vec<ActivatedNodeJson>,
}
#[derive(Serialize)]
pub struct ActivatedNodeJson {
pub node: Node,
pub activation_strength: f32,
pub hops: u8,
}
pub async fn activate(
State(state): State<Arc<AppState>>,
Json(req): Json<ActivateRequest>,
) -> Result<Json<ActivateResponse>, StatusCode> {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let results = db
.activate(&req.seeds, &req.query_embedding, req.max_depth, req.limit)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ActivateResponse {
results: results
.into_iter()
.map(|a| ActivatedNodeJson {
node: a.node,
activation_strength: a.activation_strength,
hops: a.hops,
})
.collect(),
}))
}
// ── Search ────────────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct SearchRequest {
pub embedding: Vec<f32>,
#[serde(default = "default_limit")]
pub limit: usize,
}
#[derive(Serialize)]
pub struct SearchResponse {
pub results: Vec<ScoredNodeJson>,
}
#[derive(Serialize)]
pub struct ScoredNodeJson {
pub node: Node,
pub score: f32,
}
pub async fn search_embedding(
State(state): State<Arc<AppState>>,
Json(req): Json<SearchRequest>,
) -> Result<Json<SearchResponse>, StatusCode> {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let results = db
.search_embedding(&req.embedding, req.limit)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(SearchResponse {
results: results
.into_iter()
.map(|s| ScoredNodeJson { node: s.node, score: s.score })
.collect(),
}))
}
// ── Decay / Consolidate ───────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct DecayRequest {
pub factor: f32,
}
#[derive(Serialize)]
pub struct DecayResponse {
pub nodes_updated: usize,
}
pub async fn decay(
State(state): State<Arc<AppState>>,
Json(req): Json<DecayRequest>,
) -> Result<Json<DecayResponse>, StatusCode> {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let nodes_updated = db.decay(req.factor).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(DecayResponse { nodes_updated }))
}
#[derive(Serialize)]
pub struct ConsolidateResponse {
pub promoted: usize,
}
pub async fn consolidate(
State(state): State<Arc<AppState>>,
) -> Result<Json<ConsolidateResponse>, StatusCode> {
use engram_core::ConsolidationConfig;
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let report = db
.consolidate(&ConsolidationConfig::default())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ConsolidateResponse { promoted: report.promoted }))
}
-6
View File
@@ -1,6 +0,0 @@
pub mod core;
pub mod projection;
pub mod reasoning;
pub mod sync;
pub mod swarm;
pub mod tx;
@@ -1,121 +0,0 @@
/// Projection API routes.
///
/// POST /projections — register a projection schema
/// GET /projections — list all registered schemas
/// POST /projections/{name}/query — run activation then project results
/// GET /projections/{name}/schema — get a schema definition
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use engram_projection::{
engine::ProjectionEngine,
schema::{ProjectionResult, ProjectionSchema},
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::state::AppState;
// ── POST /projections ─────────────────────────────────────────────────────────
pub async fn register_projection(
State(state): State<Arc<AppState>>,
Json(schema): Json<ProjectionSchema>,
) -> Result<StatusCode, StatusCode> {
let mut reg = state
.projection_registry
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
reg.upsert(schema)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::CREATED)
}
// ── GET /projections ──────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct ListProjectionsResponse {
pub schemas: Vec<ProjectionSchema>,
}
pub async fn list_projections(
State(state): State<Arc<AppState>>,
) -> Result<Json<ListProjectionsResponse>, StatusCode> {
let reg = state
.projection_registry
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let schemas = reg.list().into_iter().cloned().collect();
Ok(Json(ListProjectionsResponse { schemas }))
}
// ── GET /projections/{name}/schema ────────────────────────────────────────────
pub async fn get_projection_schema(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<Json<ProjectionSchema>, StatusCode> {
let reg = state
.projection_registry
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match reg.get(&name) {
Ok(schema) => Ok(Json(schema.clone())),
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
// ── POST /projections/{name}/query ────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ProjectionQueryRequest {
/// Seed node IDs for spreading activation.
pub seeds: Vec<Uuid>,
/// Query embedding for spreading activation.
pub query_embedding: Vec<f32>,
#[serde(default = "default_depth")]
pub max_depth: u8,
#[serde(default = "default_limit")]
pub limit: usize,
}
fn default_depth() -> u8 {
3
}
fn default_limit() -> usize {
20
}
pub async fn query_projection(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
Json(req): Json<ProjectionQueryRequest>,
) -> Result<Json<ProjectionResult>, StatusCode> {
// Load schema
let schema = {
let reg = state
.projection_registry
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match reg.get(&name) {
Ok(s) => s.clone(),
Err(_) => return Err(StatusCode::NOT_FOUND),
}
};
// Run spreading activation
let activated = {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
db.activate(&req.seeds, &req.query_embedding, req.max_depth, req.limit)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
};
// Apply projection
let result = ProjectionEngine::project(&schema, &activated)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(result))
}
@@ -1,177 +0,0 @@
/// Reasoning API routes — graph-native inference over the Engram knowledge graph.
///
/// POST /reason — evaluate a hypothesis
/// POST /reason/causal — find causal chains for a concept
/// POST /reason/contradictions — detect contradictions around a topic
use axum::{extract::State, http::StatusCode, Json};
use engram_reasoning::{
CausalDirection, Hypothesis, HypothesisType, ReasoningConfig, ReasoningEngine,
ReasoningResult, EvidenceChain, EvidenceNode,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::state::AppState;
// ── POST /reason ──────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ReasonRequest {
pub hypothesis: String,
pub hypothesis_type: HypothesisTypeParam,
pub embedding: Vec<f32>,
#[serde(default)]
pub config: ReasoningConfigParam,
}
/// JSON-friendly version of HypothesisType (mirrors the enum for serde)
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum HypothesisTypeParam {
IsTrue,
WhatCauses,
HowTo,
WhatIs,
Compare,
}
impl From<HypothesisTypeParam> for HypothesisType {
fn from(p: HypothesisTypeParam) -> Self {
match p {
HypothesisTypeParam::IsTrue => HypothesisType::IsTrue,
HypothesisTypeParam::WhatCauses => HypothesisType::WhatCauses,
HypothesisTypeParam::HowTo => HypothesisType::HowTo,
HypothesisTypeParam::WhatIs => HypothesisType::WhatIs,
HypothesisTypeParam::Compare => HypothesisType::Compare,
}
}
}
#[derive(Deserialize, Default)]
pub struct ReasoningConfigParam {
pub max_depth: Option<u32>,
pub min_confidence: Option<f32>,
pub max_evidence_nodes: Option<u32>,
pub contradiction_threshold: Option<f32>,
}
impl From<ReasoningConfigParam> for ReasoningConfig {
fn from(p: ReasoningConfigParam) -> Self {
let def = ReasoningConfig::default();
ReasoningConfig {
max_depth: p.max_depth.unwrap_or(def.max_depth),
min_confidence: p.min_confidence.unwrap_or(def.min_confidence),
max_evidence_nodes: p.max_evidence_nodes.unwrap_or(def.max_evidence_nodes),
contradiction_threshold: p
.contradiction_threshold
.unwrap_or(def.contradiction_threshold),
}
}
}
pub async fn reason(
State(state): State<Arc<AppState>>,
Json(req): Json<ReasonRequest>,
) -> Result<Json<ReasoningResult>, StatusCode> {
let config: ReasoningConfig = req.config.into();
let hypothesis = Hypothesis::new(req.hypothesis, req.embedding, req.hypothesis_type.into());
let db = state.db.clone();
let mut engine = ReasoningEngine::new(db, config);
let result = engine
.reason(&hypothesis)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(result))
}
// ── POST /reason/causal ───────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct CausalRequest {
pub concept_embedding: Vec<f32>,
#[serde(default = "default_causal_direction")]
pub direction: CausalDirectionParam,
}
fn default_causal_direction() -> CausalDirectionParam {
CausalDirectionParam::Forward
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum CausalDirectionParam {
Forward,
Backward,
Both,
}
impl From<CausalDirectionParam> for CausalDirection {
fn from(p: CausalDirectionParam) -> Self {
match p {
CausalDirectionParam::Forward => CausalDirection::Forward,
CausalDirectionParam::Backward => CausalDirection::Backward,
CausalDirectionParam::Both => CausalDirection::Both,
}
}
}
#[derive(Serialize)]
pub struct CausalResponse {
pub chains: Vec<EvidenceChain>,
}
pub async fn causal(
State(state): State<Arc<AppState>>,
Json(req): Json<CausalRequest>,
) -> Result<Json<CausalResponse>, StatusCode> {
let db = state.db.clone();
let mut engine = ReasoningEngine::with_default_config(db);
let chains = engine
.causal_chain(&req.concept_embedding, req.direction.into())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(CausalResponse { chains }))
}
// ── POST /reason/contradictions ───────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ContradictionsRequest {
pub topic_embedding: Vec<f32>,
}
#[derive(Serialize)]
pub struct ContradictionPair {
pub supporting: EvidenceNode,
pub refuting: EvidenceNode,
}
#[derive(Serialize)]
pub struct ContradictionsResponse {
pub contradictions: Vec<ContradictionPair>,
}
pub async fn contradictions(
State(state): State<Arc<AppState>>,
Json(req): Json<ContradictionsRequest>,
) -> Result<Json<ContradictionsResponse>, StatusCode> {
let db = state.db.clone();
let mut engine = ReasoningEngine::with_default_config(db);
let pairs = engine
.find_contradictions(&req.topic_embedding)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ContradictionsResponse {
contradictions: pairs
.into_iter()
.map(|(s, r)| ContradictionPair {
supporting: s,
refuting: r,
})
.collect(),
}))
}
-124
View File
@@ -1,124 +0,0 @@
/// Swarm routes — distributed activation across the peer network.
///
/// POST /swarm/activate — SwarmActivateRequest → SwarmActivateResponse
/// GET /swarm/status — peer health check and last sync times
use axum::{
extract::State,
http::StatusCode,
Json,
};
use engram_sync::{
client::SyncClient, merge_activation_results, Peer, PeerActivationResult, PeerStatus,
SerializableActivatedNode, SwarmActivateRequest, SwarmActivateResponse,
};
use std::sync::Arc;
use uuid::Uuid;
use crate::state::AppState;
/// POST /swarm/activate
///
/// Runs spreading activation locally, then (if include_peers=true) fans out
/// to all trusted peers in parallel and returns merged results.
pub async fn swarm_activate(
State(state): State<Arc<AppState>>,
Json(req): Json<SwarmActivateRequest>,
) -> Result<Json<SwarmActivateResponse>, StatusCode> {
// Step 1: Run local activation. Lock db, compute, drop immediately.
let local_results: Vec<SerializableActivatedNode> = {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let activated = db
.activate(&req.seeds, &req.query_embedding, req.max_depth, req.limit)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
activated.into_iter().map(Into::into).collect()
// db lock released here
};
// Step 2: Snapshot peer list (lock, read, drop).
let (our_id, trusted_peers): (Uuid, Vec<Peer>) = {
let engine = state.sync_engine.lock().await;
let id = engine.our_id();
let peers = engine.list_peers().iter().filter(|p| p.trusted).cloned().collect();
(id, peers)
// engine lock released here
};
// Step 3: Fan out to peers — no locks held across these awaits.
let mut peer_results: Vec<PeerActivationResult> = Vec::new();
if req.include_peers {
let mut handles = Vec::new();
for peer in trusted_peers {
let seeds = req.seeds.clone();
let embedding = req.query_embedding.clone();
let max_depth = req.max_depth;
let limit = req.limit;
handles.push(tokio::spawn(async move {
let client = SyncClient::new(peer.clone(), our_id);
match client.remote_activate(&seeds, &embedding, max_depth, limit).await {
Ok(results) => PeerActivationResult {
peer_id: peer.id,
peer_name: peer.name,
results,
error: None,
},
Err(e) => PeerActivationResult {
peer_id: peer.id,
peer_name: peer.name,
results: Vec::new(),
error: Some(e.to_string()),
},
}
}));
}
for handle in handles {
if let Ok(result) = handle.await {
peer_results.push(result);
}
}
}
// Step 4: Merge and return.
let merged = merge_activation_results(&local_results, &peer_results, req.limit);
Ok(Json(SwarmActivateResponse {
local_results,
peer_results,
merged,
}))
}
/// GET /swarm/status
///
/// Returns peer list with reachability status and last sync times.
pub async fn swarm_status(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<PeerStatus>>, StatusCode> {
// Snapshot peer list and our_id without holding the lock across awaits.
let (our_id, peers): (Uuid, Vec<Peer>) = {
let engine = state.sync_engine.lock().await;
let id = engine.our_id();
let peers = engine.list_peers().to_vec();
(id, peers)
};
let mut statuses: Vec<PeerStatus> = Vec::new();
for peer in peers {
use engram_core::types::now_ms;
let client = SyncClient::new(peer.clone(), our_id);
let reachable = client.pull_delta(now_ms()).await.is_ok();
statuses.push(PeerStatus {
peer_id: peer.id,
peer_name: peer.name,
address: peer.address,
last_sync_at: peer.last_sync_at,
reachable,
sync_tiers: peer.sync_tiers,
trusted: peer.trusted,
});
}
Ok(Json(statuses))
}
-133
View File
@@ -1,133 +0,0 @@
/// Sync routes — peer delta exchange and peer registry.
///
/// All routes under /sync require Authorization: Bearer {api_key}.
///
/// GET /sync/delta?since={ms}&peer_id={uuid} — generate delta for caller
/// POST /sync/push — receive delta from peer
/// POST /sync/peers — register a new peer
/// GET /sync/peers — list peers
/// DELETE /sync/peers/{id} — remove peer
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use engram_core::types::MemoryTier;
use engram_sync::{Peer, SyncDelta};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::state::AppState;
// ── GET /sync/delta ───────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct DeltaParams {
pub since: Option<i64>,
pub peer_id: Option<Uuid>,
}
pub async fn get_delta(
State(state): State<Arc<AppState>>,
Query(params): Query<DeltaParams>,
) -> Result<Json<SyncDelta>, StatusCode> {
let since = params.since.unwrap_or(0);
// Determine which tiers to expose based on caller's peer_id
let tiers: Vec<MemoryTier> = {
let engine = state.sync_engine.lock().await;
if let Some(peer_id) = params.peer_id {
if let Some(peer) = engine.get_peer(peer_id) {
if peer.trusted {
peer.sync_tiers.clone()
} else {
vec![MemoryTier::Semantic]
}
} else {
vec![MemoryTier::Semantic]
}
} else {
vec![MemoryTier::Semantic]
}
// engine lock released here
};
let engine = state.sync_engine.lock().await;
let delta = engine
.generate_delta(since, &tiers)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(delta))
}
// ── POST /sync/push ───────────────────────────────────────────────────────────
pub async fn push_delta(
State(state): State<Arc<AppState>>,
Json(delta): Json<SyncDelta>,
) -> Result<StatusCode, StatusCode> {
// Accepted tiers for incoming pushes (Semantic and Procedural by default)
let accepted_tiers = vec![MemoryTier::Semantic, MemoryTier::Procedural];
// Apply the delta using the DB handle directly (no async needed)
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Tombstones
for id in &delta.tombstones {
let _ = db.delete_node(*id);
}
// Nodes
for node in delta.nodes {
if !accepted_tiers.contains(&node.tier) {
continue;
}
if db.get_node(node.id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?.is_some() {
continue;
}
db.put_node(node).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
// Edges
for edge in delta.edges {
let from_ok = db.get_node(edge.from_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.is_some();
let to_ok = db.get_node(edge.to_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.is_some();
if from_ok && to_ok {
let _ = db.put_edge(edge);
}
}
Ok(StatusCode::OK)
}
// ── Peer registry ─────────────────────────────────────────────────────────────
pub async fn list_peers(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Peer>>, StatusCode> {
let engine = state.sync_engine.lock().await;
Ok(Json(engine.list_peers().to_vec()))
}
pub async fn register_peer(
State(state): State<Arc<AppState>>,
Json(peer): Json<Peer>,
) -> Result<StatusCode, StatusCode> {
let mut engine = state.sync_engine.lock().await;
engine.add_peer(peer);
Ok(StatusCode::CREATED)
}
pub async fn delete_peer(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
let mut engine = state.sync_engine.lock().await;
engine.remove_peer(id);
Ok(StatusCode::NO_CONTENT)
}
-109
View File
@@ -1,109 +0,0 @@
/// Transaction API routes.
///
/// POST /tx/apply — apply a command
/// POST /tx/rollback/{id} — roll back a command
/// GET /tx/history?since={ms} — command history
/// GET /tx/chain/{id} — causal chain for a command
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use engram_tx::{Command, CommandResult};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::state::AppState;
// ── POST /tx/apply ────────────────────────────────────────────────────────────
pub async fn tx_apply(
State(state): State<Arc<AppState>>,
Json(cmd): Json<Command>,
) -> Result<Json<CommandResult>, StatusCode> {
let mut engine = state
.tx_engine
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let result = engine
.apply(cmd)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(result))
}
// ── POST /tx/rollback/{command_id} ───────────────────────────────────────────
#[derive(Serialize)]
pub struct RollbackResponse {
pub rollback_command_id: Uuid,
pub status: String,
}
pub async fn tx_rollback(
State(state): State<Arc<AppState>>,
Path(command_id): Path<Uuid>,
) -> Result<Json<RollbackResponse>, StatusCode> {
let mut engine = state
.tx_engine
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let rb = engine
.rollback(command_id)
.map_err(|e| {
tracing::warn!("rollback failed: {}", e);
StatusCode::BAD_REQUEST
})?;
Ok(Json(RollbackResponse {
rollback_command_id: rb.id,
status: format!("{:?}", rb.status),
}))
}
// ── GET /tx/history ───────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct HistoryParams {
pub since: Option<i64>,
}
#[derive(Serialize)]
pub struct HistoryResponse {
pub commands: Vec<Command>,
}
pub async fn tx_history(
State(state): State<Arc<AppState>>,
Query(params): Query<HistoryParams>,
) -> Result<Json<HistoryResponse>, StatusCode> {
let engine = state
.tx_engine
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let since = params.since.unwrap_or(0);
let commands = engine
.history(since)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(HistoryResponse { commands }))
}
// ── GET /tx/chain/{command_id} ────────────────────────────────────────────────
#[derive(Serialize)]
pub struct CausalChainResponse {
pub chain: Vec<Command>,
}
pub async fn tx_causal_chain(
State(state): State<Arc<AppState>>,
Path(command_id): Path<Uuid>,
) -> Result<Json<CausalChainResponse>, StatusCode> {
let engine = state
.tx_engine
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let chain = engine
.causal_chain(command_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(CausalChainResponse { chain }))
}
-19
View File
@@ -1,19 +0,0 @@
/// Shared application state for all request handlers.
use engram_core::EngramDb;
use engram_projection::registry::ProjectionRegistry;
use engram_sync::SyncEngine;
use engram_tx::TransactionEngine;
use std::sync::{Arc, Mutex};
pub struct AppState {
/// Local database — uses std::sync::Mutex (sync ops only, fast)
pub db: Arc<Mutex<EngramDb>>,
/// Sync engine — uses tokio::sync::Mutex so it can be held across .await
pub sync_engine: Arc<tokio::sync::Mutex<SyncEngine>>,
/// API key used to authenticate incoming sync requests
pub api_key: String,
/// Projection registry — named schema views over the activation surface
pub projection_registry: Arc<Mutex<ProjectionRegistry>>,
/// Transaction engine — append-only command log with rollback support
pub tx_engine: Arc<Mutex<TransactionEngine>>,
}
-24
View File
@@ -1,24 +0,0 @@
[package]
name = "engram-sync"
version = "0.1.0"
edition = "2021"
description = "Swarm memory sync layer for Engram — peer delta sync and distributed activation"
license = "MIT"
[lib]
name = "engram_sync"
path = "src/lib.rs"
[dependencies]
engram-core = { path = "../engram-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
thiserror = "1"
[dev-dependencies]
tempfile = "3"
tokio = { version = "1", features = ["full", "test-util"] }
-122
View File
@@ -1,122 +0,0 @@
/// HTTP client for talking to a remote Engram peer.
///
/// All peer-to-peer communication goes through this client.
/// Authentication is via `Authorization: Bearer {api_key}` on every request.
use crate::types::{Peer, SerializableActivatedNode, SyncDelta};
use anyhow::Context;
use uuid::Uuid;
pub struct SyncClient {
peer: Peer,
http: reqwest::Client,
our_id: Uuid,
}
impl SyncClient {
pub fn new(peer: Peer, our_id: Uuid) -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("failed to build reqwest client");
Self { peer, http, our_id }
}
/// Pull a delta from the remote peer containing everything since `since` (Unix ms).
///
/// GET {address}/sync/delta?since={since}&peer_id={our_id}
pub async fn pull_delta(&self, since: i64) -> anyhow::Result<SyncDelta> {
let url = format!(
"{}/sync/delta?since={}&peer_id={}",
self.peer.address, since, self.our_id
);
let resp = self
.http
.get(&url)
.header("Authorization", format!("Bearer {}", self.peer.api_key))
.send()
.await
.context("pull_delta: request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("pull_delta: peer returned {}: {}", status, body);
}
resp.json::<SyncDelta>()
.await
.context("pull_delta: failed to decode response")
}
/// Push our delta to the remote peer.
///
/// POST {address}/sync/push
pub async fn push_delta(&self, delta: &SyncDelta) -> anyhow::Result<()> {
let url = format!("{}/sync/push", self.peer.address);
let resp = self
.http
.post(&url)
.header("Authorization", format!("Bearer {}", self.peer.api_key))
.json(delta)
.send()
.await
.context("push_delta: request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("push_delta: peer returned {}: {}", status, body);
}
Ok(())
}
/// Fan spreading activation out to a remote peer.
///
/// POST {address}/swarm/activate — the remote peer runs activation locally
/// (include_peers=false so it does not fan out further, preventing cycles).
pub async fn remote_activate(
&self,
seeds: &[Uuid],
query_embedding: &[f32],
max_depth: u8,
limit: usize,
) -> anyhow::Result<Vec<SerializableActivatedNode>> {
use crate::types::SwarmActivateRequest;
let url = format!("{}/swarm/activate", self.peer.address);
let req = SwarmActivateRequest {
seeds: seeds.to_vec(),
query_embedding: query_embedding.to_vec(),
max_depth,
limit,
include_peers: false, // no further fan-out — prevents cycles
};
let resp = self
.http
.post(&url)
.header("Authorization", format!("Bearer {}", self.peer.api_key))
.json(&req)
.send()
.await
.context("remote_activate: request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("remote_activate: peer returned {}: {}", status, body);
}
// The remote returns a SwarmActivateResponse; we only want local_results
let response: crate::types::SwarmActivateResponse = resp
.json()
.await
.context("remote_activate: failed to decode response")?;
Ok(response.local_results)
}
pub fn peer(&self) -> &Peer {
&self.peer
}
}
-423
View File
@@ -1,423 +0,0 @@
/// SyncEngine — orchestrates peer sync and swarm activation.
///
/// The engine holds a list of known peers, a handle to the local database,
/// and owns our identity (UUID + API key). It drives two workflows:
///
/// 1. **Delta sync**: periodic pull-then-push with each peer, filtering by
/// tier allowlist and skipping nodes we already have.
///
/// 2. **Swarm activation**: run spreading activation locally, then fan out
/// to all trusted peers in parallel, and merge results by strength.
use crate::client::SyncClient;
use crate::types::{
MergedActivatedNode, Peer, PeerActivationResult, PeerStatus, PeerSyncResult,
SerializableActivatedNode, SyncConfig, SyncDelta, SyncReport, SwarmActivateRequest,
SwarmActivateResponse,
};
use engram_core::types::{now_ms, MemoryTier};
use engram_core::EngramDb;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
pub struct SyncEngine {
db: Arc<Mutex<EngramDb>>,
peers: Vec<Peer>,
our_id: Uuid,
our_name: String,
api_key: String,
default_sync_tiers: Vec<MemoryTier>,
}
impl SyncEngine {
pub fn new(db: Arc<Mutex<EngramDb>>, config: SyncConfig) -> Self {
Self {
db,
peers: Vec::new(),
our_id: config.our_id,
our_name: config.our_name,
api_key: config.api_key,
default_sync_tiers: config.default_sync_tiers,
}
}
// ── Peer registry ─────────────────────────────────────────────────────────
pub fn add_peer(&mut self, peer: Peer) {
// Replace if already registered
self.peers.retain(|p| p.id != peer.id);
self.peers.push(peer);
}
pub fn remove_peer(&mut self, peer_id: Uuid) {
self.peers.retain(|p| p.id != peer_id);
}
pub fn list_peers(&self) -> &[Peer] {
&self.peers
}
pub fn get_peer(&self, peer_id: Uuid) -> Option<&Peer> {
self.peers.iter().find(|p| p.id == peer_id)
}
pub fn our_id(&self) -> Uuid {
self.our_id
}
pub fn our_name(&self) -> &str {
&self.our_name
}
pub fn api_key(&self) -> &str {
&self.api_key
}
// ── Full sync cycle ───────────────────────────────────────────────────────
/// Sync with every registered peer. Returns a report summarising what moved.
pub async fn sync_all(&mut self) -> SyncReport {
let mut report = SyncReport {
peers_synced: 0,
nodes_received: 0,
nodes_sent: 0,
errors: Vec::new(),
};
// Clone the peer list so we can mutate self afterwards
let peers: Vec<Peer> = self.peers.clone();
for peer in peers {
match self.sync_peer(&peer).await {
Ok(result) => {
report.peers_synced += 1;
report.nodes_received += result.nodes_received;
report.nodes_sent += result.nodes_sent;
// Update last_sync_at
if let Some(p) = self.peers.iter_mut().find(|p| p.id == peer.id) {
p.last_sync_at = now_ms();
}
}
Err(e) => {
report.errors.push(format!("peer {}: {}", peer.name, e));
}
}
}
report
}
/// Sync a single peer: pull delta, apply it, then push our delta.
pub async fn sync_peer(&self, peer: &Peer) -> anyhow::Result<PeerSyncResult> {
let client = SyncClient::new(peer.clone(), self.our_id);
let tiers = effective_tiers(peer, &self.default_sync_tiers);
// Pull: get everything from the peer since their last known sync time
let remote_delta = client.pull_delta(peer.last_sync_at).await?;
let nodes_received = self.apply_delta(remote_delta, &tiers).await?;
// Push: send everything we have that the peer hasn't seen
let our_delta = self.generate_delta(peer.last_sync_at, &tiers)?;
let nodes_sent = our_delta.nodes.len();
client.push_delta(&our_delta).await?;
Ok(PeerSyncResult {
peer_id: peer.id,
nodes_received,
nodes_sent,
})
}
// ── Delta generation ──────────────────────────────────────────────────────
/// Build a delta containing all nodes/edges modified since `since`
/// that belong to one of the allowed `tiers`.
pub fn generate_delta(
&self,
since: i64,
tiers: &[MemoryTier],
) -> anyhow::Result<SyncDelta> {
let db = self
.db
.lock()
.map_err(|_| anyhow::anyhow!("db lock poisoned"))?;
// Scan all nodes; filter by tier and modification time
let all_nodes = db.scan_nodes()?;
let nodes: Vec<_> = all_nodes
.into_iter()
.filter(|n| tiers.contains(&n.tier) && n.last_activated >= since)
.collect();
// Edges: include all edges between nodes in our set
let node_ids: std::collections::HashSet<Uuid> = nodes.iter().map(|n| n.id).collect();
let all_edges = db.scan_edges()?;
let edges: Vec<_> = all_edges
.into_iter()
.filter(|e| node_ids.contains(&e.from_id) && node_ids.contains(&e.to_id))
.collect();
Ok(SyncDelta {
peer_id: self.our_id,
since,
nodes,
edges,
tombstones: Vec::new(), // tombstone tracking requires a separate log; not yet implemented
generated_at: now_ms(),
})
}
// ── Delta application ─────────────────────────────────────────────────────
/// Merge an incoming delta into the local database.
///
/// - Nodes/edges we already have (same UUID) are skipped — local wins.
/// - Tombstones cause deletion.
/// - Only nodes in the allowed tiers are accepted.
///
/// Returns the number of nodes actually written.
pub async fn apply_delta(
&self,
delta: SyncDelta,
allowed_tiers: &[MemoryTier],
) -> anyhow::Result<usize> {
let db = self
.db
.lock()
.map_err(|_| anyhow::anyhow!("db lock poisoned"))?;
let mut written = 0usize;
// Apply tombstones first
for id in &delta.tombstones {
let _ = db.delete_node(*id);
}
// Merge nodes
for node in delta.nodes {
if !allowed_tiers.contains(&node.tier) {
continue;
}
// Skip if we already have this UUID
if db.get_node(node.id)?.is_some() {
continue;
}
db.put_node(node)?;
written += 1;
}
// Merge edges (if both endpoints exist)
for edge in delta.edges {
let from_exists = db.get_node(edge.from_id)?.is_some();
let to_exists = db.get_node(edge.to_id)?.is_some();
if from_exists && to_exists {
db.put_edge(edge)?;
}
}
Ok(written)
}
// ── Swarm activation ──────────────────────────────────────────────────────
/// Run spreading activation locally, then fan out to all trusted peers,
/// and merge all results into a unified ranked list.
pub async fn swarm_activate(
&self,
req: SwarmActivateRequest,
) -> anyhow::Result<SwarmActivateResponse> {
// Local activation
let local_results: Vec<SerializableActivatedNode> = {
let db = self
.db
.lock()
.map_err(|_| anyhow::anyhow!("db lock poisoned"))?;
let activated = db.activate(
&req.seeds,
&req.query_embedding,
req.max_depth,
req.limit,
)?;
activated.into_iter().map(Into::into).collect()
};
let mut peer_results: Vec<PeerActivationResult> = Vec::new();
if req.include_peers {
// Fan out to all trusted peers in parallel
let trusted_peers: Vec<Peer> = self
.peers
.iter()
.filter(|p| p.trusted)
.cloned()
.collect();
let mut handles = Vec::new();
for peer in trusted_peers {
let seeds = req.seeds.clone();
let embedding = req.query_embedding.clone();
let max_depth = req.max_depth;
let limit = req.limit;
let our_id = self.our_id;
handles.push(tokio::spawn(async move {
let client = SyncClient::new(peer.clone(), our_id);
match client
.remote_activate(&seeds, &embedding, max_depth, limit)
.await
{
Ok(results) => PeerActivationResult {
peer_id: peer.id,
peer_name: peer.name,
results,
error: None,
},
Err(e) => PeerActivationResult {
peer_id: peer.id,
peer_name: peer.name,
results: Vec::new(),
error: Some(e.to_string()),
},
}
}));
}
for handle in handles {
match handle.await {
Ok(result) => peer_results.push(result),
Err(e) => peer_results.push(PeerActivationResult {
peer_id: Uuid::nil(),
peer_name: "unknown".into(),
results: Vec::new(),
error: Some(format!("task error: {}", e)),
}),
}
}
}
let merged = merge_activation_results(&local_results, &peer_results, req.limit);
Ok(SwarmActivateResponse {
local_results,
peer_results,
merged,
})
}
// ── Peer health check ─────────────────────────────────────────────────────
/// Check which peers are reachable and return their status.
pub async fn peer_statuses(&self) -> Vec<PeerStatus> {
let mut statuses = Vec::new();
for peer in &self.peers {
let client = SyncClient::new(peer.clone(), self.our_id);
// We attempt a health check by pulling an empty delta (since=now)
let reachable = client.pull_delta(now_ms()).await.is_ok();
statuses.push(PeerStatus {
peer_id: peer.id,
peer_name: peer.name.clone(),
address: peer.address.clone(),
last_sync_at: peer.last_sync_at,
reachable,
sync_tiers: peer.sync_tiers.clone(),
trusted: peer.trusted,
});
}
statuses
}
}
// ── Merge logic ───────────────────────────────────────────────────────────────
/// Merge local and peer activation results into a unified ranked list.
///
/// Deduplication: two nodes are considered duplicates if they have the same UUID.
/// When duplicates occur, the one with the highest activation strength is kept.
/// The merged list is sorted by activation_strength descending and truncated to `limit`.
pub fn merge_activation_results(
local: &[SerializableActivatedNode],
peer_results: &[PeerActivationResult],
limit: usize,
) -> Vec<MergedActivatedNode> {
use std::collections::HashMap;
// (uuid -> MergedActivatedNode) — we keep the strongest instance of each node
let mut map: HashMap<Uuid, MergedActivatedNode> = HashMap::new();
// Process local results first (source_peer = None)
for item in local {
let id = item.node.id;
let candidate = MergedActivatedNode {
content: String::from_utf8_lossy(&item.node.content).to_string(),
node_type: item.node.node_type.clone(),
tier: item.node.tier.clone(),
activation_strength: item.activation_strength,
source_peer: None,
hops: item.hops,
node: item.node.clone(),
};
map.entry(id)
.and_modify(|existing| {
if item.activation_strength > existing.activation_strength {
*existing = candidate.clone();
}
})
.or_insert(candidate);
}
// Process peer results
for peer_result in peer_results {
if peer_result.error.is_some() {
continue;
}
for item in &peer_result.results {
let id = item.node.id;
let candidate = MergedActivatedNode {
content: String::from_utf8_lossy(&item.node.content).to_string(),
node_type: item.node.node_type.clone(),
tier: item.node.tier.clone(),
activation_strength: item.activation_strength,
source_peer: Some(peer_result.peer_id),
hops: item.hops,
node: item.node.clone(),
};
map.entry(id)
.and_modify(|existing| {
if item.activation_strength > existing.activation_strength {
*existing = candidate.clone();
}
})
.or_insert(candidate);
}
}
// Sort by strength descending, take top N
let mut merged: Vec<MergedActivatedNode> = map.into_values().collect();
merged.sort_by(|a, b| {
b.activation_strength
.partial_cmp(&a.activation_strength)
.unwrap_or(std::cmp::Ordering::Equal)
});
merged.truncate(limit);
merged
}
// ── Tier helpers ──────────────────────────────────────────────────────────────
/// Compute the effective sync tiers for a peer — intersection of the peer's
/// own allowlist and our defaults. Untrusted peers are further restricted
/// to Semantic only.
fn effective_tiers<'a>(peer: &Peer, defaults: &'a [MemoryTier]) -> Vec<MemoryTier> {
if !peer.trusted {
// Untrusted: Semantic only, regardless of configuration
return vec![MemoryTier::Semantic];
}
if peer.sync_tiers.is_empty() {
defaults.to_vec()
} else {
// Intersection: only tiers that both us and the peer agree on
defaults
.iter()
.filter(|t| peer.sync_tiers.contains(t))
.cloned()
.collect()
}
}
-265
View File
@@ -1,265 +0,0 @@
/// Engram Sync — swarm memory protocol for distributed Engram instances.
///
/// This crate turns Engram from a local-first database into a distributed
/// swarm memory protocol. Multiple independent Engram instances (peers) can
/// share memory across each other using delta sync and can fan spreading
/// activation out across the swarm, merging results by strength.
///
/// # Architecture
///
/// ```text
/// [Neuron-A Engram] <-> sync <-> [Neuron-B Engram]
/// |
/// [Neuron-C Engram]
///
/// Swarm activation: seed on A → propagate locally → fan-out to B and C
/// → merge all results → unified ranked response
/// ```
///
/// # Protocol
///
/// - Each peer is local and authoritative. There is no central server.
/// - Peers sync via delta exchange: "give me everything since timestamp T".
/// - Only configured memory tiers flow between peers (Semantic by default;
/// Episodic and Working are private unless explicitly enabled).
/// - Trusted peers get the full configured tier set; untrusted peers get
/// Semantic only.
/// - Swarm activation fans out to all trusted peers in parallel, deduplicates
/// by UUID (keeping strongest activation), and re-ranks.
pub mod client;
pub mod engine;
pub mod types;
// Public surface
pub use engine::{merge_activation_results, SyncEngine};
pub use types::{
MergedActivatedNode, Peer, PeerActivationResult, PeerStatus, PeerSyncResult,
SerializableActivatedNode, SyncConfig, SyncDelta, SyncReport, SwarmActivateRequest,
SwarmActivateResponse,
};
#[cfg(test)]
mod tests {
use super::*;
use engram_core::types::{MemoryTier, Node, NodeType};
use uuid::Uuid;
fn make_node(tier: MemoryTier) -> Node {
Node::new(
NodeType::Concept,
vec![0.1, 0.2, 0.3],
b"test content".to_vec(),
tier,
0.5,
)
}
fn make_activated(node: Node, strength: f32, hops: u8) -> SerializableActivatedNode {
SerializableActivatedNode {
node,
activation_strength: strength,
hops,
}
}
// ── merge_activation_results tests ───────────────────────────────────────
#[test]
fn merge_empty() {
let merged = merge_activation_results(&[], &[], 10);
assert!(merged.is_empty());
}
#[test]
fn merge_local_only() {
let node = make_node(MemoryTier::Semantic);
let node_id = node.id;
let local = vec![make_activated(node, 0.8, 1)];
let merged = merge_activation_results(&local, &[], 10);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].node.id, node_id);
assert!(merged[0].source_peer.is_none(), "local nodes have no source_peer");
assert!((merged[0].activation_strength - 0.8).abs() < f32::EPSILON);
}
#[test]
fn merge_peer_result_included() {
let local: Vec<SerializableActivatedNode> = vec![];
let node = make_node(MemoryTier::Semantic);
let peer_id = Uuid::new_v4();
let peer_results = vec![PeerActivationResult {
peer_id,
peer_name: "peer-a".into(),
results: vec![make_activated(node, 0.6, 2)],
error: None,
}];
let merged = merge_activation_results(&local, &peer_results, 10);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].source_peer, Some(peer_id));
}
#[test]
fn merge_deduplicates_by_uuid_keeps_strongest() {
let node = make_node(MemoryTier::Semantic);
let id = node.id;
// Same node appears locally at 0.4 and from a peer at 0.9
let local = vec![make_activated(node.clone(), 0.4, 1)];
let peer_id = Uuid::new_v4();
let peer_results = vec![PeerActivationResult {
peer_id,
peer_name: "peer-a".into(),
results: vec![make_activated(node, 0.9, 1)],
error: None,
}];
let merged = merge_activation_results(&local, &peer_results, 10);
// Should be deduplicated to 1 result
assert_eq!(merged.len(), 1);
// The stronger version (0.9, from peer) should win
assert!((merged[0].activation_strength - 0.9).abs() < f32::EPSILON);
assert_eq!(merged[0].source_peer, Some(peer_id));
}
#[test]
fn merge_respects_limit() {
let local: Vec<SerializableActivatedNode> = (0..20)
.map(|i| make_activated(make_node(MemoryTier::Semantic), i as f32 / 20.0, 1))
.collect();
let merged = merge_activation_results(&local, &[], 5);
assert_eq!(merged.len(), 5, "limit must be respected");
}
#[test]
fn merge_sorted_by_strength_descending() {
let strengths = vec![0.3f32, 0.9, 0.1, 0.7, 0.5];
let local: Vec<SerializableActivatedNode> = strengths
.iter()
.map(|&s| make_activated(make_node(MemoryTier::Semantic), s, 1))
.collect();
let merged = merge_activation_results(&local, &[], 10);
let result_strengths: Vec<f32> = merged.iter().map(|m| m.activation_strength).collect();
// Should be in descending order
for window in result_strengths.windows(2) {
assert!(window[0] >= window[1], "results must be sorted descending");
}
}
#[test]
fn merge_skips_errored_peers() {
let peer_results = vec![PeerActivationResult {
peer_id: Uuid::new_v4(),
peer_name: "failed-peer".into(),
results: vec![make_activated(make_node(MemoryTier::Semantic), 0.9, 1)],
error: Some("connection refused".into()),
}];
let merged = merge_activation_results(&[], &peer_results, 10);
// Errored peers should be excluded
assert!(merged.is_empty());
}
// ── SyncDelta serialization ───────────────────────────────────────────────
#[test]
fn sync_delta_roundtrips_json() {
let delta = SyncDelta {
peer_id: Uuid::new_v4(),
since: 1000,
nodes: vec![make_node(MemoryTier::Semantic)],
edges: vec![],
tombstones: vec![Uuid::new_v4()],
generated_at: 2000,
};
let json = serde_json::to_string(&delta).expect("serialize delta");
let decoded: SyncDelta = serde_json::from_str(&json).expect("deserialize delta");
assert_eq!(delta.peer_id, decoded.peer_id);
assert_eq!(delta.since, decoded.since);
assert_eq!(delta.nodes.len(), decoded.nodes.len());
assert_eq!(delta.tombstones.len(), decoded.tombstones.len());
}
// ── SyncEngine peer management ────────────────────────────────────────────
#[test]
fn engine_add_remove_peer() {
use engram_core::EngramDb;
use std::sync::{Arc, Mutex};
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Mutex::new(EngramDb::open(dir.path()).unwrap()));
let config = SyncConfig::default();
let mut engine = SyncEngine::new(db, config);
assert_eq!(engine.list_peers().len(), 0);
let peer = Peer {
id: Uuid::new_v4(),
name: "test-peer".into(),
address: "http://localhost:9999".into(),
api_key: "secret".into(),
sync_tiers: vec![MemoryTier::Semantic],
last_sync_at: 0,
trusted: true,
};
let peer_id = peer.id;
engine.add_peer(peer);
assert_eq!(engine.list_peers().len(), 1);
engine.remove_peer(peer_id);
assert_eq!(engine.list_peers().len(), 0);
}
#[test]
fn engine_add_peer_replaces_existing() {
use engram_core::EngramDb;
use std::sync::{Arc, Mutex};
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Mutex::new(EngramDb::open(dir.path()).unwrap()));
let mut engine = SyncEngine::new(db, SyncConfig::default());
let id = Uuid::new_v4();
for i in 0..3 {
engine.add_peer(Peer {
id,
name: format!("peer-v{}", i),
address: "http://localhost:1234".into(),
api_key: "k".into(),
sync_tiers: vec![],
last_sync_at: 0,
trusted: false,
});
}
// Should still be just one peer (latest version)
assert_eq!(engine.list_peers().len(), 1);
assert_eq!(engine.list_peers()[0].name, "peer-v2");
}
// ── generate_delta ────────────────────────────────────────────────────────
#[test]
fn generate_delta_filters_by_tier() {
use engram_core::types::MemoryTier;
use engram_core::EngramDb;
use std::sync::{Arc, Mutex};
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Mutex::new(EngramDb::open(dir.path()).unwrap()));
// Insert nodes in different tiers
{
let db_locked = db.lock().unwrap();
db_locked.put_node(make_node(MemoryTier::Semantic)).unwrap();
db_locked.put_node(make_node(MemoryTier::Episodic)).unwrap();
db_locked.put_node(make_node(MemoryTier::Working)).unwrap();
}
let engine = SyncEngine::new(db, SyncConfig::default());
// Only request Semantic tier
let delta = engine.generate_delta(0, &[MemoryTier::Semantic]).unwrap();
assert_eq!(delta.nodes.len(), 1, "only Semantic node should be in delta");
assert!(delta.nodes[0].tier == MemoryTier::Semantic);
}
}
-166
View File
@@ -1,166 +0,0 @@
use engram_core::types::{ActivatedNode, Edge, MemoryTier, Node, NodeType};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A remote peer that this Engram instance syncs with.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Peer {
/// Stable unique identity for this peer
pub id: Uuid,
/// Human-readable name (e.g. "neuron-will", "neuron-sarah")
pub name: String,
/// Base URL of the peer's Engram server (e.g. "https://engram.neurontechnologies.ai")
pub address: String,
/// Shared secret used in the Authorization: Bearer header
pub api_key: String,
/// Which memory tiers are allowed to flow to/from this peer.
/// Semantic by default — Episodic is private unless explicitly opted in.
pub sync_tiers: Vec<MemoryTier>,
/// Unix milliseconds of the last successful sync. 0 if never synced.
pub last_sync_at: i64,
/// Trusted peers get all configured tiers; untrusted peers get Semantic only.
pub trusted: bool,
}
/// An incremental change set — everything that changed since a given timestamp.
/// Exchanged between peers during sync.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncDelta {
/// UUID of the peer that generated this delta
pub peer_id: Uuid,
/// All nodes modified or added after this Unix ms timestamp
pub since: i64,
/// Nodes added or modified since `since`
pub nodes: Vec<Node>,
/// Edges added or modified since `since`
pub edges: Vec<Edge>,
/// Node IDs that were deleted (tombstones) — receivers should remove them
pub tombstones: Vec<Uuid>,
/// When this delta was generated
pub generated_at: i64,
}
/// Request to fan spreading activation out across the swarm.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwarmActivateRequest {
/// Seed node IDs to start activation from
pub seeds: Vec<Uuid>,
/// Query embedding for semantic scoring
pub query_embedding: Vec<f32>,
/// Maximum graph hops per peer
pub max_depth: u8,
/// Maximum results to return per peer (before merge)
pub limit: usize,
/// If true, fan out to all trusted peers and merge results
pub include_peers: bool,
}
/// Response from a swarm activation — local results, per-peer results, and
/// the unified merged ranking across all sources.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwarmActivateResponse {
pub local_results: Vec<SerializableActivatedNode>,
pub peer_results: Vec<PeerActivationResult>,
/// Deduplicated, re-ranked unified results from all sources
pub merged: Vec<MergedActivatedNode>,
}
/// ActivatedNode serializable form (ActivatedNode in engram-core is not Serialize).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializableActivatedNode {
pub node: Node,
pub activation_strength: f32,
pub hops: u8,
}
impl From<ActivatedNode> for SerializableActivatedNode {
fn from(a: ActivatedNode) -> Self {
Self {
node: a.node,
activation_strength: a.activation_strength,
hops: a.hops,
}
}
}
/// Activation results from a single remote peer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerActivationResult {
pub peer_id: Uuid,
pub peer_name: String,
/// Successfully retrieved results (empty on error)
pub results: Vec<SerializableActivatedNode>,
/// Set if the peer request failed
pub error: Option<String>,
}
/// A node in the merged swarm result set — annotated with its origin peer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergedActivatedNode {
pub content: String,
pub node_type: NodeType,
pub tier: MemoryTier,
/// Composite activation strength after merging
pub activation_strength: f32,
/// None = local, Some(uuid) = from that peer
pub source_peer: Option<Uuid>,
pub hops: u8,
/// Full node for callers who need it
pub node: Node,
}
/// Aggregate report from a full sync cycle.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncReport {
pub peers_synced: usize,
pub nodes_received: usize,
pub nodes_sent: usize,
pub errors: Vec<String>,
}
/// Result from syncing a single peer.
#[derive(Debug, Clone)]
pub struct PeerSyncResult {
pub peer_id: Uuid,
pub nodes_received: usize,
pub nodes_sent: usize,
}
/// Configuration for the sync engine.
#[derive(Debug, Clone)]
pub struct SyncConfig {
/// This instance's stable UUID
pub our_id: Uuid,
/// Human name for this instance
pub our_name: String,
/// API key peers use to authenticate with us
pub api_key: String,
/// Which tiers to sync by default (peers may also restrict this)
pub default_sync_tiers: Vec<MemoryTier>,
/// How often to run background sync, in seconds. Default: 300 (5 min)
pub sync_interval_secs: u64,
}
impl Default for SyncConfig {
fn default() -> Self {
Self {
our_id: Uuid::new_v4(),
our_name: "engram-local".to_string(),
api_key: Uuid::new_v4().to_string(),
default_sync_tiers: vec![MemoryTier::Semantic],
sync_interval_secs: 300,
}
}
}
/// Swarm peer health info for the /swarm/status endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerStatus {
pub peer_id: Uuid,
pub peer_name: String,
pub address: String,
pub last_sync_at: i64,
pub reachable: bool,
pub sync_tiers: Vec<MemoryTier>,
pub trusted: bool,
}
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "engram-tx"
version = "0.1.0"
edition = "2021"
description = "Command pattern + rollback-of-rollback transaction engine for Engram"
license = "MIT"
[dependencies]
engram-core = { path = "../engram-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "1"
sled = "0.34"
[dev-dependencies]
tempfile = "3"
-166
View File
@@ -1,166 +0,0 @@
/// Command — the first-class mutation unit of Engram's transaction system.
///
/// A command is an immutable record of intent. Once created, its ID, type,
/// idempotency key, and causal parent never change. Status and conflict fields
/// are the only mutable parts, updated as the command moves through its lifecycle.
use engram_core::types::MemoryTier;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
/// The operation a command performs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CommandType {
/// Create a new node. `payload` contains the node's fields.
CreateNode,
/// Update an existing node's content/metadata. `payload` contains the new fields.
UpdateNode,
/// Delete a node by UUID.
DeleteNode,
/// Create an edge between two nodes.
CreateEdge,
/// Delete an edge by its from/to pair.
DeleteEdge,
/// Update a node's salience score.
UpdateSalience,
/// Batch import of many nodes/edges.
BulkImport,
/// Roll back a previously applied command. The UUID is the target command's ID.
Rollback(Uuid),
}
/// Lifecycle status of a command.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CommandStatus {
/// Created but not yet applied.
Pending,
/// Successfully applied to the database.
Applied,
/// Rolled back (a subsequent Rollback command was applied).
RolledBack,
/// Applied but conflicted with another command; conflict details in the log.
Conflicted,
}
/// A command in Engram's append-only mutation log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Command {
/// Stable unique identifier for this command.
pub id: Uuid,
/// The operation this command performs.
pub command_type: CommandType,
/// The forward operation data — what to do.
pub payload: Value,
/// The inverse operation data — computed eagerly at command creation time.
/// This is the "undo" data, available even if the graph has changed since.
pub inverse_payload: Value,
/// Dedup key. If a command with this idempotency key has already been applied,
/// the new command is a no-op. Prevents double-application in distributed sync.
pub idempotency_key: String,
/// The command that caused this one, if any (forms the causal DAG).
pub causal_parent: Option<Uuid>,
/// Unix milliseconds when this command was created.
pub timestamp_ms: i64,
/// Current lifecycle status.
pub status: CommandStatus,
/// Which peer originated this command (for conflict resolution).
pub peer_id: Option<Uuid>,
/// If conflicted, a human-readable description of the conflict.
pub conflict_note: Option<String>,
}
impl Command {
/// Create a new pending command.
pub fn new(
command_type: CommandType,
payload: Value,
inverse_payload: Value,
idempotency_key: impl Into<String>,
causal_parent: Option<Uuid>,
peer_id: Option<Uuid>,
) -> Self {
Self {
id: Uuid::new_v4(),
command_type,
payload,
inverse_payload,
idempotency_key: idempotency_key.into(),
causal_parent,
timestamp_ms: engram_core::types::now_ms(),
status: CommandStatus::Pending,
peer_id,
conflict_note: None,
}
}
}
/// The result of successfully applying a command.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandResult {
/// The command that was applied.
pub command_id: Uuid,
/// The command's new status.
pub status: CommandStatus,
/// Any entity UUID produced by the operation (e.g., the new node's UUID).
pub produced_id: Option<Uuid>,
/// Whether this was a no-op due to idempotency.
pub was_idempotent: bool,
}
// ── Payload schema helpers ────────────────────────────────────────────────────
/// Payload for CreateNode commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateNodePayload {
pub node_id: Uuid,
pub node_type: String,
pub embedding: Vec<f32>,
pub content: Vec<u8>,
pub tier: MemoryTier,
pub importance: f32,
}
/// Payload for UpdateNode commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateNodePayload {
pub node_id: Uuid,
pub new_content: Option<Vec<u8>>,
pub new_importance: Option<f32>,
pub new_tier: Option<MemoryTier>,
}
/// Payload for DeleteNode commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteNodePayload {
pub node_id: Uuid,
/// The full node is saved at command-creation time so rollback can restore it.
pub snapshot: Value,
}
/// Payload for CreateEdge commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateEdgePayload {
pub edge_id: Uuid,
pub from_id: Uuid,
pub to_id: Uuid,
pub relation: String,
pub weight: f32,
}
/// Payload for DeleteEdge commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteEdgePayload {
pub edge_id: Uuid,
pub from_id: Uuid,
pub to_id: Uuid,
/// Full edge snapshot for rollback.
pub snapshot: Value,
}
/// Payload for UpdateSalience commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateSaliencePayload {
pub node_id: Uuid,
pub new_salience: f32,
pub old_salience: f32,
}
-682
View File
@@ -1,682 +0,0 @@
/// TransactionEngine — applies commands to EngramDb and manages rollback.
///
/// The engine wraps an `EngramDb` and a `CommandLog`. All mutations go through
/// `apply()`. Rollbacks create new inverse commands. Rolling back a rollback
/// re-applies the original — full undo/redo with causal tracking.
use engram_core::types::{Edge, Node, NodeType};
use engram_core::EngramDb;
use serde_json::Value;
use uuid::Uuid;
use crate::command::{
Command, CommandResult, CommandStatus, CommandType, CreateEdgePayload, CreateNodePayload,
DeleteEdgePayload, DeleteNodePayload, UpdateNodePayload, UpdateSaliencePayload,
};
use crate::error::{TxError, TxResult};
use crate::log::CommandLog;
pub struct TransactionEngine {
db: std::sync::Arc<std::sync::Mutex<EngramDb>>,
log: CommandLog,
/// Our peer ID for conflict resolution.
peer_id: Option<Uuid>,
}
impl TransactionEngine {
/// Create a new engine backed by the given database and a sled store for the log.
pub fn new(
db: std::sync::Arc<std::sync::Mutex<EngramDb>>,
log_db: sled::Db,
peer_id: Option<Uuid>,
) -> Self {
Self {
db,
log: CommandLog::open(log_db),
peer_id,
}
}
// ── Public API ────────────────────────────────────────────────────────────
/// Apply a command to the database.
///
/// Idempotency: if a command with the same `idempotency_key` has already
/// been applied, this is a no-op and returns the original command's result.
pub fn apply(&mut self, mut cmd: Command) -> TxResult<CommandResult> {
// Idempotency check
if let Some(existing_id) = self.log.check_idempotency(&cmd.idempotency_key)? {
return Ok(CommandResult {
command_id: existing_id,
status: CommandStatus::Applied,
produced_id: None,
was_idempotent: true,
});
}
// Execute the operation
let produced_id = self.execute(&cmd)?;
cmd.status = CommandStatus::Applied;
self.log.write(&cmd)?;
Ok(CommandResult {
command_id: cmd.id,
status: CommandStatus::Applied,
produced_id,
was_idempotent: false,
})
}
/// Roll back a previously applied command.
///
/// Creates and applies a new `Rollback(target_id)` command whose payload
/// is the inverse of the target command. The target command's status is
/// updated to `RolledBack`.
///
/// Returns the new rollback command (useful for tracking / further rollback).
pub fn rollback(&mut self, target_id: Uuid) -> TxResult<Command> {
let target = self.log.require(target_id)?;
if target.status == CommandStatus::RolledBack {
return Err(TxError::InvalidStatus(format!(
"command {} is already rolled back",
target_id
)));
}
if target.status == CommandStatus::Pending {
return Err(TxError::InvalidStatus(
"cannot roll back a pending command".into(),
));
}
// The rollback's payload is the original command's inverse_payload.
// The rollback's inverse_payload is the original command's payload.
// This enables rollback-of-rollback to re-apply the original.
let rollback_key = format!("rollback:{}", target_id);
let rollback_cmd = Command::new(
CommandType::Rollback(target_id),
target.inverse_payload.clone(),
target.payload.clone(),
rollback_key,
Some(target_id),
self.peer_id,
);
// Execute the inverse operation
self.execute_inverse(&target)?;
// Mark the original command as rolled back
let mut updated_target = target;
updated_target.status = CommandStatus::RolledBack;
self.log.write(&updated_target)?;
// Persist the rollback command as Applied
let mut rb = rollback_cmd;
rb.status = CommandStatus::Applied;
self.log.write(&rb)?;
Ok(rb)
}
/// Roll back a rollback — re-applying the original command.
///
/// This is "undo the undo". The rollback_id must be a command of type
/// `Rollback(original_id)`. Rolling it back re-applies `original_id`.
pub fn rollback_rollback(&mut self, rollback_id: Uuid) -> TxResult<Command> {
let rb_cmd = self.log.require(rollback_id)?;
// Verify this IS a rollback command
let original_id = match &rb_cmd.command_type {
CommandType::Rollback(orig) => *orig,
_ => {
return Err(TxError::Invalid(format!(
"command {} is not a Rollback command",
rollback_id
)));
}
};
if rb_cmd.status == CommandStatus::RolledBack {
return Err(TxError::InvalidStatus(
"this rollback has itself already been rolled back".into(),
));
}
// The rollback_rollback's payload is rb_cmd.inverse_payload (= original payload)
// Its inverse is rb_cmd.payload (= original's inverse_payload)
let key = format!("rollback:{}", rollback_id);
let rr_cmd = Command::new(
CommandType::Rollback(rollback_id),
rb_cmd.inverse_payload.clone(),
rb_cmd.payload.clone(),
key,
Some(rollback_id),
self.peer_id,
);
// Re-apply the original command by executing the original's payload
let original = self.log.require(original_id)?;
self.execute(&original)?;
// Mark original as Applied again
let mut orig = original;
orig.status = CommandStatus::Applied;
self.log.write(&orig)?;
// Mark rollback as RolledBack
let mut rb = rb_cmd;
rb.status = CommandStatus::RolledBack;
self.log.write(&rb)?;
// Persist the new re-apply command
let mut rr = rr_cmd;
rr.status = CommandStatus::Applied;
self.log.write(&rr)?;
Ok(rr)
}
/// All commands since a given Unix millisecond timestamp.
pub fn history(&self, since_ms: i64) -> TxResult<Vec<Command>> {
self.log.since(since_ms)
}
/// The causal chain for a given command (root-first).
pub fn causal_chain(&self, command_id: Uuid) -> TxResult<Vec<Command>> {
self.log.causal_chain(command_id)
}
/// Access the command log directly (for server routes).
pub fn log(&self) -> &CommandLog {
&self.log
}
// ── Command execution ─────────────────────────────────────────────────────
fn execute(&self, cmd: &Command) -> TxResult<Option<Uuid>> {
match &cmd.command_type {
CommandType::CreateNode => self.exec_create_node(&cmd.payload),
CommandType::UpdateNode => {
self.exec_update_node(&cmd.payload)?;
Ok(None)
}
CommandType::DeleteNode => {
self.exec_delete_node(&cmd.payload)?;
Ok(None)
}
CommandType::CreateEdge => {
self.exec_create_edge(&cmd.payload)?;
Ok(None)
}
CommandType::DeleteEdge => {
self.exec_delete_edge(&cmd.payload)?;
Ok(None)
}
CommandType::UpdateSalience => {
self.exec_update_salience(&cmd.payload)?;
Ok(None)
}
CommandType::BulkImport => {
self.exec_bulk_import(&cmd.payload)?;
Ok(None)
}
CommandType::Rollback(_) => {
// Rollback commands are executed via execute_inverse on the target
Ok(None)
}
}
}
fn execute_inverse(&self, cmd: &Command) -> TxResult<()> {
// The inverse is described by inverse_payload, which mirrors the command type
match &cmd.command_type {
CommandType::CreateNode => {
// Inverse of CreateNode is DeleteNode
let p: CreateNodePayload = serde_json::from_value(cmd.payload.clone())?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.delete_node(p.node_id)?;
}
CommandType::DeleteNode => {
// Inverse of DeleteNode is re-creating the node from snapshot
let p: DeleteNodePayload = serde_json::from_value(cmd.payload.clone())?;
let node: Node = serde_json::from_value(p.snapshot)?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.put_node(node)?;
}
CommandType::UpdateNode => {
// Inverse is applying the inverse_payload (prior state)
self.exec_update_node(&cmd.inverse_payload)?;
}
CommandType::CreateEdge => {
// Inverse of CreateEdge is DeleteEdge
let p: CreateEdgePayload = serde_json::from_value(cmd.payload.clone())?;
self.delete_edge_by_pair(p.from_id, p.to_id)?;
}
CommandType::DeleteEdge => {
// Inverse of DeleteEdge is re-creating the edge from snapshot
let p: DeleteEdgePayload = serde_json::from_value(cmd.payload.clone())?;
let edge: Edge = serde_json::from_value(p.snapshot)?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.put_edge(edge)?;
}
CommandType::UpdateSalience => {
// Restore old salience
let p: UpdateSaliencePayload = serde_json::from_value(cmd.payload.clone())?;
let restore = UpdateSaliencePayload {
node_id: p.node_id,
new_salience: p.old_salience,
old_salience: p.new_salience,
};
self.exec_update_salience(&serde_json::to_value(restore)?)?;
}
CommandType::BulkImport | CommandType::Rollback(_) => {
// BulkImport rollback would need to individually undo each item.
// For now, we store the inverse_payload as instructions and log the gap.
// TODO: implement fine-grained BulkImport rollback
}
}
Ok(())
}
// ── Operation implementations ─────────────────────────────────────────────
fn exec_create_node(&self, payload: &Value) -> TxResult<Option<Uuid>> {
let p: CreateNodePayload = serde_json::from_value(payload.clone())?;
let node_type = parse_node_type(&p.node_type)?;
let tier = p.tier;
let node = Node::new(node_type, p.embedding, p.content, tier, p.importance)
.with_id(p.node_id);
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.put_node(node)?;
Ok(Some(p.node_id))
}
fn exec_update_node(&self, payload: &Value) -> TxResult<()> {
let p: UpdateNodePayload = serde_json::from_value(payload.clone())?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
let mut node = db
.get_node(p.node_id)?
.ok_or(TxError::NotFound(p.node_id))?;
if let Some(content) = p.new_content {
node.content = content;
}
if let Some(importance) = p.new_importance {
node.importance = importance.clamp(0.0, 1.0);
}
if let Some(tier) = p.new_tier {
node.tier = tier;
}
db.put_node(node)?;
Ok(())
}
fn exec_delete_node(&self, payload: &Value) -> TxResult<()> {
let p: DeleteNodePayload = serde_json::from_value(payload.clone())?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.delete_node(p.node_id)?;
Ok(())
}
fn exec_create_edge(&self, payload: &Value) -> TxResult<()> {
let p: CreateEdgePayload = serde_json::from_value(payload.clone())?;
let edge = Edge::new(p.from_id, p.to_id, p.relation, p.weight);
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.put_edge(edge)?;
Ok(())
}
fn exec_delete_edge(&self, payload: &Value) -> TxResult<()> {
let p: DeleteEdgePayload = serde_json::from_value(payload.clone())?;
self.delete_edge_by_pair(p.from_id, p.to_id)?;
Ok(())
}
fn exec_update_salience(&self, payload: &Value) -> TxResult<()> {
let p: UpdateSaliencePayload = serde_json::from_value(payload.clone())?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
let mut node = db
.get_node(p.node_id)?
.ok_or(TxError::NotFound(p.node_id))?;
node.salience = p.new_salience;
db.put_node(node)?;
Ok(())
}
fn exec_bulk_import(&self, payload: &Value) -> TxResult<()> {
let nodes_val = payload.get("nodes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let edges_val = payload.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
for nv in nodes_val {
let node: Node = serde_json::from_value(nv)?;
db.put_node(node)?;
}
for ev in edges_val {
let edge: Edge = serde_json::from_value(ev)?;
db.put_edge(edge)?;
}
Ok(())
}
fn delete_edge_by_pair(&self, from_id: Uuid, to_id: Uuid) -> TxResult<()> {
// sled stores edges at "edges:from:{from}:{to}" — we remove both directions
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
// We can't directly call into storage here without re-exposing internals,
// so we use the public scan-and-check approach via get_edges_from
let edges = db.get_edges_from(from_id)?;
for edge in edges {
if edge.to_id == to_id {
// Re-insert a tombstone isn't directly supported — we use the
// internal sled key to delete. Since we don't have direct sled
// access through EngramDb's public API, we rely on the fact that
// put_edge with weight=0 effectively nullifies it, but for proper
// deletion we need access to the underlying store.
//
// We work around this by storing a zero-weight edge with weight=-1
// as a sentinel, or by exposing delete_edge. Since delete_node is
// already exposed, we add a helper. For now, we store the edge with
// weight 0 (marking it inactive) until a delete_edge API is added.
//
// TODO: add db.delete_edge() to engram-core public API.
// For now: overwrite with weight 0 to effectively disable it.
let mut tombstone = edge;
tombstone.weight = 0.0;
db.put_edge(tombstone)?;
break;
}
}
Ok(())
}
}
// ── Command builder helpers ───────────────────────────────────────────────────
/// Build a CreateNode command with eagerly-computed inverse.
pub fn build_create_node_cmd(
node: &Node,
idempotency_key: impl Into<String>,
peer_id: Option<Uuid>,
) -> Command {
let payload = serde_json::to_value(CreateNodePayload {
node_id: node.id,
node_type: node_type_to_str(&node.node_type).to_string(),
embedding: node.embedding.clone(),
content: node.content.clone(),
tier: node.tier.clone(),
importance: node.importance,
})
.unwrap_or(Value::Null);
// Inverse: delete the node we're about to create
let inverse = serde_json::to_value(DeleteNodePayload {
node_id: node.id,
snapshot: serde_json::to_value(node).unwrap_or(Value::Null),
})
.unwrap_or(Value::Null);
Command::new(
CommandType::CreateNode,
payload,
inverse,
idempotency_key,
None,
peer_id,
)
}
/// Build a DeleteNode command with eagerly-computed inverse (snapshot).
pub fn build_delete_node_cmd(
node: &Node,
idempotency_key: impl Into<String>,
peer_id: Option<Uuid>,
) -> Command {
let snapshot = serde_json::to_value(node).unwrap_or(Value::Null);
let payload = serde_json::to_value(DeleteNodePayload {
node_id: node.id,
snapshot: snapshot.clone(),
})
.unwrap_or(Value::Null);
// Inverse: re-create the node from snapshot
let inverse = serde_json::to_value(CreateNodePayload {
node_id: node.id,
node_type: node_type_to_str(&node.node_type).to_string(),
embedding: node.embedding.clone(),
content: node.content.clone(),
tier: node.tier.clone(),
importance: node.importance,
})
.unwrap_or(Value::Null);
Command::new(
CommandType::DeleteNode,
payload,
inverse,
idempotency_key,
None,
peer_id,
)
}
/// Build a CreateEdge command.
pub fn build_create_edge_cmd(
edge: &Edge,
idempotency_key: impl Into<String>,
peer_id: Option<Uuid>,
) -> Command {
let payload = serde_json::to_value(CreateEdgePayload {
edge_id: edge.id,
from_id: edge.from_id,
to_id: edge.to_id,
relation: edge.relation.clone(),
weight: edge.weight,
})
.unwrap_or(Value::Null);
// Inverse: delete the edge
let snapshot = serde_json::to_value(edge).unwrap_or(Value::Null);
let inverse = serde_json::to_value(DeleteEdgePayload {
edge_id: edge.id,
from_id: edge.from_id,
to_id: edge.to_id,
snapshot,
})
.unwrap_or(Value::Null);
Command::new(
CommandType::CreateEdge,
payload,
inverse,
idempotency_key,
None,
peer_id,
)
}
// ── Type string helpers ───────────────────────────────────────────────────────
fn parse_node_type(s: &str) -> TxResult<NodeType> {
match s {
"Memory" => Ok(NodeType::Memory),
"Concept" => Ok(NodeType::Concept),
"Event" => Ok(NodeType::Event),
"Entity" => Ok(NodeType::Entity),
"Process" => Ok(NodeType::Process),
"InternalState" => Ok(NodeType::InternalState),
other => Ok(NodeType::Custom(other.to_string())),
}
}
fn node_type_to_str(t: &NodeType) -> String {
match t {
NodeType::Memory => "Memory".to_string(),
NodeType::Concept => "Concept".to_string(),
NodeType::Event => "Event".to_string(),
NodeType::Entity => "Entity".to_string(),
NodeType::Process => "Process".to_string(),
NodeType::InternalState => "InternalState".to_string(),
NodeType::Custom(s) => s.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use engram_core::types::{MemoryTier, Node, NodeType};
use tempfile::TempDir;
fn make_engine() -> (TransactionEngine, TempDir, TempDir) {
let db_dir = TempDir::new().unwrap();
let log_dir = TempDir::new().unwrap();
let db = engram_core::EngramDb::open(db_dir.path()).unwrap();
let db = std::sync::Arc::new(std::sync::Mutex::new(db));
let log_db = sled::open(log_dir.path()).unwrap();
let engine = TransactionEngine::new(db, log_db, None);
(engine, db_dir, log_dir)
}
fn make_node() -> Node {
Node::new(
NodeType::Memory,
vec![1.0, 0.0],
b"test content".to_vec(),
MemoryTier::Semantic,
0.8,
)
}
#[test]
fn test_apply_create_node() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let cmd = build_create_node_cmd(&node, "create-test-1", None);
let result = engine.apply(cmd).unwrap();
assert_eq!(result.status, CommandStatus::Applied);
assert!(!result.was_idempotent);
// Node should now exist
let db = engine.db.lock().unwrap();
let found = db.get_node(node.id).unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().content, b"test content");
}
#[test]
fn test_idempotency() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let cmd1 = build_create_node_cmd(&node, "idem-key-42", None);
let cmd2 = build_create_node_cmd(&node, "idem-key-42", None);
let r1 = engine.apply(cmd1).unwrap();
let r2 = engine.apply(cmd2).unwrap();
assert!(!r1.was_idempotent);
assert!(r2.was_idempotent);
}
#[test]
fn test_rollback_delete_node() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let node_id = node.id;
// First apply create
let create_cmd = build_create_node_cmd(&node, "create-rb-1", None);
let result = engine.apply(create_cmd).unwrap();
// Roll back the creation — should delete the node
let rb = engine.rollback(result.command_id).unwrap();
assert_eq!(rb.status, CommandStatus::Applied);
let db = engine.db.lock().unwrap();
let found = db.get_node(node_id).unwrap();
assert!(found.is_none());
}
#[test]
fn test_rollback_of_rollback() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let node_id = node.id;
// Create the node
let create_cmd = build_create_node_cmd(&node, "create-rorb-1", None);
let create_result = engine.apply(create_cmd).unwrap();
// Roll back (delete)
let rb = engine.rollback(create_result.command_id).unwrap();
// Verify it's gone
{
let db = engine.db.lock().unwrap();
assert!(db.get_node(node_id).unwrap().is_none());
}
// Roll back the rollback (re-create)
let _rr = engine.rollback_rollback(rb.id).unwrap();
// Node should be back
let db = engine.db.lock().unwrap();
let found = db.get_node(node_id).unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().content, b"test content");
}
#[test]
fn test_history() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let cmd = build_create_node_cmd(&node, "hist-1", None);
engine.apply(cmd).unwrap();
let history = engine.history(0).unwrap();
assert!(!history.is_empty());
assert!(history.iter().any(|c| matches!(c.command_type, CommandType::CreateNode)));
}
#[test]
fn test_causal_chain() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let cmd = build_create_node_cmd(&node, "causal-1", None);
let result = engine.apply(cmd).unwrap();
// Roll back (creates a child command with causal_parent = create_cmd.id)
let rb = engine.rollback(result.command_id).unwrap();
let chain = engine.causal_chain(rb.id).unwrap();
// Chain should be [create_cmd, rollback_cmd]
assert_eq!(chain.len(), 2);
assert!(matches!(chain[0].command_type, CommandType::CreateNode));
assert!(matches!(chain[1].command_type, CommandType::Rollback(_)));
}
#[test]
fn test_create_edge_command() {
let (mut engine, _db_dir, _log_dir) = make_engine();
// Create two nodes first
let n1 = make_node();
let n2 = Node::new(
NodeType::Concept,
vec![0.0, 1.0],
b"concept".to_vec(),
MemoryTier::Semantic,
0.5,
);
engine.apply(build_create_node_cmd(&n1, "edge-n1", None)).unwrap();
engine.apply(build_create_node_cmd(&n2, "edge-n2", None)).unwrap();
// Create edge
let edge = Edge::new(n1.id, n2.id, "references", 0.7);
let edge_cmd = build_create_edge_cmd(&edge, "edge-create-1", None);
let result = engine.apply(edge_cmd).unwrap();
assert_eq!(result.status, CommandStatus::Applied);
// Verify edge exists
let db = engine.db.lock().unwrap();
let edges = db.get_edges_from(n1.id).unwrap();
assert!(!edges.is_empty());
}
}
-31
View File
@@ -1,31 +0,0 @@
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub enum TxError {
#[error("Command not found: {0}")]
NotFound(Uuid),
#[error("Command already applied (idempotency key: {0})")]
AlreadyApplied(String),
#[error("Cannot roll back a command in status {0:?}")]
InvalidStatus(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Storage error: {0}")]
Storage(#[from] sled::Error),
#[error("Engram error: {0}")]
Engram(#[from] engram_core::EngramError),
#[error("Invalid command: {0}")]
Invalid(String),
}
pub type TxResult<T> = Result<T, TxError>;
-29
View File
@@ -1,29 +0,0 @@
/// Engram Transaction Engine — Command pattern with rollback-of-rollback.
///
/// # The Central Insight
///
/// Every mutation to Engram is a Command — a first-class object with:
/// - The operation and its inverse (computed eagerly at command time)
/// - An idempotency key (same key = same command, applied once)
/// - A causal parent (which command caused this one)
/// - A timestamp and originating peer ID
///
/// Commands form a DAG of causality. You can roll back any command. You can
/// roll back a rollback (undo an undo — re-applying the original). The
/// command log is append-only: history is never rewritten.
///
/// # Rollback-of-Rollback
///
/// When you roll back command X, a new `Rollback(X)` command is created and
/// applied. Its `inverse_payload` is X's original `payload`. If you then roll
/// back the rollback, a new `Rollback(rollback_id)` is created, whose effect
/// is to re-apply X. This is a full undo/redo system with causal lineage.
pub mod command;
pub mod engine;
pub mod error;
pub mod log;
pub use command::{Command, CommandResult, CommandStatus, CommandType};
pub use engine::TransactionEngine;
pub use error::TxError;
pub use log::CommandLog;
-151
View File
@@ -1,151 +0,0 @@
/// CommandLog — append-only log of all commands, stored in sled.
///
/// Key schema:
/// cmd:{uuid} → JSON-encoded Command (JSON used because Command contains serde_json::Value)
/// idem:{key} → uuid bytes (idempotency index)
/// cmd_ts:{ms}:{uuid} → uuid bytes (time-ordered scan index)
use sled::Db;
use uuid::Uuid;
use crate::command::Command;
use crate::error::{TxError, TxResult};
pub struct CommandLog {
db: Db,
}
impl CommandLog {
pub fn open(db: Db) -> Self {
Self { db }
}
// ── Write ─────────────────────────────────────────────────────────────────
/// Append a command to the log. Overwrites if the UUID already exists
/// (used for status updates after application).
pub fn write(&self, cmd: &Command) -> TxResult<()> {
let key = cmd_key(cmd.id);
// Use JSON (not bincode) because Command contains serde_json::Value,
// which bincode cannot deserialize (DeserializeAnyNotSupported).
let val = serde_json::to_vec(cmd)?;
self.db.insert(key, val)?;
// Idempotency index: idem:{key} → uuid
let idem_key = idem_key(&cmd.idempotency_key);
self.db.insert(idem_key, cmd.id.as_bytes().to_vec())?;
// Time index: cmd_ts:{ms:016x}:{uuid} → uuid
let ts_key = ts_key(cmd.timestamp_ms, cmd.id);
self.db.insert(ts_key, cmd.id.as_bytes().to_vec())?;
Ok(())
}
// ── Read ──────────────────────────────────────────────────────────────────
pub fn get(&self, id: Uuid) -> TxResult<Option<Command>> {
match self.db.get(cmd_key(id))? {
Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
None => Ok(None),
}
}
pub fn require(&self, id: Uuid) -> TxResult<Command> {
self.get(id)?.ok_or(TxError::NotFound(id))
}
/// Check whether an idempotency key has already been applied.
/// Returns the command UUID if it exists.
pub fn check_idempotency(&self, key: &str) -> TxResult<Option<Uuid>> {
match self.db.get(idem_key(key))? {
Some(bytes) => {
let arr: [u8; 16] = bytes[..16]
.try_into()
.map_err(|_| TxError::Invalid("bad uuid bytes in idem index".into()))?;
Ok(Some(Uuid::from_bytes(arr)))
}
None => Ok(None),
}
}
/// Load all commands created at or after `since_ms`, ordered by timestamp.
pub fn since(&self, since_ms: i64) -> TxResult<Vec<Command>> {
let prefix = format!("cmd_ts:{:016x}:", since_ms);
let mut cmds = Vec::new();
for result in self.db.range(prefix.as_bytes()..) {
let (k, _v) = result?;
// Check the key starts with "cmd_ts:"
if !k.starts_with(b"cmd_ts:") {
break;
}
// Extract uuid from key: cmd_ts:{ms}:{uuid}
let key_str = std::str::from_utf8(&k)
.map_err(|e| TxError::Invalid(e.to_string()))?;
let parts: Vec<&str> = key_str.splitn(3, ':').collect();
if parts.len() < 3 {
continue;
}
// parts[1] = ms (hex), parts[2] = uuid
let ts_hex = parts[1];
let ts = i64::from_str_radix(ts_hex, 16)
.map_err(|e| TxError::Invalid(e.to_string()))?;
if ts < since_ms {
continue;
}
let id: Uuid = parts[2]
.parse()
.map_err(|e: uuid::Error| TxError::Invalid(e.to_string()))?;
if let Some(cmd) = self.get(id)? {
cmds.push(cmd);
}
}
Ok(cmds)
}
/// Load all commands in the store.
pub fn all(&self) -> TxResult<Vec<Command>> {
self.since(0)
}
/// Collect the causal chain leading to a given command (inclusive).
/// Walks `causal_parent` links back to the root.
pub fn causal_chain(&self, id: Uuid) -> TxResult<Vec<Command>> {
let mut chain = Vec::new();
let mut current_id = Some(id);
let mut visited = std::collections::HashSet::new();
while let Some(cid) = current_id {
if visited.contains(&cid) {
break; // cycle guard
}
visited.insert(cid);
match self.get(cid)? {
Some(cmd) => {
current_id = cmd.causal_parent;
chain.push(cmd);
}
None => break,
}
}
// Return root-first (reverse of walk order)
chain.reverse();
Ok(chain)
}
}
// ── Key constructors ──────────────────────────────────────────────────────────
fn cmd_key(id: Uuid) -> Vec<u8> {
format!("cmd:{}", id).into_bytes()
}
fn idem_key(key: &str) -> Vec<u8> {
format!("idem:{}", key).into_bytes()
}
fn ts_key(ts: i64, id: Uuid) -> Vec<u8> {
// Zero-padded hex timestamp for lexicographic ordering
format!("cmd_ts:{:016x}:{}", ts, id).into_bytes()
}
-265
View File
@@ -1,265 +0,0 @@
/// Basic engram demonstration.
///
/// This example builds a small memory graph, runs spreading activation,
/// performs a vector search, shows salience decay, and demonstrates
/// the consolidation engine promoting Episodic nodes to Semantic.
///
/// The nodes represent a tiny knowledge graph about the spreading activation
/// model itself — somewhat recursive, intentionally.
use engram_core::{
ActivatedNode, ConsolidationConfig, Edge, EngramDb, MemoryTier, Node, NodeType,
EDGE_ACTIVATES, EDGE_CAUSES, EDGE_EXEMPLIFIES, EDGE_REFERENCES, EDGE_TEMPORALLY_PRECEDES,
};
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// ── 1. Open database ──────────────────────────────────────────────────────
let db_path = Path::new("/tmp/engram-test");
// Clean up any previous run so we start fresh
if db_path.exists() {
std::fs::remove_dir_all(db_path)?;
}
let db = EngramDb::open(db_path)?;
println!("Engram opened at {}\n", db_path.display());
// ── 2. Insert nodes ───────────────────────────────────────────────────────
//
// We use 8-dimensional embeddings. In production these would come from a
// language model. Here they're hand-crafted to illustrate semantic proximity:
// the "activation" and "memory" cluster at [high, high, low, ...]
// while "forgetting" and "decay" cluster at [low, low, high, ...]
let node0 = Node::new(
NodeType::Concept,
vec![0.9, 0.8, 0.1, 0.2, 0.7, 0.3, 0.1, 0.4],
b"Spreading activation: memory retrieval as propagation through weighted graph".to_vec(),
MemoryTier::Semantic,
0.95,
);
let node1 = Node::new(
NodeType::Concept,
vec![0.8, 0.9, 0.2, 0.1, 0.6, 0.4, 0.2, 0.3],
b"Long-term potentiation: synaptic strengthening through co-activation".to_vec(),
MemoryTier::Semantic,
0.90,
);
let node2 = Node::new(
NodeType::Memory,
vec![0.7, 0.6, 0.3, 0.4, 0.8, 0.2, 0.1, 0.5],
b"Hebbian learning: neurons that fire together wire together".to_vec(),
MemoryTier::Episodic,
0.85,
);
let node3 = Node::new(
NodeType::Concept,
vec![0.6, 0.7, 0.4, 0.3, 0.9, 0.1, 0.2, 0.6],
b"Associative memory: retrieval by pattern completion, not address lookup".to_vec(),
MemoryTier::Semantic,
0.88,
);
let node4 = Node::new(
NodeType::Process,
vec![0.2, 0.3, 0.8, 0.9, 0.1, 0.7, 0.6, 0.2],
b"Salience decay: forgetting as adaptive pruning, not failure".to_vec(),
MemoryTier::Procedural,
0.75,
);
let node5 = Node::new(
NodeType::Event,
vec![0.3, 0.2, 0.7, 0.8, 0.2, 0.6, 0.7, 0.1],
b"Memory consolidation during sleep: hippocampal replay to neocortex".to_vec(),
MemoryTier::Episodic,
0.70,
);
let id0 = db.put_node(node0)?;
let id1 = db.put_node(node1)?;
let id2 = db.put_node(node2)?;
let id3 = db.put_node(node3)?;
let id4 = db.put_node(node4)?;
let id5 = db.put_node(node5)?;
println!("Inserted {} nodes", db.node_count()?);
println!(" [0] Spreading activation concept (seed)");
println!(" [1] Long-term potentiation");
println!(" [2] Hebbian learning");
println!(" [3] Associative memory");
println!(" [4] Salience decay (procedural)");
println!(" [5] Memory consolidation (episodic)");
println!();
// ── 3. Create edges ───────────────────────────────────────────────────────
//
// Edge weights model associative strength. Strong weights (0.9) mean these
// concepts reliably co-activate. Weaker weights mean looser association.
// Spreading activation Causes long-term potentiation (strong causal link)
db.put_edge(Edge::new(id0, id1, EDGE_CAUSES, 0.9))?;
// LTP is Referenced by Hebbian learning
db.put_edge(Edge::new(id1, id2, EDGE_REFERENCES, 0.85))?;
// Spreading activation Activates associative memory
db.put_edge(Edge::new(id0, id3, EDGE_ACTIVATES, 0.88))?;
// Hebbian learning Exemplifies associative memory
db.put_edge(Edge::new(id2, id3, EDGE_EXEMPLIFIES, 0.80))?;
// Salience decay TemporallyPrecedes naive forgetting
db.put_edge(Edge::new(id4, id5, EDGE_TEMPORALLY_PRECEDES, 0.65))?;
// LTP TemporallyPrecedes memory consolidation
db.put_edge(Edge::new(id1, id5, EDGE_TEMPORALLY_PRECEDES, 0.72))?;
println!("Inserted {} edges", db.edge_count()?);
println!(" node0 --[Causes]--> node1");
println!(" node1 --[References]--> node2");
println!(" node0 --[Activates]--> node3");
println!(" node2 --[Exemplifies]--> node3");
println!(" node4 --[TemporallyPrecedes]--> node5");
println!(" node1 --[TemporallyPrecedes]--> node5");
println!();
// ── 4. Spreading activation ───────────────────────────────────────────────
//
// Seed: node0 (spreading activation concept)
// Query embedding: similar to node3 (associative memory) — high in dims 4,5
// This should surface node3 strongly and pull in node2 via the Hebbian path.
let query_embedding = vec![0.65, 0.72, 0.35, 0.28, 0.92, 0.12, 0.18, 0.58];
println!("=== Spreading Activation ===");
println!("Seed: node0 (spreading activation concept)");
println!("Query: similar to node3 (associative memory)");
println!("Max depth: 3 hops, returning top 10");
println!();
let activated: Vec<ActivatedNode> = db.activate(&[id0], &query_embedding, 3, 10)?;
if activated.is_empty() {
println!(" (no nodes activated — check salience values)");
} else {
for a in &activated {
let content = String::from_utf8_lossy(&a.node.content);
// Truncate content for display
let display = if content.len() > 60 {
format!("{}...", &content[..60])
} else {
content.to_string()
};
println!(
" strength={:.4} hops={} salience={:.4} tier={:?}",
a.activation_strength, a.hops, a.node.salience, a.node.tier
);
println!(" \"{}\"", display);
}
}
println!();
// ── 5. Vector similarity search ───────────────────────────────────────────
//
// Pure cosine scan: no graph structure, just embedding proximity.
// Should return nodes with embeddings most similar to the query.
println!("=== Vector Similarity Search (top 3) ===");
println!("Query: associative memory embedding");
println!();
let scored = db.search_embedding(&query_embedding, 3)?;
for s in &scored {
let content = String::from_utf8_lossy(&s.node.content);
let display = if content.len() > 60 {
format!("{}...", &content[..60])
} else {
content.to_string()
};
println!(
" cosine={:.4} tier={:?} type={:?}",
s.score, s.node.tier, s.node.node_type
);
println!(" \"{}\"", display);
}
println!();
// ── 6. Node and edge counts ───────────────────────────────────────────────
println!("=== Database Statistics ===");
println!(" Nodes: {}", db.node_count()?);
println!(" Edges: {}", db.edge_count()?);
println!();
// ── 7. Salience decay ─────────────────────────────────────────────────────
//
// Apply 5% decay to all node saliences. This simulates the passage of time.
// Nodes that haven't been activated recently become less salient,
// modeling the adaptive nature of forgetting.
println!("=== Salience Decay (factor=0.95) ===");
let updated = db.decay(0.95)?;
println!(" Updated {} nodes", updated);
println!();
// Show salience before/after for a sample node
if let Some(n) = db.get_node(id0)? {
println!(
" node0 salience after decay: {:.6}",
n.salience
);
}
// Show traversal from node0
println!();
println!("=== Graph Traversal from node0 (depth=2) ===");
let reachable = db.traverse(id0, None, 2)?;
println!(" Reachable nodes (any relation, max 2 hops): {}", reachable.len());
for n in &reachable {
let content = String::from_utf8_lossy(&n.content);
let display = if content.len() > 55 {
format!("{}...", &content[..55])
} else {
content.to_string()
};
println!(" [{:?}] \"{}\"", n.tier, display);
}
println!();
// ── 8. Consolidation ──────────────────────────────────────────────────────
//
// Touch node2 (Hebbian learning — Episodic) several times to simulate it
// being frequently recalled, then run consolidation to promote it.
println!("=== Memory Consolidation ===");
// Simulate repeated activation: touch node2 six times so it crosses the
// default threshold of 5 activations.
for _ in 0..6 {
db.touch(id2)?;
}
// Confirm the node's activation count has increased.
if let Some(n) = db.get_node(id2)? {
println!(" node2 (Episodic) activation_count before consolidation: {}", n.activation_count);
println!(" node2 tier before consolidation: {:?}", n.tier);
}
let config = ConsolidationConfig {
episodic_to_semantic_threshold: 5,
salience_floor: 0.0, // no salience floor — accept any salient node
max_promotions_per_run: 10,
decay_factor: 0.98,
};
let report = db.consolidate(&config)?;
println!(" Promoted {} Episodic → Semantic", report.promoted);
println!(" Decayed {} nodes", report.decayed);
// Confirm node2 has been promoted.
if let Some(n) = db.get_node(id2)? {
println!(" node2 tier after consolidation: {:?}", n.tier);
}
println!();
println!("Done. Engram v0.1.1.");
Ok(())
}
-96
View File
@@ -1,96 +0,0 @@
/// Migration example — shows the migrate_from_neuron API.
///
/// This example creates a tiny in-memory SQLite database that mimics the
/// Neuron schema, then migrates it into an Engram sled store and prints
/// the resulting node count.
///
/// In production:
/// use engram_core::migration::{migrate_from_neuron, MigrationConfig};
/// let config = MigrationConfig::new(
/// PathBuf::from(shellexpand::tilde("~/.neuron/neuron.db").as_ref()),
/// PathBuf::from(shellexpand::tilde("~/.engram/neuron").as_ref()),
/// );
/// let report = migrate_from_neuron(&config)?;
#[cfg(feature = "migration")]
fn main() -> Result<(), Box<dyn std::error::Error>> {
use engram_core::migration::{migrate_from_neuron, MigrationConfig};
use rusqlite::Connection;
use std::path::PathBuf;
// 1. Create a temp Neuron-like SQLite database.
let tmp = tempfile::tempdir()?;
let sqlite_path = tmp.path().join("neuron.db");
let engram_path = tmp.path().join("engram");
let conn = Connection::open(&sqlite_path)?;
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)
);",
)?;
// Insert sample data.
for i in 0..5 {
conn.execute(
"INSERT INTO memory_nodes (id, content, importance, created_at, updated_at)
VALUES (?1, ?2, 'normal', 1000, 1000)",
rusqlite::params![format!("mem-{i}"), format!("Memory node {i}")],
)?;
}
for i in 0..3 {
conn.execute(
"INSERT INTO knowledge_entries (id, title, content, created_at, updated_at)
VALUES (?1, ?2, ?3, 2000, 2000)",
rusqlite::params![
format!("kn-{i}"),
format!("Concept {i}"),
format!("Body of knowledge entry {i}"),
],
)?;
}
drop(conn);
// 2. Run the migration.
println!("Running migration...");
let config = MigrationConfig {
sqlite_path,
engram_path,
embedding_dim: 64,
};
let report = migrate_from_neuron(&config)?;
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!(" Errors: {:?}", report.errors);
}
// 3. Open the result and check counts.
let db = engram_core::EngramDb::open(&config.engram_path)?;
println!();
println!("Engram node count: {}", db.node_count()?);
Ok(())
}
#[cfg(not(feature = "migration"))]
fn main() {
eprintln!("This example requires the 'migration' feature.");
eprintln!("Run with: cargo run --example migrate --features migration");
}
-25
View File
@@ -1,25 +0,0 @@
# Go Bindings (planned v0.2)
Engram-core will be exposed to Go via CGo and the `engram-ffi` shared library.
```go
// #cgo LDFLAGS: -L../../target/release -lengram_ffi
// #include "engram.h"
import "C"
import "unsafe"
func Open(path string) *EngramDb {
cpath := C.CString(path)
defer C.free(unsafe.Pointer(cpath))
handle := C.engram_open(cpath)
if handle == nil {
return nil
}
return &EngramDb{handle: handle}
}
```
## Status
Stub only. The header file (`engram.h`) will be generated by `cbindgen` in v0.2.
Full idiomatic Go wrapper with context support and error returns planned.
-197
View File
@@ -1,197 +0,0 @@
// Package engram provides Go bindings for the Engram memory substrate via CGo.
//
// Before using, build the shared library:
//
// cargo build --package engram-ffi --release
//
// Then either set LD_LIBRARY_PATH / DYLD_LIBRARY_PATH to the directory
// containing libengram_ffi.so/.dylib, or copy the library to a standard path.
//
// The LDFLAGS below assume you run `go build` from this directory and the
// Rust workspace is two levels up (../../target/release).
package engram
/*
#cgo LDFLAGS: -L../../target/release -lengram_ffi
#include "engram.h"
#include <stdlib.h>
*/
import "C"
import (
"encoding/json"
"errors"
"fmt"
"unsafe"
)
// ── Types ─────────────────────────────────────────────────────────────────────
// DB is a handle to an open Engram database.
type DB struct {
ptr *C.EngramHandle
}
// Node mirrors the Rust Node struct.
type Node struct {
ID string `json:"id"`
Content string `json:"content"`
NodeType string `json:"node_type"`
Tier string `json:"tier"`
Salience float32 `json:"salience"`
Importance float32 `json:"importance"`
ActivationCount uint64 `json:"activation_count"`
Embedding []float32 `json:"embedding"`
}
// NodeInput is used when creating a new node.
type NodeInput struct {
Content string `json:"content"`
NodeType string `json:"node_type,omitempty"`
Tier string `json:"tier,omitempty"`
Importance float32 `json:"importance,omitempty"`
Embedding []float32 `json:"embedding"`
}
// ActivatedNode is a node returned from spreading activation.
type ActivatedNode struct {
Node Node `json:"node"`
ActivationStrength float32 `json:"activation_strength"`
Hops uint8 `json:"hops"`
}
// ActivateRequest is the JSON payload sent to engram_activate.
type activateRequest struct {
Seeds []string `json:"seeds"`
QueryEmbedding []float32 `json:"query_embedding"`
MaxDepth uint8 `json:"max_depth"`
Limit int `json:"limit"`
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
// Open opens or creates an Engram database at the given path.
func Open(path string) (*DB, error) {
cpath := C.CString(path)
defer C.free(unsafe.Pointer(cpath))
ptr := C.engram_open(cpath)
if ptr == nil {
return nil, fmt.Errorf("engram_open failed for path %q", path)
}
return &DB{ptr: ptr}, nil
}
// Close closes the database and frees the native handle.
func (db *DB) Close() {
if db.ptr != nil {
C.engram_close(db.ptr)
db.ptr = nil
}
}
// ── Statistics ────────────────────────────────────────────────────────────────
// NodeCount returns the total number of nodes.
func (db *DB) NodeCount() (uint64, error) {
n := C.engram_node_count(db.ptr)
if n < 0 {
return 0, errors.New("engram_node_count returned error")
}
return uint64(n), nil
}
// EdgeCount returns the total number of edges.
func (db *DB) EdgeCount() (uint64, error) {
n := C.engram_edge_count(db.ptr)
if n < 0 {
return 0, errors.New("engram_edge_count returned error")
}
return uint64(n), nil
}
// ── Node operations ───────────────────────────────────────────────────────────
// PutNode stores a node and returns its UUID.
func (db *DB) PutNode(node *NodeInput) (string, error) {
jsonBytes, err := json.Marshal(node)
if err != nil {
return "", fmt.Errorf("marshal node: %w", err)
}
cjson := C.CString(string(jsonBytes))
defer C.free(unsafe.Pointer(cjson))
result := C.engram_put_node(db.ptr, cjson)
if result == nil {
return "", errors.New("engram_put_node returned null")
}
defer C.engram_free_string(result)
return C.GoString(result), nil
}
// GetNode retrieves a node by UUID. Returns nil if not found.
func (db *DB) GetNode(id string) (*Node, error) {
cid := C.CString(id)
defer C.free(unsafe.Pointer(cid))
result := C.engram_get_node(db.ptr, cid)
if result == nil {
return nil, nil
}
defer C.engram_free_string(result)
var node Node
if err := json.Unmarshal([]byte(C.GoString(result)), &node); err != nil {
return nil, fmt.Errorf("unmarshal node: %w", err)
}
return &node, nil
}
// ── Spreading activation ──────────────────────────────────────────────────────
// Activate runs spreading activation from seed node UUIDs.
func (db *DB) Activate(
seeds []string,
queryEmbedding []float32,
maxDepth uint8,
limit int,
) ([]*ActivatedNode, error) {
req := activateRequest{
Seeds: seeds,
QueryEmbedding: queryEmbedding,
MaxDepth: maxDepth,
Limit: limit,
}
jsonBytes, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal activate request: %w", err)
}
cjson := C.CString(string(jsonBytes))
defer C.free(unsafe.Pointer(cjson))
result := C.engram_activate(db.ptr, cjson)
if result == nil {
return nil, errors.New("engram_activate returned null")
}
defer C.engram_free_string(result)
var nodes []*ActivatedNode
if err := json.Unmarshal([]byte(C.GoString(result)), &nodes); err != nil {
return nil, fmt.Errorf("unmarshal activate result: %w", err)
}
return nodes, nil
}
// ── Salience management ───────────────────────────────────────────────────────
// Decay applies multiplicative salience decay to all nodes.
// factor should be in (0.0, 1.0). Returns the number of nodes updated.
func (db *DB) Decay(factor float32) (uint64, error) {
n := C.engram_decay(db.ptr, C.float(factor))
if n < 0 {
return 0, errors.New("engram_decay returned error")
}
return uint64(n), nil
}
-70
View File
@@ -1,70 +0,0 @@
/**
* engram.h — C header for the engram FFI.
*
* Generated from crates/engram-ffi/src/lib.rs.
* To regenerate: cargo install cbindgen && cbindgen --crate engram-ffi -o bindings/go/engram.h
*
* Build the shared library:
* cargo build --package engram-ffi --release
* # macOS: target/release/libengram_ffi.dylib
* # Linux: target/release/libengram_ffi.so
*/
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/** Opaque handle to an open EngramDb. Obtained via engram_open. */
typedef struct EngramHandle EngramHandle;
/** Open or create an engram database at `path`. Returns null on error. */
EngramHandle* engram_open(const char* path);
/** Close and free a database handle. The pointer must not be used afterwards. */
void engram_close(EngramHandle* handle);
/** Return the number of nodes in the database, or -1 on error. */
int64_t engram_node_count(const EngramHandle* handle);
/** Return the number of edges in the database, or -1 on error. */
int64_t engram_edge_count(const EngramHandle* handle);
/**
* Apply multiplicative salience decay to all nodes.
* `factor` should be in (0.0, 1.0). Returns nodes updated, or -1 on error.
*/
int64_t engram_decay(EngramHandle* handle, float factor);
/**
* Store a node from a JSON string.
* JSON: { "content": "...", "node_type": "Memory", "tier": "Episodic",
* "importance": 0.8, "embedding": [f32, ...] }
* Returns a heap-allocated UUID string on success, null on error.
* Free with engram_free_string.
*/
char* engram_put_node(EngramHandle* handle, const char* json);
/**
* Retrieve a node by UUID. Returns a heap-allocated JSON string, or null.
* Free with engram_free_string.
*/
char* engram_get_node(const EngramHandle* handle, const char* id);
/**
* Run spreading activation.
* `req_json`: { "seeds": ["uuid", ...], "query_embedding": [f32, ...],
* "max_depth": 3, "limit": 10 }
* Returns a heap-allocated JSON array, or null on error.
* Free with engram_free_string.
*/
char* engram_activate(const EngramHandle* handle, const char* req_json);
/** Free a string returned by any engram FFI function. */
void engram_free_string(char* s);
#ifdef __cplusplus
} /* extern "C" */
#endif
-130
View File
@@ -1,130 +0,0 @@
// Package engram basic test.
//
// NOTE: This test requires the FFI shared library to be compiled first:
//
// cargo build --package engram-ffi --release
//
// Then run:
//
// DYLD_LIBRARY_PATH=../../target/release go test ./...
// # or on Linux:
// LD_LIBRARY_PATH=../../target/release go test ./...
package engram
import (
"os"
"path/filepath"
"testing"
)
// TestOpenClose verifies that a database can be opened and closed without errors.
func TestOpenClose(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "test-engram"))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer db.Close()
n, err := db.NodeCount()
if err != nil {
t.Fatalf("NodeCount: %v", err)
}
if n != 0 {
t.Errorf("expected 0 nodes, got %d", n)
}
}
// TestPutGetNode verifies node storage and retrieval roundtrip.
func TestPutGetNode(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "test-engram"))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer db.Close()
input := &NodeInput{
Content: "Spreading activation is the core retrieval mechanism",
NodeType: "Concept",
Tier: "Semantic",
Importance: 0.9,
Embedding: []float32{0.1, 0.2, 0.3, 0.4},
}
id, err := db.PutNode(input)
if err != nil {
t.Fatalf("PutNode: %v", err)
}
if id == "" {
t.Fatal("expected non-empty UUID")
}
node, err := db.GetNode(id)
if err != nil {
t.Fatalf("GetNode: %v", err)
}
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Content != input.Content {
t.Errorf("content mismatch: got %q, want %q", node.Content, input.Content)
}
}
// TestNodeCount verifies node count increments.
func TestNodeCount(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "test-engram"))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer db.Close()
for i := 0; i < 3; i++ {
_, err := db.PutNode(&NodeInput{
Content: "node content",
Embedding: []float32{float32(i), 0.0, 0.0},
})
if err != nil {
t.Fatalf("PutNode[%d]: %v", i, err)
}
}
n, err := db.NodeCount()
if err != nil {
t.Fatalf("NodeCount: %v", err)
}
if n != 3 {
t.Errorf("expected 3 nodes, got %d", n)
}
}
// TestDecay verifies salience decay runs without error.
func TestDecay(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "test-engram"))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer db.Close()
_, err = db.PutNode(&NodeInput{
Content: "test node",
Embedding: []float32{1.0, 0.0},
})
if err != nil {
t.Fatalf("PutNode: %v", err)
}
updated, err := db.Decay(0.95)
if err != nil {
t.Fatalf("Decay: %v", err)
}
if updated == 0 {
t.Error("expected at least one node to be decayed")
}
}
// ensure test file exists (compile guard)
var _ = os.DevNull
-3
View File
@@ -1,3 +0,0 @@
module github.com/neuron-technologies/engram/bindings/go
go 1.21
-21
View File
@@ -1,21 +0,0 @@
# Kotlin / JVM Bindings (planned v0.2)
Engram-core will be exposed to Kotlin/JVM via the C FFI layer in `engram-ffi`.
## Approach
Use JNA (Java Native Access) or JNI with the compiled `libengram_ffi` shared library:
```
cargo build --release -p engram-ffi
# produces: target/release/libengram_ffi.dylib (macOS) / libengram_ffi.so (Linux)
```
The header file will be generated by `cbindgen` from `engram-ffi/src/lib.rs`.
## Status
Stub only. FFI functions exposed: `engram_open`, `engram_close`, `engram_node_count`,
`engram_edge_count`, `engram_decay`, `engram_free_string`.
Full Kotlin idiomatic wrapper (data classes, coroutine-friendly suspend functions) planned for v0.2.
-33
View File
@@ -1,33 +0,0 @@
plugins {
kotlin("jvm") version "1.9.23"
}
group = "ai.neuron"
version = "0.1.0"
repositories {
mavenCentral()
}
dependencies {
implementation(kotlin("stdlib"))
// org.json is available on Android; for JVM use the standalone artifact.
implementation("org.json:json:20240303")
testImplementation(kotlin("test"))
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}
tasks.test {
useJUnitPlatform()
// Point to the compiled native library.
// Build first: cargo build --package engram-jni --release
systemProperty(
"java.library.path",
"${rootProject.projectDir}/../../target/release"
)
}
kotlin {
jvmToolchain(17)
}
-1
View File
@@ -1 +0,0 @@
rootProject.name = "engram-kotlin"
@@ -1,13 +0,0 @@
package ai.neuron.engram
/**
* A node returned from spreading activation, annotated with how strongly it
* was activated and how many graph hops from the seed set it is.
*/
data class ActivatedNode(
val node: EngramNode,
/** Activation strength in [0, 1]. Higher = more relevant. */
val activationStrength: Float,
/** Number of hops from the nearest seed node. */
val hops: Int,
)
@@ -1,153 +0,0 @@
package ai.neuron.engram
import org.json.JSONArray
import org.json.JSONObject
/**
* JNI wrapper around the native Engram database.
*
* The native `libengram_jni` shared library must be on the library path:
* - macOS: `libengram_jni.dylib` in a directory on `java.library.path`
* - Linux: `libengram_jni.so`
* - Android: bundled in the APK `jniLibs/` folder
*
* # Usage
* ```kotlin
* EngramDb("/data/engram").use { db ->
* val id = db.putNode(NodeInput("Hello, Engram", NodeType.Memory))
* val node = db.getNode(id)
* println(node?.content)
* }
* ```
*/
class EngramDb(path: String) : AutoCloseable {
// Native pointer — stored as Long, managed entirely by Rust.
private val handle: Long = open(path).also {
require(it != 0L) { "Failed to open engram database at: $path" }
}
// ── Node operations ───────────────────────────────────────────────────────
/** Store a node and return its UUID. */
fun putNode(node: NodeInput): String {
val json = JSONObject().apply {
put("content", node.content)
put("node_type", node.nodeType.name)
put("tier", node.tier.name)
put("importance", node.importance)
put("embedding", JSONArray(node.embedding.toTypedArray()))
}.toString()
return putNode(handle, json) ?: error("putNode returned null")
}
/** Retrieve a node by UUID. Returns null if not found. */
fun getNode(id: String): EngramNode? {
val json = getNode(handle, id) ?: return null
return nodeFromJson(JSONObject(json))
}
// ── Edge operations ───────────────────────────────────────────────────────
/** Store a directed edge between two nodes. */
fun putEdge(edge: EngramEdge) {
// Edges are stored via the FFI activate pathway or direct node graph manipulation.
// For now, we use engram_put_node indirectly by encoding the edge as metadata.
// TODO: add engram_put_edge to the FFI surface in v0.1.2
}
// ── Vector search ─────────────────────────────────────────────────────────
/** Find the `limit` most similar nodes by embedding vector. */
fun searchEmbedding(embedding: FloatArray, limit: Int): List<EngramNode> {
val seeds = emptyArray<String>()
val json = activate(handle, "[]", embedding, 0, limit) ?: return emptyList()
return activatedNodesFromJson(json).map { it.node }
}
// ── Spreading activation ──────────────────────────────────────────────────
/** Run spreading activation from seed UUIDs. */
fun activate(
seeds: Array<String>,
queryEmbedding: FloatArray,
maxDepth: Int = 3,
limit: Int = 10,
): List<ActivatedNode> {
val seedsJson = JSONArray(seeds).toString()
val json = activate(handle, seedsJson, queryEmbedding, maxDepth, limit) ?: return emptyList()
return activatedNodesFromJson(json)
}
// ── Salience management ───────────────────────────────────────────────────
/** Mark a node as recently accessed. */
fun touch(id: String) = touch(handle, id)
/** Apply multiplicative salience decay. Returns nodes updated. */
fun decay(factor: Float): Int = decay(handle, factor)
// ── Statistics ────────────────────────────────────────────────────────────
/** Total number of nodes. */
fun nodeCount(): Long = nodeCount(handle)
/** Total number of edges. */
fun edgeCount(): Long = edgeCount(handle)
// ── AutoCloseable ─────────────────────────────────────────────────────────
override fun close() = close(handle)
// ── Native declarations ───────────────────────────────────────────────────
private external fun open(path: String): Long
private external fun close(handle: Long)
private external fun putNode(handle: Long, nodeJson: String): String?
private external fun getNode(handle: Long, id: String): String?
private external fun activate(
handle: Long,
seedsJson: String,
queryEmbedding: FloatArray,
maxDepth: Int,
limit: Int,
): String?
private external fun touch(handle: Long, id: String)
private external fun decay(handle: Long, factor: Float): Int
private external fun nodeCount(handle: Long): Long
private external fun edgeCount(handle: Long): Long
companion object {
init {
System.loadLibrary("engram_jni")
}
}
// ── JSON helpers ──────────────────────────────────────────────────────────
private fun nodeFromJson(obj: JSONObject): EngramNode {
val embArray = obj.getJSONArray("embedding")
val embedding = FloatArray(embArray.length()) { embArray.getDouble(it).toFloat() }
return EngramNode(
id = obj.getString("id"),
content = obj.getString("content"),
nodeType = NodeType.valueOf(obj.getString("node_type")),
tier = MemoryTier.valueOf(obj.getString("tier")),
salience = obj.getDouble("salience").toFloat(),
importance = obj.getDouble("importance").toFloat(),
activationCount = obj.getLong("activation_count"),
embedding = embedding,
)
}
private fun activatedNodesFromJson(json: String): List<ActivatedNode> {
val arr = JSONArray(json)
return (0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
ActivatedNode(
node = nodeFromJson(obj.getJSONObject("node")),
activationStrength = obj.getDouble("activation_strength").toFloat(),
hops = obj.getInt("hops"),
)
}
}
}
@@ -1,14 +0,0 @@
package ai.neuron.engram
/**
* A directed, typed edge between two nodes.
*
* Mirrors the Rust `Edge` struct from `engram-core`.
*/
data class EngramEdge(
val id: String,
val fromId: String,
val toId: String,
val relation: RelationType,
val weight: Float,
)
@@ -1,38 +0,0 @@
package ai.neuron.engram
/**
* A node in the Engram memory graph.
*
* Mirrors the Rust `Node` struct from `engram-core`.
*/
data class EngramNode(
val id: String,
val content: String,
val nodeType: NodeType,
val tier: MemoryTier,
val salience: Float,
val importance: Float,
val activationCount: Long,
val embedding: FloatArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is EngramNode) return false
return id == other.id
}
override fun hashCode(): Int = id.hashCode()
}
/**
* Input type for creating a new node.
* Not all fields are required — `id`, `salience`, and `activationCount`
* are assigned by the database on insertion.
*/
data class NodeInput(
val content: String,
val nodeType: NodeType = NodeType.Memory,
val tier: MemoryTier = MemoryTier.Episodic,
val importance: Float = 0.5f,
val embedding: FloatArray = FloatArray(0),
)
@@ -1,31 +0,0 @@
package ai.neuron.engram
/** The functional role of a node in the memory graph. */
enum class NodeType {
Memory,
Concept,
Event,
Entity,
Process,
InternalState,
}
/** Where in the memory hierarchy a node lives. */
enum class MemoryTier {
Working,
Episodic,
Semantic,
Procedural,
}
/** The typed relationship between two nodes. */
enum class RelationType {
Supersedes,
Causes,
Contains,
References,
Contradicts,
Exemplifies,
Activates,
TemporallyPrecedes,
}
-24
View File
@@ -1,24 +0,0 @@
[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
@@ -1,21 +0,0 @@
# TypeScript Bindings (planned v0.2)
Two paths for TypeScript/Node.js:
## Option A — WASM (browser + Node)
```
cargo build --target wasm32-unknown-unknown --release -p engram-core
wasm-bindgen target/wasm32-unknown-unknown/release/engram_core.wasm --out-dir pkg/
```
Requires `wasm-bindgen` annotations on public API. Browser-compatible, no native deps.
## Option B — Node native addon (server-side)
Use `napi-rs` to generate a Node.js native addon from `engram-core`. Faster than WASM for
server-side agents, but requires a native build step per platform.
## Status
Stub only. WASM target is the preferred path for v0.2 given the local-first, embedded philosophy.
-21
View File
@@ -1,21 +0,0 @@
{
"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
@@ -1,125 +0,0 @@
/**
* 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
@@ -1,311 +0,0 @@
/// 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
@@ -1,49 +0,0 @@
/**
* 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
@@ -1,18 +0,0 @@
{
"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"]
}
-4261
View File
File diff suppressed because it is too large Load Diff