rename crates/ to engrams/, bindings/ to receptors/

- crates/ → engrams/ (Rust engrams live here)
- bindings/ → receptors/ (cross-language access points into the graph)
- Cargo.toml workspace paths updated
This commit is contained in:
Will Anderson
2026-04-29 03:27:33 -05:00
parent 37c87da9a6
commit d1ec384b27
89 changed files with 2114 additions and 452 deletions
Generated
+1
View File
@@ -526,6 +526,7 @@ dependencies = [
"instant-distance",
"rusqlite",
"serde",
"serde_json",
"sled",
"tempfile",
"thiserror 1.0.69",
+12 -12
View File
@@ -1,17 +1,17 @@
[workspace]
resolver = "2"
members = [
"crates/engram-core",
"crates/engram-ffi",
"crates/engram-jni",
"crates/engram-migrate",
"crates/engram-sync",
"crates/engram-server",
"crates/engram-projection",
"crates/engram-tx",
"crates/engram-crypto",
"crates/engram-reasoning",
# engram-wasm is in bindings/ and compiled separately via wasm-pack
"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)
]
@@ -34,4 +34,4 @@ edition = "2021"
migration = ["engram-core/migration"]
[dependencies]
engram-core = { path = "crates/engram-core" }
engram-core = { path = "engrams/engram-core" }
+4
View File
@@ -0,0 +1,4 @@
segment_size: 524288
use_compression: false
version: 0.34
vQ
Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
segment_size: 524288
use_compression: false
version: 0.34
vQ
BIN
View File
Binary file not shown.
Binary file not shown.
@@ -15,6 +15,7 @@ migration = ["dep:rusqlite"]
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"
@@ -88,8 +88,9 @@ pub fn consolidate(db: &Db, config: &ConsolidationConfig) -> EngramResult<Consol
&& 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::put_node(db, &node)?;
graph::overwrite_node(db, &node)?;
promoted_count += 1;
if promoted_count >= config.max_promotions_per_run {
@@ -108,7 +109,7 @@ pub fn consolidate(db: &Db, config: &ConsolidationConfig) -> EngramResult<Consol
if new_sal != node.salience {
node.salience = new_sal;
storage::write_salience(db, node.id, new_sal)?;
graph::put_node(db, &node)?;
graph::overwrite_node(db, &node)?;
decayed_count += 1;
}
}
@@ -14,11 +14,12 @@
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, Node, RelationType, ScoredNode};
use crate::types::{ActivatedNode, Edge, EdgeTypeDef, Node, ScoredNode, now_ms};
use crate::vector;
use sled::Db;
use std::path::Path;
@@ -28,16 +29,32 @@ mod sled_impl {
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 node. Returns the node's UUID.
/// 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.
@@ -109,15 +126,90 @@ mod sled_impl {
// ── 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<RelationType>,
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
@@ -132,7 +224,8 @@ mod sled_impl {
node.last_activated,
node.activation_count,
);
graph::put_node(&self.db, &node)?;
// Use overwrite_node — touch is an internal state update, not a new node.
graph::overwrite_node(&self.db, &node)?;
Ok(())
}
@@ -154,7 +247,7 @@ mod sled_impl {
if new_salience != node.salience {
node.salience = new_salience;
storage::write_salience(&self.db, node.id, new_salience)?;
graph::put_node(&self.db, &node)?;
graph::overwrite_node(&self.db, &node)?;
count += 1;
}
}
@@ -226,9 +319,9 @@ mod wasm_impl {
use crate::error::{EngramError, EngramResult};
use crate::mem_storage::MemStore;
use crate::salience;
use crate::types::{ActivatedNode, Edge, Node, RelationType, ScoredNode};
use crate::types::{ActivatedNode, Edge, Node, ScoredNode};
use crate::vector;
use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::RwLock;
use uuid::Uuid;
@@ -332,7 +425,7 @@ mod wasm_impl {
pub fn traverse(
&self,
from: Uuid,
relation: Option<RelationType>,
relation: Option<&str>,
max_depth: u8,
) -> EngramResult<Vec<Node>> {
let store = self
@@ -352,8 +445,8 @@ mod wasm_impl {
}
let edges = store.read_edges_from(current_id)?;
for edge in edges {
if let Some(ref rel) = relation {
if &edge.relation != rel {
if let Some(rel) = relation {
if edge.relation != rel {
continue;
}
}
+443
View File
@@ -0,0 +1,443 @@
/// 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());
}
}
@@ -11,6 +11,9 @@ pub enum EngramError {
#[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 },
@@ -4,17 +4,25 @@
/// in both directions so that forward and backward traversals are equally cheap.
use crate::error::EngramResult;
use crate::storage;
use crate::types::{Edge, Node, RelationType};
use crate::types::{Edge, Node};
use sled::Db;
use std::collections::{HashSet, VecDeque};
use uuid::Uuid;
/// Persist a node and its embedding. Overwrites any existing node with the same id.
/// 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)
@@ -37,13 +45,13 @@ pub fn edges_to(db: &Db, to_id: Uuid) -> EngramResult<Vec<Edge>> {
/// Breadth-first traversal starting from `from`, following forward edges only.
///
/// If `relation` is Some, only edges of that type are followed.
/// The BFS respects `max_depth` hops. The seed node itself is NOT included.
/// Visited nodes are deduplicated.
/// 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<RelationType>,
relation: Option<&str>,
max_depth: u8,
) -> EngramResult<Vec<Node>> {
let mut visited: HashSet<Uuid> = HashSet::new();
@@ -60,8 +68,8 @@ pub fn traverse(
let edges = edges_from(db, current_id)?;
for edge in edges {
// Filter by relation type if specified
if let Some(ref rel) = relation {
if &edge.relation != rel {
if let Some(rel) = relation {
if edge.relation != rel {
continue;
}
}
@@ -8,7 +8,7 @@
/// # Quick Start
///
/// ```rust,no_run
/// use engram_core::{EngramDb, Node, Edge, NodeType, MemoryTier, RelationType};
/// use engram_core::{EngramDb, Node, Edge, NodeType, MemoryTier, EDGE_SUPERSEDES};
/// use std::path::Path;
///
/// let db = EngramDb::open(Path::new("/tmp/my-engram")).unwrap();
@@ -32,6 +32,8 @@
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;
@@ -48,6 +50,8 @@ pub mod migration;
pub use db::EngramDb;
pub use error::{EngramError, EngramResult};
pub use types::{
ActivatedNode, Edge, MemoryTier, Node, NodeType, RelationType, ScoredNode, now_ms,
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};
@@ -10,9 +10,8 @@
/// | `memory_nodes` | `Node { tier: Episodic, node_type: Memory }` |
/// | `knowledge_entries` | `Node { tier: Semantic, node_type: Concept }` |
///
/// Edges from `graph_edges` (type = "supersedes" or "Supersedes") are converted
/// to `Edge { relation: RelationType::Supersedes }`. Other edge types become
/// `RelationType::References`.
/// Edges from `graph_edges` are converted using their `edge_type` string directly
/// (normalised to lowercase). Unknown types map to `"references"`.
///
/// # Embeddings
///
@@ -23,7 +22,7 @@
/// TODO: wire in real embeddings from all-MiniLM-L6-v2 via the ONNX runtime.
use crate::error::{EngramError, EngramResult};
use crate::types::{Edge, MemoryTier, Node, NodeType, RelationType};
use crate::types::{Edge, MemoryTier, Node, NodeType};
use rusqlite::{Connection, OpenFlags};
use std::collections::HashMap;
use std::path::PathBuf;
@@ -227,7 +226,7 @@ pub fn migrate_from_neuron(config: &MigrationConfig) -> EngramResult<MigrationRe
None => continue,
};
let relation = edge_type_to_relation(&edge_type);
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) {
@@ -265,18 +264,22 @@ fn importance_string_to_f32(importance: &str) -> f32 {
}
}
/// Convert a Neuron edge type string to an Engram RelationType.
fn edge_type_to_relation(edge_type: &str) -> RelationType {
/// 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" => RelationType::Supersedes,
"causes" | "caused_by" => RelationType::Causes,
"contains" | "contained_by" => RelationType::Contains,
"references" | "referenced_by" => RelationType::References,
"contradicts" => RelationType::Contradicts,
"exemplifies" | "exemplified_by" => RelationType::Exemplifies,
"activates" => RelationType::Activates,
"temporally_precedes" | "follows" => RelationType::TemporallyPrecedes,
_ => RelationType::References, // safe default
"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
}
}
@@ -340,12 +343,12 @@ mod tests {
#[test]
fn edge_type_supersedes() {
assert_eq!(edge_type_to_relation("supersedes"), RelationType::Supersedes);
assert_eq!(normalise_edge_type("supersedes"), "supersedes");
}
#[test]
fn edge_type_unknown_is_references() {
assert_eq!(edge_type_to_relation("foobar"), RelationType::References);
assert_eq!(normalise_edge_type("foobar"), "references");
}
#[test]
@@ -6,6 +6,7 @@
/// 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;
@@ -35,8 +36,19 @@ pub fn salience_key(id: Uuid) -> Vec<u8> {
// ── 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)?;
@@ -52,6 +64,22 @@ pub fn write_node(db: &Db, node: &Node) -> EngramResult<()> {
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)?)),
@@ -1,6 +1,20 @@
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:
@@ -8,6 +22,7 @@ use uuid::Uuid;
/// - 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
@@ -22,6 +37,8 @@ pub enum NodeType {
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.
@@ -44,28 +61,35 @@ pub enum MemoryTier {
Procedural,
}
/// The typed relationship between two nodes.
/// 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.
///
/// Relation types encode causal, temporal, hierarchical, and logical
/// structure into the graph itself — not just into metadata.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationType {
/// This node replaces or obsoletes another
Supersedes,
/// This node is a causal precursor to another
Causes,
/// This node hierarchically contains another
Contains,
/// This node cites or points to another as supporting context
References,
/// This node is in logical tension with another
Contradicts,
/// This node is a concrete instance of a more abstract node
Exemplifies,
/// Co-activation: firing this node tends to fire the other
Activates,
/// Temporal ordering: this node came before the other
TemporallyPrecedes,
/// Fields like `deprecated`, `derived_from`, and `supersedes` are data.
/// They are set by the caller (el) and stored verbatim. Rust never inspects
/// or acts on them autonomously.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EdgeTypeDef {
/// Stable unique identifier for this edge type record
pub id: Uuid,
/// The canonical name used on edges (e.g. `"causes"`, `"resonates_with"`)
pub name: String,
/// Human-readable description of what this relation means
pub description: String,
/// Unix milliseconds when this type was first registered
pub first_observed: i64,
/// How many edges currently carry this type
pub instance_count: u64,
/// Caller-supplied confidence, 0.01.0. Stored as-is; not computed here.
pub confidence: f32,
/// Free-text note about what observation prompted this type's creation.
/// Set by el; stored verbatim.
pub derived_from: Option<String>,
/// Name of the edge type this one replaced, if any. Set by el; stored verbatim.
pub supersedes: Option<String>,
/// When true, this type should no longer be used for new edges.
/// Set by el; stored verbatim.
pub deprecated: bool,
}
/// A node in the engram graph — the fundamental unit of stored memory.
@@ -161,12 +185,17 @@ pub struct ScoredNode {
///
/// 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,
pub relation: RelationType,
/// The edge type name (e.g. `"causes"`, `"resonates_with"`). Matches the
/// `name` field of the corresponding `EdgeTypeDef` in the registry.
pub relation: String,
/// Connection strength, 0.01.0. Increases when both endpoints are
/// activated in close temporal proximity (long-term potentiation).
pub weight: f32,
@@ -176,13 +205,13 @@ pub struct Edge {
}
impl Edge {
pub fn new(from_id: Uuid, to_id: Uuid, relation: RelationType, weight: f32) -> Self {
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: relation.into(),
weight: weight.clamp(0.0, 1.0),
created_at: now,
last_fired: now,
@@ -98,7 +98,7 @@ fn matches_filter(a: &ActivatedNode, filter: &NodeFilter) -> bool {
NodeFilter::ByType(types) => {
let node_type_str = node_type_str(&a.node.node_type);
types.iter().any(|t| t == node_type_str)
types.iter().any(|t| t == &node_type_str)
}
NodeFilter::ByTier(tiers) => tiers.iter().any(|t| t == &a.node.tier),
@@ -195,14 +195,15 @@ fn traverse_json_path<'a>(doc: &'a Value, path: &str) -> Option<&'a Value> {
// ── String helpers ────────────────────────────────────────────────────────────
fn node_type_str(t: &NodeType) -> &'static str {
fn node_type_str(t: &NodeType) -> String {
match t {
NodeType::Memory => "Memory",
NodeType::Concept => "Concept",
NodeType::Event => "Event",
NodeType::Entity => "Entity",
NodeType::Process => "Process",
NodeType::InternalState => "InternalState",
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(),
}
}
@@ -33,7 +33,7 @@ use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use engram_core::{EngramDb, EngramResult};
use engram_core::types::{Node, NodeType, RelationType};
use engram_core::types::{Node, NodeType};
use uuid::Uuid;
use crate::types::{
@@ -223,7 +223,7 @@ impl ReasoningEngine {
/// Find causal chains: what causes a concept, or what a concept causes.
///
/// Traverses the graph following `RelationType::Causes` edges in the
/// Traverses the graph following `"causes"` edges in the
/// requested direction.
pub fn causal_chain(
&mut self,
@@ -252,7 +252,7 @@ impl ReasoningEngine {
let db = self.db.lock().map_err(|_| {
engram_core::EngramError::InvalidParam("db lock poisoned".into())
})?;
db.traverse(seed.id, Some(RelationType::Causes), self.config.max_depth as u8)?
db.traverse(seed.id, Some("causes"), self.config.max_depth as u8)?
};
// Build evidence nodes — always start with the seed as the first node
@@ -326,7 +326,7 @@ impl ReasoningEngine {
/// Find ordered steps for a HowTo query.
///
/// Traverses `RelationType::Causes` and `RelationType::Contains` edges
/// Traverses `"causes"` and `"contains"` edges
/// from Process/Procedural nodes matching the goal embedding.
pub fn procedural_chain(&mut self, goal_embedding: &[f32]) -> EngramResult<Vec<String>> {
let process_nodes: Vec<Node> = {
@@ -363,7 +363,7 @@ impl ReasoningEngine {
let db = self.db.lock().map_err(|_| {
engram_core::EngramError::InvalidParam("db lock poisoned".into())
})?;
db.traverse(best_process.id, Some(RelationType::Causes), self.config.max_depth as u8)?
db.traverse(best_process.id, Some("causes"), self.config.max_depth as u8)?
};
let mut ordered_steps: Vec<String> = Vec::new();
@@ -437,7 +437,7 @@ impl ReasoningEngine {
if let Some(from_node) = db.get_node(*node_id)? {
let edges = db.get_edges_from(*node_id)?;
for edge in edges {
if edge.relation == RelationType::Contradicts {
if edge.relation == "contradicts" {
if let Some(target) = db.get_node(edge.to_id)? {
let sim_a = cosine_sim(topic_embedding, &from_node.embedding);
let sim_b = cosine_sim(topic_embedding, &target.embedding);
@@ -976,16 +976,21 @@ impl ReasoningEngine {
// ── Edge mapping helpers ──────────────────────────────────────────────────────
fn relation_to_inference_edge(relation: &RelationType) -> InferenceEdgeType {
fn relation_to_inference_edge(relation: &str) -> InferenceEdgeType {
match relation {
RelationType::Causes => InferenceEdgeType::Causes,
RelationType::Contradicts => InferenceEdgeType::Contradicts,
RelationType::Supersedes => InferenceEdgeType::Implies,
RelationType::Contains => InferenceEdgeType::Requires,
RelationType::References => InferenceEdgeType::SimilarTo,
RelationType::Exemplifies => InferenceEdgeType::InstanceOf,
RelationType::Activates => InferenceEdgeType::Supports,
RelationType::TemporallyPrecedes => InferenceEdgeType::Causes,
"causes" => InferenceEdgeType::Causes,
"contradicts" => InferenceEdgeType::Contradicts,
"supersedes" => InferenceEdgeType::Implies,
"contains" => InferenceEdgeType::Requires,
"references" => InferenceEdgeType::SimilarTo,
"exemplifies" => InferenceEdgeType::InstanceOf,
"activates" => InferenceEdgeType::Supports,
"temporally_precedes" => InferenceEdgeType::Causes,
// Personhood types map to their closest structural equivalent
"grounded_in" | "reinforced_by" | "derives_from" | "resonates_with" => InferenceEdgeType::Supports,
"in_tension_with" | "challenged_by" => InferenceEdgeType::Contradicts,
"expressed_through" | "shaped_by" => InferenceEdgeType::Implies,
_ => InferenceEdgeType::SimilarTo,
}
}
@@ -15,7 +15,7 @@
/// # Quick Start
///
/// ```rust,no_run
/// use engram_core::{EngramDb, Node, Edge, NodeType, MemoryTier, RelationType};
/// use engram_core::{EngramDb, Node, Edge, NodeType, MemoryTier};
/// use engram_reasoning::{ReasoningEngine, Hypothesis, HypothesisType, ReasoningConfig};
/// use std::path::Path;
/// use std::sync::{Arc, Mutex};
@@ -9,7 +9,7 @@ mod tests {
use tempfile::TempDir;
use uuid::Uuid;
use engram_core::{Edge, EngramDb, MemoryTier, Node, NodeType, RelationType};
use engram_core::{Edge, EngramDb, MemoryTier, Node, NodeType};
use crate::{
CausalDirection, EvidenceNode, EvidenceType, Hypothesis, HypothesisType, InferenceEdge,
@@ -51,7 +51,7 @@ mod tests {
id
}
fn make_edge(db: &Arc<Mutex<EngramDb>>, from: Uuid, to: Uuid, relation: RelationType) {
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();
}
@@ -339,8 +339,8 @@ mod tests {
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, RelationType::Causes);
make_edge(&db, id_b, id_c, RelationType::Causes);
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();
@@ -373,7 +373,7 @@ mod tests {
NodeType::Memory,
0.9,
);
make_edge(&db, id_a, id_b, RelationType::Contradicts);
make_edge(&db, id_a, id_b, "contradicts");
let mut engine = make_engine(db.clone());
let contradictions = engine.find_contradictions(&emb_topic).unwrap();
@@ -482,8 +482,8 @@ mod tests {
NodeType::Process,
0.9,
);
make_edge(&db, id_1, id_2, RelationType::Causes);
make_edge(&db, id_2, id_3, RelationType::Causes);
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();
@@ -5,9 +5,9 @@
/// ## Core
/// GET /stats — node/edge counts
/// POST /nodes — create a node
/// GET /nodes/{id} — get a node
/// GET /nodes/:id — get a node
/// POST /edges — create an edge
/// GET /nodes/{id}/edges — list edges from a node
/// GET /nodes/:id/edges — list edges from a node
/// POST /activate — spreading activation
/// POST /search — embedding search
/// POST /decay — apply salience decay
@@ -18,7 +18,7 @@
/// POST /sync/push — receive incoming delta
/// POST /sync/peers — register peer
/// GET /sync/peers — list peers
/// DELETE /sync/peers/{id} — remove peer
/// DELETE /sync/peers/:id — remove peer
///
/// ## Swarm
/// POST /swarm/activate — distributed activation (auth required)
@@ -188,7 +188,7 @@ async fn main() -> anyhow::Result<()> {
.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("/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(
@@ -200,9 +200,10 @@ async fn main() -> anyhow::Result<()> {
let core_routes = Router::new()
.route("/stats", get(routes::core::get_stats))
.route("/nodes", post(routes::core::create_node))
.route("/nodes/{id}", get(routes::core::get_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("/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))
@@ -213,20 +214,20 @@ async fn main() -> anyhow::Result<()> {
.route("/projections", post(routes::projection::register_projection))
.route("/projections", get(routes::projection::list_projections))
.route(
"/projections/{name}/schema",
"/projections/:name/schema",
get(routes::projection::get_projection_schema),
)
.route(
"/projections/{name}/query",
"/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/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));
.route("/tx/chain/:command_id", get(routes::tx::tx_causal_chain));
// Reasoning routes (no auth — graph-native inference)
let reasoning_routes = Router::new()
@@ -234,18 +235,47 @@ async fn main() -> anyhow::Result<()> {
.route("/reason/causal", post(routes::reasoning::causal))
.route("/reason/contradictions", post(routes::reasoning::contradictions));
let studio_routes = Router::new()
let app = Router::new()
// Studio
.route("/", get(serve_studio_index))
.route("/studio", get(serve_studio_index))
.route("/studio/{*path}", get(serve_studio_asset));
let app = Router::new()
.merge(studio_routes)
.merge(core_routes)
.merge(sync_routes)
.merge(projection_routes)
.merge(tx_routes)
.merge(reasoning_routes)
// 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);
@@ -4,7 +4,7 @@ use axum::{
http::StatusCode,
Json,
};
use engram_core::types::{Edge, MemoryTier, Node, NodeType, RelationType};
use engram_core::types::{Edge, MemoryTier, Node, NodeType};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
@@ -54,14 +54,41 @@ pub async fn create_node(
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): Path<Uuid>,
Path(id_str): Path<String>,
) -> Result<Json<Node>, StatusCode> {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match db.get_node(id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? {
Some(node) => Ok(Json(node)),
None => Err(StatusCode::NOT_FOUND),
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)
}
}
}
@@ -71,7 +98,8 @@ pub async fn get_node(
pub struct CreateEdgeRequest {
pub from_id: Uuid,
pub to_id: Uuid,
pub relation: RelationType,
/// Edge type name, e.g. `"causes"`, `"resonates_with"`, or any dynamic type.
pub relation: String,
pub weight: f32,
}
@@ -3,7 +3,7 @@
/// 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, RelationType};
use engram_core::types::{Edge, Node, NodeType};
use engram_core::EngramDb;
use serde_json::Value;
use uuid::Uuid;
@@ -319,8 +319,7 @@ impl TransactionEngine {
fn exec_create_edge(&self, payload: &Value) -> TxResult<()> {
let p: CreateEdgePayload = serde_json::from_value(payload.clone())?;
let relation = parse_relation(&p.relation)?;
let edge = Edge::new(p.from_id, p.to_id, relation, p.weight);
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(())
@@ -465,12 +464,11 @@ pub fn build_create_edge_cmd(
idempotency_key: impl Into<String>,
peer_id: Option<Uuid>,
) -> Command {
let relation_str = relation_to_str(&edge.relation).to_string();
let payload = serde_json::to_value(CreateEdgePayload {
edge_id: edge.id,
from_id: edge.from_id,
to_id: edge.to_id,
relation: relation_str.clone(),
relation: edge.relation.clone(),
weight: edge.weight,
})
.unwrap_or(Value::Null);
@@ -505,45 +503,19 @@ fn parse_node_type(s: &str) -> TxResult<NodeType> {
"Entity" => Ok(NodeType::Entity),
"Process" => Ok(NodeType::Process),
"InternalState" => Ok(NodeType::InternalState),
_ => Err(TxError::Invalid(format!("unknown node type: {}", s))),
other => Ok(NodeType::Custom(other.to_string())),
}
}
fn node_type_to_str(t: &NodeType) -> &'static str {
fn node_type_to_str(t: &NodeType) -> String {
match t {
NodeType::Memory => "Memory",
NodeType::Concept => "Concept",
NodeType::Event => "Event",
NodeType::Entity => "Entity",
NodeType::Process => "Process",
NodeType::InternalState => "InternalState",
}
}
fn parse_relation(s: &str) -> TxResult<RelationType> {
match s {
"Supersedes" => Ok(RelationType::Supersedes),
"Causes" => Ok(RelationType::Causes),
"Contains" => Ok(RelationType::Contains),
"References" => Ok(RelationType::References),
"Contradicts" => Ok(RelationType::Contradicts),
"Exemplifies" => Ok(RelationType::Exemplifies),
"Activates" => Ok(RelationType::Activates),
"TemporallyPrecedes" => Ok(RelationType::TemporallyPrecedes),
_ => Err(TxError::Invalid(format!("unknown relation: {}", s))),
}
}
fn relation_to_str(r: &RelationType) -> &'static str {
match r {
RelationType::Supersedes => "Supersedes",
RelationType::Causes => "Causes",
RelationType::Contains => "Contains",
RelationType::References => "References",
RelationType::Contradicts => "Contradicts",
RelationType::Exemplifies => "Exemplifies",
RelationType::Activates => "Activates",
RelationType::TemporallyPrecedes => "TemporallyPrecedes",
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(),
}
}
@@ -697,7 +669,7 @@ mod tests {
engine.apply(build_create_node_cmd(&n2, "edge-n2", None)).unwrap();
// Create edge
let edge = Edge::new(n1.id, n2.id, RelationType::References, 0.7);
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);
+9 -8
View File
@@ -7,7 +7,8 @@
/// 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, RelationType,
ActivatedNode, ConsolidationConfig, Edge, EngramDb, MemoryTier, Node, NodeType,
EDGE_ACTIVATES, EDGE_CAUSES, EDGE_EXEMPLIFIES, EDGE_REFERENCES, EDGE_TEMPORALLY_PRECEDES,
};
use std::path::Path;
@@ -98,17 +99,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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, RelationType::Causes, 0.9))?;
db.put_edge(Edge::new(id0, id1, EDGE_CAUSES, 0.9))?;
// LTP is Referenced by Hebbian learning
db.put_edge(Edge::new(id1, id2, RelationType::References, 0.85))?;
db.put_edge(Edge::new(id1, id2, EDGE_REFERENCES, 0.85))?;
// Spreading activation Activates associative memory
db.put_edge(Edge::new(id0, id3, RelationType::Activates, 0.88))?;
db.put_edge(Edge::new(id0, id3, EDGE_ACTIVATES, 0.88))?;
// Hebbian learning Exemplifies associative memory
db.put_edge(Edge::new(id2, id3, RelationType::Exemplifies, 0.80))?;
// Salience decay Supersedes naive forgetting
db.put_edge(Edge::new(id4, id5, RelationType::TemporallyPrecedes, 0.65))?;
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, RelationType::TemporallyPrecedes, 0.72))?;
db.put_edge(Edge::new(id1, id5, EDGE_TEMPORALLY_PRECEDES, 0.72))?;
println!("Inserted {} edges", db.edge_count()?);
println!(" node0 --[Causes]--> node1");
+1272 -269
View File
File diff suppressed because it is too large Load Diff