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,
136 DestructiveSuperset,
144 Subset,
147 Divergent,
150}
151
152impl Ontology {
153 pub fn content_hash(&self) -> [u8; 32] {
159 let json = serde_json::to_string(self).expect("ontology serialization should not fail");
160 *blake3::hash(json.as_bytes()).as_bytes()
161 }
162
163 pub fn fingerprint(&self) -> HashSet<String> {
171 let mut facts = HashSet::new();
172
173 facts.insert(format!("fingerprint_version:{FINGERPRINT_VERSION}"));
176
177 for (type_name, type_def) in &self.node_types {
178 facts.insert(format!("type:{type_name}"));
179
180 if let Some(parent) = &type_def.parent_type {
181 facts.insert(format!("type:{type_name}:parent:{parent}"));
182 }
183
184 for (prop_name, prop_def) in &self.effective_properties(type_name) {
188 let req = if prop_def.required {
189 "required"
190 } else {
191 "optional"
192 };
193 let vt = format!("{:?}", prop_def.value_type).to_lowercase();
194 facts.insert(format!("prop:{type_name}:{prop_name}:{vt}:{req}"));
195 Self::fingerprint_constraints(&mut facts, type_name, prop_name, prop_def);
196 }
197
198 if let Some(subtypes) = &type_def.subtypes {
200 for (sub_name, sub_def) in subtypes {
201 facts.insert(format!("subtype:{type_name}:{sub_name}"));
202 for (prop_name, prop_def) in &sub_def.properties {
203 let req = if prop_def.required {
204 "required"
205 } else {
206 "optional"
207 };
208 let vt = format!("{:?}", prop_def.value_type).to_lowercase();
209 facts.insert(format!(
210 "subprop:{type_name}:{sub_name}:{prop_name}:{vt}:{req}"
211 ));
212 Self::fingerprint_constraints(
213 &mut facts,
214 &format!("{type_name}:{sub_name}"),
215 prop_name,
216 prop_def,
217 );
218 }
219 }
220 }
221 }
222
223 for (edge_name, edge_def) in &self.edge_types {
224 facts.insert(format!("edge:{edge_name}"));
225 for src in &edge_def.source_types {
226 facts.insert(format!("edge:{edge_name}:src:{src}"));
227 }
228 for tgt in &edge_def.target_types {
229 facts.insert(format!("edge:{edge_name}:tgt:{tgt}"));
230 }
231 for (prop_name, prop_def) in &edge_def.properties {
234 let req = if prop_def.required {
235 "required"
236 } else {
237 "optional"
238 };
239 let vt = format!("{:?}", prop_def.value_type).to_lowercase();
240 facts.insert(format!("edgeprop:{edge_name}:{prop_name}:{vt}:{req}"));
241 Self::fingerprint_constraints(
242 &mut facts,
243 &format!("edge:{edge_name}"),
244 prop_name,
245 prop_def,
246 );
247 }
248 }
249
250 facts
251 }
252
253 pub fn destructive_changes(&self, foreign_fingerprint: &HashSet<String>) -> Vec<String> {
263 Self::destructive_facts(&self.fingerprint(), foreign_fingerprint)
264 }
265
266 fn destructive_facts(mine: &HashSet<String>, foreign: &HashSet<String>) -> Vec<String> {
272 let mut out = Vec::new();
273
274 for fact in mine.difference(foreign) {
280 let Some(slot) = fact.strip_suffix(":required") else {
281 continue;
282 };
283 let (kind, prefix, rest) = match slot.split_once(':') {
284 Some(("prop", rest)) => ("node type", "prop", rest),
285 Some(("edgeprop", rest)) => ("edge type", "edgeprop", rest),
286 Some(("subprop", rest)) => ("subtype", "subprop", rest),
287 _ => continue,
288 };
289 let Some((owner_and_prop, _value_type)) = rest.rsplit_once(':') else {
292 continue;
293 };
294 let Some((owner, property)) = owner_and_prop.rsplit_once(':') else {
295 continue;
296 };
297
298 let peer_knows_owner = foreign.contains(&format!("type:{owner}"))
300 || foreign.contains(&format!("edge:{owner}"))
301 || owner
302 .split_once(':')
303 .is_some_and(|(t, _)| foreign.contains(&format!("type:{t}")));
304 if !peer_knows_owner {
305 continue;
306 }
307
308 let head = format!("{prefix}:{owner_and_prop}");
310 let peer_requires_it = foreign.iter().any(|f| {
311 f.strip_suffix(":required")
312 .and_then(|s| s.rsplit_once(':'))
313 .is_some_and(|(h, _)| h == head)
314 });
315 if peer_requires_it {
316 continue;
317 }
318
319 out.push(format!(
320 "required property '{property}' on {kind} '{owner}', which the peer \
321 does not require: its entries written without it would quarantine"
322 ));
323 }
324
325 let mut flipped: Vec<&str> = Vec::new();
327 for fact in mine.difference(foreign) {
328 let Some((type_name, _sub)) = fact
329 .strip_prefix("subtype:")
330 .and_then(|rest| rest.split_once(':'))
331 else {
332 continue;
333 };
334 if !foreign.contains(&format!("type:{type_name}")) || flipped.contains(&type_name) {
335 continue;
336 }
337 let peer_has_subtypes = foreign
338 .iter()
339 .any(|f| f.starts_with(&format!("subtype:{type_name}:")));
340 if !peer_has_subtypes {
341 flipped.push(type_name);
342 }
343 }
344 for type_name in flipped {
345 out.push(format!(
346 "node type '{type_name}' declares subtypes and the peer's does not: \
347 every node the peer wrote without one would quarantine"
348 ));
349 }
350
351 out.sort();
352 out
353 }
354
355 pub fn check_compatibility(
357 &self,
358 foreign_hash: &[u8; 32],
359 foreign_fingerprint: &HashSet<String>,
360 ) -> Compatibility {
361 if &self.content_hash() == foreign_hash {
362 return Compatibility::Identical;
363 }
364
365 let my_fp = self.fingerprint();
366
367 if my_fp == *foreign_fingerprint {
374 return Compatibility::Divergent;
375 }
376 if foreign_fingerprint.is_subset(&my_fp) {
377 if Self::destructive_facts(&my_fp, foreign_fingerprint).is_empty() {
378 return Compatibility::Superset;
379 }
380 return Compatibility::DestructiveSuperset;
381 }
382 if my_fp.is_subset(foreign_fingerprint) {
383 return Compatibility::Subset;
384 }
385 Compatibility::Divergent
386 }
387
388 fn fingerprint_constraints(
392 facts: &mut HashSet<String>,
393 type_name: &str,
394 prop_name: &str,
395 prop_def: &PropertyDef,
396 ) {
397 let Some(constraints) = &prop_def.constraints else {
398 return;
399 };
400 for (cname, cvalue) in constraints {
401 match cvalue {
402 serde_json::Value::Array(items) if cname == "enum" => {
405 for val in items {
406 let rendered = match val.as_str() {
407 Some(s) => s.to_string(),
408 None => val.to_string(),
409 };
410 facts.insert(format!(
411 "constraint:{type_name}:{prop_name}:enum:{rendered}"
412 ));
413 }
414 }
415 other => {
416 facts.insert(format!(
417 "constraint:{type_name}:{prop_name}:{cname}:{other}"
418 ));
419 }
420 }
421 }
422 }
423}
424
425#[derive(Debug, Clone, PartialEq)]
427pub enum ValidationError {
428 UnknownNodeType(String),
429 UnknownEdgeType(String),
430 InvalidSource {
431 edge_type: String,
432 node_type: String,
433 allowed: Vec<String>,
434 },
435 InvalidTarget {
436 edge_type: String,
437 node_type: String,
438 allowed: Vec<String>,
439 },
440 MissingRequiredProperty {
441 type_name: String,
442 property: String,
443 },
444 WrongPropertyType {
445 type_name: String,
446 property: String,
447 expected: ValueType,
448 got: String,
449 },
450 UnknownProperty {
451 type_name: String,
452 property: String,
453 },
454 MissingSubtype {
455 node_type: String,
456 allowed: Vec<String>,
457 },
458 UnknownSubtype {
459 node_type: String,
460 subtype: String,
461 allowed: Vec<String>,
462 },
463 UnexpectedSubtype {
464 node_type: String,
465 subtype: String,
466 },
467 ConstraintViolation {
469 type_name: String,
470 property: String,
471 constraint: String,
472 message: String,
473 },
474 UnknownConstraint {
476 type_name: String,
477 property: String,
478 constraint: String,
479 known: Vec<String>,
480 },
481}
482
483impl std::fmt::Display for ValidationError {
484 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485 match self {
486 ValidationError::UnknownNodeType(t) => write!(f, "unknown node type: '{t}'"),
487 ValidationError::UnknownEdgeType(t) => write!(f, "unknown edge type: '{t}'"),
488 ValidationError::InvalidSource {
489 edge_type,
490 node_type,
491 allowed,
492 } => write!(
493 f,
494 "edge '{edge_type}' cannot have source type '{node_type}' (allowed: {allowed:?})"
495 ),
496 ValidationError::InvalidTarget {
497 edge_type,
498 node_type,
499 allowed,
500 } => write!(
501 f,
502 "edge '{edge_type}' cannot have target type '{node_type}' (allowed: {allowed:?})"
503 ),
504 ValidationError::MissingRequiredProperty {
505 type_name,
506 property,
507 } => write!(f, "'{type_name}' requires property '{property}'"),
508 ValidationError::WrongPropertyType {
509 type_name,
510 property,
511 expected,
512 got,
513 } => write!(
514 f,
515 "'{type_name}'.'{property}' expects {expected:?}, got {got}"
516 ),
517 ValidationError::UnknownProperty {
518 type_name,
519 property,
520 } => write!(f, "'{type_name}' has no property '{property}' in ontology"),
521 ValidationError::MissingSubtype { node_type, allowed } => {
522 write!(f, "'{node_type}' requires a subtype (allowed: {allowed:?})")
523 }
524 ValidationError::UnknownSubtype {
525 node_type,
526 subtype,
527 allowed,
528 } => write!(
529 f,
530 "'{node_type}' has no subtype '{subtype}' (allowed: {allowed:?})"
531 ),
532 ValidationError::UnexpectedSubtype { node_type, subtype } => write!(
533 f,
534 "'{node_type}' does not define subtypes, but got subtype '{subtype}'"
535 ),
536 ValidationError::ConstraintViolation {
537 type_name,
538 property,
539 constraint,
540 message,
541 } => write!(
542 f,
543 "'{type_name}'.'{property}' violates constraint '{constraint}': {message}"
544 ),
545 ValidationError::UnknownConstraint {
546 type_name,
547 property,
548 constraint,
549 known,
550 } => write!(
551 f,
552 "'{type_name}'.'{property}' declares unknown constraint '{constraint}' \
553 (enforced: {}); nothing would check it. Prefix it '{}' to declare it \
554 deliberately unenforced.",
555 known.join(", "),
556 UNENFORCED_CONSTRAINT_PREFIX
557 ),
558 }
559 }
560}
561
562#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
564pub struct OntologyExtension {
565 #[serde(default)]
567 pub node_types: BTreeMap<String, NodeTypeDef>,
568 #[serde(default)]
570 pub edge_types: BTreeMap<String, EdgeTypeDef>,
571 #[serde(default)]
573 pub node_type_updates: BTreeMap<String, NodeTypeUpdate>,
574 #[serde(default)]
585 pub edge_type_updates: BTreeMap<String, EdgeTypeUpdate>,
586}
587
588#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
590pub struct EdgeTypeUpdate {
591 #[serde(default)]
593 pub add_source_types: Vec<String>,
594 #[serde(default)]
596 pub add_target_types: Vec<String>,
597 #[serde(default)]
599 pub add_properties: BTreeMap<String, PropertyDef>,
600}
601
602impl EdgeTypeUpdate {
603 fn is_empty(&self) -> bool {
604 self.add_source_types.is_empty()
605 && self.add_target_types.is_empty()
606 && self.add_properties.is_empty()
607 }
608}
609
610#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
612pub struct NodeTypeUpdate {
613 #[serde(default)]
615 pub add_properties: BTreeMap<String, PropertyDef>,
616 #[serde(default)]
618 pub relax_properties: Vec<String>,
619 #[serde(default)]
621 pub add_subtypes: BTreeMap<String, SubtypeDef>,
622}
623
624#[derive(Debug, Clone, PartialEq)]
626pub enum MonotonicityError {
627 DuplicateNodeType(String),
628 DuplicateEdgeType(String),
629 UnknownNodeType(String),
630 UnknownEdgeType(String),
632 UnknownBindingType {
634 edge_type: String,
635 node_type: String,
636 },
637 DuplicateBinding {
639 edge_type: String,
640 node_type: String,
641 },
642 EmptyExtension,
646 DuplicateProperty {
647 type_name: String,
648 property: String,
649 },
650 UnknownProperty {
651 type_name: String,
652 property: String,
653 },
654 RequiredPropertyOnExistingType {
658 kind: &'static str,
659 type_name: String,
660 property: String,
661 },
662 SubtypesOnSubtypelessType {
666 type_name: String,
667 subtypes: Vec<String>,
668 },
669 ValidationFailed(ValidationError),
671}
672
673impl std::fmt::Display for MonotonicityError {
674 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
675 match self {
676 MonotonicityError::DuplicateNodeType(t) => {
677 write!(f, "node type '{t}' already exists")
678 }
679 MonotonicityError::DuplicateEdgeType(t) => {
680 write!(f, "edge type '{t}' already exists")
681 }
682 MonotonicityError::UnknownNodeType(t) => {
683 write!(f, "cannot update unknown node type '{t}'")
684 }
685 MonotonicityError::UnknownEdgeType(t) => {
686 write!(f, "cannot update unknown edge type '{t}'")
687 }
688 MonotonicityError::UnknownBindingType {
689 edge_type,
690 node_type,
691 } => write!(
692 f,
693 "edge type '{edge_type}' cannot bind to unknown node type '{node_type}'"
694 ),
695 MonotonicityError::DuplicateBinding {
696 edge_type,
697 node_type,
698 } => write!(
699 f,
700 "edge type '{edge_type}' already binds '{node_type}'; this extension \
701 would change nothing"
702 ),
703 MonotonicityError::RequiredPropertyOnExistingType {
704 kind,
705 type_name,
706 property,
707 } => write!(
708 f,
709 "cannot add required property '{property}' to {kind} '{type_name}': \
710 entries written before this extension did not have to carry it, \
711 and every one of them would be quarantined on the next \
712 materialization, silently. Add it as optional (required: false) \
713 instead, or declare a new type."
714 ),
715 MonotonicityError::SubtypesOnSubtypelessType {
716 type_name,
717 subtypes,
718 } => write!(
719 f,
720 "cannot add subtypes {subtypes:?} to node type '{type_name}', which \
721 declares none: once a type has subtypes every node of it must name \
722 one, so every '{type_name}' node written so far would be quarantined \
723 on the next materialization, silently. Subtypes have to be declared \
724 when the type is created."
725 ),
726 MonotonicityError::EmptyExtension => write!(
727 f,
728 "extension expresses no change; nothing would be added. An extension \
729 that changes nothing must not be written to the log"
730 ),
731 MonotonicityError::DuplicateProperty {
732 type_name,
733 property,
734 } => {
735 write!(f, "property '{property}' already exists on '{type_name}'")
736 }
737 MonotonicityError::UnknownProperty {
738 type_name,
739 property,
740 } => {
741 write!(
742 f,
743 "property '{property}' does not exist on '{type_name}' (cannot relax)"
744 )
745 }
746 MonotonicityError::ValidationFailed(e) => {
747 write!(f, "ontology validation failed after merge: {e}")
748 }
749 }
750 }
751}
752
753impl Ontology {
754 pub fn ancestors(&self, node_type: &str) -> Vec<&str> {
759 let mut result = Vec::new();
760 let mut current = node_type;
761 for _ in 0..100 {
763 match self
764 .node_types
765 .get(current)
766 .and_then(|d| d.parent_type.as_deref())
767 {
768 Some(parent) => {
769 result.push(parent);
770 current = parent;
771 }
772 None => break,
773 }
774 }
775 result
776 }
777
778 pub fn descendants(&self, node_type: &str) -> Vec<&str> {
781 self.node_types
783 .iter()
784 .filter(|(name, _)| {
785 name.as_str() != node_type && self.ancestors(name).contains(&node_type)
786 })
787 .map(|(name, _)| name.as_str())
788 .collect()
789 }
790
791 pub fn is_subtype_of(&self, child_type: &str, parent_type: &str) -> bool {
793 child_type == parent_type || self.ancestors(child_type).contains(&parent_type)
794 }
795
796 pub fn effective_properties(&self, node_type: &str) -> BTreeMap<String, PropertyDef> {
800 let mut chain: Vec<&str> = self.ancestors(node_type);
801 chain.reverse(); chain.push(node_type);
803
804 let mut props = BTreeMap::new();
805 for t in chain {
806 if let Some(def) = self.node_types.get(t) {
807 for (k, v) in &def.properties {
808 props.insert(k.clone(), v.clone());
809 }
810 }
811 }
812 props
813 }
814
815 pub fn validate_node(
821 &self,
822 node_type: &str,
823 subtype: Option<&str>,
824 properties: &BTreeMap<String, Value>,
825 ) -> Result<(), ValidationError> {
826 self.validate_node_mode(node_type, subtype, properties, ValidationMode::Full)
827 }
828
829 pub fn validate_node_mode(
831 &self,
832 node_type: &str,
833 subtype: Option<&str>,
834 properties: &BTreeMap<String, Value>,
835 mode: ValidationMode,
836 ) -> Result<(), ValidationError> {
837 let def = self
838 .node_types
839 .get(node_type)
840 .ok_or_else(|| ValidationError::UnknownNodeType(node_type.to_string()))?;
841
842 let base_props = self.effective_properties(node_type);
844
845 match (&def.subtypes, subtype) {
846 (Some(subtypes), Some(st)) => {
848 match subtypes.get(st) {
849 Some(st_def) => {
850 let mut merged = base_props;
852 merged.extend(st_def.properties.clone());
853 validate_properties(node_type, &merged, properties, mode)
854 }
855 None => {
856 validate_properties(node_type, &base_props, properties, mode)
858 }
859 }
860 }
861 (Some(subtypes), None) => Err(ValidationError::MissingSubtype {
863 node_type: node_type.to_string(),
864 allowed: subtypes.keys().cloned().collect(),
865 }),
866 (None, Some(_st)) => validate_properties(node_type, &base_props, properties, mode),
868 (None, None) => validate_properties(node_type, &base_props, properties, mode),
870 }
871 }
872
873 pub fn validate_edge(
876 &self,
877 edge_type: &str,
878 source_node_type: &str,
879 target_node_type: &str,
880 properties: &BTreeMap<String, Value>,
881 ) -> Result<(), ValidationError> {
882 self.validate_edge_mode(
883 edge_type,
884 source_node_type,
885 target_node_type,
886 properties,
887 ValidationMode::Full,
888 )
889 }
890
891 pub fn validate_edge_mode(
893 &self,
894 edge_type: &str,
895 source_node_type: &str,
896 target_node_type: &str,
897 properties: &BTreeMap<String, Value>,
898 mode: ValidationMode,
899 ) -> Result<(), ValidationError> {
900 let def = self
901 .edge_types
902 .get(edge_type)
903 .ok_or_else(|| ValidationError::UnknownEdgeType(edge_type.to_string()))?;
904
905 if !def
908 .source_types
909 .iter()
910 .any(|t| self.is_subtype_of(source_node_type, t))
911 {
912 return Err(ValidationError::InvalidSource {
913 edge_type: edge_type.to_string(),
914 node_type: source_node_type.to_string(),
915 allowed: def.source_types.clone(),
916 });
917 }
918
919 if !def
920 .target_types
921 .iter()
922 .any(|t| self.is_subtype_of(target_node_type, t))
923 {
924 return Err(ValidationError::InvalidTarget {
925 edge_type: edge_type.to_string(),
926 node_type: target_node_type.to_string(),
927 allowed: def.target_types.clone(),
928 });
929 }
930
931 validate_properties(edge_type, &def.properties, properties, mode)
932 }
933
934 pub fn validate_edge_property_update(
938 &self,
939 edge_type: &str,
940 key: &str,
941 value: &Value,
942 ) -> Result<(), ValidationError> {
943 let def = match self.edge_types.get(edge_type) {
944 Some(d) => d,
945 None => return Ok(()), };
947 let prop_def = match def.properties.get(key) {
949 Some(d) => d,
950 None => return Ok(()),
951 };
952 if prop_def.value_type != ValueType::Any && !value_matches_type(value, &prop_def.value_type)
953 {
954 return Err(ValidationError::WrongPropertyType {
955 type_name: edge_type.to_string(),
956 property: key.to_string(),
957 expected: prop_def.value_type.clone(),
958 got: value_type_name(value).to_string(),
959 });
960 }
961 if let Some(constraints) = &prop_def.constraints {
962 validate_constraints(edge_type, key, value, constraints)?;
963 }
964 Ok(())
965 }
966
967 pub fn validate_property_update(
971 &self,
972 node_type: &str,
973 subtype: Option<&str>,
974 key: &str,
975 value: &Value,
976 ) -> Result<(), ValidationError> {
977 let def = match self.node_types.get(node_type) {
978 Some(d) => d,
979 None => return Ok(()), };
981
982 let mut merged = def.properties.clone();
984 if let (Some(subtypes), Some(st)) = (&def.subtypes, subtype) {
985 if let Some(st_def) = subtypes.get(st) {
986 merged.extend(st_def.properties.clone());
987 }
988 }
989
990 let prop_def = match merged.get(key) {
992 Some(d) => d,
993 None => return Ok(()),
994 };
995
996 if prop_def.value_type != ValueType::Any && !value_matches_type(value, &prop_def.value_type)
998 {
999 return Err(ValidationError::WrongPropertyType {
1000 type_name: node_type.to_string(),
1001 property: key.to_string(),
1002 expected: prop_def.value_type.clone(),
1003 got: value_type_name(value).to_string(),
1004 });
1005 }
1006
1007 if let Some(constraints) = &prop_def.constraints {
1009 validate_constraints(node_type, key, value, constraints)?;
1010 }
1011
1012 Ok(())
1013 }
1014
1015 pub fn validate_self(&self) -> Result<(), ValidationError> {
1018 for (edge_name, edge_def) in &self.edge_types {
1020 for src in &edge_def.source_types {
1021 if !self.node_types.contains_key(src) {
1022 return Err(ValidationError::InvalidSource {
1023 edge_type: edge_name.clone(),
1024 node_type: src.clone(),
1025 allowed: self.node_types.keys().cloned().collect(),
1026 });
1027 }
1028 }
1029 for tgt in &edge_def.target_types {
1030 if !self.node_types.contains_key(tgt) {
1031 return Err(ValidationError::InvalidTarget {
1032 edge_type: edge_name.clone(),
1033 node_type: tgt.clone(),
1034 allowed: self.node_types.keys().cloned().collect(),
1035 });
1036 }
1037 }
1038 }
1039 for (type_name, type_def) in &self.node_types {
1041 if let Some(ref parent) = type_def.parent_type {
1042 if !self.node_types.contains_key(parent) {
1043 return Err(ValidationError::UnknownNodeType(format!(
1044 "{}: parent_type '{}' does not exist",
1045 type_name, parent
1046 )));
1047 }
1048 }
1049 }
1050 for (type_name, type_def) in &self.node_types {
1056 for (prop_name, prop_def) in &type_def.properties {
1057 Self::check_constraint_names(type_name, prop_name, prop_def)?;
1058 }
1059 if let Some(subtypes) = &type_def.subtypes {
1060 for (sub_name, sub_def) in subtypes {
1061 for (prop_name, prop_def) in &sub_def.properties {
1062 Self::check_constraint_names(
1063 &format!("{type_name}:{sub_name}"),
1064 prop_name,
1065 prop_def,
1066 )?;
1067 }
1068 }
1069 }
1070 }
1071 for (edge_name, edge_def) in &self.edge_types {
1072 for (prop_name, prop_def) in &edge_def.properties {
1073 Self::check_constraint_names(edge_name, prop_name, prop_def)?;
1074 }
1075 }
1076 Ok(())
1077 }
1078
1079 fn check_constraint_names(
1080 type_name: &str,
1081 prop_name: &str,
1082 prop_def: &PropertyDef,
1083 ) -> Result<(), ValidationError> {
1084 let Some(constraints) = &prop_def.constraints else {
1085 return Ok(());
1086 };
1087 for cname in constraints.keys() {
1088 if ENFORCED_CONSTRAINTS.contains(&cname.as_str())
1089 || cname.starts_with(UNENFORCED_CONSTRAINT_PREFIX)
1090 {
1091 continue;
1092 }
1093 return Err(ValidationError::UnknownConstraint {
1094 type_name: type_name.to_string(),
1095 property: prop_name.to_string(),
1096 constraint: cname.clone(),
1097 known: ENFORCED_CONSTRAINTS.iter().map(|s| s.to_string()).collect(),
1098 });
1099 }
1100 Ok(())
1101 }
1102
1103 pub fn merge_extension(&mut self, ext: &OntologyExtension) -> Result<(), MonotonicityError> {
1115 if ext.node_types.is_empty()
1119 && ext.edge_types.is_empty()
1120 && ext.node_type_updates.values().all(|u| {
1121 u.add_properties.is_empty()
1122 && u.relax_properties.is_empty()
1123 && u.add_subtypes.is_empty()
1124 })
1125 && ext.edge_type_updates.values().all(|u| u.is_empty())
1126 {
1127 return Err(MonotonicityError::EmptyExtension);
1128 }
1129
1130 for name in ext.node_types.keys() {
1132 if self.node_types.contains_key(name) {
1133 return Err(MonotonicityError::DuplicateNodeType(name.clone()));
1134 }
1135 }
1136
1137 for name in ext.edge_types.keys() {
1139 if self.edge_types.contains_key(name) {
1140 return Err(MonotonicityError::DuplicateEdgeType(name.clone()));
1141 }
1142 }
1143
1144 for (type_name, update) in &ext.node_type_updates {
1146 let def = self
1147 .node_types
1148 .get(type_name)
1149 .ok_or_else(|| MonotonicityError::UnknownNodeType(type_name.clone()))?;
1150
1151 for prop_name in update.add_properties.keys() {
1153 if def.properties.contains_key(prop_name) {
1154 return Err(MonotonicityError::DuplicateProperty {
1155 type_name: type_name.clone(),
1156 property: prop_name.clone(),
1157 });
1158 }
1159 }
1160
1161 for prop_name in &update.relax_properties {
1163 match def.properties.get(prop_name) {
1164 Some(prop_def) if prop_def.required => {} Some(_) => {} None => {
1167 return Err(MonotonicityError::UnknownProperty {
1168 type_name: type_name.clone(),
1169 property: prop_name.clone(),
1170 });
1171 }
1172 }
1173 }
1174
1175 for (prop_name, prop_def) in &update.add_properties {
1179 if prop_def.required {
1180 return Err(MonotonicityError::RequiredPropertyOnExistingType {
1181 kind: "node type",
1182 type_name: type_name.clone(),
1183 property: prop_name.clone(),
1184 });
1185 }
1186 }
1187
1188 if !update.add_subtypes.is_empty() {
1190 match def.subtypes {
1191 None => {
1195 return Err(MonotonicityError::SubtypesOnSubtypelessType {
1196 type_name: type_name.clone(),
1197 subtypes: update.add_subtypes.keys().cloned().collect(),
1198 });
1199 }
1200 Some(ref existing) => {
1201 for st_name in update.add_subtypes.keys() {
1202 if existing.contains_key(st_name) {
1203 return Err(MonotonicityError::DuplicateProperty {
1204 type_name: type_name.clone(),
1205 property: format!("subtype:{st_name}"),
1206 });
1207 }
1208 }
1209 }
1210 }
1211
1212 for (st_name, st_def) in &update.add_subtypes {
1216 for (prop_name, prop_def) in &st_def.properties {
1217 if prop_def.required {
1218 return Err(MonotonicityError::RequiredPropertyOnExistingType {
1219 kind: "subtype",
1220 type_name: format!("{type_name}:{st_name}"),
1221 property: prop_name.clone(),
1222 });
1223 }
1224 }
1225 }
1226 }
1227 }
1228
1229 self.node_types.extend(ext.node_types.clone());
1231
1232 for (edge_name, update) in &ext.edge_type_updates {
1236 let def = self
1237 .edge_types
1238 .get(edge_name)
1239 .ok_or_else(|| MonotonicityError::UnknownEdgeType(edge_name.clone()))?;
1240
1241 for (bindings, existing) in [
1242 (&update.add_source_types, &def.source_types),
1243 (&update.add_target_types, &def.target_types),
1244 ] {
1245 for node_type in bindings {
1246 if !self.node_types.contains_key(node_type)
1248 && !ext.node_types.contains_key(node_type)
1249 {
1250 return Err(MonotonicityError::UnknownBindingType {
1251 edge_type: edge_name.clone(),
1252 node_type: node_type.clone(),
1253 });
1254 }
1255 if existing.contains(node_type) {
1256 return Err(MonotonicityError::DuplicateBinding {
1257 edge_type: edge_name.clone(),
1258 node_type: node_type.clone(),
1259 });
1260 }
1261 }
1262 }
1263
1264 for (prop_name, prop_def) in &update.add_properties {
1265 if def.properties.contains_key(prop_name) {
1266 return Err(MonotonicityError::DuplicateProperty {
1267 type_name: edge_name.clone(),
1268 property: prop_name.clone(),
1269 });
1270 }
1271 if prop_def.required {
1272 return Err(MonotonicityError::RequiredPropertyOnExistingType {
1273 kind: "edge type",
1274 type_name: edge_name.clone(),
1275 property: prop_name.clone(),
1276 });
1277 }
1278 }
1279 }
1280
1281 self.edge_types.extend(ext.edge_types.clone());
1283
1284 for (edge_name, update) in &ext.edge_type_updates {
1286 let def = self.edge_types.get_mut(edge_name).unwrap(); def.source_types.extend(update.add_source_types.clone());
1288 def.target_types.extend(update.add_target_types.clone());
1289 def.properties.extend(update.add_properties.clone());
1290 }
1291
1292 for (type_name, update) in &ext.node_type_updates {
1294 let def = self.node_types.get_mut(type_name).unwrap(); def.properties.extend(update.add_properties.clone());
1298
1299 for prop_name in &update.relax_properties {
1301 if let Some(prop_def) = def.properties.get_mut(prop_name) {
1302 prop_def.required = false;
1303 }
1304 }
1305
1306 if !update.add_subtypes.is_empty() {
1308 let subtypes = def.subtypes.get_or_insert_with(BTreeMap::new);
1309 subtypes.extend(update.add_subtypes.clone());
1310 }
1311 }
1312
1313 self.validate_self()
1315 .map_err(MonotonicityError::ValidationFailed)?;
1316
1317 Ok(())
1318 }
1319}
1320
1321fn validate_properties(
1323 type_name: &str,
1324 defs: &BTreeMap<String, PropertyDef>,
1325 values: &BTreeMap<String, Value>,
1326 mode: ValidationMode,
1327) -> Result<(), ValidationError> {
1328 if mode == ValidationMode::Full {
1331 for (prop_name, prop_def) in defs {
1332 if prop_def.required && !values.contains_key(prop_name) {
1333 return Err(ValidationError::MissingRequiredProperty {
1334 type_name: type_name.to_string(),
1335 property: prop_name.clone(),
1336 });
1337 }
1338 }
1339 }
1340
1341 for (prop_name, value) in values {
1343 let prop_def = match defs.get(prop_name) {
1346 Some(def) => def,
1347 None => continue,
1348 };
1349
1350 if prop_def.value_type != ValueType::Any {
1351 let actual_type = value_type_name(value);
1352 let expected = &prop_def.value_type;
1353 if !value_matches_type(value, expected) {
1354 return Err(ValidationError::WrongPropertyType {
1355 type_name: type_name.to_string(),
1356 property: prop_name.clone(),
1357 expected: expected.clone(),
1358 got: actual_type.to_string(),
1359 });
1360 }
1361 }
1362
1363 if let Some(constraints) = &prop_def.constraints {
1365 validate_constraints(type_name, prop_name, value, constraints)?;
1366 }
1367 }
1368
1369 Ok(())
1370}
1371
1372fn validate_constraints(
1377 type_name: &str,
1378 prop_name: &str,
1379 value: &Value,
1380 constraints: &BTreeMap<String, serde_json::Value>,
1381) -> Result<(), ValidationError> {
1382 if let Some(serde_json::Value::Array(allowed)) = constraints.get("enum") {
1384 if let Value::String(s) = value {
1385 let allowed_strs: Vec<&str> = allowed.iter().filter_map(|v| v.as_str()).collect();
1386 if !allowed_strs.contains(&s.as_str()) {
1387 return constraint_err(
1388 type_name,
1389 prop_name,
1390 "enum",
1391 format!("value '{}' not in allowed set {:?}", s, allowed_strs),
1392 );
1393 }
1394 }
1395 }
1396
1397 check_numeric_bound(
1399 type_name,
1400 prop_name,
1401 value,
1402 constraints,
1403 "min",
1404 |n, b| n < b,
1405 |n, b| format!("value {} is less than minimum {}", n, b),
1406 )?;
1407 check_numeric_bound(
1408 type_name,
1409 prop_name,
1410 value,
1411 constraints,
1412 "max",
1413 |n, b| n > b,
1414 |n, b| format!("value {} exceeds maximum {}", n, b),
1415 )?;
1416 check_numeric_bound(
1417 type_name,
1418 prop_name,
1419 value,
1420 constraints,
1421 "min_exclusive",
1422 |n, b| n <= b,
1423 |n, b| format!("value {} must be greater than {}", n, b),
1424 )?;
1425 check_numeric_bound(
1426 type_name,
1427 prop_name,
1428 value,
1429 constraints,
1430 "max_exclusive",
1431 |n, b| n >= b,
1432 |n, b| format!("value {} must be less than {}", n, b),
1433 )?;
1434
1435 check_string_length(
1437 type_name,
1438 prop_name,
1439 value,
1440 constraints,
1441 "min_length",
1442 |len, bound| len < bound,
1443 |len, bound| format!("string length {} is less than minimum {}", len, bound),
1444 )?;
1445 check_string_length(
1446 type_name,
1447 prop_name,
1448 value,
1449 constraints,
1450 "max_length",
1451 |len, bound| len > bound,
1452 |len, bound| format!("string length {} exceeds maximum {}", len, bound),
1453 )?;
1454
1455 if let Some(serde_json::Value::String(pattern)) = constraints.get("pattern") {
1457 if let Value::String(s) = value {
1458 match regex::Regex::new(pattern) {
1459 Ok(re) if !re.is_match(s) => {
1460 return constraint_err(
1461 type_name,
1462 prop_name,
1463 "pattern",
1464 format!("value '{}' does not match pattern '{}'", s, pattern),
1465 );
1466 }
1467 Err(e) => {
1468 return constraint_err(
1469 type_name,
1470 prop_name,
1471 "pattern",
1472 format!("invalid regex pattern '{}': {}", pattern, e),
1473 );
1474 }
1475 _ => {}
1476 }
1477 }
1478 }
1479
1480 Ok(())
1482}
1483
1484fn value_as_f64(value: &Value) -> Option<f64> {
1486 match value {
1487 Value::Int(n) => Some(*n as f64),
1488 Value::Float(n) => Some(*n),
1489 _ => None,
1490 }
1491}
1492
1493fn check_numeric_bound(
1495 type_name: &str,
1496 prop_name: &str,
1497 value: &Value,
1498 constraints: &BTreeMap<String, serde_json::Value>,
1499 key: &str,
1500 violates: impl Fn(f64, f64) -> bool,
1501 msg: impl Fn(f64, f64) -> String,
1502) -> Result<(), ValidationError> {
1503 if let Some(bound_val) = constraints.get(key) {
1504 if let Some(bound) = bound_val.as_f64() {
1505 if let Some(n) = value_as_f64(value) {
1506 if violates(n, bound) {
1507 return constraint_err(type_name, prop_name, key, msg(n, bound));
1508 }
1509 }
1510 }
1511 }
1512 Ok(())
1513}
1514
1515fn check_string_length(
1517 type_name: &str,
1518 prop_name: &str,
1519 value: &Value,
1520 constraints: &BTreeMap<String, serde_json::Value>,
1521 key: &str,
1522 violates: impl Fn(u64, u64) -> bool,
1523 msg: impl Fn(u64, u64) -> String,
1524) -> Result<(), ValidationError> {
1525 if let Some(serde_json::Value::Number(n)) = constraints.get(key) {
1526 if let (Some(bound), Value::String(s)) = (n.as_u64(), value) {
1527 if violates(s.len() as u64, bound) {
1528 return constraint_err(type_name, prop_name, key, msg(s.len() as u64, bound));
1529 }
1530 }
1531 }
1532 Ok(())
1533}
1534
1535fn constraint_err(
1537 type_name: &str,
1538 prop_name: &str,
1539 constraint: &str,
1540 message: String,
1541) -> Result<(), ValidationError> {
1542 Err(ValidationError::ConstraintViolation {
1543 type_name: type_name.to_string(),
1544 property: prop_name.to_string(),
1545 constraint: constraint.to_string(),
1546 message,
1547 })
1548}
1549
1550fn value_matches_type(value: &Value, expected: &ValueType) -> bool {
1551 matches!(
1552 (value, expected),
1553 (Value::Null, _)
1554 | (Value::String(_), ValueType::String)
1555 | (Value::Int(_), ValueType::Int)
1556 | (Value::Float(_), ValueType::Float)
1557 | (Value::Bool(_), ValueType::Bool)
1558 | (Value::List(_), ValueType::List)
1559 | (Value::Map(_), ValueType::Map)
1560 | (_, ValueType::Any)
1561 )
1562}
1563
1564fn value_type_name(value: &Value) -> &'static str {
1565 match value {
1566 Value::Null => "null",
1567 Value::Bool(_) => "bool",
1568 Value::Int(_) => "int",
1569 Value::Float(_) => "float",
1570 Value::String(_) => "string",
1571 Value::List(_) => "list",
1572 Value::Map(_) => "map",
1573 }
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578 use super::*;
1579
1580 #[test]
1586 fn legacy_three_field_extension_still_deserializes() {
1587 let legacy = (
1588 BTreeMap::<String, NodeTypeDef>::new(),
1589 BTreeMap::<String, EdgeTypeDef>::new(),
1590 BTreeMap::<String, NodeTypeUpdate>::new(),
1591 );
1592 let bytes = rmp_serde::to_vec(&legacy).unwrap();
1593 assert_eq!(bytes[0], 0x93, "legacy fixture is not 3 elements");
1594
1595 let restored: OntologyExtension =
1596 rmp_serde::from_slice(&bytes).expect("legacy extension must load");
1597 assert!(restored.edge_type_updates.is_empty());
1598 }
1599
1600 #[test]
1601 fn extension_wire_format_is_a_positional_array_of_four() {
1602 let ext = OntologyExtension::default();
1603 let bytes = rmp_serde::to_vec(&ext).unwrap();
1604 assert_eq!(
1605 bytes[0], 0x94,
1606 "OntologyExtension is no longer a 4-element positional array. \
1607 Field order and count are the wire format: append new fields at \
1608 the end and move PROTOCOL_VERSION."
1609 );
1610 }
1611
1612 fn devops_ontology() -> Ontology {
1613 Ontology {
1614 node_types: BTreeMap::from([
1615 (
1616 "signal".into(),
1617 NodeTypeDef {
1618 description: Some("Something observed".into()),
1619 properties: BTreeMap::from([(
1620 "severity".into(),
1621 PropertyDef {
1622 value_type: ValueType::String,
1623 required: true,
1624 description: None,
1625 constraints: None,
1626 },
1627 )]),
1628 subtypes: None,
1629 parent_type: None,
1630 },
1631 ),
1632 (
1633 "entity".into(),
1634 NodeTypeDef {
1635 description: Some("Something that exists".into()),
1636 properties: BTreeMap::from([
1637 (
1638 "status".into(),
1639 PropertyDef {
1640 value_type: ValueType::String,
1641 required: false,
1642 description: None,
1643 constraints: None,
1644 },
1645 ),
1646 (
1647 "port".into(),
1648 PropertyDef {
1649 value_type: ValueType::Int,
1650 required: false,
1651 description: None,
1652 constraints: None,
1653 },
1654 ),
1655 ]),
1656 subtypes: None,
1657 parent_type: None,
1658 },
1659 ),
1660 (
1661 "rule".into(),
1662 NodeTypeDef {
1663 description: None,
1664 properties: BTreeMap::new(),
1665 subtypes: None,
1666 parent_type: None,
1667 },
1668 ),
1669 (
1670 "action".into(),
1671 NodeTypeDef {
1672 description: None,
1673 properties: BTreeMap::new(),
1674 subtypes: None,
1675 parent_type: None,
1676 },
1677 ),
1678 ]),
1679 edge_types: BTreeMap::from([
1680 (
1681 "OBSERVES".into(),
1682 EdgeTypeDef {
1683 description: None,
1684 source_types: vec!["signal".into()],
1685 target_types: vec!["entity".into()],
1686 properties: BTreeMap::new(),
1687 },
1688 ),
1689 (
1690 "TRIGGERS".into(),
1691 EdgeTypeDef {
1692 description: None,
1693 source_types: vec!["signal".into()],
1694 target_types: vec!["rule".into()],
1695 properties: BTreeMap::new(),
1696 },
1697 ),
1698 (
1699 "RUNS_ON".into(),
1700 EdgeTypeDef {
1701 description: None,
1702 source_types: vec!["entity".into()],
1703 target_types: vec!["entity".into()],
1704 properties: BTreeMap::new(),
1705 },
1706 ),
1707 ]),
1708 }
1709 }
1710
1711 #[test]
1714 fn validate_node_valid() {
1715 let ont = devops_ontology();
1716 let props = BTreeMap::from([("severity".into(), Value::String("critical".into()))]);
1717 assert!(ont.validate_node("signal", None, &props).is_ok());
1718 }
1719
1720 #[test]
1721 fn validate_node_unknown_type() {
1722 let ont = devops_ontology();
1723 let err = ont
1724 .validate_node("potato", None, &BTreeMap::new())
1725 .unwrap_err();
1726 assert!(matches!(err, ValidationError::UnknownNodeType(t) if t == "potato"));
1727 }
1728
1729 #[test]
1730 fn validate_node_missing_required() {
1731 let ont = devops_ontology();
1732 let err = ont
1733 .validate_node("signal", None, &BTreeMap::new())
1734 .unwrap_err();
1735 assert!(
1736 matches!(err, ValidationError::MissingRequiredProperty { property, .. } if property == "severity")
1737 );
1738 }
1739
1740 #[test]
1741 fn validate_node_wrong_type() {
1742 let ont = devops_ontology();
1743 let props = BTreeMap::from([("severity".into(), Value::Int(5))]);
1744 let err = ont.validate_node("signal", None, &props).unwrap_err();
1745 assert!(
1746 matches!(err, ValidationError::WrongPropertyType { property, .. } if property == "severity")
1747 );
1748 }
1749
1750 #[test]
1751 fn validate_node_unknown_property_accepted() {
1752 let ont = devops_ontology();
1754 let props = BTreeMap::from([
1755 ("severity".into(), Value::String("warn".into())),
1756 ("bogus".into(), Value::Bool(true)),
1757 ]);
1758 assert!(ont.validate_node("signal", None, &props).is_ok());
1759 }
1760
1761 #[test]
1762 fn validate_node_optional_property_absent() {
1763 let ont = devops_ontology();
1764 assert!(ont.validate_node("entity", None, &BTreeMap::new()).is_ok());
1766 }
1767
1768 #[test]
1769 fn validate_node_null_accepted_for_any_type() {
1770 let ont = devops_ontology();
1771 let props = BTreeMap::from([("severity".into(), Value::Null)]);
1773 assert!(ont.validate_node("signal", None, &props).is_ok());
1774 }
1775
1776 #[test]
1779 fn validate_edge_valid() {
1780 let ont = devops_ontology();
1781 assert!(ont
1782 .validate_edge("OBSERVES", "signal", "entity", &BTreeMap::new())
1783 .is_ok());
1784 }
1785
1786 #[test]
1787 fn validate_edge_unknown_type() {
1788 let ont = devops_ontology();
1789 let err = ont
1790 .validate_edge("FLIES_TO", "signal", "entity", &BTreeMap::new())
1791 .unwrap_err();
1792 assert!(matches!(err, ValidationError::UnknownEdgeType(t) if t == "FLIES_TO"));
1793 }
1794
1795 #[test]
1796 fn validate_edge_invalid_source() {
1797 let ont = devops_ontology();
1798 let err = ont
1800 .validate_edge("OBSERVES", "entity", "entity", &BTreeMap::new())
1801 .unwrap_err();
1802 assert!(matches!(err, ValidationError::InvalidSource { .. }));
1803 }
1804
1805 #[test]
1806 fn validate_edge_invalid_target() {
1807 let ont = devops_ontology();
1808 let err = ont
1810 .validate_edge("OBSERVES", "signal", "signal", &BTreeMap::new())
1811 .unwrap_err();
1812 assert!(matches!(err, ValidationError::InvalidTarget { .. }));
1813 }
1814
1815 #[test]
1818 fn validate_self_consistent() {
1819 let ont = devops_ontology();
1820 assert!(ont.validate_self().is_ok());
1821 }
1822
1823 #[test]
1824 fn validate_self_dangling_source() {
1825 let ont = Ontology {
1826 node_types: BTreeMap::from([(
1827 "entity".into(),
1828 NodeTypeDef {
1829 description: None,
1830 properties: BTreeMap::new(),
1831 subtypes: None,
1832 parent_type: None,
1833 },
1834 )]),
1835 edge_types: BTreeMap::from([(
1836 "OBSERVES".into(),
1837 EdgeTypeDef {
1838 description: None,
1839 source_types: vec!["ghost".into()], target_types: vec!["entity".into()],
1841 properties: BTreeMap::new(),
1842 },
1843 )]),
1844 };
1845 let err = ont.validate_self().unwrap_err();
1846 assert!(
1847 matches!(err, ValidationError::InvalidSource { node_type, .. } if node_type == "ghost")
1848 );
1849 }
1850
1851 #[test]
1852 fn validate_self_dangling_target() {
1853 let ont = Ontology {
1854 node_types: BTreeMap::from([(
1855 "signal".into(),
1856 NodeTypeDef {
1857 description: None,
1858 properties: BTreeMap::new(),
1859 subtypes: None,
1860 parent_type: None,
1861 },
1862 )]),
1863 edge_types: BTreeMap::from([(
1864 "OBSERVES".into(),
1865 EdgeTypeDef {
1866 description: None,
1867 source_types: vec!["signal".into()],
1868 target_types: vec!["phantom".into()], properties: BTreeMap::new(),
1870 },
1871 )]),
1872 };
1873 let err = ont.validate_self().unwrap_err();
1874 assert!(
1875 matches!(err, ValidationError::InvalidTarget { node_type, .. } if node_type == "phantom")
1876 );
1877 }
1878
1879 fn constrained_ontology() -> Ontology {
1884 Ontology {
1885 node_types: BTreeMap::from([(
1886 "item".into(),
1887 NodeTypeDef {
1888 description: None,
1889 properties: BTreeMap::from([
1890 (
1891 "slug".into(),
1892 PropertyDef {
1893 value_type: ValueType::String,
1894 required: false,
1895 description: None,
1896 constraints: Some(BTreeMap::from([
1897 (
1898 "pattern".to_string(),
1899 serde_json::Value::String("^[a-z0-9-]+$".to_string()),
1900 ),
1901 (
1902 "min_length".to_string(),
1903 serde_json::Value::Number(1.into()),
1904 ),
1905 (
1906 "max_length".to_string(),
1907 serde_json::Value::Number(63.into()),
1908 ),
1909 ])),
1910 },
1911 ),
1912 (
1913 "score".into(),
1914 PropertyDef {
1915 value_type: ValueType::Float,
1916 required: false,
1917 description: None,
1918 constraints: Some(BTreeMap::from([
1919 ("min_exclusive".to_string(), serde_json::json!(0.0)),
1920 ("max_exclusive".to_string(), serde_json::json!(100.0)),
1921 ])),
1922 },
1923 ),
1924 ]),
1925 subtypes: None,
1926 parent_type: None,
1927 },
1928 )]),
1929 edge_types: BTreeMap::new(),
1930 }
1931 }
1932
1933 #[test]
1934 fn pattern_valid_slug() {
1935 let ont = constrained_ontology();
1936 let props = BTreeMap::from([("slug".into(), Value::String("my-project-1".into()))]);
1937 assert!(ont.validate_node("item", None, &props).is_ok());
1938 }
1939
1940 #[test]
1941 fn pattern_rejects_uppercase() {
1942 let ont = constrained_ontology();
1943 let props = BTreeMap::from([("slug".into(), Value::String("My-Project".into()))]);
1944 assert!(ont.validate_node("item", None, &props).is_err());
1945 }
1946
1947 #[test]
1948 fn pattern_rejects_spaces() {
1949 let ont = constrained_ontology();
1950 let props = BTreeMap::from([("slug".into(), Value::String("has space".into()))]);
1951 assert!(ont.validate_node("item", None, &props).is_err());
1952 }
1953
1954 #[test]
1955 fn min_length_accepts_valid() {
1956 let ont = constrained_ontology();
1957 let props = BTreeMap::from([("slug".into(), Value::String("a".into()))]);
1958 assert!(ont.validate_node("item", None, &props).is_ok());
1959 }
1960
1961 #[test]
1962 fn min_length_rejects_empty() {
1963 let ont = constrained_ontology();
1964 let props = BTreeMap::from([("slug".into(), Value::String("".into()))]);
1965 let err = ont.validate_node("item", None, &props).unwrap_err();
1966 assert!(
1967 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_length")
1968 );
1969 }
1970
1971 #[test]
1972 fn max_length_rejects_too_long() {
1973 let ont = constrained_ontology();
1974 let long = "a".repeat(64);
1975 let props = BTreeMap::from([("slug".into(), Value::String(long))]);
1976 let err = ont.validate_node("item", None, &props).unwrap_err();
1977 assert!(
1978 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_length")
1979 );
1980 }
1981
1982 #[test]
1983 fn max_length_accepts_boundary() {
1984 let ont = constrained_ontology();
1985 let exact = "a".repeat(63);
1986 let props = BTreeMap::from([("slug".into(), Value::String(exact))]);
1987 assert!(ont.validate_node("item", None, &props).is_ok());
1988 }
1989
1990 #[test]
1991 fn min_exclusive_rejects_boundary() {
1992 let ont = constrained_ontology();
1993 let props = BTreeMap::from([("score".into(), Value::Float(0.0))]);
1994 let err = ont.validate_node("item", None, &props).unwrap_err();
1995 assert!(
1996 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_exclusive")
1997 );
1998 }
1999
2000 #[test]
2001 fn min_exclusive_accepts_above() {
2002 let ont = constrained_ontology();
2003 let props = BTreeMap::from([("score".into(), Value::Float(0.001))]);
2004 assert!(ont.validate_node("item", None, &props).is_ok());
2005 }
2006
2007 #[test]
2008 fn max_exclusive_rejects_boundary() {
2009 let ont = constrained_ontology();
2010 let props = BTreeMap::from([("score".into(), Value::Float(100.0))]);
2011 let err = ont.validate_node("item", None, &props).unwrap_err();
2012 assert!(
2013 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_exclusive")
2014 );
2015 }
2016
2017 #[test]
2018 fn max_exclusive_accepts_below() {
2019 let ont = constrained_ontology();
2020 let props = BTreeMap::from([("score".into(), Value::Float(99.999))]);
2021 assert!(ont.validate_node("item", None, &props).is_ok());
2022 }
2023
2024 #[test]
2027 fn ontology_roundtrip_msgpack() {
2028 let ont = devops_ontology();
2029 let bytes = rmp_serde::to_vec(&ont).unwrap();
2030 let decoded: Ontology = rmp_serde::from_slice(&bytes).unwrap();
2031 assert_eq!(ont, decoded);
2032 }
2033
2034 #[test]
2035 fn ontology_roundtrip_json() {
2036 let ont = devops_ontology();
2037 let json = serde_json::to_string(&ont).unwrap();
2038 let decoded: Ontology = serde_json::from_str(&json).unwrap();
2039 assert_eq!(ont, decoded);
2040 }
2041
2042 fn hierarchy_ontology() -> Ontology {
2045 Ontology {
2048 node_types: BTreeMap::from([
2049 (
2050 "thing".into(),
2051 NodeTypeDef {
2052 description: None,
2053 properties: BTreeMap::from([(
2054 "name".into(),
2055 PropertyDef {
2056 value_type: ValueType::String,
2057 required: true,
2058 description: None,
2059 constraints: None,
2060 },
2061 )]),
2062 subtypes: None,
2063 parent_type: None, },
2065 ),
2066 (
2067 "entity".into(),
2068 NodeTypeDef {
2069 description: None,
2070 properties: BTreeMap::from([(
2071 "status".into(),
2072 PropertyDef {
2073 value_type: ValueType::String,
2074 required: false,
2075 description: None,
2076 constraints: None,
2077 },
2078 )]),
2079 subtypes: None,
2080 parent_type: Some("thing".into()), },
2082 ),
2083 (
2084 "server".into(),
2085 NodeTypeDef {
2086 description: None,
2087 properties: BTreeMap::from([(
2088 "ip".into(),
2089 PropertyDef {
2090 value_type: ValueType::String,
2091 required: false,
2092 description: None,
2093 constraints: None,
2094 },
2095 )]),
2096 subtypes: None,
2097 parent_type: Some("entity".into()), },
2099 ),
2100 (
2101 "event".into(),
2102 NodeTypeDef {
2103 description: None,
2104 properties: BTreeMap::new(),
2105 subtypes: None,
2106 parent_type: Some("thing".into()), },
2108 ),
2109 ]),
2110 edge_types: BTreeMap::from([(
2111 "RELATES_TO".into(),
2112 EdgeTypeDef {
2113 description: None,
2114 source_types: vec!["thing".into()], target_types: vec!["entity".into()], properties: BTreeMap::new(),
2117 },
2118 )]),
2119 }
2120 }
2121
2122 #[test]
2123 fn ancestors_empty_for_root() {
2124 let ont = hierarchy_ontology();
2125 assert!(ont.ancestors("thing").is_empty());
2126 }
2127
2128 #[test]
2129 fn ancestors_single_parent() {
2130 let ont = hierarchy_ontology();
2131 assert_eq!(ont.ancestors("entity"), vec!["thing"]);
2132 }
2133
2134 #[test]
2135 fn ancestors_transitive() {
2136 let ont = hierarchy_ontology();
2137 assert_eq!(ont.ancestors("server"), vec!["entity", "thing"]);
2139 }
2140
2141 #[test]
2142 fn descendants_of_root() {
2143 let ont = hierarchy_ontology();
2144 let mut desc = ont.descendants("thing");
2145 desc.sort();
2146 assert_eq!(desc, vec!["entity", "event", "server"]);
2147 }
2148
2149 #[test]
2150 fn descendants_of_entity() {
2151 let ont = hierarchy_ontology();
2152 assert_eq!(ont.descendants("entity"), vec!["server"]);
2153 }
2154
2155 #[test]
2156 fn descendants_of_leaf() {
2157 let ont = hierarchy_ontology();
2158 assert!(ont.descendants("server").is_empty());
2159 }
2160
2161 #[test]
2162 fn is_subtype_of_self() {
2163 let ont = hierarchy_ontology();
2164 assert!(ont.is_subtype_of("server", "server"));
2165 }
2166
2167 #[test]
2168 fn is_subtype_of_parent() {
2169 let ont = hierarchy_ontology();
2170 assert!(ont.is_subtype_of("server", "entity"));
2171 assert!(ont.is_subtype_of("server", "thing"));
2172 }
2173
2174 #[test]
2175 fn is_not_subtype_of_sibling() {
2176 let ont = hierarchy_ontology();
2177 assert!(!ont.is_subtype_of("server", "event"));
2178 }
2179
2180 #[test]
2181 fn effective_properties_inherits() {
2182 let ont = hierarchy_ontology();
2183 let props = ont.effective_properties("server");
2184 assert!(props.contains_key("name"));
2186 assert!(props.contains_key("status"));
2187 assert!(props.contains_key("ip"));
2188 }
2189
2190 #[test]
2191 fn effective_properties_root_has_own_only() {
2192 let ont = hierarchy_ontology();
2193 let props = ont.effective_properties("thing");
2194 assert!(props.contains_key("name"));
2195 assert!(!props.contains_key("status"));
2196 }
2197
2198 #[test]
2199 fn validate_node_inherits_required_from_ancestor() {
2200 let ont = hierarchy_ontology();
2201 let err = ont.validate_node("server", None, &BTreeMap::new());
2203 assert!(err.is_err());
2204
2205 let props = BTreeMap::from([("name".into(), Value::String("web-01".into()))]);
2206 assert!(ont.validate_node("server", None, &props).is_ok());
2207 }
2208
2209 #[test]
2210 fn validate_edge_hierarchy_aware() {
2211 let ont = hierarchy_ontology();
2212 let empty = BTreeMap::new();
2215 assert!(ont
2216 .validate_edge("RELATES_TO", "server", "server", &empty)
2217 .is_ok());
2218 assert!(ont
2219 .validate_edge("RELATES_TO", "event", "entity", &empty)
2220 .is_ok());
2221 assert!(ont
2222 .validate_edge("RELATES_TO", "thing", "entity", &empty)
2223 .is_ok());
2224 }
2225
2226 #[test]
2227 fn validate_edge_hierarchy_rejects_wrong_branch() {
2228 let ont = hierarchy_ontology();
2229 let empty = BTreeMap::new();
2231 assert!(ont
2232 .validate_edge("RELATES_TO", "thing", "event", &empty)
2233 .is_err());
2234 }
2235
2236 #[test]
2237 fn validate_self_rejects_dangling_parent() {
2238 let ont = Ontology {
2239 node_types: BTreeMap::from([(
2240 "orphan".into(),
2241 NodeTypeDef {
2242 description: None,
2243 properties: BTreeMap::new(),
2244 subtypes: None,
2245 parent_type: Some("ghost".into()), },
2247 )]),
2248 edge_types: BTreeMap::new(),
2249 };
2250 assert!(ont.validate_self().is_err());
2251 }
2252
2253 fn pet_ontology() -> Ontology {
2256 Ontology {
2257 node_types: BTreeMap::from([
2258 (
2259 "animal".into(),
2260 NodeTypeDef {
2261 description: None,
2262 properties: BTreeMap::from([(
2263 "name".into(),
2264 PropertyDef {
2265 value_type: ValueType::String,
2266 required: true,
2267 description: None,
2268 constraints: None,
2269 },
2270 )]),
2271 subtypes: None,
2272 parent_type: None,
2273 },
2274 ),
2275 (
2276 "shelter".into(),
2277 NodeTypeDef {
2278 description: None,
2279 properties: BTreeMap::new(),
2280 subtypes: None,
2281 parent_type: None,
2282 },
2283 ),
2284 ]),
2285 edge_types: BTreeMap::from([(
2286 "LIVES_AT".into(),
2287 EdgeTypeDef {
2288 description: None,
2289 source_types: vec!["animal".into()],
2290 target_types: vec!["shelter".into()],
2291 properties: BTreeMap::new(),
2292 },
2293 )]),
2294 }
2295 }
2296
2297 fn prop(required: bool) -> PropertyDef {
2298 PropertyDef {
2299 value_type: ValueType::String,
2300 required,
2301 description: None,
2302 constraints: None,
2303 }
2304 }
2305
2306 fn subtype(props: BTreeMap<String, PropertyDef>) -> SubtypeDef {
2307 SubtypeDef {
2308 description: None,
2309 properties: props,
2310 }
2311 }
2312
2313 #[test]
2316 fn refuses_required_property_on_an_existing_node_type() {
2317 let mut ont = pet_ontology();
2318 let ext = OntologyExtension {
2319 node_type_updates: BTreeMap::from([(
2320 "animal".into(),
2321 NodeTypeUpdate {
2322 add_properties: BTreeMap::from([("owner".into(), prop(true))]),
2323 ..Default::default()
2324 },
2325 )]),
2326 ..Default::default()
2327 };
2328 assert_eq!(
2329 ont.merge_extension(&ext),
2330 Err(MonotonicityError::RequiredPropertyOnExistingType {
2331 kind: "node type",
2332 type_name: "animal".into(),
2333 property: "owner".into(),
2334 })
2335 );
2336 assert_eq!(ont, pet_ontology());
2338 }
2339
2340 #[test]
2341 fn refuses_required_property_on_an_existing_edge_type() {
2342 let mut ont = pet_ontology();
2343 let ext = OntologyExtension {
2344 edge_type_updates: BTreeMap::from([(
2345 "LIVES_AT".into(),
2346 EdgeTypeUpdate {
2347 add_properties: BTreeMap::from([("since".into(), prop(true))]),
2348 ..Default::default()
2349 },
2350 )]),
2351 ..Default::default()
2352 };
2353 assert!(matches!(
2354 ont.merge_extension(&ext),
2355 Err(MonotonicityError::RequiredPropertyOnExistingType {
2356 kind: "edge type",
2357 ..
2358 })
2359 ));
2360 assert_eq!(ont, pet_ontology());
2361 }
2362
2363 #[test]
2364 fn refuses_the_first_subtype_on_a_subtypeless_type() {
2365 let mut ont = pet_ontology();
2366 let ext = OntologyExtension {
2367 node_type_updates: BTreeMap::from([(
2368 "animal".into(),
2369 NodeTypeUpdate {
2370 add_subtypes: BTreeMap::from([("dog".into(), subtype(BTreeMap::new()))]),
2371 ..Default::default()
2372 },
2373 )]),
2374 ..Default::default()
2375 };
2376 assert_eq!(
2377 ont.merge_extension(&ext),
2378 Err(MonotonicityError::SubtypesOnSubtypelessType {
2379 type_name: "animal".into(),
2380 subtypes: vec!["dog".into()],
2381 })
2382 );
2383 assert_eq!(ont, pet_ontology());
2384 }
2385
2386 #[test]
2387 fn refuses_required_property_on_a_new_subtype() {
2388 let mut ont = pet_ontology();
2391 ont.node_types.get_mut("animal").unwrap().subtypes =
2392 Some(BTreeMap::from([("cat".into(), subtype(BTreeMap::new()))]));
2393 let before = ont.clone();
2394
2395 let ext = OntologyExtension {
2396 node_type_updates: BTreeMap::from([(
2397 "animal".into(),
2398 NodeTypeUpdate {
2399 add_subtypes: BTreeMap::from([(
2400 "dog".into(),
2401 subtype(BTreeMap::from([("breed".into(), prop(true))])),
2402 )]),
2403 ..Default::default()
2404 },
2405 )]),
2406 ..Default::default()
2407 };
2408 assert!(matches!(
2409 ont.merge_extension(&ext),
2410 Err(MonotonicityError::RequiredPropertyOnExistingType {
2411 kind: "subtype",
2412 ..
2413 })
2414 ));
2415 assert_eq!(ont, before);
2416 }
2417
2418 #[test]
2419 fn allows_an_optional_property_and_a_subtype_on_a_subtyped_type() {
2420 let mut ont = pet_ontology();
2421 ont.node_types.get_mut("animal").unwrap().subtypes =
2422 Some(BTreeMap::from([("cat".into(), subtype(BTreeMap::new()))]));
2423
2424 let ext = OntologyExtension {
2425 node_type_updates: BTreeMap::from([(
2426 "animal".into(),
2427 NodeTypeUpdate {
2428 add_properties: BTreeMap::from([("owner".into(), prop(false))]),
2429 add_subtypes: BTreeMap::from([(
2430 "dog".into(),
2431 subtype(BTreeMap::from([("breed".into(), prop(false))])),
2432 )]),
2433 ..Default::default()
2434 },
2435 )]),
2436 ..Default::default()
2437 };
2438 assert_eq!(ont.merge_extension(&ext), Ok(()));
2439 }
2440
2441 #[test]
2442 fn allows_a_required_property_on_a_brand_new_type() {
2443 let mut ont = pet_ontology();
2444 let ext = OntologyExtension {
2445 node_types: BTreeMap::from([(
2446 "vet".into(),
2447 NodeTypeDef {
2448 description: None,
2449 properties: BTreeMap::from([("license".into(), prop(true))]),
2450 subtypes: None,
2451 parent_type: None,
2452 },
2453 )]),
2454 ..Default::default()
2455 };
2456 assert_eq!(ont.merge_extension(&ext), Ok(()));
2457 }
2458
2459 #[test]
2462 fn destructive_changes_flags_a_newly_required_property() {
2463 let old = pet_ontology();
2464 let mut new = pet_ontology();
2465 new.node_types
2466 .get_mut("shelter")
2467 .unwrap()
2468 .properties
2469 .insert("capacity".into(), prop(true));
2470
2471 let changes = new.destructive_changes(&old.fingerprint());
2472 assert_eq!(changes.len(), 1, "{changes:?}");
2473 assert!(changes[0].contains("capacity") && changes[0].contains("shelter"));
2474 }
2475
2476 #[test]
2477 fn destructive_changes_flags_a_property_the_peer_holds_as_optional() {
2478 let mut old = pet_ontology();
2481 old.node_types
2482 .get_mut("shelter")
2483 .unwrap()
2484 .properties
2485 .insert("capacity".into(), prop(false));
2486 let mut new = pet_ontology();
2487 new.node_types
2488 .get_mut("shelter")
2489 .unwrap()
2490 .properties
2491 .insert("capacity".into(), prop(true));
2492
2493 assert_eq!(new.destructive_changes(&old.fingerprint()).len(), 1);
2494 }
2495
2496 #[test]
2497 fn destructive_changes_flags_a_newly_required_edge_property() {
2498 let old = pet_ontology();
2499 let mut new = pet_ontology();
2500 new.edge_types
2501 .get_mut("LIVES_AT")
2502 .unwrap()
2503 .properties
2504 .insert("since".into(), prop(true));
2505
2506 let changes = new.destructive_changes(&old.fingerprint());
2507 assert_eq!(changes.len(), 1, "{changes:?}");
2508 assert!(changes[0].contains("since") && changes[0].contains("LIVES_AT"));
2509 }
2510
2511 #[test]
2512 fn destructive_changes_flags_a_newly_required_subtype_property() {
2513 let mut old = pet_ontology();
2514 old.node_types.get_mut("animal").unwrap().subtypes =
2515 Some(BTreeMap::from([("cat".into(), subtype(BTreeMap::new()))]));
2516 let mut new = old.clone();
2517 new.node_types
2518 .get_mut("animal")
2519 .unwrap()
2520 .subtypes
2521 .as_mut()
2522 .unwrap()
2523 .insert(
2524 "dog".into(),
2525 subtype(BTreeMap::from([("breed".into(), prop(true))])),
2526 );
2527
2528 let changes = new.destructive_changes(&old.fingerprint());
2529 assert_eq!(changes.len(), 1, "{changes:?}");
2530 assert!(changes[0].contains("breed"), "{changes:?}");
2531 }
2532
2533 #[test]
2534 fn destructive_changes_flags_the_subtype_flip_once_per_type() {
2535 let old = pet_ontology();
2536 let mut new = pet_ontology();
2537 new.node_types.get_mut("animal").unwrap().subtypes = Some(BTreeMap::from([
2538 ("cat".into(), subtype(BTreeMap::new())),
2539 ("dog".into(), subtype(BTreeMap::new())),
2540 ]));
2541
2542 let changes = new.destructive_changes(&old.fingerprint());
2543 assert_eq!(changes.len(), 1, "two subtypes, one flip: {changes:?}");
2544 assert!(changes[0].contains("animal"));
2545 }
2546
2547 #[test]
2548 fn destructive_changes_ignores_a_type_the_peer_never_had() {
2549 let old = pet_ontology();
2551 let mut new = pet_ontology();
2552 new.node_types.insert(
2553 "vet".into(),
2554 NodeTypeDef {
2555 description: None,
2556 properties: BTreeMap::from([("license".into(), prop(true))]),
2557 subtypes: Some(BTreeMap::from([(
2558 "surgeon".into(),
2559 subtype(BTreeMap::new()),
2560 )])),
2561 parent_type: None,
2562 },
2563 );
2564 assert_eq!(
2565 new.destructive_changes(&old.fingerprint()),
2566 Vec::<String>::new()
2567 );
2568 }
2569
2570 #[test]
2571 fn destructive_changes_ignores_an_optional_property() {
2572 let old = pet_ontology();
2573 let mut new = pet_ontology();
2574 new.node_types
2575 .get_mut("shelter")
2576 .unwrap()
2577 .properties
2578 .insert("capacity".into(), prop(false));
2579 assert_eq!(
2580 new.destructive_changes(&old.fingerprint()),
2581 Vec::<String>::new()
2582 );
2583 }
2584
2585 #[test]
2586 fn check_compatibility_separates_destructive_from_safe_supersets() {
2587 let old = pet_ontology();
2588 let (old_hash, old_fp) = (old.content_hash(), old.fingerprint());
2589
2590 let mut safe = pet_ontology();
2591 safe.node_types
2592 .get_mut("shelter")
2593 .unwrap()
2594 .properties
2595 .insert("capacity".into(), prop(false));
2596 assert_eq!(
2597 safe.check_compatibility(&old_hash, &old_fp),
2598 Compatibility::Superset
2599 );
2600
2601 let mut destructive = pet_ontology();
2602 destructive
2603 .node_types
2604 .get_mut("shelter")
2605 .unwrap()
2606 .properties
2607 .insert("capacity".into(), prop(true));
2608 assert_eq!(
2609 destructive.check_compatibility(&old_hash, &old_fp),
2610 Compatibility::DestructiveSuperset
2611 );
2612 }
2613
2614 #[test]
2615 fn check_compatibility_subset_and_divergent_are_unaffected() {
2616 let old = pet_ontology();
2617 let mut newer = pet_ontology();
2618 newer
2619 .node_types
2620 .get_mut("shelter")
2621 .unwrap()
2622 .properties
2623 .insert("capacity".into(), prop(true));
2624
2625 assert_eq!(
2626 old.check_compatibility(&newer.content_hash(), &newer.fingerprint()),
2627 Compatibility::Subset
2628 );
2629 }
2630
2631 #[test]
2632 fn content_hash_deterministic() {
2633 let a = pet_ontology();
2634 let b = pet_ontology();
2635 assert_eq!(a.content_hash(), b.content_hash());
2636 }
2637
2638 #[test]
2639 fn content_hash_is_32_bytes() {
2640 let ont = pet_ontology();
2641 let hash = ont.content_hash();
2642 assert_eq!(hash.len(), 32);
2643 assert_ne!(hash, [0u8; 32]); }
2645
2646 #[test]
2647 fn content_hash_changes_on_new_type() {
2648 let mut ont = pet_ontology();
2649 let hash_before = ont.content_hash();
2650 ont.node_types.insert(
2651 "volunteer".into(),
2652 NodeTypeDef {
2653 description: None,
2654 properties: BTreeMap::new(),
2655 subtypes: None,
2656 parent_type: None,
2657 },
2658 );
2659 let hash_after = ont.content_hash();
2660 assert_ne!(hash_before, hash_after);
2661 }
2662
2663 #[test]
2664 fn content_hash_changes_on_new_property() {
2665 let mut ont = pet_ontology();
2666 let hash_before = ont.content_hash();
2667 ont.node_types.get_mut("animal").unwrap().properties.insert(
2668 "microchip_id".into(),
2669 PropertyDef {
2670 value_type: ValueType::String,
2671 required: false,
2672 description: None,
2673 constraints: None,
2674 },
2675 );
2676 let hash_after = ont.content_hash();
2677 assert_ne!(hash_before, hash_after);
2678 }
2679
2680 #[test]
2681 fn fingerprint_contains_types() {
2682 let ont = pet_ontology();
2683 let fp = ont.fingerprint();
2684 assert!(fp.contains("type:animal"));
2685 assert!(fp.contains("type:shelter"));
2686 assert!(fp.contains("edge:LIVES_AT"));
2687 }
2688
2689 #[test]
2690 fn fingerprint_contains_properties() {
2691 let ont = pet_ontology();
2692 let fp = ont.fingerprint();
2693 assert!(fp.contains("prop:animal:name:string:required"));
2694 }
2695
2696 #[test]
2697 fn fingerprint_contains_edge_constraints() {
2698 let ont = pet_ontology();
2699 let fp = ont.fingerprint();
2700 assert!(fp.contains("edge:LIVES_AT:src:animal"));
2701 assert!(fp.contains("edge:LIVES_AT:tgt:shelter"));
2702 }
2703
2704 #[test]
2705 fn fingerprint_contains_parent_type() {
2706 let ont = Ontology {
2707 node_types: BTreeMap::from([
2708 (
2709 "entity".into(),
2710 NodeTypeDef {
2711 description: None,
2712 properties: BTreeMap::new(),
2713 subtypes: None,
2714 parent_type: None,
2715 },
2716 ),
2717 (
2718 "server".into(),
2719 NodeTypeDef {
2720 description: None,
2721 properties: BTreeMap::new(),
2722 subtypes: None,
2723 parent_type: Some("entity".into()),
2724 },
2725 ),
2726 ]),
2727 edge_types: BTreeMap::new(),
2728 };
2729 let fp = ont.fingerprint();
2730 assert!(fp.contains("type:server:parent:entity"));
2731 }
2732
2733 #[test]
2734 fn fingerprint_contains_subtypes() {
2735 let ont = Ontology {
2736 node_types: BTreeMap::from([(
2737 "entity".into(),
2738 NodeTypeDef {
2739 description: None,
2740 properties: BTreeMap::new(),
2741 subtypes: Some(BTreeMap::from([(
2742 "project".into(),
2743 SubtypeDef {
2744 description: None,
2745 properties: BTreeMap::from([(
2746 "slug".into(),
2747 PropertyDef {
2748 value_type: ValueType::String,
2749 required: true,
2750 description: None,
2751 constraints: None,
2752 },
2753 )]),
2754 },
2755 )])),
2756 parent_type: None,
2757 },
2758 )]),
2759 edge_types: BTreeMap::new(),
2760 };
2761 let fp = ont.fingerprint();
2762 assert!(fp.contains("subtype:entity:project"));
2763 assert!(fp.contains("subprop:entity:project:slug:string:required"));
2764 }
2765
2766 #[test]
2767 fn fingerprint_superset_after_extension() {
2768 let base = pet_ontology();
2769 let base_fp = base.fingerprint();
2770
2771 let mut extended = pet_ontology();
2772 extended.node_types.insert(
2773 "volunteer".into(),
2774 NodeTypeDef {
2775 description: None,
2776 properties: BTreeMap::new(),
2777 subtypes: None,
2778 parent_type: None,
2779 },
2780 );
2781 let ext_fp = extended.fingerprint();
2782
2783 assert!(base_fp.is_subset(&ext_fp));
2785 assert!(!ext_fp.is_subset(&base_fp));
2786 }
2787
2788 #[test]
2789 fn check_compatibility_identical() {
2790 let a = pet_ontology();
2791 let b = pet_ontology();
2792 let verdict = a.check_compatibility(&b.content_hash(), &b.fingerprint());
2793 assert_eq!(verdict, Compatibility::Identical);
2794 }
2795
2796 #[test]
2797 fn check_compatibility_superset() {
2798 let base = pet_ontology();
2799
2800 let mut extended = pet_ontology();
2801 extended.node_types.insert(
2802 "volunteer".into(),
2803 NodeTypeDef {
2804 description: None,
2805 properties: BTreeMap::new(),
2806 subtypes: None,
2807 parent_type: None,
2808 },
2809 );
2810
2811 let verdict = extended.check_compatibility(&base.content_hash(), &base.fingerprint());
2813 assert_eq!(verdict, Compatibility::Superset);
2814 }
2815
2816 #[test]
2817 fn check_compatibility_subset() {
2818 let base = pet_ontology();
2819
2820 let mut extended = pet_ontology();
2821 extended.node_types.insert(
2822 "volunteer".into(),
2823 NodeTypeDef {
2824 description: None,
2825 properties: BTreeMap::new(),
2826 subtypes: None,
2827 parent_type: None,
2828 },
2829 );
2830
2831 let verdict = base.check_compatibility(&extended.content_hash(), &extended.fingerprint());
2833 assert_eq!(verdict, Compatibility::Subset);
2834 }
2835
2836 #[test]
2837 fn check_compatibility_divergent() {
2838 let mut branch_a = pet_ontology();
2840 branch_a.node_types.insert(
2841 "volunteer".into(),
2842 NodeTypeDef {
2843 description: None,
2844 properties: BTreeMap::new(),
2845 subtypes: None,
2846 parent_type: None,
2847 },
2848 );
2849
2850 let mut branch_b = pet_ontology();
2851 branch_b.node_types.insert(
2852 "adoption".into(),
2853 NodeTypeDef {
2854 description: None,
2855 properties: BTreeMap::new(),
2856 subtypes: None,
2857 parent_type: None,
2858 },
2859 );
2860
2861 let verdict =
2862 branch_a.check_compatibility(&branch_b.content_hash(), &branch_b.fingerprint());
2863 assert_eq!(verdict, Compatibility::Divergent);
2864 }
2865
2866 #[test]
2867 fn fingerprint_contains_enum_constraints() {
2868 let ont = Ontology {
2869 node_types: BTreeMap::from([(
2870 "server".into(),
2871 NodeTypeDef {
2872 description: None,
2873 properties: BTreeMap::from([(
2874 "status".into(),
2875 PropertyDef {
2876 value_type: ValueType::String,
2877 required: true,
2878 description: None,
2879 constraints: Some(BTreeMap::from([(
2880 "enum".into(),
2881 serde_json::json!(["active", "standby"]),
2882 )])),
2883 },
2884 )]),
2885 subtypes: None,
2886 parent_type: None,
2887 },
2888 )]),
2889 edge_types: BTreeMap::new(),
2890 };
2891 let fp = ont.fingerprint();
2892 assert!(fp.contains("constraint:server:status:enum:active"));
2893 assert!(fp.contains("constraint:server:status:enum:standby"));
2894 }
2895}