use std::collections::{BTreeMap, BTreeSet};
use exo_core::Hash256;
use exo_dag_db_api::MemoryGraphStyle;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const LAYERED_GRAPH_INVARIANT_REPORT_SCHEMA_VERSION: &str = "layered_graph_invariant_report_v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LayeredGraphLayerKind {
Root,
Repository,
KnowledgeGraph,
SourceSubgraph,
TaskSubgraph,
Rollup,
Route,
}
impl LayeredGraphLayerKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Root => "root",
Self::Repository => "repository",
Self::KnowledgeGraph => "knowledge_graph",
Self::SourceSubgraph => "source_subgraph",
Self::TaskSubgraph => "task_subgraph",
Self::Rollup => "rollup",
Self::Route => "route",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LayeredGraphMembershipRole {
Root,
Container,
Member,
Summary,
RouteAnchor,
}
impl LayeredGraphMembershipRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Root => "root",
Self::Container => "container",
Self::Member => "member",
Self::Summary => "summary",
Self::RouteAnchor => "route_anchor",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LayeredGraphLayerEdgeKind {
ContainsSubgraph,
DrillsDownTo,
RollsUpTo,
CrossLayerRef,
SummarizesLayer,
}
impl LayeredGraphLayerEdgeKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ContainsSubgraph => "contains_subgraph",
Self::DrillsDownTo => "drills_down_to",
Self::RollsUpTo => "rolls_up_to",
Self::CrossLayerRef => "cross_layer_ref",
Self::SummarizesLayer => "summarizes_layer",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LayeredGraphNodeRef {
pub graph_node_id: Hash256,
pub tenant_id: String,
pub namespace: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayeredGraphLayer {
pub layer_id: Hash256,
pub tenant_id: String,
pub namespace: String,
pub root_memory_id: Hash256,
pub parent_layer_id: Option<Hash256>,
pub parent_graph_node_id: Option<Hash256>,
pub layer_depth: u32,
pub layer_kind: LayeredGraphLayerKind,
pub graph_style: MemoryGraphStyle,
pub layer_path: String,
pub metadata: serde_json::Value,
pub created_at_physical_ms: u64,
pub created_at_logical: u32,
pub updated_at_physical_ms: u64,
pub updated_at_logical: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayeredGraphMembership {
pub layer_membership_id: Hash256,
pub tenant_id: String,
pub namespace: String,
pub layer_id: Hash256,
pub graph_node_id: Hash256,
pub graph_style: MemoryGraphStyle,
pub membership_role: LayeredGraphMembershipRole,
pub local_node_rank: u32,
pub metadata: serde_json::Value,
pub created_at_physical_ms: u64,
pub created_at_logical: u32,
pub updated_at_physical_ms: u64,
pub updated_at_logical: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayeredGraphLayerEdge {
pub layer_edge_id: Hash256,
pub tenant_id: String,
pub namespace: String,
pub graph_style: MemoryGraphStyle,
pub from_layer_id: Hash256,
pub to_layer_id: Hash256,
pub edge_kind: LayeredGraphLayerEdgeKind,
pub receipt_hash: Option<Hash256>,
pub metadata: serde_json::Value,
pub created_at_physical_ms: u64,
pub created_at_logical: u32,
pub updated_at_physical_ms: u64,
pub updated_at_logical: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LayeredGraphValidationStatus {
Passed,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LayeredGraphInvariantFailure {
pub invariant_code: String,
pub subject_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayeredGraphInvariantReport {
pub schema_version: String,
pub validation_status: LayeredGraphValidationStatus,
pub checked_graph_node_count: usize,
pub checked_layer_count: usize,
pub checked_membership_count: usize,
pub checked_layer_edge_count: usize,
pub failed_invariants: Vec<LayeredGraphInvariantFailure>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum LayeredGraphInvariantError {
#[error("layered_graph_invariants_failed: {failed_count}")]
Failed {
failed_count: usize,
failed_invariants: Vec<LayeredGraphInvariantFailure>,
},
}
#[must_use]
pub fn build_layered_graph_invariant_report(
graph_nodes: &[LayeredGraphNodeRef],
layers: &[LayeredGraphLayer],
memberships: &[LayeredGraphMembership],
layer_edges: &[LayeredGraphLayerEdge],
) -> LayeredGraphInvariantReport {
let mut failed_invariants = Vec::new();
let graph_node_index = graph_nodes
.iter()
.map(|node| (node.graph_node_id, node))
.collect::<BTreeMap<_, _>>();
let layer_index = layers
.iter()
.map(|layer| (layer.layer_id, layer))
.collect::<BTreeMap<_, _>>();
let membership_pairs = memberships
.iter()
.map(|membership| (membership.layer_id, membership.graph_node_id))
.collect::<BTreeSet<_>>();
validate_layer_records(layers, &layer_index, &mut failed_invariants);
validate_membership_records(
memberships,
&layer_index,
&graph_node_index,
&mut failed_invariants,
);
validate_parent_node_bindings(layers, &membership_pairs, &mut failed_invariants);
validate_layer_edge_records(layer_edges, &layer_index, &mut failed_invariants);
let validation_status = if failed_invariants.is_empty() {
LayeredGraphValidationStatus::Passed
} else {
LayeredGraphValidationStatus::Failed
};
LayeredGraphInvariantReport {
schema_version: LAYERED_GRAPH_INVARIANT_REPORT_SCHEMA_VERSION.to_owned(),
validation_status,
checked_graph_node_count: graph_nodes.len(),
checked_layer_count: layers.len(),
checked_membership_count: memberships.len(),
checked_layer_edge_count: layer_edges.len(),
failed_invariants,
}
}
pub fn validate_layered_graph_invariants(
graph_nodes: &[LayeredGraphNodeRef],
layers: &[LayeredGraphLayer],
memberships: &[LayeredGraphMembership],
layer_edges: &[LayeredGraphLayerEdge],
) -> Result<LayeredGraphInvariantReport, LayeredGraphInvariantError> {
let report =
build_layered_graph_invariant_report(graph_nodes, layers, memberships, layer_edges);
if report.failed_invariants.is_empty() {
Ok(report)
} else {
Err(LayeredGraphInvariantError::Failed {
failed_count: report.failed_invariants.len(),
failed_invariants: report.failed_invariants,
})
}
}
fn validate_layer_records(
layers: &[LayeredGraphLayer],
layer_index: &BTreeMap<Hash256, &LayeredGraphLayer>,
failed_invariants: &mut Vec<LayeredGraphInvariantFailure>,
) {
let mut scoped_paths = BTreeSet::new();
for layer in layers {
let subject = layer.layer_id.to_string();
if layer.layer_path.is_empty() {
push_failure(
failed_invariants,
"layered_empty_layer_path",
subject.clone(),
);
}
let scoped_path = (
layer.tenant_id.clone(),
layer.namespace.clone(),
layer.layer_path.clone(),
);
if !scoped_paths.insert(scoped_path) {
push_failure(
failed_invariants,
"layered_duplicate_layer_path",
subject.clone(),
);
}
if layer.layer_depth == 0 {
if layer.parent_layer_id.is_some() || layer.parent_graph_node_id.is_some() {
push_failure(failed_invariants, "layered_root_has_parent", subject);
}
continue;
}
let Some(parent_layer_id) = layer.parent_layer_id else {
push_failure(
failed_invariants,
"layered_child_missing_parent_layer",
subject.clone(),
);
continue;
};
if layer.parent_graph_node_id.is_none() {
push_failure(
failed_invariants,
"layered_child_missing_parent_node",
subject.clone(),
);
}
let Some(parent_layer) = layer_index.get(&parent_layer_id) else {
push_failure(
failed_invariants,
"layered_orphan_child_layer",
subject.clone(),
);
continue;
};
if layer.tenant_id != parent_layer.tenant_id {
push_failure(
failed_invariants,
"layered_tenant_mismatch",
subject.clone(),
);
}
if layer.namespace != parent_layer.namespace {
push_failure(
failed_invariants,
"layered_namespace_mismatch",
subject.clone(),
);
}
if layer.layer_depth != parent_layer.layer_depth.saturating_add(1) {
push_failure(failed_invariants, "layered_depth_mismatch", subject);
}
}
}
fn validate_membership_records(
memberships: &[LayeredGraphMembership],
layer_index: &BTreeMap<Hash256, &LayeredGraphLayer>,
graph_node_index: &BTreeMap<Hash256, &LayeredGraphNodeRef>,
failed_invariants: &mut Vec<LayeredGraphInvariantFailure>,
) {
for membership in memberships {
let subject = membership.layer_membership_id.to_string();
let Some(layer) = layer_index.get(&membership.layer_id) else {
push_failure(
failed_invariants,
"layered_membership_missing_layer",
subject.clone(),
);
continue;
};
let Some(graph_node) = graph_node_index.get(&membership.graph_node_id) else {
push_failure(
failed_invariants,
"layered_membership_missing_graph_node",
subject,
);
continue;
};
if membership.tenant_id != layer.tenant_id || membership.tenant_id != graph_node.tenant_id {
push_failure(
failed_invariants,
"layered_tenant_mismatch",
subject.clone(),
);
}
if membership.namespace != layer.namespace || membership.namespace != graph_node.namespace {
push_failure(failed_invariants, "layered_namespace_mismatch", subject);
}
}
}
fn validate_parent_node_bindings(
layers: &[LayeredGraphLayer],
membership_pairs: &BTreeSet<(Hash256, Hash256)>,
failed_invariants: &mut Vec<LayeredGraphInvariantFailure>,
) {
for layer in layers {
let (Some(parent_layer_id), Some(parent_graph_node_id)) =
(layer.parent_layer_id, layer.parent_graph_node_id)
else {
continue;
};
if !membership_pairs.contains(&(parent_layer_id, parent_graph_node_id)) {
push_failure(
failed_invariants,
"layered_parent_node_not_in_parent_layer",
layer.layer_id.to_string(),
);
}
}
}
fn validate_layer_edge_records(
layer_edges: &[LayeredGraphLayerEdge],
layer_index: &BTreeMap<Hash256, &LayeredGraphLayer>,
failed_invariants: &mut Vec<LayeredGraphInvariantFailure>,
) {
for layer_edge in layer_edges {
let subject = layer_edge.layer_edge_id.to_string();
let Some(from_layer) = layer_index.get(&layer_edge.from_layer_id) else {
push_failure(
failed_invariants,
"layered_dangling_layer_edge",
subject.clone(),
);
continue;
};
let Some(to_layer) = layer_index.get(&layer_edge.to_layer_id) else {
push_failure(
failed_invariants,
"layered_dangling_layer_edge",
subject.clone(),
);
continue;
};
if layer_edge.tenant_id != from_layer.tenant_id
|| layer_edge.tenant_id != to_layer.tenant_id
{
push_failure(
failed_invariants,
"layered_tenant_mismatch",
subject.clone(),
);
}
if layer_edge.namespace != from_layer.namespace
|| layer_edge.namespace != to_layer.namespace
{
push_failure(failed_invariants, "layered_namespace_mismatch", subject);
}
}
}
fn push_failure(
failed_invariants: &mut Vec<LayeredGraphInvariantFailure>,
invariant_code: &str,
subject_id: String,
) {
failed_invariants.push(LayeredGraphInvariantFailure {
invariant_code: invariant_code.to_owned(),
subject_id,
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layered_graph_schema_exports_stable_labels() {
assert_eq!(
LayeredGraphLayerKind::KnowledgeGraph.as_str(),
"knowledge_graph"
);
assert_eq!(LayeredGraphLayerKind::Root.as_str(), "root");
assert_eq!(LayeredGraphLayerKind::Repository.as_str(), "repository");
assert_eq!(
LayeredGraphLayerKind::SourceSubgraph.as_str(),
"source_subgraph"
);
assert_eq!(
LayeredGraphLayerKind::TaskSubgraph.as_str(),
"task_subgraph"
);
assert_eq!(LayeredGraphLayerKind::Rollup.as_str(), "rollup");
assert_eq!(LayeredGraphLayerKind::Route.as_str(), "route");
assert_eq!(LayeredGraphMembershipRole::Root.as_str(), "root");
assert_eq!(LayeredGraphMembershipRole::Container.as_str(), "container");
assert_eq!(LayeredGraphMembershipRole::Member.as_str(), "member");
assert_eq!(LayeredGraphMembershipRole::Summary.as_str(), "summary");
assert_eq!(
LayeredGraphMembershipRole::RouteAnchor.as_str(),
"route_anchor"
);
assert_eq!(
LayeredGraphLayerEdgeKind::ContainsSubgraph.as_str(),
"contains_subgraph"
);
assert_eq!(
LayeredGraphLayerEdgeKind::DrillsDownTo.as_str(),
"drills_down_to"
);
assert_eq!(LayeredGraphLayerEdgeKind::RollsUpTo.as_str(), "rolls_up_to");
assert_eq!(
LayeredGraphLayerEdgeKind::CrossLayerRef.as_str(),
"cross_layer_ref"
);
assert_eq!(
LayeredGraphLayerEdgeKind::SummarizesLayer.as_str(),
"summarizes_layer"
);
assert_eq!(
LAYERED_GRAPH_INVARIANT_REPORT_SCHEMA_VERSION,
"layered_graph_invariant_report_v1"
);
}
#[test]
fn layered_graph_invariants_accept_valid_root_and_child() {
let graph_nodes = fixture_graph_nodes();
let layers = fixture_layers();
let memberships = fixture_memberships();
let layer_edges = fixture_layer_edges();
let report =
validate_layered_graph_invariants(&graph_nodes, &layers, &memberships, &layer_edges)
.expect("valid fixture passes layered graph invariants");
assert_eq!(
report.validation_status,
LayeredGraphValidationStatus::Passed
);
assert_eq!(report.checked_layer_count, 2);
assert!(report.failed_invariants.is_empty());
}
#[test]
fn layered_graph_invariants_reject_duplicate_paths() {
let graph_nodes = fixture_graph_nodes();
let mut layers = fixture_layers();
layers[1].layer_path = layers[0].layer_path.clone();
let error = validate_layered_graph_invariants(
&graph_nodes,
&layers,
&fixture_memberships(),
&fixture_layer_edges(),
)
.expect_err("duplicate scoped paths fail");
assert_error_has_code(error, "layered_duplicate_layer_path");
}
#[test]
fn layered_graph_invariants_reject_invalid_layer_records() {
assert_layer_failure(
|layers| layers[0].layer_path.clear(),
"layered_empty_layer_path",
);
assert_layer_failure(
|layers| layers[0].parent_layer_id = Some(h(0x12)),
"layered_root_has_parent",
);
assert_layer_failure(
|layers| layers[1].parent_layer_id = None,
"layered_child_missing_parent_layer",
);
assert_layer_failure(
|layers| layers[1].parent_graph_node_id = None,
"layered_child_missing_parent_node",
);
assert_layer_failure(
|layers| layers[1].parent_layer_id = Some(h(0x99)),
"layered_orphan_child_layer",
);
assert_layer_failure(
|layers| layers[1].tenant_id = "tenant-b".to_owned(),
"layered_tenant_mismatch",
);
assert_layer_failure(
|layers| layers[1].namespace = "other".to_owned(),
"layered_namespace_mismatch",
);
assert_layer_failure(|layers| layers[1].layer_depth = 2, "layered_depth_mismatch");
}
#[test]
fn layered_graph_invariants_reject_invalid_membership_records() {
assert_membership_failure(
|memberships| memberships[1].layer_id = h(0x99),
"layered_membership_missing_layer",
);
assert_membership_failure(
|memberships| memberships[1].graph_node_id = h(0x99),
"layered_membership_missing_graph_node",
);
assert_membership_failure(
|memberships| memberships[1].tenant_id = "tenant-b".to_owned(),
"layered_tenant_mismatch",
);
assert_membership_failure(
|memberships| memberships[1].namespace = "other".to_owned(),
"layered_namespace_mismatch",
);
}
#[test]
fn layered_graph_invariants_reject_dangling_layer_edges() {
let graph_nodes = fixture_graph_nodes();
let mut layer_edges = fixture_layer_edges();
layer_edges[0].to_layer_id = h(0x99);
let error = validate_layered_graph_invariants(
&graph_nodes,
&fixture_layers(),
&fixture_memberships(),
&layer_edges,
)
.expect_err("dangling layer edge fails");
assert_error_has_code(error, "layered_dangling_layer_edge");
}
#[test]
fn layered_graph_invariants_reject_invalid_layer_edge_records() {
assert_layer_edge_failure(
|layer_edges| layer_edges[0].from_layer_id = h(0x99),
"layered_dangling_layer_edge",
);
assert_layer_edge_failure(
|layer_edges| layer_edges[0].tenant_id = "tenant-b".to_owned(),
"layered_tenant_mismatch",
);
assert_layer_edge_failure(
|layer_edges| layer_edges[0].namespace = "other".to_owned(),
"layered_namespace_mismatch",
);
}
#[test]
fn layered_graph_invariants_reject_parent_node_outside_parent_layer() {
let graph_nodes = fixture_graph_nodes();
let memberships = vec![fixture_memberships()[1].clone()];
let error = validate_layered_graph_invariants(
&graph_nodes,
&fixture_layers(),
&memberships,
&fixture_layer_edges(),
)
.expect_err("parent node must be member of parent layer");
assert_error_has_code(error, "layered_parent_node_not_in_parent_layer");
}
fn assert_layer_failure(mutate: impl FnOnce(&mut Vec<LayeredGraphLayer>), expected_code: &str) {
let graph_nodes = fixture_graph_nodes();
let mut layers = fixture_layers();
mutate(&mut layers);
let error = validate_layered_graph_invariants(
&graph_nodes,
&layers,
&fixture_memberships(),
&fixture_layer_edges(),
)
.expect_err("invalid layer record fails");
assert_error_has_code(error, expected_code);
}
fn assert_membership_failure(
mutate: impl FnOnce(&mut Vec<LayeredGraphMembership>),
expected_code: &str,
) {
let graph_nodes = fixture_graph_nodes();
let mut memberships = fixture_memberships();
mutate(&mut memberships);
let error = validate_layered_graph_invariants(
&graph_nodes,
&fixture_layers(),
&memberships,
&fixture_layer_edges(),
)
.expect_err("invalid membership record fails");
assert_error_has_code(error, expected_code);
}
fn assert_layer_edge_failure(
mutate: impl FnOnce(&mut Vec<LayeredGraphLayerEdge>),
expected_code: &str,
) {
let graph_nodes = fixture_graph_nodes();
let mut layer_edges = fixture_layer_edges();
mutate(&mut layer_edges);
let error = validate_layered_graph_invariants(
&graph_nodes,
&fixture_layers(),
&fixture_memberships(),
&layer_edges,
)
.expect_err("invalid layer edge record fails");
assert_error_has_code(error, expected_code);
}
fn assert_error_has_code(error: LayeredGraphInvariantError, code: &str) {
let LayeredGraphInvariantError::Failed {
failed_invariants, ..
} = error;
assert!(
failed_invariants
.iter()
.any(|failure| failure.invariant_code == code),
"missing {code} in {failed_invariants:?}"
);
}
fn fixture_graph_nodes() -> Vec<LayeredGraphNodeRef> {
vec![
LayeredGraphNodeRef {
graph_node_id: h(0x21),
tenant_id: "tenant-a".to_owned(),
namespace: "default".to_owned(),
},
LayeredGraphNodeRef {
graph_node_id: h(0x22),
tenant_id: "tenant-a".to_owned(),
namespace: "default".to_owned(),
},
]
}
fn fixture_layers() -> Vec<LayeredGraphLayer> {
vec![
LayeredGraphLayer {
layer_id: h(0x11),
tenant_id: "tenant-a".to_owned(),
namespace: "default".to_owned(),
root_memory_id: h(0x31),
parent_layer_id: None,
parent_graph_node_id: None,
layer_depth: 0,
layer_kind: LayeredGraphLayerKind::Root,
graph_style: MemoryGraphStyle::SemanticCatalogGraph,
layer_path: "root".to_owned(),
metadata: serde_json::json!({}),
created_at_physical_ms: 1,
created_at_logical: 0,
updated_at_physical_ms: 1,
updated_at_logical: 1,
},
LayeredGraphLayer {
layer_id: h(0x12),
tenant_id: "tenant-a".to_owned(),
namespace: "default".to_owned(),
root_memory_id: h(0x32),
parent_layer_id: Some(h(0x11)),
parent_graph_node_id: Some(h(0x21)),
layer_depth: 1,
layer_kind: LayeredGraphLayerKind::Repository,
graph_style: MemoryGraphStyle::DependencyDag,
layer_path: "root/repository".to_owned(),
metadata: serde_json::json!({}),
created_at_physical_ms: 2,
created_at_logical: 0,
updated_at_physical_ms: 2,
updated_at_logical: 1,
},
]
}
fn fixture_memberships() -> Vec<LayeredGraphMembership> {
vec![
LayeredGraphMembership {
layer_membership_id: h(0x41),
tenant_id: "tenant-a".to_owned(),
namespace: "default".to_owned(),
layer_id: h(0x11),
graph_node_id: h(0x21),
graph_style: MemoryGraphStyle::SemanticCatalogGraph,
membership_role: LayeredGraphMembershipRole::Root,
local_node_rank: 0,
metadata: serde_json::json!({}),
created_at_physical_ms: 1,
created_at_logical: 0,
updated_at_physical_ms: 1,
updated_at_logical: 1,
},
LayeredGraphMembership {
layer_membership_id: h(0x42),
tenant_id: "tenant-a".to_owned(),
namespace: "default".to_owned(),
layer_id: h(0x12),
graph_node_id: h(0x22),
graph_style: MemoryGraphStyle::DependencyDag,
membership_role: LayeredGraphMembershipRole::Member,
local_node_rank: 0,
metadata: serde_json::json!({}),
created_at_physical_ms: 2,
created_at_logical: 0,
updated_at_physical_ms: 2,
updated_at_logical: 1,
},
]
}
fn fixture_layer_edges() -> Vec<LayeredGraphLayerEdge> {
vec![LayeredGraphLayerEdge {
layer_edge_id: h(0x51),
tenant_id: "tenant-a".to_owned(),
namespace: "default".to_owned(),
graph_style: MemoryGraphStyle::DependencyDag,
from_layer_id: h(0x11),
to_layer_id: h(0x12),
edge_kind: LayeredGraphLayerEdgeKind::ContainsSubgraph,
receipt_hash: None,
metadata: serde_json::json!({}),
created_at_physical_ms: 3,
created_at_logical: 0,
updated_at_physical_ms: 3,
updated_at_logical: 1,
}]
}
const fn h(byte: u8) -> Hash256 {
Hash256::from_bytes([byte; 32])
}
}