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 61a4632163
commit 909c1577f1
89 changed files with 2114 additions and 452 deletions
@@ -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>>>,
}