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