Archived
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:
Generated
-3185
File diff suppressed because it is too large
Load Diff
-37
@@ -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" }
|
||||
@@ -1,4 +0,0 @@
|
||||
segment_size: 524288
|
||||
use_compression: false
|
||||
version: 0.34
|
||||
vQ�
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,4 +0,0 @@
|
||||
segment_size: 524288
|
||||
use_compression: false
|
||||
version: 0.34
|
||||
vQ�
|
||||
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
@@ -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 2–4)
|
||||
/// * `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)
|
||||
}
|
||||
@@ -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.0–1.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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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:")
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.0–1.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.0–5.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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.0–1.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.0–1.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.0–1.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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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, "[]");
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>>>,
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.0–1.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.5–0.8)
|
||||
IndirectSupport,
|
||||
/// Refutes via an inference chain (cosine sim 0.5–0.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.0–1.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.0–1.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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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>>,
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
module github.com/neuron-technologies/engram/bindings/go
|
||||
|
||||
go 1.21
|
||||
@@ -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.
|
||||
@@ -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 +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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 2–4)
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user