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:
@@ -0,0 +1,17 @@
|
||||
[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"
|
||||
@@ -0,0 +1,463 @@
|
||||
/// 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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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>;
|
||||
@@ -0,0 +1,33 @@
|
||||
/// 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,
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/// 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>>>,
|
||||
}
|
||||
Reference in New Issue
Block a user