1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashSet};
3
4use crate::entry::Value;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ValueType {
10 String,
11 Int,
12 Float,
13 Bool,
14 List,
15 Map,
16 Any,
18}
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct PropertyDef {
23 pub value_type: ValueType,
24 #[serde(default)]
25 pub required: bool,
26 #[serde(default)]
27 pub description: Option<String>,
28 #[serde(default)]
32 pub constraints: Option<BTreeMap<String, serde_json::Value>>,
33}
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct SubtypeDef {
38 #[serde(default)]
39 pub description: Option<String>,
40 #[serde(default)]
41 pub properties: BTreeMap<String, PropertyDef>,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct NodeTypeDef {
51 #[serde(default)]
52 pub description: Option<String>,
53 #[serde(default)]
54 pub properties: BTreeMap<String, PropertyDef>,
55 #[serde(default)]
58 pub subtypes: Option<BTreeMap<String, SubtypeDef>>,
59 #[serde(default)]
64 pub parent_type: Option<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct EdgeTypeDef {
70 #[serde(default)]
71 pub description: Option<String>,
72 pub source_types: Vec<String>,
74 pub target_types: Vec<String>,
76 #[serde(default)]
77 pub properties: BTreeMap<String, PropertyDef>,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct Ontology {
86 pub node_types: BTreeMap<String, NodeTypeDef>,
87 pub edge_types: BTreeMap<String, EdgeTypeDef>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum Compatibility {
93 Identical,
95 Superset,
97 Subset,
100 Divergent,
103}
104
105impl Ontology {
106 pub fn content_hash(&self) -> [u8; 32] {
112 let json = serde_json::to_string(self).expect("ontology serialization should not fail");
113 *blake3::hash(json.as_bytes()).as_bytes()
114 }
115
116 pub fn fingerprint(&self) -> HashSet<String> {
124 let mut facts = HashSet::new();
125
126 for (type_name, type_def) in &self.node_types {
127 facts.insert(format!("type:{type_name}"));
128
129 if let Some(parent) = &type_def.parent_type {
130 facts.insert(format!("type:{type_name}:parent:{parent}"));
131 }
132
133 for (prop_name, prop_def) in &type_def.properties {
135 let req = if prop_def.required {
136 "required"
137 } else {
138 "optional"
139 };
140 let vt = format!("{:?}", prop_def.value_type).to_lowercase();
141 facts.insert(format!("prop:{type_name}:{prop_name}:{vt}:{req}"));
142 Self::fingerprint_constraints(&mut facts, type_name, prop_name, prop_def);
143 }
144
145 if let Some(subtypes) = &type_def.subtypes {
147 for (sub_name, sub_def) in subtypes {
148 facts.insert(format!("subtype:{type_name}:{sub_name}"));
149 for (prop_name, prop_def) in &sub_def.properties {
150 let req = if prop_def.required {
151 "required"
152 } else {
153 "optional"
154 };
155 let vt = format!("{:?}", prop_def.value_type).to_lowercase();
156 facts.insert(format!(
157 "subprop:{type_name}:{sub_name}:{prop_name}:{vt}:{req}"
158 ));
159 Self::fingerprint_constraints(
160 &mut facts,
161 &format!("{type_name}:{sub_name}"),
162 prop_name,
163 prop_def,
164 );
165 }
166 }
167 }
168 }
169
170 for (edge_name, edge_def) in &self.edge_types {
171 facts.insert(format!("edge:{edge_name}"));
172 for src in &edge_def.source_types {
173 facts.insert(format!("edge:{edge_name}:src:{src}"));
174 }
175 for tgt in &edge_def.target_types {
176 facts.insert(format!("edge:{edge_name}:tgt:{tgt}"));
177 }
178 }
179
180 facts
181 }
182
183 pub fn check_compatibility(
185 &self,
186 foreign_hash: &[u8; 32],
187 foreign_fingerprint: &HashSet<String>,
188 ) -> Compatibility {
189 if &self.content_hash() == foreign_hash {
190 return Compatibility::Identical;
191 }
192
193 let my_fp = self.fingerprint();
194 let is_superset = foreign_fingerprint.is_subset(&my_fp);
195 let is_subset = my_fp.is_subset(foreign_fingerprint);
196
197 match (is_superset, is_subset) {
198 (true, false) => Compatibility::Superset,
199 (false, true) => Compatibility::Subset,
200 (true, true) => Compatibility::Identical, (false, false) => Compatibility::Divergent,
202 }
203 }
204
205 fn fingerprint_constraints(
206 facts: &mut HashSet<String>,
207 type_name: &str,
208 prop_name: &str,
209 prop_def: &PropertyDef,
210 ) {
211 if let Some(constraints) = &prop_def.constraints {
212 if let Some(enum_vals) = constraints.get("enum") {
213 if let Some(arr) = enum_vals.as_array() {
214 for val in arr {
215 if let Some(s) = val.as_str() {
216 facts.insert(format!("constraint:{type_name}:{prop_name}:enum:{s}"));
217 }
218 }
219 }
220 }
221 }
222 }
223}
224
225#[derive(Debug, Clone, PartialEq)]
227pub enum ValidationError {
228 UnknownNodeType(String),
229 UnknownEdgeType(String),
230 InvalidSource {
231 edge_type: String,
232 node_type: String,
233 allowed: Vec<String>,
234 },
235 InvalidTarget {
236 edge_type: String,
237 node_type: String,
238 allowed: Vec<String>,
239 },
240 MissingRequiredProperty {
241 type_name: String,
242 property: String,
243 },
244 WrongPropertyType {
245 type_name: String,
246 property: String,
247 expected: ValueType,
248 got: String,
249 },
250 UnknownProperty {
251 type_name: String,
252 property: String,
253 },
254 MissingSubtype {
255 node_type: String,
256 allowed: Vec<String>,
257 },
258 UnknownSubtype {
259 node_type: String,
260 subtype: String,
261 allowed: Vec<String>,
262 },
263 UnexpectedSubtype {
264 node_type: String,
265 subtype: String,
266 },
267 ConstraintViolation {
269 type_name: String,
270 property: String,
271 constraint: String,
272 message: String,
273 },
274}
275
276impl std::fmt::Display for ValidationError {
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 match self {
279 ValidationError::UnknownNodeType(t) => write!(f, "unknown node type: '{t}'"),
280 ValidationError::UnknownEdgeType(t) => write!(f, "unknown edge type: '{t}'"),
281 ValidationError::InvalidSource {
282 edge_type,
283 node_type,
284 allowed,
285 } => write!(
286 f,
287 "edge '{edge_type}' cannot have source type '{node_type}' (allowed: {allowed:?})"
288 ),
289 ValidationError::InvalidTarget {
290 edge_type,
291 node_type,
292 allowed,
293 } => write!(
294 f,
295 "edge '{edge_type}' cannot have target type '{node_type}' (allowed: {allowed:?})"
296 ),
297 ValidationError::MissingRequiredProperty {
298 type_name,
299 property,
300 } => write!(f, "'{type_name}' requires property '{property}'"),
301 ValidationError::WrongPropertyType {
302 type_name,
303 property,
304 expected,
305 got,
306 } => write!(
307 f,
308 "'{type_name}'.'{property}' expects {expected:?}, got {got}"
309 ),
310 ValidationError::UnknownProperty {
311 type_name,
312 property,
313 } => write!(f, "'{type_name}' has no property '{property}' in ontology"),
314 ValidationError::MissingSubtype { node_type, allowed } => {
315 write!(f, "'{node_type}' requires a subtype (allowed: {allowed:?})")
316 }
317 ValidationError::UnknownSubtype {
318 node_type,
319 subtype,
320 allowed,
321 } => write!(
322 f,
323 "'{node_type}' has no subtype '{subtype}' (allowed: {allowed:?})"
324 ),
325 ValidationError::UnexpectedSubtype { node_type, subtype } => write!(
326 f,
327 "'{node_type}' does not define subtypes, but got subtype '{subtype}'"
328 ),
329 ValidationError::ConstraintViolation {
330 type_name,
331 property,
332 constraint,
333 message,
334 } => write!(
335 f,
336 "'{type_name}'.'{property}' violates constraint '{constraint}': {message}"
337 ),
338 }
339 }
340}
341
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344pub struct OntologyExtension {
345 #[serde(default)]
347 pub node_types: BTreeMap<String, NodeTypeDef>,
348 #[serde(default)]
350 pub edge_types: BTreeMap<String, EdgeTypeDef>,
351 #[serde(default)]
353 pub node_type_updates: BTreeMap<String, NodeTypeUpdate>,
354}
355
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
358pub struct NodeTypeUpdate {
359 #[serde(default)]
361 pub add_properties: BTreeMap<String, PropertyDef>,
362 #[serde(default)]
364 pub relax_properties: Vec<String>,
365 #[serde(default)]
367 pub add_subtypes: BTreeMap<String, SubtypeDef>,
368}
369
370#[derive(Debug, Clone, PartialEq)]
372pub enum MonotonicityError {
373 DuplicateNodeType(String),
374 DuplicateEdgeType(String),
375 UnknownNodeType(String),
376 DuplicateProperty {
377 type_name: String,
378 property: String,
379 },
380 UnknownProperty {
381 type_name: String,
382 property: String,
383 },
384 ValidationFailed(ValidationError),
386}
387
388impl std::fmt::Display for MonotonicityError {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 match self {
391 MonotonicityError::DuplicateNodeType(t) => {
392 write!(f, "node type '{t}' already exists")
393 }
394 MonotonicityError::DuplicateEdgeType(t) => {
395 write!(f, "edge type '{t}' already exists")
396 }
397 MonotonicityError::UnknownNodeType(t) => {
398 write!(f, "cannot update unknown node type '{t}'")
399 }
400 MonotonicityError::DuplicateProperty {
401 type_name,
402 property,
403 } => {
404 write!(f, "property '{property}' already exists on '{type_name}'")
405 }
406 MonotonicityError::UnknownProperty {
407 type_name,
408 property,
409 } => {
410 write!(
411 f,
412 "property '{property}' does not exist on '{type_name}' (cannot relax)"
413 )
414 }
415 MonotonicityError::ValidationFailed(e) => {
416 write!(f, "ontology validation failed after merge: {e}")
417 }
418 }
419 }
420}
421
422impl Ontology {
423 pub fn ancestors(&self, node_type: &str) -> Vec<&str> {
428 let mut result = Vec::new();
429 let mut current = node_type;
430 for _ in 0..100 {
432 match self
433 .node_types
434 .get(current)
435 .and_then(|d| d.parent_type.as_deref())
436 {
437 Some(parent) => {
438 result.push(parent);
439 current = parent;
440 }
441 None => break,
442 }
443 }
444 result
445 }
446
447 pub fn descendants(&self, node_type: &str) -> Vec<&str> {
450 self.node_types
452 .iter()
453 .filter(|(name, _)| {
454 name.as_str() != node_type && self.ancestors(name).contains(&node_type)
455 })
456 .map(|(name, _)| name.as_str())
457 .collect()
458 }
459
460 pub fn is_subtype_of(&self, child_type: &str, parent_type: &str) -> bool {
462 child_type == parent_type || self.ancestors(child_type).contains(&parent_type)
463 }
464
465 pub fn effective_properties(&self, node_type: &str) -> BTreeMap<String, PropertyDef> {
469 let mut chain: Vec<&str> = self.ancestors(node_type);
470 chain.reverse(); chain.push(node_type);
472
473 let mut props = BTreeMap::new();
474 for t in chain {
475 if let Some(def) = self.node_types.get(t) {
476 for (k, v) in &def.properties {
477 props.insert(k.clone(), v.clone());
478 }
479 }
480 }
481 props
482 }
483
484 pub fn validate_node(
490 &self,
491 node_type: &str,
492 subtype: Option<&str>,
493 properties: &BTreeMap<String, Value>,
494 ) -> Result<(), ValidationError> {
495 let def = self
496 .node_types
497 .get(node_type)
498 .ok_or_else(|| ValidationError::UnknownNodeType(node_type.to_string()))?;
499
500 let base_props = self.effective_properties(node_type);
502
503 match (&def.subtypes, subtype) {
504 (Some(subtypes), Some(st)) => {
506 match subtypes.get(st) {
507 Some(st_def) => {
508 let mut merged = base_props;
510 merged.extend(st_def.properties.clone());
511 validate_properties(node_type, &merged, properties)
512 }
513 None => {
514 validate_properties(node_type, &base_props, properties)
516 }
517 }
518 }
519 (Some(subtypes), None) => Err(ValidationError::MissingSubtype {
521 node_type: node_type.to_string(),
522 allowed: subtypes.keys().cloned().collect(),
523 }),
524 (None, Some(_st)) => validate_properties(node_type, &base_props, properties),
526 (None, None) => validate_properties(node_type, &base_props, properties),
528 }
529 }
530
531 pub fn validate_edge(
534 &self,
535 edge_type: &str,
536 source_node_type: &str,
537 target_node_type: &str,
538 properties: &BTreeMap<String, Value>,
539 ) -> Result<(), ValidationError> {
540 let def = self
541 .edge_types
542 .get(edge_type)
543 .ok_or_else(|| ValidationError::UnknownEdgeType(edge_type.to_string()))?;
544
545 if !def
548 .source_types
549 .iter()
550 .any(|t| self.is_subtype_of(source_node_type, t))
551 {
552 return Err(ValidationError::InvalidSource {
553 edge_type: edge_type.to_string(),
554 node_type: source_node_type.to_string(),
555 allowed: def.source_types.clone(),
556 });
557 }
558
559 if !def
560 .target_types
561 .iter()
562 .any(|t| self.is_subtype_of(target_node_type, t))
563 {
564 return Err(ValidationError::InvalidTarget {
565 edge_type: edge_type.to_string(),
566 node_type: target_node_type.to_string(),
567 allowed: def.target_types.clone(),
568 });
569 }
570
571 validate_properties(edge_type, &def.properties, properties)
572 }
573
574 pub fn validate_property_update(
578 &self,
579 node_type: &str,
580 subtype: Option<&str>,
581 key: &str,
582 value: &Value,
583 ) -> Result<(), ValidationError> {
584 let def = match self.node_types.get(node_type) {
585 Some(d) => d,
586 None => return Ok(()), };
588
589 let mut merged = def.properties.clone();
591 if let (Some(subtypes), Some(st)) = (&def.subtypes, subtype) {
592 if let Some(st_def) = subtypes.get(st) {
593 merged.extend(st_def.properties.clone());
594 }
595 }
596
597 let prop_def = match merged.get(key) {
599 Some(d) => d,
600 None => return Ok(()),
601 };
602
603 if prop_def.value_type != ValueType::Any && !value_matches_type(value, &prop_def.value_type)
605 {
606 return Err(ValidationError::WrongPropertyType {
607 type_name: node_type.to_string(),
608 property: key.to_string(),
609 expected: prop_def.value_type.clone(),
610 got: value_type_name(value).to_string(),
611 });
612 }
613
614 if let Some(constraints) = &prop_def.constraints {
616 validate_constraints(node_type, key, value, constraints)?;
617 }
618
619 Ok(())
620 }
621
622 pub fn validate_self(&self) -> Result<(), ValidationError> {
625 for (edge_name, edge_def) in &self.edge_types {
627 for src in &edge_def.source_types {
628 if !self.node_types.contains_key(src) {
629 return Err(ValidationError::InvalidSource {
630 edge_type: edge_name.clone(),
631 node_type: src.clone(),
632 allowed: self.node_types.keys().cloned().collect(),
633 });
634 }
635 }
636 for tgt in &edge_def.target_types {
637 if !self.node_types.contains_key(tgt) {
638 return Err(ValidationError::InvalidTarget {
639 edge_type: edge_name.clone(),
640 node_type: tgt.clone(),
641 allowed: self.node_types.keys().cloned().collect(),
642 });
643 }
644 }
645 }
646 for (type_name, type_def) in &self.node_types {
648 if let Some(ref parent) = type_def.parent_type {
649 if !self.node_types.contains_key(parent) {
650 return Err(ValidationError::UnknownNodeType(format!(
651 "{}: parent_type '{}' does not exist",
652 type_name, parent
653 )));
654 }
655 }
656 }
657 Ok(())
658 }
659
660 pub fn merge_extension(&mut self, ext: &OntologyExtension) -> Result<(), MonotonicityError> {
666 for name in ext.node_types.keys() {
668 if self.node_types.contains_key(name) {
669 return Err(MonotonicityError::DuplicateNodeType(name.clone()));
670 }
671 }
672
673 for name in ext.edge_types.keys() {
675 if self.edge_types.contains_key(name) {
676 return Err(MonotonicityError::DuplicateEdgeType(name.clone()));
677 }
678 }
679
680 for (type_name, update) in &ext.node_type_updates {
682 let def = self
683 .node_types
684 .get(type_name)
685 .ok_or_else(|| MonotonicityError::UnknownNodeType(type_name.clone()))?;
686
687 for prop_name in update.add_properties.keys() {
689 if def.properties.contains_key(prop_name) {
690 return Err(MonotonicityError::DuplicateProperty {
691 type_name: type_name.clone(),
692 property: prop_name.clone(),
693 });
694 }
695 }
696
697 for prop_name in &update.relax_properties {
699 match def.properties.get(prop_name) {
700 Some(prop_def) if prop_def.required => {} Some(_) => {} None => {
703 return Err(MonotonicityError::UnknownProperty {
704 type_name: type_name.clone(),
705 property: prop_name.clone(),
706 });
707 }
708 }
709 }
710
711 if !update.add_subtypes.is_empty() {
713 if let Some(ref existing) = def.subtypes {
714 for st_name in update.add_subtypes.keys() {
715 if existing.contains_key(st_name) {
716 return Err(MonotonicityError::DuplicateProperty {
717 type_name: type_name.clone(),
718 property: format!("subtype:{st_name}"),
719 });
720 }
721 }
722 }
723 }
724 }
725
726 self.node_types.extend(ext.node_types.clone());
728
729 self.edge_types.extend(ext.edge_types.clone());
731
732 for (type_name, update) in &ext.node_type_updates {
734 let def = self.node_types.get_mut(type_name).unwrap(); def.properties.extend(update.add_properties.clone());
738
739 for prop_name in &update.relax_properties {
741 if let Some(prop_def) = def.properties.get_mut(prop_name) {
742 prop_def.required = false;
743 }
744 }
745
746 if !update.add_subtypes.is_empty() {
748 let subtypes = def.subtypes.get_or_insert_with(BTreeMap::new);
749 subtypes.extend(update.add_subtypes.clone());
750 }
751 }
752
753 self.validate_self()
755 .map_err(MonotonicityError::ValidationFailed)?;
756
757 Ok(())
758 }
759}
760
761fn validate_properties(
763 type_name: &str,
764 defs: &BTreeMap<String, PropertyDef>,
765 values: &BTreeMap<String, Value>,
766) -> Result<(), ValidationError> {
767 for (prop_name, prop_def) in defs {
769 if prop_def.required && !values.contains_key(prop_name) {
770 return Err(ValidationError::MissingRequiredProperty {
771 type_name: type_name.to_string(),
772 property: prop_name.clone(),
773 });
774 }
775 }
776
777 for (prop_name, value) in values {
779 let prop_def = match defs.get(prop_name) {
782 Some(def) => def,
783 None => continue,
784 };
785
786 if prop_def.value_type != ValueType::Any {
787 let actual_type = value_type_name(value);
788 let expected = &prop_def.value_type;
789 if !value_matches_type(value, expected) {
790 return Err(ValidationError::WrongPropertyType {
791 type_name: type_name.to_string(),
792 property: prop_name.clone(),
793 expected: expected.clone(),
794 got: actual_type.to_string(),
795 });
796 }
797 }
798
799 if let Some(constraints) = &prop_def.constraints {
801 validate_constraints(type_name, prop_name, value, constraints)?;
802 }
803 }
804
805 Ok(())
806}
807
808fn validate_constraints(
813 type_name: &str,
814 prop_name: &str,
815 value: &Value,
816 constraints: &BTreeMap<String, serde_json::Value>,
817) -> Result<(), ValidationError> {
818 if let Some(serde_json::Value::Array(allowed)) = constraints.get("enum") {
820 if let Value::String(s) = value {
821 let allowed_strs: Vec<&str> = allowed.iter().filter_map(|v| v.as_str()).collect();
822 if !allowed_strs.contains(&s.as_str()) {
823 return constraint_err(
824 type_name,
825 prop_name,
826 "enum",
827 format!("value '{}' not in allowed set {:?}", s, allowed_strs),
828 );
829 }
830 }
831 }
832
833 check_numeric_bound(
835 type_name,
836 prop_name,
837 value,
838 constraints,
839 "min",
840 |n, b| n < b,
841 |n, b| format!("value {} is less than minimum {}", n, b),
842 )?;
843 check_numeric_bound(
844 type_name,
845 prop_name,
846 value,
847 constraints,
848 "max",
849 |n, b| n > b,
850 |n, b| format!("value {} exceeds maximum {}", n, b),
851 )?;
852 check_numeric_bound(
853 type_name,
854 prop_name,
855 value,
856 constraints,
857 "min_exclusive",
858 |n, b| n <= b,
859 |n, b| format!("value {} must be greater than {}", n, b),
860 )?;
861 check_numeric_bound(
862 type_name,
863 prop_name,
864 value,
865 constraints,
866 "max_exclusive",
867 |n, b| n >= b,
868 |n, b| format!("value {} must be less than {}", n, b),
869 )?;
870
871 check_string_length(
873 type_name,
874 prop_name,
875 value,
876 constraints,
877 "min_length",
878 |len, bound| len < bound,
879 |len, bound| format!("string length {} is less than minimum {}", len, bound),
880 )?;
881 check_string_length(
882 type_name,
883 prop_name,
884 value,
885 constraints,
886 "max_length",
887 |len, bound| len > bound,
888 |len, bound| format!("string length {} exceeds maximum {}", len, bound),
889 )?;
890
891 if let Some(serde_json::Value::String(pattern)) = constraints.get("pattern") {
893 if let Value::String(s) = value {
894 match regex::Regex::new(pattern) {
895 Ok(re) if !re.is_match(s) => {
896 return constraint_err(
897 type_name,
898 prop_name,
899 "pattern",
900 format!("value '{}' does not match pattern '{}'", s, pattern),
901 );
902 }
903 Err(e) => {
904 return constraint_err(
905 type_name,
906 prop_name,
907 "pattern",
908 format!("invalid regex pattern '{}': {}", pattern, e),
909 );
910 }
911 _ => {}
912 }
913 }
914 }
915
916 Ok(())
918}
919
920fn value_as_f64(value: &Value) -> Option<f64> {
922 match value {
923 Value::Int(n) => Some(*n as f64),
924 Value::Float(n) => Some(*n),
925 _ => None,
926 }
927}
928
929fn check_numeric_bound(
931 type_name: &str,
932 prop_name: &str,
933 value: &Value,
934 constraints: &BTreeMap<String, serde_json::Value>,
935 key: &str,
936 violates: impl Fn(f64, f64) -> bool,
937 msg: impl Fn(f64, f64) -> String,
938) -> Result<(), ValidationError> {
939 if let Some(bound_val) = constraints.get(key) {
940 if let Some(bound) = bound_val.as_f64() {
941 if let Some(n) = value_as_f64(value) {
942 if violates(n, bound) {
943 return constraint_err(type_name, prop_name, key, msg(n, bound));
944 }
945 }
946 }
947 }
948 Ok(())
949}
950
951fn check_string_length(
953 type_name: &str,
954 prop_name: &str,
955 value: &Value,
956 constraints: &BTreeMap<String, serde_json::Value>,
957 key: &str,
958 violates: impl Fn(u64, u64) -> bool,
959 msg: impl Fn(u64, u64) -> String,
960) -> Result<(), ValidationError> {
961 if let Some(serde_json::Value::Number(n)) = constraints.get(key) {
962 if let (Some(bound), Value::String(s)) = (n.as_u64(), value) {
963 if violates(s.len() as u64, bound) {
964 return constraint_err(type_name, prop_name, key, msg(s.len() as u64, bound));
965 }
966 }
967 }
968 Ok(())
969}
970
971fn constraint_err(
973 type_name: &str,
974 prop_name: &str,
975 constraint: &str,
976 message: String,
977) -> Result<(), ValidationError> {
978 Err(ValidationError::ConstraintViolation {
979 type_name: type_name.to_string(),
980 property: prop_name.to_string(),
981 constraint: constraint.to_string(),
982 message,
983 })
984}
985
986fn value_matches_type(value: &Value, expected: &ValueType) -> bool {
987 matches!(
988 (value, expected),
989 (Value::Null, _)
990 | (Value::String(_), ValueType::String)
991 | (Value::Int(_), ValueType::Int)
992 | (Value::Float(_), ValueType::Float)
993 | (Value::Bool(_), ValueType::Bool)
994 | (Value::List(_), ValueType::List)
995 | (Value::Map(_), ValueType::Map)
996 | (_, ValueType::Any)
997 )
998}
999
1000fn value_type_name(value: &Value) -> &'static str {
1001 match value {
1002 Value::Null => "null",
1003 Value::Bool(_) => "bool",
1004 Value::Int(_) => "int",
1005 Value::Float(_) => "float",
1006 Value::String(_) => "string",
1007 Value::List(_) => "list",
1008 Value::Map(_) => "map",
1009 }
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014 use super::*;
1015
1016 fn devops_ontology() -> Ontology {
1017 Ontology {
1018 node_types: BTreeMap::from([
1019 (
1020 "signal".into(),
1021 NodeTypeDef {
1022 description: Some("Something observed".into()),
1023 properties: BTreeMap::from([(
1024 "severity".into(),
1025 PropertyDef {
1026 value_type: ValueType::String,
1027 required: true,
1028 description: None,
1029 constraints: None,
1030 },
1031 )]),
1032 subtypes: None,
1033 parent_type: None,
1034 },
1035 ),
1036 (
1037 "entity".into(),
1038 NodeTypeDef {
1039 description: Some("Something that exists".into()),
1040 properties: BTreeMap::from([
1041 (
1042 "status".into(),
1043 PropertyDef {
1044 value_type: ValueType::String,
1045 required: false,
1046 description: None,
1047 constraints: None,
1048 },
1049 ),
1050 (
1051 "port".into(),
1052 PropertyDef {
1053 value_type: ValueType::Int,
1054 required: false,
1055 description: None,
1056 constraints: None,
1057 },
1058 ),
1059 ]),
1060 subtypes: None,
1061 parent_type: None,
1062 },
1063 ),
1064 (
1065 "rule".into(),
1066 NodeTypeDef {
1067 description: None,
1068 properties: BTreeMap::new(),
1069 subtypes: None,
1070 parent_type: None,
1071 },
1072 ),
1073 (
1074 "action".into(),
1075 NodeTypeDef {
1076 description: None,
1077 properties: BTreeMap::new(),
1078 subtypes: None,
1079 parent_type: None,
1080 },
1081 ),
1082 ]),
1083 edge_types: BTreeMap::from([
1084 (
1085 "OBSERVES".into(),
1086 EdgeTypeDef {
1087 description: None,
1088 source_types: vec!["signal".into()],
1089 target_types: vec!["entity".into()],
1090 properties: BTreeMap::new(),
1091 },
1092 ),
1093 (
1094 "TRIGGERS".into(),
1095 EdgeTypeDef {
1096 description: None,
1097 source_types: vec!["signal".into()],
1098 target_types: vec!["rule".into()],
1099 properties: BTreeMap::new(),
1100 },
1101 ),
1102 (
1103 "RUNS_ON".into(),
1104 EdgeTypeDef {
1105 description: None,
1106 source_types: vec!["entity".into()],
1107 target_types: vec!["entity".into()],
1108 properties: BTreeMap::new(),
1109 },
1110 ),
1111 ]),
1112 }
1113 }
1114
1115 #[test]
1118 fn validate_node_valid() {
1119 let ont = devops_ontology();
1120 let props = BTreeMap::from([("severity".into(), Value::String("critical".into()))]);
1121 assert!(ont.validate_node("signal", None, &props).is_ok());
1122 }
1123
1124 #[test]
1125 fn validate_node_unknown_type() {
1126 let ont = devops_ontology();
1127 let err = ont
1128 .validate_node("potato", None, &BTreeMap::new())
1129 .unwrap_err();
1130 assert!(matches!(err, ValidationError::UnknownNodeType(t) if t == "potato"));
1131 }
1132
1133 #[test]
1134 fn validate_node_missing_required() {
1135 let ont = devops_ontology();
1136 let err = ont
1137 .validate_node("signal", None, &BTreeMap::new())
1138 .unwrap_err();
1139 assert!(
1140 matches!(err, ValidationError::MissingRequiredProperty { property, .. } if property == "severity")
1141 );
1142 }
1143
1144 #[test]
1145 fn validate_node_wrong_type() {
1146 let ont = devops_ontology();
1147 let props = BTreeMap::from([("severity".into(), Value::Int(5))]);
1148 let err = ont.validate_node("signal", None, &props).unwrap_err();
1149 assert!(
1150 matches!(err, ValidationError::WrongPropertyType { property, .. } if property == "severity")
1151 );
1152 }
1153
1154 #[test]
1155 fn validate_node_unknown_property_accepted() {
1156 let ont = devops_ontology();
1158 let props = BTreeMap::from([
1159 ("severity".into(), Value::String("warn".into())),
1160 ("bogus".into(), Value::Bool(true)),
1161 ]);
1162 assert!(ont.validate_node("signal", None, &props).is_ok());
1163 }
1164
1165 #[test]
1166 fn validate_node_optional_property_absent() {
1167 let ont = devops_ontology();
1168 assert!(ont.validate_node("entity", None, &BTreeMap::new()).is_ok());
1170 }
1171
1172 #[test]
1173 fn validate_node_null_accepted_for_any_type() {
1174 let ont = devops_ontology();
1175 let props = BTreeMap::from([("severity".into(), Value::Null)]);
1177 assert!(ont.validate_node("signal", None, &props).is_ok());
1178 }
1179
1180 #[test]
1183 fn validate_edge_valid() {
1184 let ont = devops_ontology();
1185 assert!(ont
1186 .validate_edge("OBSERVES", "signal", "entity", &BTreeMap::new())
1187 .is_ok());
1188 }
1189
1190 #[test]
1191 fn validate_edge_unknown_type() {
1192 let ont = devops_ontology();
1193 let err = ont
1194 .validate_edge("FLIES_TO", "signal", "entity", &BTreeMap::new())
1195 .unwrap_err();
1196 assert!(matches!(err, ValidationError::UnknownEdgeType(t) if t == "FLIES_TO"));
1197 }
1198
1199 #[test]
1200 fn validate_edge_invalid_source() {
1201 let ont = devops_ontology();
1202 let err = ont
1204 .validate_edge("OBSERVES", "entity", "entity", &BTreeMap::new())
1205 .unwrap_err();
1206 assert!(matches!(err, ValidationError::InvalidSource { .. }));
1207 }
1208
1209 #[test]
1210 fn validate_edge_invalid_target() {
1211 let ont = devops_ontology();
1212 let err = ont
1214 .validate_edge("OBSERVES", "signal", "signal", &BTreeMap::new())
1215 .unwrap_err();
1216 assert!(matches!(err, ValidationError::InvalidTarget { .. }));
1217 }
1218
1219 #[test]
1222 fn validate_self_consistent() {
1223 let ont = devops_ontology();
1224 assert!(ont.validate_self().is_ok());
1225 }
1226
1227 #[test]
1228 fn validate_self_dangling_source() {
1229 let ont = Ontology {
1230 node_types: BTreeMap::from([(
1231 "entity".into(),
1232 NodeTypeDef {
1233 description: None,
1234 properties: BTreeMap::new(),
1235 subtypes: None,
1236 parent_type: None,
1237 },
1238 )]),
1239 edge_types: BTreeMap::from([(
1240 "OBSERVES".into(),
1241 EdgeTypeDef {
1242 description: None,
1243 source_types: vec!["ghost".into()], target_types: vec!["entity".into()],
1245 properties: BTreeMap::new(),
1246 },
1247 )]),
1248 };
1249 let err = ont.validate_self().unwrap_err();
1250 assert!(
1251 matches!(err, ValidationError::InvalidSource { node_type, .. } if node_type == "ghost")
1252 );
1253 }
1254
1255 #[test]
1256 fn validate_self_dangling_target() {
1257 let ont = Ontology {
1258 node_types: BTreeMap::from([(
1259 "signal".into(),
1260 NodeTypeDef {
1261 description: None,
1262 properties: BTreeMap::new(),
1263 subtypes: None,
1264 parent_type: None,
1265 },
1266 )]),
1267 edge_types: BTreeMap::from([(
1268 "OBSERVES".into(),
1269 EdgeTypeDef {
1270 description: None,
1271 source_types: vec!["signal".into()],
1272 target_types: vec!["phantom".into()], properties: BTreeMap::new(),
1274 },
1275 )]),
1276 };
1277 let err = ont.validate_self().unwrap_err();
1278 assert!(
1279 matches!(err, ValidationError::InvalidTarget { node_type, .. } if node_type == "phantom")
1280 );
1281 }
1282
1283 fn constrained_ontology() -> Ontology {
1288 Ontology {
1289 node_types: BTreeMap::from([(
1290 "item".into(),
1291 NodeTypeDef {
1292 description: None,
1293 properties: BTreeMap::from([
1294 (
1295 "slug".into(),
1296 PropertyDef {
1297 value_type: ValueType::String,
1298 required: false,
1299 description: None,
1300 constraints: Some(BTreeMap::from([
1301 (
1302 "pattern".to_string(),
1303 serde_json::Value::String("^[a-z0-9-]+$".to_string()),
1304 ),
1305 (
1306 "min_length".to_string(),
1307 serde_json::Value::Number(1.into()),
1308 ),
1309 (
1310 "max_length".to_string(),
1311 serde_json::Value::Number(63.into()),
1312 ),
1313 ])),
1314 },
1315 ),
1316 (
1317 "score".into(),
1318 PropertyDef {
1319 value_type: ValueType::Float,
1320 required: false,
1321 description: None,
1322 constraints: Some(BTreeMap::from([
1323 ("min_exclusive".to_string(), serde_json::json!(0.0)),
1324 ("max_exclusive".to_string(), serde_json::json!(100.0)),
1325 ])),
1326 },
1327 ),
1328 ]),
1329 subtypes: None,
1330 parent_type: None,
1331 },
1332 )]),
1333 edge_types: BTreeMap::new(),
1334 }
1335 }
1336
1337 #[test]
1338 fn pattern_valid_slug() {
1339 let ont = constrained_ontology();
1340 let props = BTreeMap::from([("slug".into(), Value::String("my-project-1".into()))]);
1341 assert!(ont.validate_node("item", None, &props).is_ok());
1342 }
1343
1344 #[test]
1345 fn pattern_rejects_uppercase() {
1346 let ont = constrained_ontology();
1347 let props = BTreeMap::from([("slug".into(), Value::String("My-Project".into()))]);
1348 assert!(ont.validate_node("item", None, &props).is_err());
1349 }
1350
1351 #[test]
1352 fn pattern_rejects_spaces() {
1353 let ont = constrained_ontology();
1354 let props = BTreeMap::from([("slug".into(), Value::String("has space".into()))]);
1355 assert!(ont.validate_node("item", None, &props).is_err());
1356 }
1357
1358 #[test]
1359 fn min_length_accepts_valid() {
1360 let ont = constrained_ontology();
1361 let props = BTreeMap::from([("slug".into(), Value::String("a".into()))]);
1362 assert!(ont.validate_node("item", None, &props).is_ok());
1363 }
1364
1365 #[test]
1366 fn min_length_rejects_empty() {
1367 let ont = constrained_ontology();
1368 let props = BTreeMap::from([("slug".into(), Value::String("".into()))]);
1369 let err = ont.validate_node("item", None, &props).unwrap_err();
1370 assert!(
1371 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_length")
1372 );
1373 }
1374
1375 #[test]
1376 fn max_length_rejects_too_long() {
1377 let ont = constrained_ontology();
1378 let long = "a".repeat(64);
1379 let props = BTreeMap::from([("slug".into(), Value::String(long))]);
1380 let err = ont.validate_node("item", None, &props).unwrap_err();
1381 assert!(
1382 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_length")
1383 );
1384 }
1385
1386 #[test]
1387 fn max_length_accepts_boundary() {
1388 let ont = constrained_ontology();
1389 let exact = "a".repeat(63);
1390 let props = BTreeMap::from([("slug".into(), Value::String(exact))]);
1391 assert!(ont.validate_node("item", None, &props).is_ok());
1392 }
1393
1394 #[test]
1395 fn min_exclusive_rejects_boundary() {
1396 let ont = constrained_ontology();
1397 let props = BTreeMap::from([("score".into(), Value::Float(0.0))]);
1398 let err = ont.validate_node("item", None, &props).unwrap_err();
1399 assert!(
1400 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_exclusive")
1401 );
1402 }
1403
1404 #[test]
1405 fn min_exclusive_accepts_above() {
1406 let ont = constrained_ontology();
1407 let props = BTreeMap::from([("score".into(), Value::Float(0.001))]);
1408 assert!(ont.validate_node("item", None, &props).is_ok());
1409 }
1410
1411 #[test]
1412 fn max_exclusive_rejects_boundary() {
1413 let ont = constrained_ontology();
1414 let props = BTreeMap::from([("score".into(), Value::Float(100.0))]);
1415 let err = ont.validate_node("item", None, &props).unwrap_err();
1416 assert!(
1417 matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_exclusive")
1418 );
1419 }
1420
1421 #[test]
1422 fn max_exclusive_accepts_below() {
1423 let ont = constrained_ontology();
1424 let props = BTreeMap::from([("score".into(), Value::Float(99.999))]);
1425 assert!(ont.validate_node("item", None, &props).is_ok());
1426 }
1427
1428 #[test]
1431 fn ontology_roundtrip_msgpack() {
1432 let ont = devops_ontology();
1433 let bytes = rmp_serde::to_vec(&ont).unwrap();
1434 let decoded: Ontology = rmp_serde::from_slice(&bytes).unwrap();
1435 assert_eq!(ont, decoded);
1436 }
1437
1438 #[test]
1439 fn ontology_roundtrip_json() {
1440 let ont = devops_ontology();
1441 let json = serde_json::to_string(&ont).unwrap();
1442 let decoded: Ontology = serde_json::from_str(&json).unwrap();
1443 assert_eq!(ont, decoded);
1444 }
1445
1446 fn hierarchy_ontology() -> Ontology {
1449 Ontology {
1452 node_types: BTreeMap::from([
1453 (
1454 "thing".into(),
1455 NodeTypeDef {
1456 description: None,
1457 properties: BTreeMap::from([(
1458 "name".into(),
1459 PropertyDef {
1460 value_type: ValueType::String,
1461 required: true,
1462 description: None,
1463 constraints: None,
1464 },
1465 )]),
1466 subtypes: None,
1467 parent_type: None, },
1469 ),
1470 (
1471 "entity".into(),
1472 NodeTypeDef {
1473 description: None,
1474 properties: BTreeMap::from([(
1475 "status".into(),
1476 PropertyDef {
1477 value_type: ValueType::String,
1478 required: false,
1479 description: None,
1480 constraints: None,
1481 },
1482 )]),
1483 subtypes: None,
1484 parent_type: Some("thing".into()), },
1486 ),
1487 (
1488 "server".into(),
1489 NodeTypeDef {
1490 description: None,
1491 properties: BTreeMap::from([(
1492 "ip".into(),
1493 PropertyDef {
1494 value_type: ValueType::String,
1495 required: false,
1496 description: None,
1497 constraints: None,
1498 },
1499 )]),
1500 subtypes: None,
1501 parent_type: Some("entity".into()), },
1503 ),
1504 (
1505 "event".into(),
1506 NodeTypeDef {
1507 description: None,
1508 properties: BTreeMap::new(),
1509 subtypes: None,
1510 parent_type: Some("thing".into()), },
1512 ),
1513 ]),
1514 edge_types: BTreeMap::from([(
1515 "RELATES_TO".into(),
1516 EdgeTypeDef {
1517 description: None,
1518 source_types: vec!["thing".into()], target_types: vec!["entity".into()], properties: BTreeMap::new(),
1521 },
1522 )]),
1523 }
1524 }
1525
1526 #[test]
1527 fn ancestors_empty_for_root() {
1528 let ont = hierarchy_ontology();
1529 assert!(ont.ancestors("thing").is_empty());
1530 }
1531
1532 #[test]
1533 fn ancestors_single_parent() {
1534 let ont = hierarchy_ontology();
1535 assert_eq!(ont.ancestors("entity"), vec!["thing"]);
1536 }
1537
1538 #[test]
1539 fn ancestors_transitive() {
1540 let ont = hierarchy_ontology();
1541 assert_eq!(ont.ancestors("server"), vec!["entity", "thing"]);
1543 }
1544
1545 #[test]
1546 fn descendants_of_root() {
1547 let ont = hierarchy_ontology();
1548 let mut desc = ont.descendants("thing");
1549 desc.sort();
1550 assert_eq!(desc, vec!["entity", "event", "server"]);
1551 }
1552
1553 #[test]
1554 fn descendants_of_entity() {
1555 let ont = hierarchy_ontology();
1556 assert_eq!(ont.descendants("entity"), vec!["server"]);
1557 }
1558
1559 #[test]
1560 fn descendants_of_leaf() {
1561 let ont = hierarchy_ontology();
1562 assert!(ont.descendants("server").is_empty());
1563 }
1564
1565 #[test]
1566 fn is_subtype_of_self() {
1567 let ont = hierarchy_ontology();
1568 assert!(ont.is_subtype_of("server", "server"));
1569 }
1570
1571 #[test]
1572 fn is_subtype_of_parent() {
1573 let ont = hierarchy_ontology();
1574 assert!(ont.is_subtype_of("server", "entity"));
1575 assert!(ont.is_subtype_of("server", "thing"));
1576 }
1577
1578 #[test]
1579 fn is_not_subtype_of_sibling() {
1580 let ont = hierarchy_ontology();
1581 assert!(!ont.is_subtype_of("server", "event"));
1582 }
1583
1584 #[test]
1585 fn effective_properties_inherits() {
1586 let ont = hierarchy_ontology();
1587 let props = ont.effective_properties("server");
1588 assert!(props.contains_key("name"));
1590 assert!(props.contains_key("status"));
1591 assert!(props.contains_key("ip"));
1592 }
1593
1594 #[test]
1595 fn effective_properties_root_has_own_only() {
1596 let ont = hierarchy_ontology();
1597 let props = ont.effective_properties("thing");
1598 assert!(props.contains_key("name"));
1599 assert!(!props.contains_key("status"));
1600 }
1601
1602 #[test]
1603 fn validate_node_inherits_required_from_ancestor() {
1604 let ont = hierarchy_ontology();
1605 let err = ont.validate_node("server", None, &BTreeMap::new());
1607 assert!(err.is_err());
1608
1609 let props = BTreeMap::from([("name".into(), Value::String("web-01".into()))]);
1610 assert!(ont.validate_node("server", None, &props).is_ok());
1611 }
1612
1613 #[test]
1614 fn validate_edge_hierarchy_aware() {
1615 let ont = hierarchy_ontology();
1616 let empty = BTreeMap::new();
1619 assert!(ont
1620 .validate_edge("RELATES_TO", "server", "server", &empty)
1621 .is_ok());
1622 assert!(ont
1623 .validate_edge("RELATES_TO", "event", "entity", &empty)
1624 .is_ok());
1625 assert!(ont
1626 .validate_edge("RELATES_TO", "thing", "entity", &empty)
1627 .is_ok());
1628 }
1629
1630 #[test]
1631 fn validate_edge_hierarchy_rejects_wrong_branch() {
1632 let ont = hierarchy_ontology();
1633 let empty = BTreeMap::new();
1635 assert!(ont
1636 .validate_edge("RELATES_TO", "thing", "event", &empty)
1637 .is_err());
1638 }
1639
1640 #[test]
1641 fn validate_self_rejects_dangling_parent() {
1642 let ont = Ontology {
1643 node_types: BTreeMap::from([(
1644 "orphan".into(),
1645 NodeTypeDef {
1646 description: None,
1647 properties: BTreeMap::new(),
1648 subtypes: None,
1649 parent_type: Some("ghost".into()), },
1651 )]),
1652 edge_types: BTreeMap::new(),
1653 };
1654 assert!(ont.validate_self().is_err());
1655 }
1656
1657 fn pet_ontology() -> Ontology {
1660 Ontology {
1661 node_types: BTreeMap::from([
1662 (
1663 "animal".into(),
1664 NodeTypeDef {
1665 description: None,
1666 properties: BTreeMap::from([(
1667 "name".into(),
1668 PropertyDef {
1669 value_type: ValueType::String,
1670 required: true,
1671 description: None,
1672 constraints: None,
1673 },
1674 )]),
1675 subtypes: None,
1676 parent_type: None,
1677 },
1678 ),
1679 (
1680 "shelter".into(),
1681 NodeTypeDef {
1682 description: None,
1683 properties: BTreeMap::new(),
1684 subtypes: None,
1685 parent_type: None,
1686 },
1687 ),
1688 ]),
1689 edge_types: BTreeMap::from([(
1690 "LIVES_AT".into(),
1691 EdgeTypeDef {
1692 description: None,
1693 source_types: vec!["animal".into()],
1694 target_types: vec!["shelter".into()],
1695 properties: BTreeMap::new(),
1696 },
1697 )]),
1698 }
1699 }
1700
1701 #[test]
1702 fn content_hash_deterministic() {
1703 let a = pet_ontology();
1704 let b = pet_ontology();
1705 assert_eq!(a.content_hash(), b.content_hash());
1706 }
1707
1708 #[test]
1709 fn content_hash_is_32_bytes() {
1710 let ont = pet_ontology();
1711 let hash = ont.content_hash();
1712 assert_eq!(hash.len(), 32);
1713 assert_ne!(hash, [0u8; 32]); }
1715
1716 #[test]
1717 fn content_hash_changes_on_new_type() {
1718 let mut ont = pet_ontology();
1719 let hash_before = ont.content_hash();
1720 ont.node_types.insert(
1721 "volunteer".into(),
1722 NodeTypeDef {
1723 description: None,
1724 properties: BTreeMap::new(),
1725 subtypes: None,
1726 parent_type: None,
1727 },
1728 );
1729 let hash_after = ont.content_hash();
1730 assert_ne!(hash_before, hash_after);
1731 }
1732
1733 #[test]
1734 fn content_hash_changes_on_new_property() {
1735 let mut ont = pet_ontology();
1736 let hash_before = ont.content_hash();
1737 ont.node_types.get_mut("animal").unwrap().properties.insert(
1738 "microchip_id".into(),
1739 PropertyDef {
1740 value_type: ValueType::String,
1741 required: false,
1742 description: None,
1743 constraints: None,
1744 },
1745 );
1746 let hash_after = ont.content_hash();
1747 assert_ne!(hash_before, hash_after);
1748 }
1749
1750 #[test]
1751 fn fingerprint_contains_types() {
1752 let ont = pet_ontology();
1753 let fp = ont.fingerprint();
1754 assert!(fp.contains("type:animal"));
1755 assert!(fp.contains("type:shelter"));
1756 assert!(fp.contains("edge:LIVES_AT"));
1757 }
1758
1759 #[test]
1760 fn fingerprint_contains_properties() {
1761 let ont = pet_ontology();
1762 let fp = ont.fingerprint();
1763 assert!(fp.contains("prop:animal:name:string:required"));
1764 }
1765
1766 #[test]
1767 fn fingerprint_contains_edge_constraints() {
1768 let ont = pet_ontology();
1769 let fp = ont.fingerprint();
1770 assert!(fp.contains("edge:LIVES_AT:src:animal"));
1771 assert!(fp.contains("edge:LIVES_AT:tgt:shelter"));
1772 }
1773
1774 #[test]
1775 fn fingerprint_contains_parent_type() {
1776 let ont = Ontology {
1777 node_types: BTreeMap::from([
1778 (
1779 "entity".into(),
1780 NodeTypeDef {
1781 description: None,
1782 properties: BTreeMap::new(),
1783 subtypes: None,
1784 parent_type: None,
1785 },
1786 ),
1787 (
1788 "server".into(),
1789 NodeTypeDef {
1790 description: None,
1791 properties: BTreeMap::new(),
1792 subtypes: None,
1793 parent_type: Some("entity".into()),
1794 },
1795 ),
1796 ]),
1797 edge_types: BTreeMap::new(),
1798 };
1799 let fp = ont.fingerprint();
1800 assert!(fp.contains("type:server:parent:entity"));
1801 }
1802
1803 #[test]
1804 fn fingerprint_contains_subtypes() {
1805 let ont = Ontology {
1806 node_types: BTreeMap::from([(
1807 "entity".into(),
1808 NodeTypeDef {
1809 description: None,
1810 properties: BTreeMap::new(),
1811 subtypes: Some(BTreeMap::from([(
1812 "project".into(),
1813 SubtypeDef {
1814 description: None,
1815 properties: BTreeMap::from([(
1816 "slug".into(),
1817 PropertyDef {
1818 value_type: ValueType::String,
1819 required: true,
1820 description: None,
1821 constraints: None,
1822 },
1823 )]),
1824 },
1825 )])),
1826 parent_type: None,
1827 },
1828 )]),
1829 edge_types: BTreeMap::new(),
1830 };
1831 let fp = ont.fingerprint();
1832 assert!(fp.contains("subtype:entity:project"));
1833 assert!(fp.contains("subprop:entity:project:slug:string:required"));
1834 }
1835
1836 #[test]
1837 fn fingerprint_superset_after_extension() {
1838 let base = pet_ontology();
1839 let base_fp = base.fingerprint();
1840
1841 let mut extended = pet_ontology();
1842 extended.node_types.insert(
1843 "volunteer".into(),
1844 NodeTypeDef {
1845 description: None,
1846 properties: BTreeMap::new(),
1847 subtypes: None,
1848 parent_type: None,
1849 },
1850 );
1851 let ext_fp = extended.fingerprint();
1852
1853 assert!(base_fp.is_subset(&ext_fp));
1855 assert!(!ext_fp.is_subset(&base_fp));
1856 }
1857
1858 #[test]
1859 fn check_compatibility_identical() {
1860 let a = pet_ontology();
1861 let b = pet_ontology();
1862 let verdict = a.check_compatibility(&b.content_hash(), &b.fingerprint());
1863 assert_eq!(verdict, Compatibility::Identical);
1864 }
1865
1866 #[test]
1867 fn check_compatibility_superset() {
1868 let base = pet_ontology();
1869
1870 let mut extended = pet_ontology();
1871 extended.node_types.insert(
1872 "volunteer".into(),
1873 NodeTypeDef {
1874 description: None,
1875 properties: BTreeMap::new(),
1876 subtypes: None,
1877 parent_type: None,
1878 },
1879 );
1880
1881 let verdict = extended.check_compatibility(&base.content_hash(), &base.fingerprint());
1883 assert_eq!(verdict, Compatibility::Superset);
1884 }
1885
1886 #[test]
1887 fn check_compatibility_subset() {
1888 let base = pet_ontology();
1889
1890 let mut extended = pet_ontology();
1891 extended.node_types.insert(
1892 "volunteer".into(),
1893 NodeTypeDef {
1894 description: None,
1895 properties: BTreeMap::new(),
1896 subtypes: None,
1897 parent_type: None,
1898 },
1899 );
1900
1901 let verdict = base.check_compatibility(&extended.content_hash(), &extended.fingerprint());
1903 assert_eq!(verdict, Compatibility::Subset);
1904 }
1905
1906 #[test]
1907 fn check_compatibility_divergent() {
1908 let mut branch_a = pet_ontology();
1910 branch_a.node_types.insert(
1911 "volunteer".into(),
1912 NodeTypeDef {
1913 description: None,
1914 properties: BTreeMap::new(),
1915 subtypes: None,
1916 parent_type: None,
1917 },
1918 );
1919
1920 let mut branch_b = pet_ontology();
1921 branch_b.node_types.insert(
1922 "adoption".into(),
1923 NodeTypeDef {
1924 description: None,
1925 properties: BTreeMap::new(),
1926 subtypes: None,
1927 parent_type: None,
1928 },
1929 );
1930
1931 let verdict =
1932 branch_a.check_compatibility(&branch_b.content_hash(), &branch_b.fingerprint());
1933 assert_eq!(verdict, Compatibility::Divergent);
1934 }
1935
1936 #[test]
1937 fn fingerprint_contains_enum_constraints() {
1938 let ont = Ontology {
1939 node_types: BTreeMap::from([(
1940 "server".into(),
1941 NodeTypeDef {
1942 description: None,
1943 properties: BTreeMap::from([(
1944 "status".into(),
1945 PropertyDef {
1946 value_type: ValueType::String,
1947 required: true,
1948 description: None,
1949 constraints: Some(BTreeMap::from([(
1950 "enum".into(),
1951 serde_json::json!(["active", "standby"]),
1952 )])),
1953 },
1954 )]),
1955 subtypes: None,
1956 parent_type: None,
1957 },
1958 )]),
1959 edge_types: BTreeMap::new(),
1960 };
1961 let fp = ont.fingerprint();
1962 assert!(fp.contains("constraint:server:status:enum:active"));
1963 assert!(fp.contains("constraint:server:status:enum:standby"));
1964 }
1965}