Skip to main content

silk/
ontology.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4use crate::entry::Value;
5
6/// The type of a property value.
7#[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    /// Accept any Value variant.
17    Any,
18}
19
20/// Definition of a single property on a node or edge type.
21#[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}
29
30/// Definition of a subtype within a node type (D-024).
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct SubtypeDef {
33    #[serde(default)]
34    pub description: Option<String>,
35    #[serde(default)]
36    pub properties: BTreeMap<String, PropertyDef>,
37}
38
39/// Definition of a node type in the ontology.
40///
41/// If `subtypes` is `Some`, then `add_node` requires a `subtype` parameter
42/// and properties are validated against the subtype's definition.
43/// If `subtypes` is `None`, the type works as before (D-024 backward compat).
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct NodeTypeDef {
46    #[serde(default)]
47    pub description: Option<String>,
48    #[serde(default)]
49    pub properties: BTreeMap<String, PropertyDef>,
50    /// Optional subtype definitions. When present, `add_node` must specify
51    /// a subtype and properties are validated per-subtype (D-024).
52    #[serde(default)]
53    pub subtypes: Option<BTreeMap<String, SubtypeDef>>,
54}
55
56/// Definition of an edge type in the ontology.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct EdgeTypeDef {
59    #[serde(default)]
60    pub description: Option<String>,
61    /// Which node types can be the source of this edge.
62    pub source_types: Vec<String>,
63    /// Which node types can be the target of this edge.
64    pub target_types: Vec<String>,
65    #[serde(default)]
66    pub properties: BTreeMap<String, PropertyDef>,
67}
68
69/// Immutable ontology — the vocabulary and rules of a Silk graph.
70///
71/// Defined once at genesis, locked forever. Every operation is validated
72/// against this ontology before being appended to the DAG.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct Ontology {
75    pub node_types: BTreeMap<String, NodeTypeDef>,
76    pub edge_types: BTreeMap<String, EdgeTypeDef>,
77}
78
79/// Validation errors returned when an operation violates the ontology.
80#[derive(Debug, Clone, PartialEq)]
81pub enum ValidationError {
82    UnknownNodeType(String),
83    UnknownEdgeType(String),
84    InvalidSource {
85        edge_type: String,
86        node_type: String,
87        allowed: Vec<String>,
88    },
89    InvalidTarget {
90        edge_type: String,
91        node_type: String,
92        allowed: Vec<String>,
93    },
94    MissingRequiredProperty {
95        type_name: String,
96        property: String,
97    },
98    WrongPropertyType {
99        type_name: String,
100        property: String,
101        expected: ValueType,
102        got: String,
103    },
104    UnknownProperty {
105        type_name: String,
106        property: String,
107    },
108    MissingSubtype {
109        node_type: String,
110        allowed: Vec<String>,
111    },
112    UnknownSubtype {
113        node_type: String,
114        subtype: String,
115        allowed: Vec<String>,
116    },
117    UnexpectedSubtype {
118        node_type: String,
119        subtype: String,
120    },
121}
122
123impl std::fmt::Display for ValidationError {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            ValidationError::UnknownNodeType(t) => write!(f, "unknown node type: '{t}'"),
127            ValidationError::UnknownEdgeType(t) => write!(f, "unknown edge type: '{t}'"),
128            ValidationError::InvalidSource {
129                edge_type,
130                node_type,
131                allowed,
132            } => write!(
133                f,
134                "edge '{edge_type}' cannot have source type '{node_type}' (allowed: {allowed:?})"
135            ),
136            ValidationError::InvalidTarget {
137                edge_type,
138                node_type,
139                allowed,
140            } => write!(
141                f,
142                "edge '{edge_type}' cannot have target type '{node_type}' (allowed: {allowed:?})"
143            ),
144            ValidationError::MissingRequiredProperty {
145                type_name,
146                property,
147            } => write!(f, "'{type_name}' requires property '{property}'"),
148            ValidationError::WrongPropertyType {
149                type_name,
150                property,
151                expected,
152                got,
153            } => write!(
154                f,
155                "'{type_name}'.'{property}' expects {expected:?}, got {got}"
156            ),
157            ValidationError::UnknownProperty {
158                type_name,
159                property,
160            } => write!(f, "'{type_name}' has no property '{property}' in ontology"),
161            ValidationError::MissingSubtype { node_type, allowed } => {
162                write!(f, "'{node_type}' requires a subtype (allowed: {allowed:?})")
163            }
164            ValidationError::UnknownSubtype {
165                node_type,
166                subtype,
167                allowed,
168            } => write!(
169                f,
170                "'{node_type}' has no subtype '{subtype}' (allowed: {allowed:?})"
171            ),
172            ValidationError::UnexpectedSubtype { node_type, subtype } => write!(
173                f,
174                "'{node_type}' does not define subtypes, but got subtype '{subtype}'"
175            ),
176        }
177    }
178}
179
180/// An additive ontology extension — monotonic evolution only (R-03).
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182pub struct OntologyExtension {
183    /// New node types to add.
184    #[serde(default)]
185    pub node_types: BTreeMap<String, NodeTypeDef>,
186    /// New edge types to add.
187    #[serde(default)]
188    pub edge_types: BTreeMap<String, EdgeTypeDef>,
189    /// Updates to existing node types (add properties, subtypes, relax required).
190    #[serde(default)]
191    pub node_type_updates: BTreeMap<String, NodeTypeUpdate>,
192}
193
194/// Additive update to an existing node type.
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196pub struct NodeTypeUpdate {
197    /// New optional properties to add.
198    #[serde(default)]
199    pub add_properties: BTreeMap<String, PropertyDef>,
200    /// Properties to relax from required to optional.
201    #[serde(default)]
202    pub relax_properties: Vec<String>,
203    /// New subtypes to add.
204    #[serde(default)]
205    pub add_subtypes: BTreeMap<String, SubtypeDef>,
206}
207
208/// Errors from monotonic ontology extension (R-03).
209#[derive(Debug, Clone, PartialEq)]
210pub enum MonotonicityError {
211    DuplicateNodeType(String),
212    DuplicateEdgeType(String),
213    UnknownNodeType(String),
214    DuplicateProperty {
215        type_name: String,
216        property: String,
217    },
218    UnknownProperty {
219        type_name: String,
220        property: String,
221    },
222    /// Wraps a ValidationError from validate_self() after merge.
223    ValidationFailed(ValidationError),
224}
225
226impl std::fmt::Display for MonotonicityError {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        match self {
229            MonotonicityError::DuplicateNodeType(t) => {
230                write!(f, "node type '{t}' already exists")
231            }
232            MonotonicityError::DuplicateEdgeType(t) => {
233                write!(f, "edge type '{t}' already exists")
234            }
235            MonotonicityError::UnknownNodeType(t) => {
236                write!(f, "cannot update unknown node type '{t}'")
237            }
238            MonotonicityError::DuplicateProperty {
239                type_name,
240                property,
241            } => {
242                write!(f, "property '{property}' already exists on '{type_name}'")
243            }
244            MonotonicityError::UnknownProperty {
245                type_name,
246                property,
247            } => {
248                write!(
249                    f,
250                    "property '{property}' does not exist on '{type_name}' (cannot relax)"
251                )
252            }
253            MonotonicityError::ValidationFailed(e) => {
254                write!(f, "ontology validation failed after merge: {e}")
255            }
256        }
257    }
258}
259
260impl Ontology {
261    /// Validate that a node type exists and its properties conform.
262    ///
263    /// If the type defines subtypes (D-024), `subtype` must be `Some` and
264    /// properties are validated against the subtype's definition.
265    /// If the type does not define subtypes, `subtype` must be `None`.
266    pub fn validate_node(
267        &self,
268        node_type: &str,
269        subtype: Option<&str>,
270        properties: &BTreeMap<String, Value>,
271    ) -> Result<(), ValidationError> {
272        let def = self
273            .node_types
274            .get(node_type)
275            .ok_or_else(|| ValidationError::UnknownNodeType(node_type.to_string()))?;
276
277        match (&def.subtypes, subtype) {
278            // Type has subtypes and caller provided one
279            (Some(subtypes), Some(st)) => {
280                match subtypes.get(st) {
281                    Some(st_def) => {
282                        // Known subtype — merge type-level + subtype-level properties
283                        let mut merged = def.properties.clone();
284                        merged.extend(st_def.properties.clone());
285                        validate_properties(node_type, &merged, properties)
286                    }
287                    None => {
288                        // D-026: unknown subtype — validate type-level properties only
289                        validate_properties(node_type, &def.properties, properties)
290                    }
291                }
292            }
293            // Type has subtypes but caller didn't provide one — error
294            (Some(subtypes), None) => Err(ValidationError::MissingSubtype {
295                node_type: node_type.to_string(),
296                allowed: subtypes.keys().cloned().collect(),
297            }),
298            // D-026: accept subtypes even if type doesn't declare any
299            (None, Some(_st)) => validate_properties(node_type, &def.properties, properties),
300            // Type has no subtypes and caller didn't provide one — validate as before
301            (None, None) => validate_properties(node_type, &def.properties, properties),
302        }
303    }
304
305    /// Validate that an edge type exists, source/target types are allowed,
306    /// and properties conform.
307    pub fn validate_edge(
308        &self,
309        edge_type: &str,
310        source_node_type: &str,
311        target_node_type: &str,
312        properties: &BTreeMap<String, Value>,
313    ) -> Result<(), ValidationError> {
314        let def = self
315            .edge_types
316            .get(edge_type)
317            .ok_or_else(|| ValidationError::UnknownEdgeType(edge_type.to_string()))?;
318
319        if !def.source_types.iter().any(|t| t == source_node_type) {
320            return Err(ValidationError::InvalidSource {
321                edge_type: edge_type.to_string(),
322                node_type: source_node_type.to_string(),
323                allowed: def.source_types.clone(),
324            });
325        }
326
327        if !def.target_types.iter().any(|t| t == target_node_type) {
328            return Err(ValidationError::InvalidTarget {
329                edge_type: edge_type.to_string(),
330                node_type: target_node_type.to_string(),
331                allowed: def.target_types.clone(),
332            });
333        }
334
335        validate_properties(edge_type, &def.properties, properties)
336    }
337
338    /// Validate that the ontology itself is internally consistent.
339    /// All source_types/target_types in edge defs must reference existing node types.
340    pub fn validate_self(&self) -> Result<(), ValidationError> {
341        for (edge_name, edge_def) in &self.edge_types {
342            for src in &edge_def.source_types {
343                if !self.node_types.contains_key(src) {
344                    return Err(ValidationError::InvalidSource {
345                        edge_type: edge_name.clone(),
346                        node_type: src.clone(),
347                        allowed: self.node_types.keys().cloned().collect(),
348                    });
349                }
350            }
351            for tgt in &edge_def.target_types {
352                if !self.node_types.contains_key(tgt) {
353                    return Err(ValidationError::InvalidTarget {
354                        edge_type: edge_name.clone(),
355                        node_type: tgt.clone(),
356                        allowed: self.node_types.keys().cloned().collect(),
357                    });
358                }
359            }
360        }
361        Ok(())
362    }
363
364    /// R-03: Merge an additive extension into this ontology.
365    /// Only monotonic (additive) changes are allowed:
366    /// - New node types (must not already exist)
367    /// - New edge types (must not already exist)
368    /// - Updates to existing node types: add properties, relax required→optional, add subtypes
369    pub fn merge_extension(&mut self, ext: &OntologyExtension) -> Result<(), MonotonicityError> {
370        // Validate: new node types don't already exist
371        for name in ext.node_types.keys() {
372            if self.node_types.contains_key(name) {
373                return Err(MonotonicityError::DuplicateNodeType(name.clone()));
374            }
375        }
376
377        // Validate: new edge types don't already exist
378        for name in ext.edge_types.keys() {
379            if self.edge_types.contains_key(name) {
380                return Err(MonotonicityError::DuplicateEdgeType(name.clone()));
381            }
382        }
383
384        // Validate node_type_updates reference existing types
385        for (type_name, update) in &ext.node_type_updates {
386            let def = self
387                .node_types
388                .get(type_name)
389                .ok_or_else(|| MonotonicityError::UnknownNodeType(type_name.clone()))?;
390
391            // Validate: add_properties don't already exist
392            for prop_name in update.add_properties.keys() {
393                if def.properties.contains_key(prop_name) {
394                    return Err(MonotonicityError::DuplicateProperty {
395                        type_name: type_name.clone(),
396                        property: prop_name.clone(),
397                    });
398                }
399            }
400
401            // Validate: relax_properties exist and are currently required
402            for prop_name in &update.relax_properties {
403                match def.properties.get(prop_name) {
404                    Some(prop_def) if prop_def.required => {} // ok
405                    Some(_) => {} // already optional — idempotent, allow it
406                    None => {
407                        return Err(MonotonicityError::UnknownProperty {
408                            type_name: type_name.clone(),
409                            property: prop_name.clone(),
410                        });
411                    }
412                }
413            }
414
415            // Validate: add_subtypes don't already exist (if subtypes are defined)
416            if !update.add_subtypes.is_empty() {
417                if let Some(ref existing) = def.subtypes {
418                    for st_name in update.add_subtypes.keys() {
419                        if existing.contains_key(st_name) {
420                            return Err(MonotonicityError::DuplicateProperty {
421                                type_name: type_name.clone(),
422                                property: format!("subtype:{st_name}"),
423                            });
424                        }
425                    }
426                }
427            }
428        }
429
430        // Apply: extend node_types
431        self.node_types.extend(ext.node_types.clone());
432
433        // Apply: extend edge_types
434        self.edge_types.extend(ext.edge_types.clone());
435
436        // Apply: update existing node types
437        for (type_name, update) in &ext.node_type_updates {
438            let def = self.node_types.get_mut(type_name).unwrap(); // validated above
439
440            // Add new properties
441            def.properties.extend(update.add_properties.clone());
442
443            // Relax required → optional
444            for prop_name in &update.relax_properties {
445                if let Some(prop_def) = def.properties.get_mut(prop_name) {
446                    prop_def.required = false;
447                }
448            }
449
450            // Add subtypes
451            if !update.add_subtypes.is_empty() {
452                let subtypes = def.subtypes.get_or_insert_with(BTreeMap::new);
453                subtypes.extend(update.add_subtypes.clone());
454            }
455        }
456
457        // Validate the merged ontology is internally consistent
458        self.validate_self()
459            .map_err(MonotonicityError::ValidationFailed)?;
460
461        Ok(())
462    }
463}
464
465/// Validate properties against their definitions.
466fn validate_properties(
467    type_name: &str,
468    defs: &BTreeMap<String, PropertyDef>,
469    values: &BTreeMap<String, Value>,
470) -> Result<(), ValidationError> {
471    // Check required properties are present
472    for (prop_name, prop_def) in defs {
473        if prop_def.required && !values.contains_key(prop_name) {
474            return Err(ValidationError::MissingRequiredProperty {
475                type_name: type_name.to_string(),
476                property: prop_name.clone(),
477            });
478        }
479    }
480
481    // Check all provided properties are known and correctly typed
482    for (prop_name, value) in values {
483        // D-026: accept unknown properties without validation.
484        // The ontology defines the minimum, not the maximum.
485        let prop_def = match defs.get(prop_name) {
486            Some(def) => def,
487            None => continue,
488        };
489
490        if prop_def.value_type != ValueType::Any {
491            let actual_type = value_type_name(value);
492            let expected = &prop_def.value_type;
493            if !value_matches_type(value, expected) {
494                return Err(ValidationError::WrongPropertyType {
495                    type_name: type_name.to_string(),
496                    property: prop_name.clone(),
497                    expected: expected.clone(),
498                    got: actual_type.to_string(),
499                });
500            }
501        }
502    }
503
504    Ok(())
505}
506
507fn value_matches_type(value: &Value, expected: &ValueType) -> bool {
508    matches!(
509        (value, expected),
510        (Value::Null, _)
511            | (Value::String(_), ValueType::String)
512            | (Value::Int(_), ValueType::Int)
513            | (Value::Float(_), ValueType::Float)
514            | (Value::Bool(_), ValueType::Bool)
515            | (Value::List(_), ValueType::List)
516            | (Value::Map(_), ValueType::Map)
517            | (_, ValueType::Any)
518    )
519}
520
521fn value_type_name(value: &Value) -> &'static str {
522    match value {
523        Value::Null => "null",
524        Value::Bool(_) => "bool",
525        Value::Int(_) => "int",
526        Value::Float(_) => "float",
527        Value::String(_) => "string",
528        Value::List(_) => "list",
529        Value::Map(_) => "map",
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    fn devops_ontology() -> Ontology {
538        Ontology {
539            node_types: BTreeMap::from([
540                (
541                    "signal".into(),
542                    NodeTypeDef {
543                        description: Some("Something observed".into()),
544                        properties: BTreeMap::from([(
545                            "severity".into(),
546                            PropertyDef {
547                                value_type: ValueType::String,
548                                required: true,
549                                description: None,
550                            },
551                        )]),
552                        subtypes: None,
553                    },
554                ),
555                (
556                    "entity".into(),
557                    NodeTypeDef {
558                        description: Some("Something that exists".into()),
559                        properties: BTreeMap::from([
560                            (
561                                "status".into(),
562                                PropertyDef {
563                                    value_type: ValueType::String,
564                                    required: false,
565                                    description: None,
566                                },
567                            ),
568                            (
569                                "port".into(),
570                                PropertyDef {
571                                    value_type: ValueType::Int,
572                                    required: false,
573                                    description: None,
574                                },
575                            ),
576                        ]),
577                        subtypes: None,
578                    },
579                ),
580                (
581                    "rule".into(),
582                    NodeTypeDef {
583                        description: None,
584                        properties: BTreeMap::new(),
585                        subtypes: None,
586                    },
587                ),
588                (
589                    "action".into(),
590                    NodeTypeDef {
591                        description: None,
592                        properties: BTreeMap::new(),
593                        subtypes: None,
594                    },
595                ),
596            ]),
597            edge_types: BTreeMap::from([
598                (
599                    "OBSERVES".into(),
600                    EdgeTypeDef {
601                        description: None,
602                        source_types: vec!["signal".into()],
603                        target_types: vec!["entity".into()],
604                        properties: BTreeMap::new(),
605                    },
606                ),
607                (
608                    "TRIGGERS".into(),
609                    EdgeTypeDef {
610                        description: None,
611                        source_types: vec!["signal".into()],
612                        target_types: vec!["rule".into()],
613                        properties: BTreeMap::new(),
614                    },
615                ),
616                (
617                    "RUNS_ON".into(),
618                    EdgeTypeDef {
619                        description: None,
620                        source_types: vec!["entity".into()],
621                        target_types: vec!["entity".into()],
622                        properties: BTreeMap::new(),
623                    },
624                ),
625            ]),
626        }
627    }
628
629    // --- Node validation ---
630
631    #[test]
632    fn validate_node_valid() {
633        let ont = devops_ontology();
634        let props = BTreeMap::from([("severity".into(), Value::String("critical".into()))]);
635        assert!(ont.validate_node("signal", None, &props).is_ok());
636    }
637
638    #[test]
639    fn validate_node_unknown_type() {
640        let ont = devops_ontology();
641        let err = ont
642            .validate_node("potato", None, &BTreeMap::new())
643            .unwrap_err();
644        assert!(matches!(err, ValidationError::UnknownNodeType(t) if t == "potato"));
645    }
646
647    #[test]
648    fn validate_node_missing_required() {
649        let ont = devops_ontology();
650        let err = ont
651            .validate_node("signal", None, &BTreeMap::new())
652            .unwrap_err();
653        assert!(
654            matches!(err, ValidationError::MissingRequiredProperty { property, .. } if property == "severity")
655        );
656    }
657
658    #[test]
659    fn validate_node_wrong_type() {
660        let ont = devops_ontology();
661        let props = BTreeMap::from([("severity".into(), Value::Int(5))]);
662        let err = ont.validate_node("signal", None, &props).unwrap_err();
663        assert!(
664            matches!(err, ValidationError::WrongPropertyType { property, .. } if property == "severity")
665        );
666    }
667
668    #[test]
669    fn validate_node_unknown_property_accepted() {
670        // D-026: unknown properties are accepted without validation
671        let ont = devops_ontology();
672        let props = BTreeMap::from([
673            ("severity".into(), Value::String("warn".into())),
674            ("bogus".into(), Value::Bool(true)),
675        ]);
676        assert!(ont.validate_node("signal", None, &props).is_ok());
677    }
678
679    #[test]
680    fn validate_node_optional_property_absent() {
681        let ont = devops_ontology();
682        // entity has optional "status" — omitting it is fine
683        assert!(ont.validate_node("entity", None, &BTreeMap::new()).is_ok());
684    }
685
686    #[test]
687    fn validate_node_null_accepted_for_any_type() {
688        let ont = devops_ontology();
689        // Null is accepted for any typed property (represents absence)
690        let props = BTreeMap::from([("severity".into(), Value::Null)]);
691        assert!(ont.validate_node("signal", None, &props).is_ok());
692    }
693
694    // --- Edge validation ---
695
696    #[test]
697    fn validate_edge_valid() {
698        let ont = devops_ontology();
699        assert!(ont
700            .validate_edge("OBSERVES", "signal", "entity", &BTreeMap::new())
701            .is_ok());
702    }
703
704    #[test]
705    fn validate_edge_unknown_type() {
706        let ont = devops_ontology();
707        let err = ont
708            .validate_edge("FLIES_TO", "signal", "entity", &BTreeMap::new())
709            .unwrap_err();
710        assert!(matches!(err, ValidationError::UnknownEdgeType(t) if t == "FLIES_TO"));
711    }
712
713    #[test]
714    fn validate_edge_invalid_source() {
715        let ont = devops_ontology();
716        // OBSERVES requires source=signal, not entity
717        let err = ont
718            .validate_edge("OBSERVES", "entity", "entity", &BTreeMap::new())
719            .unwrap_err();
720        assert!(matches!(err, ValidationError::InvalidSource { .. }));
721    }
722
723    #[test]
724    fn validate_edge_invalid_target() {
725        let ont = devops_ontology();
726        // OBSERVES requires target=entity, not signal
727        let err = ont
728            .validate_edge("OBSERVES", "signal", "signal", &BTreeMap::new())
729            .unwrap_err();
730        assert!(matches!(err, ValidationError::InvalidTarget { .. }));
731    }
732
733    // --- Self-validation ---
734
735    #[test]
736    fn validate_self_consistent() {
737        let ont = devops_ontology();
738        assert!(ont.validate_self().is_ok());
739    }
740
741    #[test]
742    fn validate_self_dangling_source() {
743        let ont = Ontology {
744            node_types: BTreeMap::from([(
745                "entity".into(),
746                NodeTypeDef {
747                    description: None,
748                    properties: BTreeMap::new(),
749                    subtypes: None,
750                },
751            )]),
752            edge_types: BTreeMap::from([(
753                "OBSERVES".into(),
754                EdgeTypeDef {
755                    description: None,
756                    source_types: vec!["ghost".into()], // doesn't exist
757                    target_types: vec!["entity".into()],
758                    properties: BTreeMap::new(),
759                },
760            )]),
761        };
762        let err = ont.validate_self().unwrap_err();
763        assert!(
764            matches!(err, ValidationError::InvalidSource { node_type, .. } if node_type == "ghost")
765        );
766    }
767
768    #[test]
769    fn validate_self_dangling_target() {
770        let ont = Ontology {
771            node_types: BTreeMap::from([(
772                "signal".into(),
773                NodeTypeDef {
774                    description: None,
775                    properties: BTreeMap::new(),
776                    subtypes: None,
777                },
778            )]),
779            edge_types: BTreeMap::from([(
780                "OBSERVES".into(),
781                EdgeTypeDef {
782                    description: None,
783                    source_types: vec!["signal".into()],
784                    target_types: vec!["phantom".into()], // doesn't exist
785                    properties: BTreeMap::new(),
786                },
787            )]),
788        };
789        let err = ont.validate_self().unwrap_err();
790        assert!(
791            matches!(err, ValidationError::InvalidTarget { node_type, .. } if node_type == "phantom")
792        );
793    }
794
795    // --- Serialization ---
796
797    #[test]
798    fn ontology_roundtrip_msgpack() {
799        let ont = devops_ontology();
800        let bytes = rmp_serde::to_vec(&ont).unwrap();
801        let decoded: Ontology = rmp_serde::from_slice(&bytes).unwrap();
802        assert_eq!(ont, decoded);
803    }
804
805    #[test]
806    fn ontology_roundtrip_json() {
807        let ont = devops_ontology();
808        let json = serde_json::to_string(&ont).unwrap();
809        let decoded: Ontology = serde_json::from_str(&json).unwrap();
810        assert_eq!(ont, decoded);
811    }
812}