Skip to main content

silk/
ontology.rs

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