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