use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use crate::entry::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ValueType {
String,
Int,
Float,
Bool,
List,
Map,
Any,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PropertyDef {
pub value_type: ValueType,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub constraints: Option<BTreeMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SubtypeDef {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub properties: BTreeMap<String, PropertyDef>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NodeTypeDef {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub properties: BTreeMap<String, PropertyDef>,
#[serde(default)]
pub subtypes: Option<BTreeMap<String, SubtypeDef>>,
#[serde(default)]
pub parent_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EdgeTypeDef {
#[serde(default)]
pub description: Option<String>,
pub source_types: Vec<String>,
pub target_types: Vec<String>,
#[serde(default)]
pub properties: BTreeMap<String, PropertyDef>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Ontology {
pub node_types: BTreeMap<String, NodeTypeDef>,
pub edge_types: BTreeMap<String, EdgeTypeDef>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Compatibility {
Identical,
Superset,
Subset,
Divergent,
}
impl Ontology {
pub fn content_hash(&self) -> [u8; 32] {
let json = serde_json::to_string(self).expect("ontology serialization should not fail");
*blake3::hash(json.as_bytes()).as_bytes()
}
pub fn fingerprint(&self) -> HashSet<String> {
let mut facts = HashSet::new();
for (type_name, type_def) in &self.node_types {
facts.insert(format!("type:{type_name}"));
if let Some(parent) = &type_def.parent_type {
facts.insert(format!("type:{type_name}:parent:{parent}"));
}
for (prop_name, prop_def) in &type_def.properties {
let req = if prop_def.required {
"required"
} else {
"optional"
};
let vt = format!("{:?}", prop_def.value_type).to_lowercase();
facts.insert(format!("prop:{type_name}:{prop_name}:{vt}:{req}"));
Self::fingerprint_constraints(&mut facts, type_name, prop_name, prop_def);
}
if let Some(subtypes) = &type_def.subtypes {
for (sub_name, sub_def) in subtypes {
facts.insert(format!("subtype:{type_name}:{sub_name}"));
for (prop_name, prop_def) in &sub_def.properties {
let req = if prop_def.required {
"required"
} else {
"optional"
};
let vt = format!("{:?}", prop_def.value_type).to_lowercase();
facts.insert(format!(
"subprop:{type_name}:{sub_name}:{prop_name}:{vt}:{req}"
));
Self::fingerprint_constraints(
&mut facts,
&format!("{type_name}:{sub_name}"),
prop_name,
prop_def,
);
}
}
}
}
for (edge_name, edge_def) in &self.edge_types {
facts.insert(format!("edge:{edge_name}"));
for src in &edge_def.source_types {
facts.insert(format!("edge:{edge_name}:src:{src}"));
}
for tgt in &edge_def.target_types {
facts.insert(format!("edge:{edge_name}:tgt:{tgt}"));
}
}
facts
}
pub fn check_compatibility(
&self,
foreign_hash: &[u8; 32],
foreign_fingerprint: &HashSet<String>,
) -> Compatibility {
if &self.content_hash() == foreign_hash {
return Compatibility::Identical;
}
let my_fp = self.fingerprint();
let is_superset = foreign_fingerprint.is_subset(&my_fp);
let is_subset = my_fp.is_subset(foreign_fingerprint);
match (is_superset, is_subset) {
(true, false) => Compatibility::Superset,
(false, true) => Compatibility::Subset,
(true, true) => Compatibility::Identical, (false, false) => Compatibility::Divergent,
}
}
fn fingerprint_constraints(
facts: &mut HashSet<String>,
type_name: &str,
prop_name: &str,
prop_def: &PropertyDef,
) {
if let Some(constraints) = &prop_def.constraints {
if let Some(enum_vals) = constraints.get("enum") {
if let Some(arr) = enum_vals.as_array() {
for val in arr {
if let Some(s) = val.as_str() {
facts.insert(format!("constraint:{type_name}:{prop_name}:enum:{s}"));
}
}
}
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ValidationError {
UnknownNodeType(String),
UnknownEdgeType(String),
InvalidSource {
edge_type: String,
node_type: String,
allowed: Vec<String>,
},
InvalidTarget {
edge_type: String,
node_type: String,
allowed: Vec<String>,
},
MissingRequiredProperty {
type_name: String,
property: String,
},
WrongPropertyType {
type_name: String,
property: String,
expected: ValueType,
got: String,
},
UnknownProperty {
type_name: String,
property: String,
},
MissingSubtype {
node_type: String,
allowed: Vec<String>,
},
UnknownSubtype {
node_type: String,
subtype: String,
allowed: Vec<String>,
},
UnexpectedSubtype {
node_type: String,
subtype: String,
},
ConstraintViolation {
type_name: String,
property: String,
constraint: String,
message: String,
},
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationError::UnknownNodeType(t) => write!(f, "unknown node type: '{t}'"),
ValidationError::UnknownEdgeType(t) => write!(f, "unknown edge type: '{t}'"),
ValidationError::InvalidSource {
edge_type,
node_type,
allowed,
} => write!(
f,
"edge '{edge_type}' cannot have source type '{node_type}' (allowed: {allowed:?})"
),
ValidationError::InvalidTarget {
edge_type,
node_type,
allowed,
} => write!(
f,
"edge '{edge_type}' cannot have target type '{node_type}' (allowed: {allowed:?})"
),
ValidationError::MissingRequiredProperty {
type_name,
property,
} => write!(f, "'{type_name}' requires property '{property}'"),
ValidationError::WrongPropertyType {
type_name,
property,
expected,
got,
} => write!(
f,
"'{type_name}'.'{property}' expects {expected:?}, got {got}"
),
ValidationError::UnknownProperty {
type_name,
property,
} => write!(f, "'{type_name}' has no property '{property}' in ontology"),
ValidationError::MissingSubtype { node_type, allowed } => {
write!(f, "'{node_type}' requires a subtype (allowed: {allowed:?})")
}
ValidationError::UnknownSubtype {
node_type,
subtype,
allowed,
} => write!(
f,
"'{node_type}' has no subtype '{subtype}' (allowed: {allowed:?})"
),
ValidationError::UnexpectedSubtype { node_type, subtype } => write!(
f,
"'{node_type}' does not define subtypes, but got subtype '{subtype}'"
),
ValidationError::ConstraintViolation {
type_name,
property,
constraint,
message,
} => write!(
f,
"'{type_name}'.'{property}' violates constraint '{constraint}': {message}"
),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OntologyExtension {
#[serde(default)]
pub node_types: BTreeMap<String, NodeTypeDef>,
#[serde(default)]
pub edge_types: BTreeMap<String, EdgeTypeDef>,
#[serde(default)]
pub node_type_updates: BTreeMap<String, NodeTypeUpdate>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NodeTypeUpdate {
#[serde(default)]
pub add_properties: BTreeMap<String, PropertyDef>,
#[serde(default)]
pub relax_properties: Vec<String>,
#[serde(default)]
pub add_subtypes: BTreeMap<String, SubtypeDef>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MonotonicityError {
DuplicateNodeType(String),
DuplicateEdgeType(String),
UnknownNodeType(String),
DuplicateProperty {
type_name: String,
property: String,
},
UnknownProperty {
type_name: String,
property: String,
},
ValidationFailed(ValidationError),
}
impl std::fmt::Display for MonotonicityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MonotonicityError::DuplicateNodeType(t) => {
write!(f, "node type '{t}' already exists")
}
MonotonicityError::DuplicateEdgeType(t) => {
write!(f, "edge type '{t}' already exists")
}
MonotonicityError::UnknownNodeType(t) => {
write!(f, "cannot update unknown node type '{t}'")
}
MonotonicityError::DuplicateProperty {
type_name,
property,
} => {
write!(f, "property '{property}' already exists on '{type_name}'")
}
MonotonicityError::UnknownProperty {
type_name,
property,
} => {
write!(
f,
"property '{property}' does not exist on '{type_name}' (cannot relax)"
)
}
MonotonicityError::ValidationFailed(e) => {
write!(f, "ontology validation failed after merge: {e}")
}
}
}
}
impl Ontology {
pub fn ancestors(&self, node_type: &str) -> Vec<&str> {
let mut result = Vec::new();
let mut current = node_type;
for _ in 0..100 {
match self
.node_types
.get(current)
.and_then(|d| d.parent_type.as_deref())
{
Some(parent) => {
result.push(parent);
current = parent;
}
None => break,
}
}
result
}
pub fn descendants(&self, node_type: &str) -> Vec<&str> {
self.node_types
.iter()
.filter(|(name, _)| {
name.as_str() != node_type && self.ancestors(name).contains(&node_type)
})
.map(|(name, _)| name.as_str())
.collect()
}
pub fn is_subtype_of(&self, child_type: &str, parent_type: &str) -> bool {
child_type == parent_type || self.ancestors(child_type).contains(&parent_type)
}
pub fn effective_properties(&self, node_type: &str) -> BTreeMap<String, PropertyDef> {
let mut chain: Vec<&str> = self.ancestors(node_type);
chain.reverse(); chain.push(node_type);
let mut props = BTreeMap::new();
for t in chain {
if let Some(def) = self.node_types.get(t) {
for (k, v) in &def.properties {
props.insert(k.clone(), v.clone());
}
}
}
props
}
pub fn validate_node(
&self,
node_type: &str,
subtype: Option<&str>,
properties: &BTreeMap<String, Value>,
) -> Result<(), ValidationError> {
let def = self
.node_types
.get(node_type)
.ok_or_else(|| ValidationError::UnknownNodeType(node_type.to_string()))?;
let base_props = self.effective_properties(node_type);
match (&def.subtypes, subtype) {
(Some(subtypes), Some(st)) => {
match subtypes.get(st) {
Some(st_def) => {
let mut merged = base_props;
merged.extend(st_def.properties.clone());
validate_properties(node_type, &merged, properties)
}
None => {
validate_properties(node_type, &base_props, properties)
}
}
}
(Some(subtypes), None) => Err(ValidationError::MissingSubtype {
node_type: node_type.to_string(),
allowed: subtypes.keys().cloned().collect(),
}),
(None, Some(_st)) => validate_properties(node_type, &base_props, properties),
(None, None) => validate_properties(node_type, &base_props, properties),
}
}
pub fn validate_edge(
&self,
edge_type: &str,
source_node_type: &str,
target_node_type: &str,
properties: &BTreeMap<String, Value>,
) -> Result<(), ValidationError> {
let def = self
.edge_types
.get(edge_type)
.ok_or_else(|| ValidationError::UnknownEdgeType(edge_type.to_string()))?;
if !def
.source_types
.iter()
.any(|t| self.is_subtype_of(source_node_type, t))
{
return Err(ValidationError::InvalidSource {
edge_type: edge_type.to_string(),
node_type: source_node_type.to_string(),
allowed: def.source_types.clone(),
});
}
if !def
.target_types
.iter()
.any(|t| self.is_subtype_of(target_node_type, t))
{
return Err(ValidationError::InvalidTarget {
edge_type: edge_type.to_string(),
node_type: target_node_type.to_string(),
allowed: def.target_types.clone(),
});
}
validate_properties(edge_type, &def.properties, properties)
}
pub fn validate_property_update(
&self,
node_type: &str,
subtype: Option<&str>,
key: &str,
value: &Value,
) -> Result<(), ValidationError> {
let def = match self.node_types.get(node_type) {
Some(d) => d,
None => return Ok(()), };
let mut merged = def.properties.clone();
if let (Some(subtypes), Some(st)) = (&def.subtypes, subtype) {
if let Some(st_def) = subtypes.get(st) {
merged.extend(st_def.properties.clone());
}
}
let prop_def = match merged.get(key) {
Some(d) => d,
None => return Ok(()),
};
if prop_def.value_type != ValueType::Any && !value_matches_type(value, &prop_def.value_type)
{
return Err(ValidationError::WrongPropertyType {
type_name: node_type.to_string(),
property: key.to_string(),
expected: prop_def.value_type.clone(),
got: value_type_name(value).to_string(),
});
}
if let Some(constraints) = &prop_def.constraints {
validate_constraints(node_type, key, value, constraints)?;
}
Ok(())
}
pub fn validate_self(&self) -> Result<(), ValidationError> {
for (edge_name, edge_def) in &self.edge_types {
for src in &edge_def.source_types {
if !self.node_types.contains_key(src) {
return Err(ValidationError::InvalidSource {
edge_type: edge_name.clone(),
node_type: src.clone(),
allowed: self.node_types.keys().cloned().collect(),
});
}
}
for tgt in &edge_def.target_types {
if !self.node_types.contains_key(tgt) {
return Err(ValidationError::InvalidTarget {
edge_type: edge_name.clone(),
node_type: tgt.clone(),
allowed: self.node_types.keys().cloned().collect(),
});
}
}
}
for (type_name, type_def) in &self.node_types {
if let Some(ref parent) = type_def.parent_type {
if !self.node_types.contains_key(parent) {
return Err(ValidationError::UnknownNodeType(format!(
"{}: parent_type '{}' does not exist",
type_name, parent
)));
}
}
}
Ok(())
}
pub fn merge_extension(&mut self, ext: &OntologyExtension) -> Result<(), MonotonicityError> {
for name in ext.node_types.keys() {
if self.node_types.contains_key(name) {
return Err(MonotonicityError::DuplicateNodeType(name.clone()));
}
}
for name in ext.edge_types.keys() {
if self.edge_types.contains_key(name) {
return Err(MonotonicityError::DuplicateEdgeType(name.clone()));
}
}
for (type_name, update) in &ext.node_type_updates {
let def = self
.node_types
.get(type_name)
.ok_or_else(|| MonotonicityError::UnknownNodeType(type_name.clone()))?;
for prop_name in update.add_properties.keys() {
if def.properties.contains_key(prop_name) {
return Err(MonotonicityError::DuplicateProperty {
type_name: type_name.clone(),
property: prop_name.clone(),
});
}
}
for prop_name in &update.relax_properties {
match def.properties.get(prop_name) {
Some(prop_def) if prop_def.required => {} Some(_) => {} None => {
return Err(MonotonicityError::UnknownProperty {
type_name: type_name.clone(),
property: prop_name.clone(),
});
}
}
}
if !update.add_subtypes.is_empty() {
if let Some(ref existing) = def.subtypes {
for st_name in update.add_subtypes.keys() {
if existing.contains_key(st_name) {
return Err(MonotonicityError::DuplicateProperty {
type_name: type_name.clone(),
property: format!("subtype:{st_name}"),
});
}
}
}
}
}
self.node_types.extend(ext.node_types.clone());
self.edge_types.extend(ext.edge_types.clone());
for (type_name, update) in &ext.node_type_updates {
let def = self.node_types.get_mut(type_name).unwrap();
def.properties.extend(update.add_properties.clone());
for prop_name in &update.relax_properties {
if let Some(prop_def) = def.properties.get_mut(prop_name) {
prop_def.required = false;
}
}
if !update.add_subtypes.is_empty() {
let subtypes = def.subtypes.get_or_insert_with(BTreeMap::new);
subtypes.extend(update.add_subtypes.clone());
}
}
self.validate_self()
.map_err(MonotonicityError::ValidationFailed)?;
Ok(())
}
}
fn validate_properties(
type_name: &str,
defs: &BTreeMap<String, PropertyDef>,
values: &BTreeMap<String, Value>,
) -> Result<(), ValidationError> {
for (prop_name, prop_def) in defs {
if prop_def.required && !values.contains_key(prop_name) {
return Err(ValidationError::MissingRequiredProperty {
type_name: type_name.to_string(),
property: prop_name.clone(),
});
}
}
for (prop_name, value) in values {
let prop_def = match defs.get(prop_name) {
Some(def) => def,
None => continue,
};
if prop_def.value_type != ValueType::Any {
let actual_type = value_type_name(value);
let expected = &prop_def.value_type;
if !value_matches_type(value, expected) {
return Err(ValidationError::WrongPropertyType {
type_name: type_name.to_string(),
property: prop_name.clone(),
expected: expected.clone(),
got: actual_type.to_string(),
});
}
}
if let Some(constraints) = &prop_def.constraints {
validate_constraints(type_name, prop_name, value, constraints)?;
}
}
Ok(())
}
fn validate_constraints(
type_name: &str,
prop_name: &str,
value: &Value,
constraints: &BTreeMap<String, serde_json::Value>,
) -> Result<(), ValidationError> {
if let Some(serde_json::Value::Array(allowed)) = constraints.get("enum") {
if let Value::String(s) = value {
let allowed_strs: Vec<&str> = allowed.iter().filter_map(|v| v.as_str()).collect();
if !allowed_strs.contains(&s.as_str()) {
return constraint_err(
type_name,
prop_name,
"enum",
format!("value '{}' not in allowed set {:?}", s, allowed_strs),
);
}
}
}
check_numeric_bound(
type_name,
prop_name,
value,
constraints,
"min",
|n, b| n < b,
|n, b| format!("value {} is less than minimum {}", n, b),
)?;
check_numeric_bound(
type_name,
prop_name,
value,
constraints,
"max",
|n, b| n > b,
|n, b| format!("value {} exceeds maximum {}", n, b),
)?;
check_numeric_bound(
type_name,
prop_name,
value,
constraints,
"min_exclusive",
|n, b| n <= b,
|n, b| format!("value {} must be greater than {}", n, b),
)?;
check_numeric_bound(
type_name,
prop_name,
value,
constraints,
"max_exclusive",
|n, b| n >= b,
|n, b| format!("value {} must be less than {}", n, b),
)?;
check_string_length(
type_name,
prop_name,
value,
constraints,
"min_length",
|len, bound| len < bound,
|len, bound| format!("string length {} is less than minimum {}", len, bound),
)?;
check_string_length(
type_name,
prop_name,
value,
constraints,
"max_length",
|len, bound| len > bound,
|len, bound| format!("string length {} exceeds maximum {}", len, bound),
)?;
if let Some(serde_json::Value::String(pattern)) = constraints.get("pattern") {
if let Value::String(s) = value {
match regex::Regex::new(pattern) {
Ok(re) if !re.is_match(s) => {
return constraint_err(
type_name,
prop_name,
"pattern",
format!("value '{}' does not match pattern '{}'", s, pattern),
);
}
Err(e) => {
return constraint_err(
type_name,
prop_name,
"pattern",
format!("invalid regex pattern '{}': {}", pattern, e),
);
}
_ => {}
}
}
}
Ok(())
}
fn value_as_f64(value: &Value) -> Option<f64> {
match value {
Value::Int(n) => Some(*n as f64),
Value::Float(n) => Some(*n),
_ => None,
}
}
fn check_numeric_bound(
type_name: &str,
prop_name: &str,
value: &Value,
constraints: &BTreeMap<String, serde_json::Value>,
key: &str,
violates: impl Fn(f64, f64) -> bool,
msg: impl Fn(f64, f64) -> String,
) -> Result<(), ValidationError> {
if let Some(bound_val) = constraints.get(key) {
if let Some(bound) = bound_val.as_f64() {
if let Some(n) = value_as_f64(value) {
if violates(n, bound) {
return constraint_err(type_name, prop_name, key, msg(n, bound));
}
}
}
}
Ok(())
}
fn check_string_length(
type_name: &str,
prop_name: &str,
value: &Value,
constraints: &BTreeMap<String, serde_json::Value>,
key: &str,
violates: impl Fn(u64, u64) -> bool,
msg: impl Fn(u64, u64) -> String,
) -> Result<(), ValidationError> {
if let Some(serde_json::Value::Number(n)) = constraints.get(key) {
if let (Some(bound), Value::String(s)) = (n.as_u64(), value) {
if violates(s.len() as u64, bound) {
return constraint_err(type_name, prop_name, key, msg(s.len() as u64, bound));
}
}
}
Ok(())
}
fn constraint_err(
type_name: &str,
prop_name: &str,
constraint: &str,
message: String,
) -> Result<(), ValidationError> {
Err(ValidationError::ConstraintViolation {
type_name: type_name.to_string(),
property: prop_name.to_string(),
constraint: constraint.to_string(),
message,
})
}
fn value_matches_type(value: &Value, expected: &ValueType) -> bool {
matches!(
(value, expected),
(Value::Null, _)
| (Value::String(_), ValueType::String)
| (Value::Int(_), ValueType::Int)
| (Value::Float(_), ValueType::Float)
| (Value::Bool(_), ValueType::Bool)
| (Value::List(_), ValueType::List)
| (Value::Map(_), ValueType::Map)
| (_, ValueType::Any)
)
}
fn value_type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Int(_) => "int",
Value::Float(_) => "float",
Value::String(_) => "string",
Value::List(_) => "list",
Value::Map(_) => "map",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn devops_ontology() -> Ontology {
Ontology {
node_types: BTreeMap::from([
(
"signal".into(),
NodeTypeDef {
description: Some("Something observed".into()),
properties: BTreeMap::from([(
"severity".into(),
PropertyDef {
value_type: ValueType::String,
required: true,
description: None,
constraints: None,
},
)]),
subtypes: None,
parent_type: None,
},
),
(
"entity".into(),
NodeTypeDef {
description: Some("Something that exists".into()),
properties: BTreeMap::from([
(
"status".into(),
PropertyDef {
value_type: ValueType::String,
required: false,
description: None,
constraints: None,
},
),
(
"port".into(),
PropertyDef {
value_type: ValueType::Int,
required: false,
description: None,
constraints: None,
},
),
]),
subtypes: None,
parent_type: None,
},
),
(
"rule".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
),
(
"action".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
),
]),
edge_types: BTreeMap::from([
(
"OBSERVES".into(),
EdgeTypeDef {
description: None,
source_types: vec!["signal".into()],
target_types: vec!["entity".into()],
properties: BTreeMap::new(),
},
),
(
"TRIGGERS".into(),
EdgeTypeDef {
description: None,
source_types: vec!["signal".into()],
target_types: vec!["rule".into()],
properties: BTreeMap::new(),
},
),
(
"RUNS_ON".into(),
EdgeTypeDef {
description: None,
source_types: vec!["entity".into()],
target_types: vec!["entity".into()],
properties: BTreeMap::new(),
},
),
]),
}
}
#[test]
fn validate_node_valid() {
let ont = devops_ontology();
let props = BTreeMap::from([("severity".into(), Value::String("critical".into()))]);
assert!(ont.validate_node("signal", None, &props).is_ok());
}
#[test]
fn validate_node_unknown_type() {
let ont = devops_ontology();
let err = ont
.validate_node("potato", None, &BTreeMap::new())
.unwrap_err();
assert!(matches!(err, ValidationError::UnknownNodeType(t) if t == "potato"));
}
#[test]
fn validate_node_missing_required() {
let ont = devops_ontology();
let err = ont
.validate_node("signal", None, &BTreeMap::new())
.unwrap_err();
assert!(
matches!(err, ValidationError::MissingRequiredProperty { property, .. } if property == "severity")
);
}
#[test]
fn validate_node_wrong_type() {
let ont = devops_ontology();
let props = BTreeMap::from([("severity".into(), Value::Int(5))]);
let err = ont.validate_node("signal", None, &props).unwrap_err();
assert!(
matches!(err, ValidationError::WrongPropertyType { property, .. } if property == "severity")
);
}
#[test]
fn validate_node_unknown_property_accepted() {
let ont = devops_ontology();
let props = BTreeMap::from([
("severity".into(), Value::String("warn".into())),
("bogus".into(), Value::Bool(true)),
]);
assert!(ont.validate_node("signal", None, &props).is_ok());
}
#[test]
fn validate_node_optional_property_absent() {
let ont = devops_ontology();
assert!(ont.validate_node("entity", None, &BTreeMap::new()).is_ok());
}
#[test]
fn validate_node_null_accepted_for_any_type() {
let ont = devops_ontology();
let props = BTreeMap::from([("severity".into(), Value::Null)]);
assert!(ont.validate_node("signal", None, &props).is_ok());
}
#[test]
fn validate_edge_valid() {
let ont = devops_ontology();
assert!(ont
.validate_edge("OBSERVES", "signal", "entity", &BTreeMap::new())
.is_ok());
}
#[test]
fn validate_edge_unknown_type() {
let ont = devops_ontology();
let err = ont
.validate_edge("FLIES_TO", "signal", "entity", &BTreeMap::new())
.unwrap_err();
assert!(matches!(err, ValidationError::UnknownEdgeType(t) if t == "FLIES_TO"));
}
#[test]
fn validate_edge_invalid_source() {
let ont = devops_ontology();
let err = ont
.validate_edge("OBSERVES", "entity", "entity", &BTreeMap::new())
.unwrap_err();
assert!(matches!(err, ValidationError::InvalidSource { .. }));
}
#[test]
fn validate_edge_invalid_target() {
let ont = devops_ontology();
let err = ont
.validate_edge("OBSERVES", "signal", "signal", &BTreeMap::new())
.unwrap_err();
assert!(matches!(err, ValidationError::InvalidTarget { .. }));
}
#[test]
fn validate_self_consistent() {
let ont = devops_ontology();
assert!(ont.validate_self().is_ok());
}
#[test]
fn validate_self_dangling_source() {
let ont = Ontology {
node_types: BTreeMap::from([(
"entity".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
)]),
edge_types: BTreeMap::from([(
"OBSERVES".into(),
EdgeTypeDef {
description: None,
source_types: vec!["ghost".into()], target_types: vec!["entity".into()],
properties: BTreeMap::new(),
},
)]),
};
let err = ont.validate_self().unwrap_err();
assert!(
matches!(err, ValidationError::InvalidSource { node_type, .. } if node_type == "ghost")
);
}
#[test]
fn validate_self_dangling_target() {
let ont = Ontology {
node_types: BTreeMap::from([(
"signal".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
)]),
edge_types: BTreeMap::from([(
"OBSERVES".into(),
EdgeTypeDef {
description: None,
source_types: vec!["signal".into()],
target_types: vec!["phantom".into()], properties: BTreeMap::new(),
},
)]),
};
let err = ont.validate_self().unwrap_err();
assert!(
matches!(err, ValidationError::InvalidTarget { node_type, .. } if node_type == "phantom")
);
}
fn constrained_ontology() -> Ontology {
Ontology {
node_types: BTreeMap::from([(
"item".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::from([
(
"slug".into(),
PropertyDef {
value_type: ValueType::String,
required: false,
description: None,
constraints: Some(BTreeMap::from([
(
"pattern".to_string(),
serde_json::Value::String("^[a-z0-9-]+$".to_string()),
),
(
"min_length".to_string(),
serde_json::Value::Number(1.into()),
),
(
"max_length".to_string(),
serde_json::Value::Number(63.into()),
),
])),
},
),
(
"score".into(),
PropertyDef {
value_type: ValueType::Float,
required: false,
description: None,
constraints: Some(BTreeMap::from([
("min_exclusive".to_string(), serde_json::json!(0.0)),
("max_exclusive".to_string(), serde_json::json!(100.0)),
])),
},
),
]),
subtypes: None,
parent_type: None,
},
)]),
edge_types: BTreeMap::new(),
}
}
#[test]
fn pattern_valid_slug() {
let ont = constrained_ontology();
let props = BTreeMap::from([("slug".into(), Value::String("my-project-1".into()))]);
assert!(ont.validate_node("item", None, &props).is_ok());
}
#[test]
fn pattern_rejects_uppercase() {
let ont = constrained_ontology();
let props = BTreeMap::from([("slug".into(), Value::String("My-Project".into()))]);
assert!(ont.validate_node("item", None, &props).is_err());
}
#[test]
fn pattern_rejects_spaces() {
let ont = constrained_ontology();
let props = BTreeMap::from([("slug".into(), Value::String("has space".into()))]);
assert!(ont.validate_node("item", None, &props).is_err());
}
#[test]
fn min_length_accepts_valid() {
let ont = constrained_ontology();
let props = BTreeMap::from([("slug".into(), Value::String("a".into()))]);
assert!(ont.validate_node("item", None, &props).is_ok());
}
#[test]
fn min_length_rejects_empty() {
let ont = constrained_ontology();
let props = BTreeMap::from([("slug".into(), Value::String("".into()))]);
let err = ont.validate_node("item", None, &props).unwrap_err();
assert!(
matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_length")
);
}
#[test]
fn max_length_rejects_too_long() {
let ont = constrained_ontology();
let long = "a".repeat(64);
let props = BTreeMap::from([("slug".into(), Value::String(long))]);
let err = ont.validate_node("item", None, &props).unwrap_err();
assert!(
matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_length")
);
}
#[test]
fn max_length_accepts_boundary() {
let ont = constrained_ontology();
let exact = "a".repeat(63);
let props = BTreeMap::from([("slug".into(), Value::String(exact))]);
assert!(ont.validate_node("item", None, &props).is_ok());
}
#[test]
fn min_exclusive_rejects_boundary() {
let ont = constrained_ontology();
let props = BTreeMap::from([("score".into(), Value::Float(0.0))]);
let err = ont.validate_node("item", None, &props).unwrap_err();
assert!(
matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_exclusive")
);
}
#[test]
fn min_exclusive_accepts_above() {
let ont = constrained_ontology();
let props = BTreeMap::from([("score".into(), Value::Float(0.001))]);
assert!(ont.validate_node("item", None, &props).is_ok());
}
#[test]
fn max_exclusive_rejects_boundary() {
let ont = constrained_ontology();
let props = BTreeMap::from([("score".into(), Value::Float(100.0))]);
let err = ont.validate_node("item", None, &props).unwrap_err();
assert!(
matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_exclusive")
);
}
#[test]
fn max_exclusive_accepts_below() {
let ont = constrained_ontology();
let props = BTreeMap::from([("score".into(), Value::Float(99.999))]);
assert!(ont.validate_node("item", None, &props).is_ok());
}
#[test]
fn ontology_roundtrip_msgpack() {
let ont = devops_ontology();
let bytes = rmp_serde::to_vec(&ont).unwrap();
let decoded: Ontology = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(ont, decoded);
}
#[test]
fn ontology_roundtrip_json() {
let ont = devops_ontology();
let json = serde_json::to_string(&ont).unwrap();
let decoded: Ontology = serde_json::from_str(&json).unwrap();
assert_eq!(ont, decoded);
}
fn hierarchy_ontology() -> Ontology {
Ontology {
node_types: BTreeMap::from([
(
"thing".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::from([(
"name".into(),
PropertyDef {
value_type: ValueType::String,
required: true,
description: None,
constraints: None,
},
)]),
subtypes: None,
parent_type: None, },
),
(
"entity".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::from([(
"status".into(),
PropertyDef {
value_type: ValueType::String,
required: false,
description: None,
constraints: None,
},
)]),
subtypes: None,
parent_type: Some("thing".into()), },
),
(
"server".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::from([(
"ip".into(),
PropertyDef {
value_type: ValueType::String,
required: false,
description: None,
constraints: None,
},
)]),
subtypes: None,
parent_type: Some("entity".into()), },
),
(
"event".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: Some("thing".into()), },
),
]),
edge_types: BTreeMap::from([(
"RELATES_TO".into(),
EdgeTypeDef {
description: None,
source_types: vec!["thing".into()], target_types: vec!["entity".into()], properties: BTreeMap::new(),
},
)]),
}
}
#[test]
fn ancestors_empty_for_root() {
let ont = hierarchy_ontology();
assert!(ont.ancestors("thing").is_empty());
}
#[test]
fn ancestors_single_parent() {
let ont = hierarchy_ontology();
assert_eq!(ont.ancestors("entity"), vec!["thing"]);
}
#[test]
fn ancestors_transitive() {
let ont = hierarchy_ontology();
assert_eq!(ont.ancestors("server"), vec!["entity", "thing"]);
}
#[test]
fn descendants_of_root() {
let ont = hierarchy_ontology();
let mut desc = ont.descendants("thing");
desc.sort();
assert_eq!(desc, vec!["entity", "event", "server"]);
}
#[test]
fn descendants_of_entity() {
let ont = hierarchy_ontology();
assert_eq!(ont.descendants("entity"), vec!["server"]);
}
#[test]
fn descendants_of_leaf() {
let ont = hierarchy_ontology();
assert!(ont.descendants("server").is_empty());
}
#[test]
fn is_subtype_of_self() {
let ont = hierarchy_ontology();
assert!(ont.is_subtype_of("server", "server"));
}
#[test]
fn is_subtype_of_parent() {
let ont = hierarchy_ontology();
assert!(ont.is_subtype_of("server", "entity"));
assert!(ont.is_subtype_of("server", "thing"));
}
#[test]
fn is_not_subtype_of_sibling() {
let ont = hierarchy_ontology();
assert!(!ont.is_subtype_of("server", "event"));
}
#[test]
fn effective_properties_inherits() {
let ont = hierarchy_ontology();
let props = ont.effective_properties("server");
assert!(props.contains_key("name"));
assert!(props.contains_key("status"));
assert!(props.contains_key("ip"));
}
#[test]
fn effective_properties_root_has_own_only() {
let ont = hierarchy_ontology();
let props = ont.effective_properties("thing");
assert!(props.contains_key("name"));
assert!(!props.contains_key("status"));
}
#[test]
fn validate_node_inherits_required_from_ancestor() {
let ont = hierarchy_ontology();
let err = ont.validate_node("server", None, &BTreeMap::new());
assert!(err.is_err());
let props = BTreeMap::from([("name".into(), Value::String("web-01".into()))]);
assert!(ont.validate_node("server", None, &props).is_ok());
}
#[test]
fn validate_edge_hierarchy_aware() {
let ont = hierarchy_ontology();
let empty = BTreeMap::new();
assert!(ont
.validate_edge("RELATES_TO", "server", "server", &empty)
.is_ok());
assert!(ont
.validate_edge("RELATES_TO", "event", "entity", &empty)
.is_ok());
assert!(ont
.validate_edge("RELATES_TO", "thing", "entity", &empty)
.is_ok());
}
#[test]
fn validate_edge_hierarchy_rejects_wrong_branch() {
let ont = hierarchy_ontology();
let empty = BTreeMap::new();
assert!(ont
.validate_edge("RELATES_TO", "thing", "event", &empty)
.is_err());
}
#[test]
fn validate_self_rejects_dangling_parent() {
let ont = Ontology {
node_types: BTreeMap::from([(
"orphan".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: Some("ghost".into()), },
)]),
edge_types: BTreeMap::new(),
};
assert!(ont.validate_self().is_err());
}
fn pet_ontology() -> Ontology {
Ontology {
node_types: BTreeMap::from([
(
"animal".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::from([(
"name".into(),
PropertyDef {
value_type: ValueType::String,
required: true,
description: None,
constraints: None,
},
)]),
subtypes: None,
parent_type: None,
},
),
(
"shelter".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
),
]),
edge_types: BTreeMap::from([(
"LIVES_AT".into(),
EdgeTypeDef {
description: None,
source_types: vec!["animal".into()],
target_types: vec!["shelter".into()],
properties: BTreeMap::new(),
},
)]),
}
}
#[test]
fn content_hash_deterministic() {
let a = pet_ontology();
let b = pet_ontology();
assert_eq!(a.content_hash(), b.content_hash());
}
#[test]
fn content_hash_is_32_bytes() {
let ont = pet_ontology();
let hash = ont.content_hash();
assert_eq!(hash.len(), 32);
assert_ne!(hash, [0u8; 32]); }
#[test]
fn content_hash_changes_on_new_type() {
let mut ont = pet_ontology();
let hash_before = ont.content_hash();
ont.node_types.insert(
"volunteer".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
);
let hash_after = ont.content_hash();
assert_ne!(hash_before, hash_after);
}
#[test]
fn content_hash_changes_on_new_property() {
let mut ont = pet_ontology();
let hash_before = ont.content_hash();
ont.node_types.get_mut("animal").unwrap().properties.insert(
"microchip_id".into(),
PropertyDef {
value_type: ValueType::String,
required: false,
description: None,
constraints: None,
},
);
let hash_after = ont.content_hash();
assert_ne!(hash_before, hash_after);
}
#[test]
fn fingerprint_contains_types() {
let ont = pet_ontology();
let fp = ont.fingerprint();
assert!(fp.contains("type:animal"));
assert!(fp.contains("type:shelter"));
assert!(fp.contains("edge:LIVES_AT"));
}
#[test]
fn fingerprint_contains_properties() {
let ont = pet_ontology();
let fp = ont.fingerprint();
assert!(fp.contains("prop:animal:name:string:required"));
}
#[test]
fn fingerprint_contains_edge_constraints() {
let ont = pet_ontology();
let fp = ont.fingerprint();
assert!(fp.contains("edge:LIVES_AT:src:animal"));
assert!(fp.contains("edge:LIVES_AT:tgt:shelter"));
}
#[test]
fn fingerprint_contains_parent_type() {
let ont = Ontology {
node_types: BTreeMap::from([
(
"entity".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
),
(
"server".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: Some("entity".into()),
},
),
]),
edge_types: BTreeMap::new(),
};
let fp = ont.fingerprint();
assert!(fp.contains("type:server:parent:entity"));
}
#[test]
fn fingerprint_contains_subtypes() {
let ont = Ontology {
node_types: BTreeMap::from([(
"entity".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: Some(BTreeMap::from([(
"project".into(),
SubtypeDef {
description: None,
properties: BTreeMap::from([(
"slug".into(),
PropertyDef {
value_type: ValueType::String,
required: true,
description: None,
constraints: None,
},
)]),
},
)])),
parent_type: None,
},
)]),
edge_types: BTreeMap::new(),
};
let fp = ont.fingerprint();
assert!(fp.contains("subtype:entity:project"));
assert!(fp.contains("subprop:entity:project:slug:string:required"));
}
#[test]
fn fingerprint_superset_after_extension() {
let base = pet_ontology();
let base_fp = base.fingerprint();
let mut extended = pet_ontology();
extended.node_types.insert(
"volunteer".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
);
let ext_fp = extended.fingerprint();
assert!(base_fp.is_subset(&ext_fp));
assert!(!ext_fp.is_subset(&base_fp));
}
#[test]
fn check_compatibility_identical() {
let a = pet_ontology();
let b = pet_ontology();
let verdict = a.check_compatibility(&b.content_hash(), &b.fingerprint());
assert_eq!(verdict, Compatibility::Identical);
}
#[test]
fn check_compatibility_superset() {
let base = pet_ontology();
let mut extended = pet_ontology();
extended.node_types.insert(
"volunteer".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
);
let verdict = extended.check_compatibility(&base.content_hash(), &base.fingerprint());
assert_eq!(verdict, Compatibility::Superset);
}
#[test]
fn check_compatibility_subset() {
let base = pet_ontology();
let mut extended = pet_ontology();
extended.node_types.insert(
"volunteer".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
);
let verdict = base.check_compatibility(&extended.content_hash(), &extended.fingerprint());
assert_eq!(verdict, Compatibility::Subset);
}
#[test]
fn check_compatibility_divergent() {
let mut branch_a = pet_ontology();
branch_a.node_types.insert(
"volunteer".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
);
let mut branch_b = pet_ontology();
branch_b.node_types.insert(
"adoption".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::new(),
subtypes: None,
parent_type: None,
},
);
let verdict =
branch_a.check_compatibility(&branch_b.content_hash(), &branch_b.fingerprint());
assert_eq!(verdict, Compatibility::Divergent);
}
#[test]
fn fingerprint_contains_enum_constraints() {
let ont = Ontology {
node_types: BTreeMap::from([(
"server".into(),
NodeTypeDef {
description: None,
properties: BTreeMap::from([(
"status".into(),
PropertyDef {
value_type: ValueType::String,
required: true,
description: None,
constraints: Some(BTreeMap::from([(
"enum".into(),
serde_json::json!(["active", "standby"]),
)])),
},
)]),
subtypes: None,
parent_type: None,
},
)]),
edge_types: BTreeMap::new(),
};
let fp = ont.fingerprint();
assert!(fp.contains("constraint:server:status:enum:active"));
assert!(fp.contains("constraint:server:status:enum:standby"));
}
}