1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashSet};
3
4use crate::entry::Value;
5
6pub const ENFORCED_CONSTRAINTS: [&str; 8] = [
11 "enum",
12 "min",
13 "max",
14 "min_exclusive",
15 "max_exclusive",
16 "min_length",
17 "max_length",
18 "pattern",
19];
20
21pub const UNENFORCED_CONSTRAINT_PREFIX: &str = "x_";
25
26pub const FINGERPRINT_VERSION: u32 = 2;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ValidationMode {
34 Full,
36 SkipRequired,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum ValueType {
48 String,
49 Int,
50 Float,
51 Bool,
52 List,
53 Map,
54 Any,
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct PropertyDef {
61 pub value_type: ValueType,
62 #[serde(default)]
63 pub required: bool,
64 #[serde(default)]
65 pub description: Option<String>,
66 #[serde(default)]
70 pub constraints: Option<BTreeMap<String, serde_json::Value>>,
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct SubtypeDef {
76 #[serde(default)]
77 pub description: Option<String>,
78 #[serde(default)]
79 pub properties: BTreeMap<String, PropertyDef>,
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct NodeTypeDef {
89 #[serde(default)]
90 pub description: Option<String>,
91 #[serde(default)]
92 pub properties: BTreeMap<String, PropertyDef>,
93 #[serde(default)]
96 pub subtypes: Option<BTreeMap<String, SubtypeDef>>,
97 #[serde(default)]
102 pub parent_type: Option<String>,
103}
104
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct EdgeTypeDef {
108 #[serde(default)]
109 pub description: Option<String>,
110 pub source_types: Vec<String>,
112 pub target_types: Vec<String>,
114 #[serde(default)]
115 pub properties: BTreeMap<String, PropertyDef>,
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123pub struct Ontology {
124 pub node_types: BTreeMap<String, NodeTypeDef>,
125 pub edge_types: BTreeMap<String, EdgeTypeDef>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum Compatibility {
131 Identical,
133 Superset,
135 Subset,
138 Divergent,
141}
142
143impl Ontology {
144 pub fn content_hash(&self) -> [u8; 32] {
150 let json = serde_json::to_string(self).expect("ontology serialization should not fail");
151 *blake3::hash(json.as_bytes()).as_bytes()
152 }
153
154 pub fn fingerprint(&self) -> HashSet<String> {
162 let mut facts = HashSet::new();
163
164 facts.insert(format!("fingerprint_version:{FINGERPRINT_VERSION}"));
167
168 for (type_name, type_def) in &self.node_types {
169 facts.insert(format!("type:{type_name}"));
170
171 if let Some(parent) = &type_def.parent_type {
172 facts.insert(format!("type:{type_name}:parent:{parent}"));
173 }
174
175 for (prop_name, prop_def) in &self.effective_properties(type_name) {
179 let req = if prop_def.required {
180 "required"
181 } else {
182 "optional"
183 };
184 let vt = format!("{:?}", prop_def.value_type).to_lowercase();
185 facts.insert(format!("prop:{type_name}:{prop_name}:{vt}:{req}"));
186 Self::fingerprint_constraints(&mut facts, type_name, prop_name, prop_def);
187 }
188
189 if let Some(subtypes) = &type_def.subtypes {
191 for (sub_name, sub_def) in subtypes {
192 facts.insert(format!("subtype:{type_name}:{sub_name}"));
193 for (prop_name, prop_def) in &sub_def.properties {
194 let req = if prop_def.required {
195 "required"
196 } else {
197 "optional"
198 };
199 let vt = format!("{:?}", prop_def.value_type).to_lowercase();
200 facts.insert(format!(
201 "subprop:{type_name}:{sub_name}:{prop_name}:{vt}:{req}"
202 ));
203 Self::fingerprint_constraints(
204 &mut facts,
205 &format!("{type_name}:{sub_name}"),
206 prop_name,
207 prop_def,
208 );
209 }
210 }
211 }
212 }
213
214 for (edge_name, edge_def) in &self.edge_types {
215 facts.insert(format!("edge:{edge_name}"));
216 for src in &edge_def.source_types {
217 facts.insert(format!("edge:{edge_name}:src:{src}"));
218 }
219 for tgt in &edge_def.target_types {
220 facts.insert(format!("edge:{edge_name}:tgt:{tgt}"));
221 }
222 for (prop_name, prop_def) in &edge_def.properties {
225 let req = if prop_def.required {
226 "required"
227 } else {
228 "optional"
229 };
230 let vt = format!("{:?}", prop_def.value_type).to_lowercase();
231 facts.insert(format!("edgeprop:{edge_name}:{prop_name}:{vt}:{req}"));
232 Self::fingerprint_constraints(
233 &mut facts,
234 &format!("edge:{edge_name}"),
235 prop_name,
236 prop_def,
237 );
238 }
239 }
240
241 facts
242 }
243
244 pub fn check_compatibility(
246 &self,
247 foreign_hash: &[u8; 32],
248 foreign_fingerprint: &HashSet<String>,
249 ) -> Compatibility {
250 if &self.content_hash() == foreign_hash {
251 return Compatibility::Identical;
252 }
253
254 let my_fp = self.fingerprint();
255
256 if my_fp == *foreign_fingerprint {
263 return Compatibility::Divergent;
264 }
265 if foreign_fingerprint.is_subset(&my_fp) {
266 return Compatibility::Superset;
267 }
268 if my_fp.is_subset(foreign_fingerprint) {
269 return Compatibility::Subset;
270 }
271 Compatibility::Divergent
272 }
273
274 fn fingerprint_constraints(
278 facts: &mut HashSet<String>,
279 type_name: &str,
280 prop_name: &str,
281 prop_def: &PropertyDef,
282 ) {
283 let Some(constraints) = &prop_def.constraints else {
284 return;
285 };
286 for (cname, cvalue) in constraints {
287 match cvalue {
288 serde_json::Value::Array(items) if cname == "enum" => {
291 for val in items {
292 let rendered = match val.as_str() {
293 Some(s) => s.to_string(),
294 None => val.to_string(),
295 };
296 facts.insert(format!(
297 "constraint:{type_name}:{prop_name}:enum:{rendered}"
298 ));
299 }
300 }
301 other => {
302 facts.insert(format!(
303 "constraint:{type_name}:{prop_name}:{cname}:{other}"
304 ));
305 }
306 }
307 }
308 }
309}
310
311#[derive(Debug, Clone, PartialEq)]
313pub enum ValidationError {
314 UnknownNodeType(String),
315 UnknownEdgeType(String),
316 InvalidSource {
317 edge_type: String,
318 node_type: String,
319 allowed: Vec<String>,
320 },
321 InvalidTarget {
322 edge_type: String,
323 node_type: String,
324 allowed: Vec<String>,
325 },
326 MissingRequiredProperty {
327 type_name: String,
328 property: String,
329 },
330 WrongPropertyType {
331 type_name: String,
332 property: String,
333 expected: ValueType,
334 got: String,
335 },
336 UnknownProperty {
337 type_name: String,
338 property: String,
339 },
340 MissingSubtype {
341 node_type: String,
342 allowed: Vec<String>,
343 },
344 UnknownSubtype {
345 node_type: String,
346 subtype: String,
347 allowed: Vec<String>,
348 },
349 UnexpectedSubtype {
350 node_type: String,
351 subtype: String,
352 },
353 ConstraintViolation {
355 type_name: String,
356 property: String,
357 constraint: String,
358 message: String,
359 },
360 UnknownConstraint {
362 type_name: String,
363 property: String,
364 constraint: String,
365 known: Vec<String>,
366 },
367}
368
369impl std::fmt::Display for ValidationError {
370 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371 match self {
372 ValidationError::UnknownNodeType(t) => write!(f, "unknown node type: '{t}'"),
373 ValidationError::UnknownEdgeType(t) => write!(f, "unknown edge type: '{t}'"),
374 ValidationError::InvalidSource {
375 edge_type,
376 node_type,
377 allowed,
378 } => write!(
379 f,
380 "edge '{edge_type}' cannot have source type '{node_type}' (allowed: {allowed:?})"
381 ),
382 ValidationError::InvalidTarget {
383 edge_type,
384 node_type,
385 allowed,
386 } => write!(
387 f,
388 "edge '{edge_type}' cannot have target type '{node_type}' (allowed: {allowed:?})"
389 ),
390 ValidationError::MissingRequiredProperty {
391 type_name,
392 property,
393 } => write!(f, "'{type_name}' requires property '{property}'"),
394 ValidationError::WrongPropertyType {
395 type_name,
396 property,
397 expected,
398 got,
399 } => write!(
400 f,
401 "'{type_name}'.'{property}' expects {expected:?}, got {got}"
402 ),
403 ValidationError::UnknownProperty {
404 type_name,
405 property,
406 } => write!(f, "'{type_name}' has no property '{property}' in ontology"),
407 ValidationError::MissingSubtype { node_type, allowed } => {
408 write!(f, "'{node_type}' requires a subtype (allowed: {allowed:?})")
409 }
410 ValidationError::UnknownSubtype {
411 node_type,
412 subtype,
413 allowed,
414 } => write!(
415 f,
416 "'{node_type}' has no subtype '{subtype}' (allowed: {allowed:?})"
417 ),
418 ValidationError::UnexpectedSubtype { node_type, subtype } => write!(
419 f,
420 "'{node_type}' does not define subtypes, but got subtype '{subtype}'"
421 ),
422 ValidationError::ConstraintViolation {
423 type_name,
424 property,
425 constraint,
426 message,
427 } => write!(
428 f,
429 "'{type_name}'.'{property}' violates constraint '{constraint}': {message}"
430 ),
431 ValidationError::UnknownConstraint {
432 type_name,
433 property,
434 constraint,
435 known,
436 } => write!(
437 f,
438 "'{type_name}'.'{property}' declares unknown constraint '{constraint}' \
439 (enforced: {}); nothing would check it. Prefix it '{}' to declare it \
440 deliberately unenforced.",
441 known.join(", "),
442 UNENFORCED_CONSTRAINT_PREFIX
443 ),
444 }
445 }
446}
447
448#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
450pub struct OntologyExtension {
451 #[serde(default)]
453 pub node_types: BTreeMap<String, NodeTypeDef>,
454 #[serde(default)]
456 pub edge_types: BTreeMap<String, EdgeTypeDef>,
457 #[serde(default)]
459 pub node_type_updates: BTreeMap<String, NodeTypeUpdate>,
460}
461
462#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
464pub struct NodeTypeUpdate {
465 #[serde(default)]
467 pub add_properties: BTreeMap<String, PropertyDef>,
468 #[serde(default)]
470 pub relax_properties: Vec<String>,
471 #[serde(default)]
473 pub add_subtypes: BTreeMap<String, SubtypeDef>,
474}
475
476#[derive(Debug, Clone, PartialEq)]
478pub enum MonotonicityError {
479 DuplicateNodeType(String),
480 DuplicateEdgeType(String),
481 UnknownNodeType(String),
482 DuplicateProperty {
483 type_name: String,
484 property: String,
485 },
486 UnknownProperty {
487 type_name: String,
488 property: String,
489 },
490 ValidationFailed(ValidationError),
492}
493
494impl std::fmt::Display for MonotonicityError {
495 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496 match self {
497 MonotonicityError::DuplicateNodeType(t) => {
498 write!(f, "node type '{t}' already exists")
499 }
500 MonotonicityError::DuplicateEdgeType(t) => {
501 write!(f, "edge type '{t}' already exists")
502 }
503 MonotonicityError::UnknownNodeType(t) => {
504 write!(f, "cannot update unknown node type '{t}'")
505 }
506 MonotonicityError::DuplicateProperty {
507 type_name,
508 property,
509 } => {
510 write!(f, "property '{property}' already exists on '{type_name}'")
511 }
512 MonotonicityError::UnknownProperty {
513 type_name,
514 property,
515 } => {
516 write!(
517 f,
518 "property '{property}' does not exist on '{type_name}' (cannot relax)"
519 )
520 }
521 MonotonicityError::ValidationFailed(e) => {
522 write!(f, "ontology validation failed after merge: {e}")
523 }
524 }
525 }
526}
527
528impl Ontology {
529 pub fn ancestors(&self, node_type: &str) -> Vec<&str> {
534 let mut result = Vec::new();
535 let mut current = node_type;
536 for _ in 0..100 {
538 match self
539 .node_types
540 .get(current)
541 .and_then(|d| d.parent_type.as_deref())
542 {
543 Some(parent) => {
544 result.push(parent);
545 current = parent;
546 }
547 None => break,
548 }
549 }
550 result
551 }
552
553 pub fn descendants(&self, node_type: &str) -> Vec<&str> {
556 self.node_types
558 .iter()
559 .filter(|(name, _)| {
560 name.as_str() != node_type && self.ancestors(name).contains(&node_type)
561 })
562 .map(|(name, _)| name.as_str())
563 .collect()
564 }
565
566 pub fn is_subtype_of(&self, child_type: &str, parent_type: &str) -> bool {
568 child_type == parent_type || self.ancestors(child_type).contains(&parent_type)
569 }
570
571 pub fn effective_properties(&self, node_type: &str) -> BTreeMap<String, PropertyDef> {
575 let mut chain: Vec<&str> = self.ancestors(node_type);
576 chain.reverse(); chain.push(node_type);
578
579 let mut props = BTreeMap::new();
580 for t in chain {
581 if let Some(def) = self.node_types.get(t) {
582 for (k, v) in &def.properties {
583 props.insert(k.clone(), v.clone());
584 }
585 }
586 }
587 props
588 }
589
590 pub fn validate_node(
596 &self,
597 node_type: &str,
598 subtype: Option<&str>,
599 properties: &BTreeMap<String, Value>,
600 ) -> Result<(), ValidationError> {
601 self.validate_node_mode(node_type, subtype, properties, ValidationMode::Full)
602 }
603
604 pub fn validate_node_mode(
606 &self,
607 node_type: &str,
608 subtype: Option<&str>,
609 properties: &BTreeMap<String, Value>,
610 mode: ValidationMode,
611 ) -> Result<(), ValidationError> {
612 let def = self
613 .node_types
614 .get(node_type)
615 .ok_or_else(|| ValidationError::UnknownNodeType(node_type.to_string()))?;
616
617 let base_props = self.effective_properties(node_type);
619
620 match (&def.subtypes, subtype) {
621 (Some(subtypes), Some(st)) => {
623 match subtypes.get(st) {
624 Some(st_def) => {
625 let mut merged = base_props;
627 merged.extend(st_def.properties.clone());
628 validate_properties(node_type, &merged, properties, mode)
629 }
630 None => {
631 validate_properties(node_type, &base_props, properties, mode)
633 }
634 }
635 }
636 (Some(subtypes), None) => Err(ValidationError::MissingSubtype {
638 node_type: node_type.to_string(),
639 allowed: subtypes.keys().cloned().collect(),
640 }),
641 (None, Some(_st)) => validate_properties(node_type, &base_props, properties, mode),
643 (None, None) => validate_properties(node_type, &base_props, properties, mode),
645 }
646 }
647
648 pub fn validate_edge(
651 &self,
652 edge_type: &str,
653 source_node_type: &str,
654 target_node_type: &str,
655 properties: &BTreeMap<String, Value>,
656 ) -> Result<(), ValidationError> {
657 self.validate_edge_mode(
658 edge_type,
659 source_node_type,
660 target_node_type,
661 properties,
662 ValidationMode::Full,
663 )
664 }
665
666 pub fn validate_edge_mode(
668 &self,
669 edge_type: &str,
670 source_node_type: &str,
671 target_node_type: &str,
672 properties: &BTreeMap<String, Value>,
673 mode: ValidationMode,
674 ) -> Result<(), ValidationError> {
675 let def = self
676 .edge_types
677 .get(edge_type)
678 .ok_or_else(|| ValidationError::UnknownEdgeType(edge_type.to_string()))?;
679
680 if !def
683 .source_types
684 .iter()
685 .any(|t| self.is_subtype_of(source_node_type, t))
686 {
687 return Err(ValidationError::InvalidSource {
688 edge_type: edge_type.to_string(),
689 node_type: source_node_type.to_string(),
690 allowed: def.source_types.clone(),
691 });
692 }
693
694 if !def
695 .target_types
696 .iter()
697 .any(|t| self.is_subtype_of(target_node_type, t))
698 {
699 return Err(ValidationError::InvalidTarget {
700 edge_type: edge_type.to_string(),
701 node_type: target_node_type.to_string(),
702 allowed: def.target_types.clone(),
703 });
704 }
705
706 validate_properties(edge_type, &def.properties, properties, mode)
707 }
708
709 pub fn validate_edge_property_update(
713 &self,
714 edge_type: &str,
715 key: &str,
716 value: &Value,
717 ) -> Result<(), ValidationError> {
718 let def = match self.edge_types.get(edge_type) {
719 Some(d) => d,
720 None => return Ok(()), };
722 let prop_def = match def.properties.get(key) {
724 Some(d) => d,
725 None => return Ok(()),
726 };
727 if prop_def.value_type != ValueType::Any && !value_matches_type(value, &prop_def.value_type)
728 {
729 return Err(ValidationError::WrongPropertyType {
730 type_name: edge_type.to_string(),
731 property: key.to_string(),
732 expected: prop_def.value_type.clone(),
733 got: value_type_name(value).to_string(),
734 });
735 }
736 if let Some(constraints) = &prop_def.constraints {
737 validate_constraints(edge_type, key, value, constraints)?;
738 }
739 Ok(())
740 }
741
742 pub fn validate_property_update(
746 &self,
747 node_type: &str,
748 subtype: Option<&str>,
749 key: &str,
750 value: &Value,
751 ) -> Result<(), ValidationError> {
752 let def = match self.node_types.get(node_type) {
753 Some(d) => d,
754 None => return Ok(()), };
756
757 let mut merged = def.properties.clone();
759 if let (Some(subtypes), Some(st)) = (&def.subtypes, subtype) {
760 if let Some(st_def) = subtypes.get(st) {
761 merged.extend(st_def.properties.clone());
762 }
763 }
764
765 let prop_def = match merged.get(key) {
767 Some(d) => d,
768 None => return Ok(()),
769 };
770
771 if prop_def.value_type != ValueType::Any && !value_matches_type(value, &prop_def.value_type)
773 {
774 return Err(ValidationError::WrongPropertyType {
775 type_name: node_type.to_string(),
776 property: key.to_string(),
777 expected: prop_def.value_type.clone(),
778 got: value_type_name(value).to_string(),
779 });
780 }
781
782 if let Some(constraints) = &prop_def.constraints {
784 validate_constraints(node_type, key, value, constraints)?;
785 }
786
787 Ok(())
788 }
789
790 pub fn validate_self(&self) -> Result<(), ValidationError> {
793 for (edge_name, edge_def) in &self.edge_types {
795 for src in &edge_def.source_types {
796 if !self.node_types.contains_key(src) {
797 return Err(ValidationError::InvalidSource {
798 edge_type: edge_name.clone(),
799 node_type: src.clone(),
800 allowed: self.node_types.keys().cloned().collect(),
801 });
802 }
803 }
804 for tgt in &edge_def.target_types {
805 if !self.node_types.contains_key(tgt) {
806 return Err(ValidationError::InvalidTarget {
807 edge_type: edge_name.clone(),
808 node_type: tgt.clone(),
809 allowed: self.node_types.keys().cloned().collect(),
810 });
811 }
812 }
813 }
814 for (type_name, type_def) in &self.node_types {
816 if let Some(ref parent) = type_def.parent_type {
817 if !self.node_types.contains_key(parent) {
818 return Err(ValidationError::UnknownNodeType(format!(
819 "{}: parent_type '{}' does not exist",
820 type_name, parent
821 )));
822 }
823 }
824 }
825 for (type_name, type_def) in &self.node_types {
831 for (prop_name, prop_def) in &type_def.properties {
832 Self::check_constraint_names(type_name, prop_name, prop_def)?;
833 }
834 if let Some(subtypes) = &type_def.subtypes {
835 for (sub_name, sub_def) in subtypes {
836 for (prop_name, prop_def) in &sub_def.properties {
837 Self::check_constraint_names(
838 &format!("{type_name}:{sub_name}"),
839 prop_name,
840 prop_def,
841 )?;
842 }
843 }
844 }
845 }
846 for (edge_name, edge_def) in &self.edge_types {
847 for (prop_name, prop_def) in &edge_def.properties {
848 Self::check_constraint_names(edge_name, prop_name, prop_def)?;
849 }
850 }
851 Ok(())
852 }
853
854 fn check_constraint_names(
855 type_name: &str,
856 prop_name: &str,
857 prop_def: &PropertyDef,
858 ) -> Result<(), ValidationError> {
859 let Some(constraints) = &prop_def.constraints else {
860 return Ok(());
861 };
862 for cname in constraints.keys() {
863 if ENFORCED_CONSTRAINTS.contains(&cname.as_str())
864 || cname.starts_with(UNENFORCED_CONSTRAINT_PREFIX)
865 {
866 continue;
867 }
868 return Err(ValidationError::UnknownConstraint {
869 type_name: type_name.to_string(),
870 property: prop_name.to_string(),
871 constraint: cname.clone(),
872 known: ENFORCED_CONSTRAINTS.iter().map(|s| s.to_string()).collect(),
873 });
874 }
875 Ok(())
876 }
877
878 pub fn merge_extension(&mut self, ext: &OntologyExtension) -> Result<(), MonotonicityError> {
884 for name in ext.node_types.keys() {
886 if self.node_types.contains_key(name) {
887 return Err(MonotonicityError::DuplicateNodeType(name.clone()));
888 }
889 }
890
891 for name in ext.edge_types.keys() {
893 if self.edge_types.contains_key(name) {
894 return Err(MonotonicityError::DuplicateEdgeType(name.clone()));
895 }
896 }
897
898 for (type_name, update) in &ext.node_type_updates {
900 let def = self
901 .node_types
902 .get(type_name)
903 .ok_or_else(|| MonotonicityError::UnknownNodeType(type_name.clone()))?;
904
905 for prop_name in update.add_properties.keys() {
907 if def.properties.contains_key(prop_name) {
908 return Err(MonotonicityError::DuplicateProperty {
909 type_name: type_name.clone(),
910 property: prop_name.clone(),
911 });
912 }
913 }
914
915 for prop_name in &update.relax_properties {
917 match def.properties.get(prop_name) {
918 Some(prop_def) if prop_def.required => {} Some(_) => {} None => {
921 return Err(MonotonicityError::UnknownProperty {
922 type_name: type_name.clone(),
923 property: prop_name.clone(),
924 });
925 }
926 }
927 }
928
929 if !update.add_subtypes.is_empty() {
931 if let Some(ref existing) = def.subtypes {
932 for st_name in update.add_subtypes.keys() {
933 if existing.contains_key(st_name) {
934 return Err(MonotonicityError::DuplicateProperty {
935 type_name: type_name.clone(),
936 property: format!("subtype:{st_name}"),
937 });
938 }
939 }
940 }
941 }
942 }
943
944 self.node_types.extend(ext.node_types.clone());
946
947 self.edge_types.extend(ext.edge_types.clone());
949
950 for (type_name, update) in &ext.node_type_updates {
952 let def = self.node_types.get_mut(type_name).unwrap(); def.properties.extend(update.add_properties.clone());
956
957 for prop_name in &update.relax_properties {
959 if let Some(prop_def) = def.properties.get_mut(prop_name) {
960 prop_def.required = false;
961 }
962 }
963
964 if !update.add_subtypes.is_empty() {
966 let subtypes = def.subtypes.get_or_insert_with(BTreeMap::new);
967 subtypes.extend(update.add_subtypes.clone());
968 }
969 }
970
971 self.validate_self()
973 .map_err(MonotonicityError::ValidationFailed)?;
974
975 Ok(())
976 }
977}
978
979fn validate_properties(
981 type_name: &str,
982 defs: &BTreeMap<String, PropertyDef>,
983 values: &BTreeMap<String, Value>,
984 mode: ValidationMode,
985) -> Result<(), ValidationError> {
986 if mode == ValidationMode::Full {
989 for (prop_name, prop_def) in defs {
990 if prop_def.required && !values.contains_key(prop_name) {
991 return Err(ValidationError::MissingRequiredProperty {
992 type_name: type_name.to_string(),
993 property: prop_name.clone(),
994 });
995 }
996 }
997 }
998
999 for (prop_name, value) in values {
1001 let prop_def = match defs.get(prop_name) {
1004 Some(def) => def,
1005 None => continue,
1006 };
1007
1008 if prop_def.value_type != ValueType::Any {
1009 let actual_type = value_type_name(value);
1010 let expected = &prop_def.value_type;
1011 if !value_matches_type(value, expected) {
1012 return Err(ValidationError::WrongPropertyType {
1013 type_name: type_name.to_string(),
1014 property: prop_name.clone(),
1015 expected: expected.clone(),
1016 got: actual_type.to_string(),
1017 });
1018 }
1019 }
1020
1021 if let Some(constraints) = &prop_def.constraints {
1023 validate_constraints(type_name, prop_name, value, constraints)?;
1024 }
1025 }
1026
1027 Ok(())
1028}
1029
1030fn validate_constraints(
1035 type_name: &str,
1036 prop_name: &str,
1037 value: &Value,
1038 constraints: &BTreeMap<String, serde_json::Value>,
1039) -> Result<(), ValidationError> {
1040 if let Some(serde_json::Value::Array(allowed)) = constraints.get("enum") {
1042 if let Value::String(s) = value {
1043 let allowed_strs: Vec<&str> = allowed.iter().filter_map(|v| v.as_str()).collect();
1044 if !allowed_strs.contains(&s.as_str()) {
1045 return constraint_err(
1046 type_name,
1047 prop_name,
1048 "enum",
1049 format!("value '{}' not in allowed set {:?}", s, allowed_strs),
1050 );
1051 }
1052 }
1053 }
1054
1055 check_numeric_bound(
1057 type_name,
1058 prop_name,
1059 value,
1060 constraints,
1061 "min",
1062 |n, b| n < b,
1063 |n, b| format!("value {} is less than minimum {}", n, b),
1064 )?;
1065 check_numeric_bound(
1066 type_name,
1067 prop_name,
1068 value,
1069 constraints,
1070 "max",
1071 |n, b| n > b,
1072 |n, b| format!("value {} exceeds maximum {}", n, b),
1073 )?;
1074 check_numeric_bound(
1075 type_name,
1076 prop_name,
1077 value,
1078 constraints,
1079 "min_exclusive",
1080 |n, b| n <= b,
1081 |n, b| format!("value {} must be greater than {}", n, b),
1082 )?;
1083 check_numeric_bound(
1084 type_name,
1085 prop_name,
1086 value,
1087 constraints,
1088 "max_exclusive",
1089 |n, b| n >= b,
1090 |n, b| format!("value {} must be less than {}", n, b),
1091 )?;
1092
1093 check_string_length(
1095 type_name,
1096 prop_name,
1097 value,
1098 constraints,
1099 "min_length",
1100 |len, bound| len < bound,
1101 |len, bound| format!("string length {} is less than minimum {}", len, bound),
1102 )?;
1103 check_string_length(
1104 type_name,
1105 prop_name,
1106 value,
1107 constraints,
1108 "max_length",
1109 |len, bound| len > bound,
1110 |len, bound| format!("string length {} exceeds maximum {}", len, bound),
1111 )?;
1112
1113 if let Some(serde_json::Value::String(pattern)) = constraints.get("pattern") {
1115 if let Value::String(s) = value {
1116 match regex::Regex::new(pattern) {
1117 Ok(re) if !re.is_match(s) => {
1118 return constraint_err(
1119 type_name,
1120 prop_name,
1121 "pattern",
1122 format!("value '{}' does not match pattern '{}'", s, pattern),
1123 );
1124 }
1125 Err(e) => {
1126 return constraint_err(
1127 type_name,
1128 prop_name,
1129 "pattern",
1130 format!("invalid regex pattern '{}': {}", pattern, e),
1131 );
1132 }
1133 _ => {}
1134 }
1135 }
1136 }
1137
1138 Ok(())
1140}
1141
1142fn value_as_f64(value: &Value) -> Option<f64> {
1144 match value {
1145 Value::Int(n) => Some(*n as f64),
1146 Value::Float(n) => Some(*n),
1147 _ => None,
1148 }
1149}
1150
1151fn check_numeric_bound(
1153 type_name: &str,
1154 prop_name: &str,
1155 value: &Value,
1156 constraints: &BTreeMap<String, serde_json::Value>,
1157 key: &str,
1158 violates: impl Fn(f64, f64) -> bool,
1159 msg: impl Fn(f64, f64) -> String,
1160) -> Result<(), ValidationError> {
1161 if let Some(bound_val) = constraints.get(key) {
1162 if let Some(bound) = bound_val.as_f64() {
1163 if let Some(n) = value_as_f64(value) {
1164 if violates(n, bound) {
1165 return constraint_err(type_name, prop_name, key, msg(n, bound));
1166 }
1167 }
1168 }
1169 }
1170 Ok(())
1171}
1172
1173fn check_string_length(
1175 type_name: &str,
1176 prop_name: &str,
1177 value: &Value,
1178 constraints: &BTreeMap<String, serde_json::Value>,
1179 key: &str,
1180 violates: impl Fn(u64, u64) -> bool,
1181 msg: impl Fn(u64, u64) -> String,
1182) -> Result<(), ValidationError> {
1183 if let Some(serde_json::Value::Number(n)) = constraints.get(key) {
1184 if let (Some(bound), Value::String(s)) = (n.as_u64(), value) {
1185 if violates(s.len() as u64, bound) {
1186 return constraint_err(type_name, prop_name, key, msg(s.len() as u64, bound));
1187 }
1188 }
1189 }
1190 Ok(())
1191}
1192
1193fn constraint_err(
1195 type_name: &str,
1196 prop_name: &str,
1197 constraint: &str,
1198 message: String,
1199) -> Result<(), ValidationError> {
1200 Err(ValidationError::ConstraintViolation {
1201 type_name: type_name.to_string(),
1202 property: prop_name.to_string(),
1203 constraint: constraint.to_string(),
1204 message,
1205 })
1206}
1207
1208fn value_matches_type(value: &Value, expected: &ValueType) -> bool {
1209 matches!(
1210 (value, expected),
1211 (Value::Null, _)
1212 | (Value::String(_), ValueType::String)
1213 | (Value::Int(_), ValueType::Int)
1214 | (Value::Float(_), ValueType::Float)
1215 | (Value::Bool(_), ValueType::Bool)
1216 | (Value::List(_), ValueType::List)
1217 | (Value::Map(_), ValueType::Map)
1218 | (_, ValueType::Any)
1219 )
1220}
1221
1222fn value_type_name(value: &Value) -> &'static str {
1223 match value {
1224 Value::Null => "null",
1225 Value::Bool(_) => "bool",
1226 Value::Int(_) => "int",
1227 Value::Float(_) => "float",
1228 Value::String(_) => "string",
1229 Value::List(_) => "list",
1230 Value::Map(_) => "map",
1231 }
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236 use super::*;
1237
1238 fn devops_ontology() -> Ontology {
1239 Ontology {
1240 node_types: BTreeMap::from([
1241 (
1242 "signal".into(),
1243 NodeTypeDef {
1244 description: Some("Something observed".into()),
1245 properties: BTreeMap::from([(
1246 "severity".into(),
1247 PropertyDef {
1248 value_type: ValueType::String,
1249 required: true,
1250 description: None,
1251 constraints: None,
1252 },
1253 )]),
1254 subtypes: None,
1255 parent_type: None,
1256 },
1257 ),
1258 (
1259 "entity".into(),
1260 NodeTypeDef {
1261 description: Some("Something that exists".into()),
1262 properties: BTreeMap::from([
1263 (
1264 "status".into(),
1265 PropertyDef {
1266 value_type: ValueType::String,
1267 required: false,
1268 description: None,
1269 constraints: None,
1270 },
1271 ),
1272 (
1273 "port".into(),
1274 PropertyDef {
1275 value_type: ValueType::Int,
1276 required: false,
1277 description: None,
1278 constraints: None,
1279 },
1280 ),
1281 ]),
1282 subtypes: None,
1283 parent_type: None,
1284 },
1285 ),
1286 (
1287 "rule".into(),
1288 NodeTypeDef {
1289 description: None,
1290 properties: BTreeMap::new(),
1291 subtypes: None,
1292 parent_type: None,
1293 },
1294 ),
1295 (
1296 "action".into(),
1297 NodeTypeDef {
1298 description: None,
1299 properties: BTreeMap::new(),
1300 subtypes: None,
1301 parent_type: None,
1302 },
1303 ),
1304 ]),
1305 edge_types: BTreeMap::from([
1306 (
1307 "OBSERVES".into(),
1308 EdgeTypeDef {
1309 description: None,
1310 source_types: vec!["signal".into()],
1311 target_types: vec!["entity".into()],
1312 properties: BTreeMap::new(),
1313 },
1314 ),
1315 (
1316 "TRIGGERS".into(),
1317 EdgeTypeDef {
1318 description: None,
1319 source_types: vec!["signal".into()],
1320 target_types: vec!["rule".into()],
1321 properties: BTreeMap::new(),
1322 },
1323 ),
1324 (
1325 "RUNS_ON".into(),
1326 EdgeTypeDef {
1327 description: None,
1328 source_types: vec!["entity".into()],
1329 target_types: vec!["entity".into()],
1330 properties: BTreeMap::new(),
1331 },
1332 ),
1333 ]),
1334 }
1335 }
1336
1337 #[test]
1340 fn validate_node_valid() {
1341 let ont = devops_ontology();
1342 let props = BTreeMap::from([("severity".into(), Value::String("critical".into()))]);
1343 assert!(ont.validate_node("signal", None, &props).is_ok());
1344 }
1345
1346 #[test]
1347 fn validate_node_unknown_type() {
1348 let ont = devops_ontology();
1349 let err = ont
1350 .validate_node("potato", None, &BTreeMap::new())
1351 .unwrap_err();
1352 assert!(matches!(err, ValidationError::UnknownNodeType(t) if t == "potato"));
1353 }
1354
1355 #[test]
1356 fn validate_node_missing_required() {
1357 let ont = devops_ontology();
1358 let err = ont
1359 .validate_node("signal", None, &BTreeMap::new())
1360 .unwrap_err();
1361 assert!(
1362 matches!(err, ValidationError::MissingRequiredProperty { property, .. } if property == "severity")
1363 );
1364 }
1365
1366 #[test]
1367 fn validate_node_wrong_type() {
1368 let ont = devops_ontology();
1369 let props = BTreeMap::from([("severity".into(), Value::Int(5))]);
1370 let err = ont.validate_node("signal", None, &props).unwrap_err();
1371 assert!(
1372 matches!(err, ValidationError::WrongPropertyType { property, .. } if property == "severity")
1373 );
1374 }
1375
1376 #[test]
1377 fn validate_node_unknown_property_accepted() {
1378 let ont = devops_ontology();
1380 let props = BTreeMap::from([
1381 ("severity".into(), Value::String("warn".into())),
1382 ("bogus".into(), Value::Bool(true)),
1383 ]);
1384 assert!(ont.validate_node("signal", None, &props).is_ok());
1385 }
1386
1387 #[test]
1388 fn validate_node_optional_property_absent() {
1389 let ont = devops_ontology();
1390 assert!(ont.validate_node("entity", None, &BTreeMap::new()).is_ok());
1392 }
1393
1394 #[test]
1395 fn validate_node_null_accepted_for_any_type() {
1396 let ont = devops_ontology();
1397 let props = BTreeMap::from([("severity".into(), Value::Null)]);
1399 assert!(ont.validate_node("signal", None, &props).is_ok());
1400 }
1401
1402 #[test]
1405 fn validate_edge_valid() {
1406 let ont = devops_ontology();
1407 assert!(ont
1408 .validate_edge("OBSERVES", "signal", "entity", &BTreeMap::new())
1409 .is_ok());
1410 }
1411
1412 #[test]
1413 fn validate_edge_unknown_type() {
1414 let ont = devops_ontology();
1415 let err = ont
1416 .validate_edge("FLIES_TO", "signal", "entity", &BTreeMap::new())
1417 .unwrap_err();
1418 assert!(matches!(err, ValidationError::UnknownEdgeType(t) if t == "FLIES_TO"));
1419 }
1420
1421 #[test]
1422 fn validate_edge_invalid_source() {
1423 let ont = devops_ontology();
1424 let err = ont
1426 .validate_edge("OBSERVES", "entity", "entity", &BTreeMap::new())
1427 .unwrap_err();
1428 assert!(matches!(err, ValidationError::InvalidSource { .. }));
1429 }
1430
1431 #[test]
1432 fn validate_edge_invalid_target() {
1433 let ont = devops_ontology();
1434 let err = ont
1436 .validate_edge("OBSERVES", "signal", "signal", &BTreeMap::new())
1437 .unwrap_err();
1438 assert!(matches!(err, ValidationError::InvalidTarget { .. }));
1439 }
1440
1441 #[test]
1444 fn validate_self_consistent() {
1445 let ont = devops_ontology();
1446 assert!(ont.validate_self().is_ok());
1447 }
1448
1449 #[test]
1450 fn validate_self_dangling_source() {
1451 let ont = Ontology {
1452 node_types: BTreeMap::from([(
1453 "entity".into(),
1454 NodeTypeDef {
1455 description: None,
1456 properties: BTreeMap::new(),
1457 subtypes: None,
1458 parent_type: None,
1459 },
1460 )]),
1461 edge_types: BTreeMap::from([(
1462 "OBSERVES".into(),
1463 EdgeTypeDef {
1464 description: None,
1465 source_types: vec!["ghost".into()], target_types: vec!["entity".into()],
1467 properties: BTreeMap::new(),
1468 },
1469 )]),
1470 };
1471 let err = ont.validate_self().unwrap_err();
1472 assert!(
1473 matches!(err, ValidationError::InvalidSource { node_type, .. } if node_type == "ghost")
1474 );
1475 }
1476
1477 #[test]
1478 fn validate_self_dangling_target() {
1479 let ont = Ontology {
1480 node_types: BTreeMap::from([(
1481 "signal".into(),
1482 NodeTypeDef {
1483 description: None,
1484 properties: BTreeMap::new(),
1485 subtypes: None,
1486 parent_type: None,
1487 },
1488 )]),
1489 edge_types: BTreeMap::from([(
1490 "OBSERVES".into(),
1491 EdgeTypeDef {
1492 description: None,
1493 source_types: vec!["signal".into()],
1494 target_types: vec!["phantom".into()], properties: BTreeMap::new(),
1496 },
1497 )]),
1498 };
1499 let err = ont.validate_self().unwrap_err();
1500 assert!(
1501 matches!(err, ValidationError::InvalidTarget { node_type, .. } if node_type == "phantom")
1502 );
1503 }
1504
1505 fn constrained_ontology() -> Ontology {
1510 Ontology {
1511 node_types: BTreeMap::from([(
1512 "item".into(),
1513 NodeTypeDef {
1514 description: None,
1515 properties: BTreeMap::from([
1516 (
1517 "slug".into(),
1518 PropertyDef {
1519 value_type: ValueType::String,
1520 required: false,
1521 description: None,
1522 constraints: Some(BTreeMap::from([
1523 (
1524 "pattern".to_string(),
1525 serde_json::Value::String("^[a-z0-9-]+$".to_string()),
1526 ),
1527 (
1528 "min_length".to_string(),
1529 serde_json::Value::Number(1.into()),
1530 ),
1531 (
1532 "max_length".to_string(),
1533 serde_json::Value::Number(63.into()),
1534 ),
1535 ])),
1536 },
1537 ),
1538 (
1539 "score".into(),
1540 PropertyDef {
1541 value_type: ValueType::Float,
1542 required: false,
1543 description: None,
1544 constraints: Some(BTreeMap::from([
1545 ("min_exclusive".to_string(), serde_json::json!(0.0)),
1546 ("max_exclusive".to_string(), serde_json::json!(100.0)),
1547 ])),
1548 },
1549 ),
1550 ]),
1551 subtypes: None,
1552 parent_type: None,
1553 },
1554 )]),
1555 edge_types: BTreeMap::new(),
1556 }
1557 }
1558
1559 #[test]
1560 fn pattern_valid_slug() {
1561 let ont = constrained_ontology();
1562 let props = BTreeMap::from([("slug".into(), Value::String("my-project-1".into()))]);
1563 assert!(ont.validate_node("item", None, &props).is_ok());
1564 }
1565
1566 #[test]
1567 fn pattern_rejects_uppercase() {
1568 let ont = constrained_ontology();
1569 let props = BTreeMap::from([("slug".into(), Value::String("My-Project".into()))]);
1570 assert!(ont.validate_node("item", None, &props).is_err());
1571 }
1572
1573 #[test]
1574 fn pattern_rejects_spaces() {
1575 let ont = constrained_ontology();
1576 let props = BTreeMap::from([("slug".into(), Value::String("has space".into()))]);
1577 assert!(ont.validate_node("item", None, &props).is_err());
1578 }
1579
1580 #[test]
1581 fn min_length_accepts_valid() {
1582 let ont = constrained_ontology();
1583 let props = BTreeMap::from([("slug".into(), Value::String("a".into()))]);
1584 assert!(ont.validate_node("item", None, &props).is_ok());
1585 }
1586
1587 #[test]
1588 fn min_length_rejects_empty() {
1589 let ont = constrained_ontology();
1590 let props = BTreeMap::from([("slug".into(), Value::String("".into()))]);
1591 let err = ont.validate_node("item", None, &props).unwrap_err();
1592 assert!(
1593 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_length")
1594 );
1595 }
1596
1597 #[test]
1598 fn max_length_rejects_too_long() {
1599 let ont = constrained_ontology();
1600 let long = "a".repeat(64);
1601 let props = BTreeMap::from([("slug".into(), Value::String(long))]);
1602 let err = ont.validate_node("item", None, &props).unwrap_err();
1603 assert!(
1604 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_length")
1605 );
1606 }
1607
1608 #[test]
1609 fn max_length_accepts_boundary() {
1610 let ont = constrained_ontology();
1611 let exact = "a".repeat(63);
1612 let props = BTreeMap::from([("slug".into(), Value::String(exact))]);
1613 assert!(ont.validate_node("item", None, &props).is_ok());
1614 }
1615
1616 #[test]
1617 fn min_exclusive_rejects_boundary() {
1618 let ont = constrained_ontology();
1619 let props = BTreeMap::from([("score".into(), Value::Float(0.0))]);
1620 let err = ont.validate_node("item", None, &props).unwrap_err();
1621 assert!(
1622 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_exclusive")
1623 );
1624 }
1625
1626 #[test]
1627 fn min_exclusive_accepts_above() {
1628 let ont = constrained_ontology();
1629 let props = BTreeMap::from([("score".into(), Value::Float(0.001))]);
1630 assert!(ont.validate_node("item", None, &props).is_ok());
1631 }
1632
1633 #[test]
1634 fn max_exclusive_rejects_boundary() {
1635 let ont = constrained_ontology();
1636 let props = BTreeMap::from([("score".into(), Value::Float(100.0))]);
1637 let err = ont.validate_node("item", None, &props).unwrap_err();
1638 assert!(
1639 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_exclusive")
1640 );
1641 }
1642
1643 #[test]
1644 fn max_exclusive_accepts_below() {
1645 let ont = constrained_ontology();
1646 let props = BTreeMap::from([("score".into(), Value::Float(99.999))]);
1647 assert!(ont.validate_node("item", None, &props).is_ok());
1648 }
1649
1650 #[test]
1653 fn ontology_roundtrip_msgpack() {
1654 let ont = devops_ontology();
1655 let bytes = rmp_serde::to_vec(&ont).unwrap();
1656 let decoded: Ontology = rmp_serde::from_slice(&bytes).unwrap();
1657 assert_eq!(ont, decoded);
1658 }
1659
1660 #[test]
1661 fn ontology_roundtrip_json() {
1662 let ont = devops_ontology();
1663 let json = serde_json::to_string(&ont).unwrap();
1664 let decoded: Ontology = serde_json::from_str(&json).unwrap();
1665 assert_eq!(ont, decoded);
1666 }
1667
1668 fn hierarchy_ontology() -> Ontology {
1671 Ontology {
1674 node_types: BTreeMap::from([
1675 (
1676 "thing".into(),
1677 NodeTypeDef {
1678 description: None,
1679 properties: BTreeMap::from([(
1680 "name".into(),
1681 PropertyDef {
1682 value_type: ValueType::String,
1683 required: true,
1684 description: None,
1685 constraints: None,
1686 },
1687 )]),
1688 subtypes: None,
1689 parent_type: None, },
1691 ),
1692 (
1693 "entity".into(),
1694 NodeTypeDef {
1695 description: None,
1696 properties: BTreeMap::from([(
1697 "status".into(),
1698 PropertyDef {
1699 value_type: ValueType::String,
1700 required: false,
1701 description: None,
1702 constraints: None,
1703 },
1704 )]),
1705 subtypes: None,
1706 parent_type: Some("thing".into()), },
1708 ),
1709 (
1710 "server".into(),
1711 NodeTypeDef {
1712 description: None,
1713 properties: BTreeMap::from([(
1714 "ip".into(),
1715 PropertyDef {
1716 value_type: ValueType::String,
1717 required: false,
1718 description: None,
1719 constraints: None,
1720 },
1721 )]),
1722 subtypes: None,
1723 parent_type: Some("entity".into()), },
1725 ),
1726 (
1727 "event".into(),
1728 NodeTypeDef {
1729 description: None,
1730 properties: BTreeMap::new(),
1731 subtypes: None,
1732 parent_type: Some("thing".into()), },
1734 ),
1735 ]),
1736 edge_types: BTreeMap::from([(
1737 "RELATES_TO".into(),
1738 EdgeTypeDef {
1739 description: None,
1740 source_types: vec!["thing".into()], target_types: vec!["entity".into()], properties: BTreeMap::new(),
1743 },
1744 )]),
1745 }
1746 }
1747
1748 #[test]
1749 fn ancestors_empty_for_root() {
1750 let ont = hierarchy_ontology();
1751 assert!(ont.ancestors("thing").is_empty());
1752 }
1753
1754 #[test]
1755 fn ancestors_single_parent() {
1756 let ont = hierarchy_ontology();
1757 assert_eq!(ont.ancestors("entity"), vec!["thing"]);
1758 }
1759
1760 #[test]
1761 fn ancestors_transitive() {
1762 let ont = hierarchy_ontology();
1763 assert_eq!(ont.ancestors("server"), vec!["entity", "thing"]);
1765 }
1766
1767 #[test]
1768 fn descendants_of_root() {
1769 let ont = hierarchy_ontology();
1770 let mut desc = ont.descendants("thing");
1771 desc.sort();
1772 assert_eq!(desc, vec!["entity", "event", "server"]);
1773 }
1774
1775 #[test]
1776 fn descendants_of_entity() {
1777 let ont = hierarchy_ontology();
1778 assert_eq!(ont.descendants("entity"), vec!["server"]);
1779 }
1780
1781 #[test]
1782 fn descendants_of_leaf() {
1783 let ont = hierarchy_ontology();
1784 assert!(ont.descendants("server").is_empty());
1785 }
1786
1787 #[test]
1788 fn is_subtype_of_self() {
1789 let ont = hierarchy_ontology();
1790 assert!(ont.is_subtype_of("server", "server"));
1791 }
1792
1793 #[test]
1794 fn is_subtype_of_parent() {
1795 let ont = hierarchy_ontology();
1796 assert!(ont.is_subtype_of("server", "entity"));
1797 assert!(ont.is_subtype_of("server", "thing"));
1798 }
1799
1800 #[test]
1801 fn is_not_subtype_of_sibling() {
1802 let ont = hierarchy_ontology();
1803 assert!(!ont.is_subtype_of("server", "event"));
1804 }
1805
1806 #[test]
1807 fn effective_properties_inherits() {
1808 let ont = hierarchy_ontology();
1809 let props = ont.effective_properties("server");
1810 assert!(props.contains_key("name"));
1812 assert!(props.contains_key("status"));
1813 assert!(props.contains_key("ip"));
1814 }
1815
1816 #[test]
1817 fn effective_properties_root_has_own_only() {
1818 let ont = hierarchy_ontology();
1819 let props = ont.effective_properties("thing");
1820 assert!(props.contains_key("name"));
1821 assert!(!props.contains_key("status"));
1822 }
1823
1824 #[test]
1825 fn validate_node_inherits_required_from_ancestor() {
1826 let ont = hierarchy_ontology();
1827 let err = ont.validate_node("server", None, &BTreeMap::new());
1829 assert!(err.is_err());
1830
1831 let props = BTreeMap::from([("name".into(), Value::String("web-01".into()))]);
1832 assert!(ont.validate_node("server", None, &props).is_ok());
1833 }
1834
1835 #[test]
1836 fn validate_edge_hierarchy_aware() {
1837 let ont = hierarchy_ontology();
1838 let empty = BTreeMap::new();
1841 assert!(ont
1842 .validate_edge("RELATES_TO", "server", "server", &empty)
1843 .is_ok());
1844 assert!(ont
1845 .validate_edge("RELATES_TO", "event", "entity", &empty)
1846 .is_ok());
1847 assert!(ont
1848 .validate_edge("RELATES_TO", "thing", "entity", &empty)
1849 .is_ok());
1850 }
1851
1852 #[test]
1853 fn validate_edge_hierarchy_rejects_wrong_branch() {
1854 let ont = hierarchy_ontology();
1855 let empty = BTreeMap::new();
1857 assert!(ont
1858 .validate_edge("RELATES_TO", "thing", "event", &empty)
1859 .is_err());
1860 }
1861
1862 #[test]
1863 fn validate_self_rejects_dangling_parent() {
1864 let ont = Ontology {
1865 node_types: BTreeMap::from([(
1866 "orphan".into(),
1867 NodeTypeDef {
1868 description: None,
1869 properties: BTreeMap::new(),
1870 subtypes: None,
1871 parent_type: Some("ghost".into()), },
1873 )]),
1874 edge_types: BTreeMap::new(),
1875 };
1876 assert!(ont.validate_self().is_err());
1877 }
1878
1879 fn pet_ontology() -> Ontology {
1882 Ontology {
1883 node_types: BTreeMap::from([
1884 (
1885 "animal".into(),
1886 NodeTypeDef {
1887 description: None,
1888 properties: BTreeMap::from([(
1889 "name".into(),
1890 PropertyDef {
1891 value_type: ValueType::String,
1892 required: true,
1893 description: None,
1894 constraints: None,
1895 },
1896 )]),
1897 subtypes: None,
1898 parent_type: None,
1899 },
1900 ),
1901 (
1902 "shelter".into(),
1903 NodeTypeDef {
1904 description: None,
1905 properties: BTreeMap::new(),
1906 subtypes: None,
1907 parent_type: None,
1908 },
1909 ),
1910 ]),
1911 edge_types: BTreeMap::from([(
1912 "LIVES_AT".into(),
1913 EdgeTypeDef {
1914 description: None,
1915 source_types: vec!["animal".into()],
1916 target_types: vec!["shelter".into()],
1917 properties: BTreeMap::new(),
1918 },
1919 )]),
1920 }
1921 }
1922
1923 #[test]
1924 fn content_hash_deterministic() {
1925 let a = pet_ontology();
1926 let b = pet_ontology();
1927 assert_eq!(a.content_hash(), b.content_hash());
1928 }
1929
1930 #[test]
1931 fn content_hash_is_32_bytes() {
1932 let ont = pet_ontology();
1933 let hash = ont.content_hash();
1934 assert_eq!(hash.len(), 32);
1935 assert_ne!(hash, [0u8; 32]); }
1937
1938 #[test]
1939 fn content_hash_changes_on_new_type() {
1940 let mut ont = pet_ontology();
1941 let hash_before = ont.content_hash();
1942 ont.node_types.insert(
1943 "volunteer".into(),
1944 NodeTypeDef {
1945 description: None,
1946 properties: BTreeMap::new(),
1947 subtypes: None,
1948 parent_type: None,
1949 },
1950 );
1951 let hash_after = ont.content_hash();
1952 assert_ne!(hash_before, hash_after);
1953 }
1954
1955 #[test]
1956 fn content_hash_changes_on_new_property() {
1957 let mut ont = pet_ontology();
1958 let hash_before = ont.content_hash();
1959 ont.node_types.get_mut("animal").unwrap().properties.insert(
1960 "microchip_id".into(),
1961 PropertyDef {
1962 value_type: ValueType::String,
1963 required: false,
1964 description: None,
1965 constraints: None,
1966 },
1967 );
1968 let hash_after = ont.content_hash();
1969 assert_ne!(hash_before, hash_after);
1970 }
1971
1972 #[test]
1973 fn fingerprint_contains_types() {
1974 let ont = pet_ontology();
1975 let fp = ont.fingerprint();
1976 assert!(fp.contains("type:animal"));
1977 assert!(fp.contains("type:shelter"));
1978 assert!(fp.contains("edge:LIVES_AT"));
1979 }
1980
1981 #[test]
1982 fn fingerprint_contains_properties() {
1983 let ont = pet_ontology();
1984 let fp = ont.fingerprint();
1985 assert!(fp.contains("prop:animal:name:string:required"));
1986 }
1987
1988 #[test]
1989 fn fingerprint_contains_edge_constraints() {
1990 let ont = pet_ontology();
1991 let fp = ont.fingerprint();
1992 assert!(fp.contains("edge:LIVES_AT:src:animal"));
1993 assert!(fp.contains("edge:LIVES_AT:tgt:shelter"));
1994 }
1995
1996 #[test]
1997 fn fingerprint_contains_parent_type() {
1998 let ont = Ontology {
1999 node_types: BTreeMap::from([
2000 (
2001 "entity".into(),
2002 NodeTypeDef {
2003 description: None,
2004 properties: BTreeMap::new(),
2005 subtypes: None,
2006 parent_type: None,
2007 },
2008 ),
2009 (
2010 "server".into(),
2011 NodeTypeDef {
2012 description: None,
2013 properties: BTreeMap::new(),
2014 subtypes: None,
2015 parent_type: Some("entity".into()),
2016 },
2017 ),
2018 ]),
2019 edge_types: BTreeMap::new(),
2020 };
2021 let fp = ont.fingerprint();
2022 assert!(fp.contains("type:server:parent:entity"));
2023 }
2024
2025 #[test]
2026 fn fingerprint_contains_subtypes() {
2027 let ont = Ontology {
2028 node_types: BTreeMap::from([(
2029 "entity".into(),
2030 NodeTypeDef {
2031 description: None,
2032 properties: BTreeMap::new(),
2033 subtypes: Some(BTreeMap::from([(
2034 "project".into(),
2035 SubtypeDef {
2036 description: None,
2037 properties: BTreeMap::from([(
2038 "slug".into(),
2039 PropertyDef {
2040 value_type: ValueType::String,
2041 required: true,
2042 description: None,
2043 constraints: None,
2044 },
2045 )]),
2046 },
2047 )])),
2048 parent_type: None,
2049 },
2050 )]),
2051 edge_types: BTreeMap::new(),
2052 };
2053 let fp = ont.fingerprint();
2054 assert!(fp.contains("subtype:entity:project"));
2055 assert!(fp.contains("subprop:entity:project:slug:string:required"));
2056 }
2057
2058 #[test]
2059 fn fingerprint_superset_after_extension() {
2060 let base = pet_ontology();
2061 let base_fp = base.fingerprint();
2062
2063 let mut extended = pet_ontology();
2064 extended.node_types.insert(
2065 "volunteer".into(),
2066 NodeTypeDef {
2067 description: None,
2068 properties: BTreeMap::new(),
2069 subtypes: None,
2070 parent_type: None,
2071 },
2072 );
2073 let ext_fp = extended.fingerprint();
2074
2075 assert!(base_fp.is_subset(&ext_fp));
2077 assert!(!ext_fp.is_subset(&base_fp));
2078 }
2079
2080 #[test]
2081 fn check_compatibility_identical() {
2082 let a = pet_ontology();
2083 let b = pet_ontology();
2084 let verdict = a.check_compatibility(&b.content_hash(), &b.fingerprint());
2085 assert_eq!(verdict, Compatibility::Identical);
2086 }
2087
2088 #[test]
2089 fn check_compatibility_superset() {
2090 let base = pet_ontology();
2091
2092 let mut extended = pet_ontology();
2093 extended.node_types.insert(
2094 "volunteer".into(),
2095 NodeTypeDef {
2096 description: None,
2097 properties: BTreeMap::new(),
2098 subtypes: None,
2099 parent_type: None,
2100 },
2101 );
2102
2103 let verdict = extended.check_compatibility(&base.content_hash(), &base.fingerprint());
2105 assert_eq!(verdict, Compatibility::Superset);
2106 }
2107
2108 #[test]
2109 fn check_compatibility_subset() {
2110 let base = pet_ontology();
2111
2112 let mut extended = pet_ontology();
2113 extended.node_types.insert(
2114 "volunteer".into(),
2115 NodeTypeDef {
2116 description: None,
2117 properties: BTreeMap::new(),
2118 subtypes: None,
2119 parent_type: None,
2120 },
2121 );
2122
2123 let verdict = base.check_compatibility(&extended.content_hash(), &extended.fingerprint());
2125 assert_eq!(verdict, Compatibility::Subset);
2126 }
2127
2128 #[test]
2129 fn check_compatibility_divergent() {
2130 let mut branch_a = pet_ontology();
2132 branch_a.node_types.insert(
2133 "volunteer".into(),
2134 NodeTypeDef {
2135 description: None,
2136 properties: BTreeMap::new(),
2137 subtypes: None,
2138 parent_type: None,
2139 },
2140 );
2141
2142 let mut branch_b = pet_ontology();
2143 branch_b.node_types.insert(
2144 "adoption".into(),
2145 NodeTypeDef {
2146 description: None,
2147 properties: BTreeMap::new(),
2148 subtypes: None,
2149 parent_type: None,
2150 },
2151 );
2152
2153 let verdict =
2154 branch_a.check_compatibility(&branch_b.content_hash(), &branch_b.fingerprint());
2155 assert_eq!(verdict, Compatibility::Divergent);
2156 }
2157
2158 #[test]
2159 fn fingerprint_contains_enum_constraints() {
2160 let ont = Ontology {
2161 node_types: BTreeMap::from([(
2162 "server".into(),
2163 NodeTypeDef {
2164 description: None,
2165 properties: BTreeMap::from([(
2166 "status".into(),
2167 PropertyDef {
2168 value_type: ValueType::String,
2169 required: true,
2170 description: None,
2171 constraints: Some(BTreeMap::from([(
2172 "enum".into(),
2173 serde_json::json!(["active", "standby"]),
2174 )])),
2175 },
2176 )]),
2177 subtypes: None,
2178 parent_type: None,
2179 },
2180 )]),
2181 edge_types: BTreeMap::new(),
2182 };
2183 let fp = ont.fingerprint();
2184 assert!(fp.contains("constraint:server:status:enum:active"));
2185 assert!(fp.contains("constraint:server:status:enum:standby"));
2186 }
2187}