Skip to main content

silk/
ontology.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashSet};
3
4use crate::entry::Value;
5
6/// Every constraint name the validator enforces. Single source of truth:
7/// `validate_constraints` dispatches on it, `fingerprint` emits a fact for
8/// each, and `validate_self` rejects any other name. Adding a constraint
9/// means adding it here, which makes the other two fail until they cover it.
10pub const ENFORCED_CONSTRAINTS: [&str; 8] = [
11    "enum",
12    "min",
13    "max",
14    "min_exclusive",
15    "max_exclusive",
16    "min_length",
17    "max_length",
18    "pattern",
19];
20
21/// Prefix reserved for constraints this validator does not enforce. Declaring
22/// one is an explicit statement that nothing will check it (S3); any other
23/// unknown name is a typo and is rejected.
24pub const UNENFORCED_CONSTRAINT_PREFIX: &str = "x_";
25
26/// Version of the fingerprint formula. Bumped whenever the emitter changes
27/// what facts it produces, so that an emitter upgrade is a nameable condition
28/// rather than a false fork (S10). v1 emitted a fact for `enum` only.
29pub const FINGERPRINT_VERSION: u32 = 2;
30
31/// What a materialization pass enforces.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ValidationMode {
34    /// Everything: required properties, declared types, constraints, endpoints.
35    Full,
36    /// Everything except required-property presence. Used only for a
37    /// checkpoint's synthetic inner ops, whose `AddNode` carries an empty
38    /// property map by design (EXP-02) with the values arriving as separate
39    /// `UpdateProperty` ops. Narrower than the old blanket bypass (S2):
40    /// declared types, constraints and edge endpoints are still enforced.
41    SkipRequired,
42}
43
44/// The type of a property value.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum ValueType {
48    String,
49    Int,
50    Float,
51    Bool,
52    List,
53    Map,
54    /// Accept any Value variant.
55    Any,
56}
57
58/// Definition of a single property on a node or edge type.
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct PropertyDef {
61    pub value_type: ValueType,
62    #[serde(default)]
63    pub required: bool,
64    #[serde(default)]
65    pub description: Option<String>,
66    /// Extensible constraints — validated at write time.
67    /// Built-in: "enum" (list of allowed values), "min"/"max" (numeric range).
68    /// Community contributions welcome for additional constraint types.
69    #[serde(default)]
70    pub constraints: Option<BTreeMap<String, serde_json::Value>>,
71}
72
73/// Definition of a subtype within a node type (D-024).
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct SubtypeDef {
76    #[serde(default)]
77    pub description: Option<String>,
78    #[serde(default)]
79    pub properties: BTreeMap<String, PropertyDef>,
80}
81
82/// Definition of a node type in the ontology.
83///
84/// If `subtypes` is `Some`, then `add_node` requires a `subtype` parameter
85/// and properties are validated against the subtype's definition.
86/// If `subtypes` is `None`, the type works as before (D-024 backward compat).
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct NodeTypeDef {
89    #[serde(default)]
90    pub description: Option<String>,
91    #[serde(default)]
92    pub properties: BTreeMap<String, PropertyDef>,
93    /// Optional subtype definitions. When present, `add_node` must specify
94    /// a subtype and properties are validated per-subtype (D-024).
95    #[serde(default)]
96    pub subtypes: Option<BTreeMap<String, SubtypeDef>>,
97    /// RDFS-level class hierarchy (Step 2). If set, this type is a subclass
98    /// of `parent_type`. Queries for the parent type include this type.
99    /// Edge constraints accepting the parent type also accept this type.
100    /// Properties are inherited from the parent (child overrides on conflict).
101    #[serde(default)]
102    pub parent_type: Option<String>,
103}
104
105/// Definition of an edge type in the ontology.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct EdgeTypeDef {
108    #[serde(default)]
109    pub description: Option<String>,
110    /// Which node types can be the source of this edge.
111    pub source_types: Vec<String>,
112    /// Which node types can be the target of this edge.
113    pub target_types: Vec<String>,
114    #[serde(default)]
115    pub properties: BTreeMap<String, PropertyDef>,
116}
117
118/// Immutable ontology — the vocabulary and rules of a Silk graph.
119///
120/// Defined once at genesis, locked forever. Every operation is validated
121/// against this ontology before being appended to the DAG.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123pub struct Ontology {
124    pub node_types: BTreeMap<String, NodeTypeDef>,
125    pub edge_types: BTreeMap<String, EdgeTypeDef>,
126}
127
128/// Result of comparing two ontologies for sync compatibility.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum Compatibility {
131    /// Same resolved ontology (identical hash).
132    Identical,
133    /// Local contains everything remote has, plus more. Safe to merge.
134    Superset,
135    /// Remote has types/properties local doesn't have yet. ExtendOntology
136    /// entries in the sync payload will resolve the gap.
137    Subset,
138    /// Neither is a superset. Incompatible fork, cannot be resolved
139    /// by additive evolution alone.
140    Divergent,
141}
142
143impl Ontology {
144    /// BLAKE3 hash of the canonical JSON representation.
145    ///
146    /// Two ontologies with identical resolved state produce the same hash,
147    /// regardless of how they got there (genesis path, extension order).
148    /// BTreeMap gives deterministic key ordering.
149    pub fn content_hash(&self) -> [u8; 32] {
150        let json = serde_json::to_string(self).expect("ontology serialization should not fail");
151        *blake3::hash(json.as_bytes()).as_bytes()
152    }
153
154    /// Set of atomic facts about this ontology's structure.
155    ///
156    /// Each fact is a string: "type:Animal", "prop:Animal:name:string:required",
157    /// "edge:LIVES_AT", "edge:LIVES_AT:src:Animal", "subtype:Entity:Project", etc.
158    ///
159    /// Under additive-only evolution, a newer ontology's fingerprint is a strict
160    /// superset of an older one's. Set comparison gives the compatibility verdict.
161    pub fn fingerprint(&self) -> HashSet<String> {
162        let mut facts = HashSet::new();
163
164        // S10: identify the emitter's formula, so an upgrade is distinguishable
165        // from a real fork.
166        facts.insert(format!("fingerprint_version:{FINGERPRINT_VERSION}"));
167
168        for (type_name, type_def) in &self.node_types {
169            facts.insert(format!("type:{type_name}"));
170
171            if let Some(parent) = &type_def.parent_type {
172                facts.insert(format!("type:{type_name}:parent:{parent}"));
173            }
174
175            // H3: emit membership from the RESOLVED table, not the declaration
176            // syntax, so a slot attached by any route (own, parent chain) is a
177            // fact on the type that actually carries it.
178            for (prop_name, prop_def) in &self.effective_properties(type_name) {
179                let req = if prop_def.required {
180                    "required"
181                } else {
182                    "optional"
183                };
184                let vt = format!("{:?}", prop_def.value_type).to_lowercase();
185                facts.insert(format!("prop:{type_name}:{prop_name}:{vt}:{req}"));
186                Self::fingerprint_constraints(&mut facts, type_name, prop_name, prop_def);
187            }
188
189            // Subtypes
190            if let Some(subtypes) = &type_def.subtypes {
191                for (sub_name, sub_def) in subtypes {
192                    facts.insert(format!("subtype:{type_name}:{sub_name}"));
193                    for (prop_name, prop_def) in &sub_def.properties {
194                        let req = if prop_def.required {
195                            "required"
196                        } else {
197                            "optional"
198                        };
199                        let vt = format!("{:?}", prop_def.value_type).to_lowercase();
200                        facts.insert(format!(
201                            "subprop:{type_name}:{sub_name}:{prop_name}:{vt}:{req}"
202                        ));
203                        Self::fingerprint_constraints(
204                            &mut facts,
205                            &format!("{type_name}:{sub_name}"),
206                            prop_name,
207                            prop_def,
208                        );
209                    }
210                }
211            }
212        }
213
214        for (edge_name, edge_def) in &self.edge_types {
215            facts.insert(format!("edge:{edge_name}"));
216            for src in &edge_def.source_types {
217                facts.insert(format!("edge:{edge_name}:src:{src}"));
218            }
219            for tgt in &edge_def.target_types {
220                facts.insert(format!("edge:{edge_name}:tgt:{tgt}"));
221            }
222            // H3: edge properties are validated (validate_edge ->
223            // validate_properties) and so must be fingerprinted.
224            for (prop_name, prop_def) in &edge_def.properties {
225                let req = if prop_def.required {
226                    "required"
227                } else {
228                    "optional"
229                };
230                let vt = format!("{:?}", prop_def.value_type).to_lowercase();
231                facts.insert(format!("edgeprop:{edge_name}:{prop_name}:{vt}:{req}"));
232                Self::fingerprint_constraints(
233                    &mut facts,
234                    &format!("edge:{edge_name}"),
235                    prop_name,
236                    prop_def,
237                );
238            }
239        }
240
241        facts
242    }
243
244    /// Compare this ontology against a foreign peer's hash and fingerprint.
245    pub fn check_compatibility(
246        &self,
247        foreign_hash: &[u8; 32],
248        foreign_fingerprint: &HashSet<String>,
249    ) -> Compatibility {
250        if &self.content_hash() == foreign_hash {
251            return Compatibility::Identical;
252        }
253
254        let my_fp = self.fingerprint();
255
256        // H3: ordered superset-then-subset. Equal fact sets with different
257        // hashes used to fall into a `(true, true) => Identical` arm commented
258        // "shouldn't happen" — which is exactly what fired whenever the
259        // emitter was blind to a constraint the validator enforced. With the
260        // emitter complete, equal facts and a differing hash is a genuine
261        // divergence and must be reported as one.
262        if my_fp == *foreign_fingerprint {
263            return Compatibility::Divergent;
264        }
265        if foreign_fingerprint.is_subset(&my_fp) {
266            return Compatibility::Superset;
267        }
268        if my_fp.is_subset(foreign_fingerprint) {
269            return Compatibility::Subset;
270        }
271        Compatibility::Divergent
272    }
273
274    /// Emit one fact per declared constraint, walking the constraint map the
275    /// validator consults rather than a hand-written parallel list (H3).
276    /// Values are serialized canonically so differing bounds differ as facts.
277    fn fingerprint_constraints(
278        facts: &mut HashSet<String>,
279        type_name: &str,
280        prop_name: &str,
281        prop_def: &PropertyDef,
282    ) {
283        let Some(constraints) = &prop_def.constraints else {
284            return;
285        };
286        for (cname, cvalue) in constraints {
287            match cvalue {
288                // Enum members are emitted individually so that adding a member
289                // is a superset rather than a divergence.
290                serde_json::Value::Array(items) if cname == "enum" => {
291                    for val in items {
292                        let rendered = match val.as_str() {
293                            Some(s) => s.to_string(),
294                            None => val.to_string(),
295                        };
296                        facts.insert(format!(
297                            "constraint:{type_name}:{prop_name}:enum:{rendered}"
298                        ));
299                    }
300                }
301                other => {
302                    facts.insert(format!(
303                        "constraint:{type_name}:{prop_name}:{cname}:{other}"
304                    ));
305                }
306            }
307        }
308    }
309}
310
311/// Validation errors returned when an operation violates the ontology.
312#[derive(Debug, Clone, PartialEq)]
313pub enum ValidationError {
314    UnknownNodeType(String),
315    UnknownEdgeType(String),
316    InvalidSource {
317        edge_type: String,
318        node_type: String,
319        allowed: Vec<String>,
320    },
321    InvalidTarget {
322        edge_type: String,
323        node_type: String,
324        allowed: Vec<String>,
325    },
326    MissingRequiredProperty {
327        type_name: String,
328        property: String,
329    },
330    WrongPropertyType {
331        type_name: String,
332        property: String,
333        expected: ValueType,
334        got: String,
335    },
336    UnknownProperty {
337        type_name: String,
338        property: String,
339    },
340    MissingSubtype {
341        node_type: String,
342        allowed: Vec<String>,
343    },
344    UnknownSubtype {
345        node_type: String,
346        subtype: String,
347        allowed: Vec<String>,
348    },
349    UnexpectedSubtype {
350        node_type: String,
351        subtype: String,
352    },
353    /// A property value violates a constraint (enum, range, etc.)
354    ConstraintViolation {
355        type_name: String,
356        property: String,
357        constraint: String,
358        message: String,
359    },
360    /// A constraint name no validator enforces (S3): silently inert otherwise.
361    UnknownConstraint {
362        type_name: String,
363        property: String,
364        constraint: String,
365        known: Vec<String>,
366    },
367}
368
369impl std::fmt::Display for ValidationError {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        match self {
372            ValidationError::UnknownNodeType(t) => write!(f, "unknown node type: '{t}'"),
373            ValidationError::UnknownEdgeType(t) => write!(f, "unknown edge type: '{t}'"),
374            ValidationError::InvalidSource {
375                edge_type,
376                node_type,
377                allowed,
378            } => write!(
379                f,
380                "edge '{edge_type}' cannot have source type '{node_type}' (allowed: {allowed:?})"
381            ),
382            ValidationError::InvalidTarget {
383                edge_type,
384                node_type,
385                allowed,
386            } => write!(
387                f,
388                "edge '{edge_type}' cannot have target type '{node_type}' (allowed: {allowed:?})"
389            ),
390            ValidationError::MissingRequiredProperty {
391                type_name,
392                property,
393            } => write!(f, "'{type_name}' requires property '{property}'"),
394            ValidationError::WrongPropertyType {
395                type_name,
396                property,
397                expected,
398                got,
399            } => write!(
400                f,
401                "'{type_name}'.'{property}' expects {expected:?}, got {got}"
402            ),
403            ValidationError::UnknownProperty {
404                type_name,
405                property,
406            } => write!(f, "'{type_name}' has no property '{property}' in ontology"),
407            ValidationError::MissingSubtype { node_type, allowed } => {
408                write!(f, "'{node_type}' requires a subtype (allowed: {allowed:?})")
409            }
410            ValidationError::UnknownSubtype {
411                node_type,
412                subtype,
413                allowed,
414            } => write!(
415                f,
416                "'{node_type}' has no subtype '{subtype}' (allowed: {allowed:?})"
417            ),
418            ValidationError::UnexpectedSubtype { node_type, subtype } => write!(
419                f,
420                "'{node_type}' does not define subtypes, but got subtype '{subtype}'"
421            ),
422            ValidationError::ConstraintViolation {
423                type_name,
424                property,
425                constraint,
426                message,
427            } => write!(
428                f,
429                "'{type_name}'.'{property}' violates constraint '{constraint}': {message}"
430            ),
431            ValidationError::UnknownConstraint {
432                type_name,
433                property,
434                constraint,
435                known,
436            } => write!(
437                f,
438                "'{type_name}'.'{property}' declares unknown constraint '{constraint}' \
439                 (enforced: {}); nothing would check it. Prefix it '{}' to declare it \
440                 deliberately unenforced.",
441                known.join(", "),
442                UNENFORCED_CONSTRAINT_PREFIX
443            ),
444        }
445    }
446}
447
448/// An additive ontology extension — monotonic evolution only (R-03).
449#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
450pub struct OntologyExtension {
451    /// New node types to add.
452    #[serde(default)]
453    pub node_types: BTreeMap<String, NodeTypeDef>,
454    /// New edge types to add.
455    #[serde(default)]
456    pub edge_types: BTreeMap<String, EdgeTypeDef>,
457    /// Updates to existing node types (add properties, subtypes, relax required).
458    #[serde(default)]
459    pub node_type_updates: BTreeMap<String, NodeTypeUpdate>,
460    /// Updates to existing edge types (widen endpoint bindings, add properties).
461    ///
462    /// Added after a downstream outage: binding a new source or target type to
463    /// an edge type that already exists had no vocabulary at all, and the
464    /// attempt was silently accepted as a no-op. Widening is monotonic — the
465    /// edge type only ever accepts more — so it is safe for convergence.
466    ///
467    /// NOTE: this field is appended LAST. `OntologyExtension` serializes as a
468    /// positional array, so field order is the wire format; a new field must
469    /// go at the end, and older builds cannot read extensions that carry it.
470    #[serde(default)]
471    pub edge_type_updates: BTreeMap<String, EdgeTypeUpdate>,
472}
473
474/// Monotonic update to an existing edge type.
475#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
476pub struct EdgeTypeUpdate {
477    /// Node types to add to `source_types` (widening only).
478    #[serde(default)]
479    pub add_source_types: Vec<String>,
480    /// Node types to add to `target_types` (widening only).
481    #[serde(default)]
482    pub add_target_types: Vec<String>,
483    /// New optional properties on the edge type.
484    #[serde(default)]
485    pub add_properties: BTreeMap<String, PropertyDef>,
486}
487
488impl EdgeTypeUpdate {
489    fn is_empty(&self) -> bool {
490        self.add_source_types.is_empty()
491            && self.add_target_types.is_empty()
492            && self.add_properties.is_empty()
493    }
494}
495
496/// Additive update to an existing node type.
497#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
498pub struct NodeTypeUpdate {
499    /// New optional properties to add.
500    #[serde(default)]
501    pub add_properties: BTreeMap<String, PropertyDef>,
502    /// Properties to relax from required to optional.
503    #[serde(default)]
504    pub relax_properties: Vec<String>,
505    /// New subtypes to add.
506    #[serde(default)]
507    pub add_subtypes: BTreeMap<String, SubtypeDef>,
508}
509
510/// Errors from monotonic ontology extension (R-03).
511#[derive(Debug, Clone, PartialEq)]
512pub enum MonotonicityError {
513    DuplicateNodeType(String),
514    DuplicateEdgeType(String),
515    UnknownNodeType(String),
516    /// An `edge_type_updates` entry names an edge type that does not exist.
517    UnknownEdgeType(String),
518    /// A new endpoint binding references a node type that does not exist.
519    UnknownBindingType {
520        edge_type: String,
521        node_type: String,
522    },
523    /// A binding that is already present — the extension would change nothing.
524    DuplicateBinding {
525        edge_type: String,
526        node_type: String,
527    },
528    /// The extension parses but expresses no change (S-noop). Accepting it
529    /// writes a no-op entry to the replicated log and tells the caller the
530    /// schema evolved when it has not.
531    EmptyExtension,
532    DuplicateProperty {
533        type_name: String,
534        property: String,
535    },
536    UnknownProperty {
537        type_name: String,
538        property: String,
539    },
540    /// Wraps a ValidationError from validate_self() after merge.
541    ValidationFailed(ValidationError),
542}
543
544impl std::fmt::Display for MonotonicityError {
545    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546        match self {
547            MonotonicityError::DuplicateNodeType(t) => {
548                write!(f, "node type '{t}' already exists")
549            }
550            MonotonicityError::DuplicateEdgeType(t) => {
551                write!(f, "edge type '{t}' already exists")
552            }
553            MonotonicityError::UnknownNodeType(t) => {
554                write!(f, "cannot update unknown node type '{t}'")
555            }
556            MonotonicityError::UnknownEdgeType(t) => {
557                write!(f, "cannot update unknown edge type '{t}'")
558            }
559            MonotonicityError::UnknownBindingType {
560                edge_type,
561                node_type,
562            } => write!(
563                f,
564                "edge type '{edge_type}' cannot bind to unknown node type '{node_type}'"
565            ),
566            MonotonicityError::DuplicateBinding {
567                edge_type,
568                node_type,
569            } => write!(
570                f,
571                "edge type '{edge_type}' already binds '{node_type}'; this extension \
572                 would change nothing"
573            ),
574            MonotonicityError::EmptyExtension => write!(
575                f,
576                "extension expresses no change; nothing would be added. An extension \
577                 that changes nothing must not be written to the log"
578            ),
579            MonotonicityError::DuplicateProperty {
580                type_name,
581                property,
582            } => {
583                write!(f, "property '{property}' already exists on '{type_name}'")
584            }
585            MonotonicityError::UnknownProperty {
586                type_name,
587                property,
588            } => {
589                write!(
590                    f,
591                    "property '{property}' does not exist on '{type_name}' (cannot relax)"
592                )
593            }
594            MonotonicityError::ValidationFailed(e) => {
595                write!(f, "ontology validation failed after merge: {e}")
596            }
597        }
598    }
599}
600
601impl Ontology {
602    // -- RDFS-level class hierarchy (Step 2) --
603
604    /// Return all ancestor types of `node_type` (transitive parent_type chain).
605    /// Does not include `node_type` itself. Returns empty vec if no parent.
606    pub fn ancestors(&self, node_type: &str) -> Vec<&str> {
607        let mut result = Vec::new();
608        let mut current = node_type;
609        // Guard against cycles (max 100 levels — no real ontology is deeper)
610        for _ in 0..100 {
611            match self
612                .node_types
613                .get(current)
614                .and_then(|d| d.parent_type.as_deref())
615            {
616                Some(parent) => {
617                    result.push(parent);
618                    current = parent;
619                }
620                None => break,
621            }
622        }
623        result
624    }
625
626    /// Return all descendant types of `node_type` (types whose ancestor chain includes it).
627    /// Does not include `node_type` itself.
628    pub fn descendants(&self, node_type: &str) -> Vec<&str> {
629        // Collect all types that have node_type anywhere in their ancestor chain.
630        self.node_types
631            .iter()
632            .filter(|(name, _)| {
633                name.as_str() != node_type && self.ancestors(name).contains(&node_type)
634            })
635            .map(|(name, _)| name.as_str())
636            .collect()
637    }
638
639    /// Check if `child_type` is the same as or a descendant of `parent_type`.
640    pub fn is_subtype_of(&self, child_type: &str, parent_type: &str) -> bool {
641        child_type == parent_type || self.ancestors(child_type).contains(&parent_type)
642    }
643
644    /// Get all properties for a type, including those inherited from ancestors.
645    /// Ancestors' properties are applied first (most general), then overridden
646    /// by more specific types. Same order as Python MRO: parent first, child overrides.
647    pub fn effective_properties(&self, node_type: &str) -> BTreeMap<String, PropertyDef> {
648        let mut chain: Vec<&str> = self.ancestors(node_type);
649        chain.reverse(); // most general first
650        chain.push(node_type);
651
652        let mut props = BTreeMap::new();
653        for t in chain {
654            if let Some(def) = self.node_types.get(t) {
655                for (k, v) in &def.properties {
656                    props.insert(k.clone(), v.clone());
657                }
658            }
659        }
660        props
661    }
662
663    /// Validate that a node type exists and its properties conform.
664    ///
665    /// If the type defines subtypes (D-024), `subtype` must be `Some` and
666    /// properties are validated against the subtype's definition.
667    /// If the type does not define subtypes, `subtype` must be `None`.
668    pub fn validate_node(
669        &self,
670        node_type: &str,
671        subtype: Option<&str>,
672        properties: &BTreeMap<String, Value>,
673    ) -> Result<(), ValidationError> {
674        self.validate_node_mode(node_type, subtype, properties, ValidationMode::Full)
675    }
676
677    /// `validate_node` with an explicit enforcement mode (S2).
678    pub fn validate_node_mode(
679        &self,
680        node_type: &str,
681        subtype: Option<&str>,
682        properties: &BTreeMap<String, Value>,
683        mode: ValidationMode,
684    ) -> Result<(), ValidationError> {
685        let def = self
686            .node_types
687            .get(node_type)
688            .ok_or_else(|| ValidationError::UnknownNodeType(node_type.to_string()))?;
689
690        // Step 2: use effective_properties (includes inherited from ancestors)
691        let base_props = self.effective_properties(node_type);
692
693        match (&def.subtypes, subtype) {
694            // Type has subtypes and caller provided one
695            (Some(subtypes), Some(st)) => {
696                match subtypes.get(st) {
697                    Some(st_def) => {
698                        // Known subtype — merge inherited + type-level + subtype-level
699                        let mut merged = base_props;
700                        merged.extend(st_def.properties.clone());
701                        validate_properties(node_type, &merged, properties, mode)
702                    }
703                    None => {
704                        // D-026: unknown subtype — validate inherited + type-level only
705                        validate_properties(node_type, &base_props, properties, mode)
706                    }
707                }
708            }
709            // Type has subtypes but caller didn't provide one — error
710            (Some(subtypes), None) => Err(ValidationError::MissingSubtype {
711                node_type: node_type.to_string(),
712                allowed: subtypes.keys().cloned().collect(),
713            }),
714            // D-026: accept subtypes even if type doesn't declare any
715            (None, Some(_st)) => validate_properties(node_type, &base_props, properties, mode),
716            // Type has no subtypes and caller didn't provide one — validate as before
717            (None, None) => validate_properties(node_type, &base_props, properties, mode),
718        }
719    }
720
721    /// Validate that an edge type exists, source/target types are allowed,
722    /// and properties conform.
723    pub fn validate_edge(
724        &self,
725        edge_type: &str,
726        source_node_type: &str,
727        target_node_type: &str,
728        properties: &BTreeMap<String, Value>,
729    ) -> Result<(), ValidationError> {
730        self.validate_edge_mode(
731            edge_type,
732            source_node_type,
733            target_node_type,
734            properties,
735            ValidationMode::Full,
736        )
737    }
738
739    /// `validate_edge` with an explicit enforcement mode (S2).
740    pub fn validate_edge_mode(
741        &self,
742        edge_type: &str,
743        source_node_type: &str,
744        target_node_type: &str,
745        properties: &BTreeMap<String, Value>,
746        mode: ValidationMode,
747    ) -> Result<(), ValidationError> {
748        let def = self
749            .edge_types
750            .get(edge_type)
751            .ok_or_else(|| ValidationError::UnknownEdgeType(edge_type.to_string()))?;
752
753        // Hierarchy-aware: accept if actual type IS one of the allowed types
754        // OR is a descendant of any allowed type (RDFS rdfs9).
755        if !def
756            .source_types
757            .iter()
758            .any(|t| self.is_subtype_of(source_node_type, t))
759        {
760            return Err(ValidationError::InvalidSource {
761                edge_type: edge_type.to_string(),
762                node_type: source_node_type.to_string(),
763                allowed: def.source_types.clone(),
764            });
765        }
766
767        if !def
768            .target_types
769            .iter()
770            .any(|t| self.is_subtype_of(target_node_type, t))
771        {
772            return Err(ValidationError::InvalidTarget {
773                edge_type: edge_type.to_string(),
774                node_type: target_node_type.to_string(),
775                allowed: def.target_types.clone(),
776            });
777        }
778
779        validate_properties(edge_type, &def.properties, properties, mode)
780    }
781
782    /// Validate a single property update on an EDGE (H2). The node-shaped
783    /// `validate_property_update` cannot serve here: callers that looked up
784    /// only nodes silently skipped validation for every edge property.
785    pub fn validate_edge_property_update(
786        &self,
787        edge_type: &str,
788        key: &str,
789        value: &Value,
790    ) -> Result<(), ValidationError> {
791        let def = match self.edge_types.get(edge_type) {
792            Some(d) => d,
793            None => return Ok(()), // Unknown edge type — can't validate
794        };
795        // D-026: unknown properties accepted without validation.
796        let prop_def = match def.properties.get(key) {
797            Some(d) => d,
798            None => return Ok(()),
799        };
800        if prop_def.value_type != ValueType::Any && !value_matches_type(value, &prop_def.value_type)
801        {
802            return Err(ValidationError::WrongPropertyType {
803                type_name: edge_type.to_string(),
804                property: key.to_string(),
805                expected: prop_def.value_type.clone(),
806                got: value_type_name(value).to_string(),
807            });
808        }
809        if let Some(constraints) = &prop_def.constraints {
810            validate_constraints(edge_type, key, value, constraints)?;
811        }
812        Ok(())
813    }
814
815    /// Validate a single property update against the ontology.
816    /// Checks that the value type matches the property definition.
817    /// Unknown properties are accepted (D-026: ontology defines minimum, not maximum).
818    pub fn validate_property_update(
819        &self,
820        node_type: &str,
821        subtype: Option<&str>,
822        key: &str,
823        value: &Value,
824    ) -> Result<(), ValidationError> {
825        let def = match self.node_types.get(node_type) {
826            Some(d) => d,
827            None => return Ok(()), // Unknown node type — can't validate
828        };
829
830        // Merge type-level + subtype-level property definitions
831        let mut merged = def.properties.clone();
832        if let (Some(subtypes), Some(st)) = (&def.subtypes, subtype) {
833            if let Some(st_def) = subtypes.get(st) {
834                merged.extend(st_def.properties.clone());
835            }
836        }
837
838        // D-026: unknown properties accepted without validation
839        let prop_def = match merged.get(key) {
840            Some(d) => d,
841            None => return Ok(()),
842        };
843
844        // Type check
845        if prop_def.value_type != ValueType::Any && !value_matches_type(value, &prop_def.value_type)
846        {
847            return Err(ValidationError::WrongPropertyType {
848                type_name: node_type.to_string(),
849                property: key.to_string(),
850                expected: prop_def.value_type.clone(),
851                got: value_type_name(value).to_string(),
852            });
853        }
854
855        // Constraint check
856        if let Some(constraints) = &prop_def.constraints {
857            validate_constraints(node_type, key, value, constraints)?;
858        }
859
860        Ok(())
861    }
862
863    /// Validate that the ontology itself is internally consistent.
864    /// All source_types/target_types in edge defs must reference existing node types.
865    pub fn validate_self(&self) -> Result<(), ValidationError> {
866        // Validate edge source/target references
867        for (edge_name, edge_def) in &self.edge_types {
868            for src in &edge_def.source_types {
869                if !self.node_types.contains_key(src) {
870                    return Err(ValidationError::InvalidSource {
871                        edge_type: edge_name.clone(),
872                        node_type: src.clone(),
873                        allowed: self.node_types.keys().cloned().collect(),
874                    });
875                }
876            }
877            for tgt in &edge_def.target_types {
878                if !self.node_types.contains_key(tgt) {
879                    return Err(ValidationError::InvalidTarget {
880                        edge_type: edge_name.clone(),
881                        node_type: tgt.clone(),
882                        allowed: self.node_types.keys().cloned().collect(),
883                    });
884                }
885            }
886        }
887        // Validate parent_type references (Step 2: class hierarchy)
888        for (type_name, type_def) in &self.node_types {
889            if let Some(ref parent) = type_def.parent_type {
890                if !self.node_types.contains_key(parent) {
891                    return Err(ValidationError::UnknownNodeType(format!(
892                        "{}: parent_type '{}' does not exist",
893                        type_name, parent
894                    )));
895                }
896            }
897        }
898        // S3: an unknown constraint name is inert forever and invisible to the
899        // fingerprint, so a typo silently disables the rule it was meant to
900        // impose. Reject it here, which covers both ontology entry points
901        // (construction and extension). `x_` stays available for constraints
902        // this validator deliberately does not enforce.
903        for (type_name, type_def) in &self.node_types {
904            for (prop_name, prop_def) in &type_def.properties {
905                Self::check_constraint_names(type_name, prop_name, prop_def)?;
906            }
907            if let Some(subtypes) = &type_def.subtypes {
908                for (sub_name, sub_def) in subtypes {
909                    for (prop_name, prop_def) in &sub_def.properties {
910                        Self::check_constraint_names(
911                            &format!("{type_name}:{sub_name}"),
912                            prop_name,
913                            prop_def,
914                        )?;
915                    }
916                }
917            }
918        }
919        for (edge_name, edge_def) in &self.edge_types {
920            for (prop_name, prop_def) in &edge_def.properties {
921                Self::check_constraint_names(edge_name, prop_name, prop_def)?;
922            }
923        }
924        Ok(())
925    }
926
927    fn check_constraint_names(
928        type_name: &str,
929        prop_name: &str,
930        prop_def: &PropertyDef,
931    ) -> Result<(), ValidationError> {
932        let Some(constraints) = &prop_def.constraints else {
933            return Ok(());
934        };
935        for cname in constraints.keys() {
936            if ENFORCED_CONSTRAINTS.contains(&cname.as_str())
937                || cname.starts_with(UNENFORCED_CONSTRAINT_PREFIX)
938            {
939                continue;
940            }
941            return Err(ValidationError::UnknownConstraint {
942                type_name: type_name.to_string(),
943                property: prop_name.to_string(),
944                constraint: cname.clone(),
945                known: ENFORCED_CONSTRAINTS.iter().map(|s| s.to_string()).collect(),
946            });
947        }
948        Ok(())
949    }
950
951    /// R-03: Merge an additive extension into this ontology.
952    /// Only monotonic (additive) changes are allowed:
953    /// - New node types (must not already exist)
954    /// - New edge types (must not already exist)
955    /// - Updates to existing node types: add properties, relax required→optional, add subtypes
956    pub fn merge_extension(&mut self, ext: &OntologyExtension) -> Result<(), MonotonicityError> {
957        // An extension that expresses no change must not be accepted: it
958        // returns a hash, appends to the replicated log, and tells the caller
959        // the schema evolved when nothing did.
960        if ext.node_types.is_empty()
961            && ext.edge_types.is_empty()
962            && ext.node_type_updates.values().all(|u| {
963                u.add_properties.is_empty()
964                    && u.relax_properties.is_empty()
965                    && u.add_subtypes.is_empty()
966            })
967            && ext.edge_type_updates.values().all(|u| u.is_empty())
968        {
969            return Err(MonotonicityError::EmptyExtension);
970        }
971
972        // Validate: new node types don't already exist
973        for name in ext.node_types.keys() {
974            if self.node_types.contains_key(name) {
975                return Err(MonotonicityError::DuplicateNodeType(name.clone()));
976            }
977        }
978
979        // Validate: new edge types don't already exist
980        for name in ext.edge_types.keys() {
981            if self.edge_types.contains_key(name) {
982                return Err(MonotonicityError::DuplicateEdgeType(name.clone()));
983            }
984        }
985
986        // Validate node_type_updates reference existing types
987        for (type_name, update) in &ext.node_type_updates {
988            let def = self
989                .node_types
990                .get(type_name)
991                .ok_or_else(|| MonotonicityError::UnknownNodeType(type_name.clone()))?;
992
993            // Validate: add_properties don't already exist
994            for prop_name in update.add_properties.keys() {
995                if def.properties.contains_key(prop_name) {
996                    return Err(MonotonicityError::DuplicateProperty {
997                        type_name: type_name.clone(),
998                        property: prop_name.clone(),
999                    });
1000                }
1001            }
1002
1003            // Validate: relax_properties exist and are currently required
1004            for prop_name in &update.relax_properties {
1005                match def.properties.get(prop_name) {
1006                    Some(prop_def) if prop_def.required => {} // ok
1007                    Some(_) => {} // already optional — idempotent, allow it
1008                    None => {
1009                        return Err(MonotonicityError::UnknownProperty {
1010                            type_name: type_name.clone(),
1011                            property: prop_name.clone(),
1012                        });
1013                    }
1014                }
1015            }
1016
1017            // Validate: add_subtypes don't already exist (if subtypes are defined)
1018            if !update.add_subtypes.is_empty() {
1019                if let Some(ref existing) = def.subtypes {
1020                    for st_name in update.add_subtypes.keys() {
1021                        if existing.contains_key(st_name) {
1022                            return Err(MonotonicityError::DuplicateProperty {
1023                                type_name: type_name.clone(),
1024                                property: format!("subtype:{st_name}"),
1025                            });
1026                        }
1027                    }
1028                }
1029            }
1030        }
1031
1032        // Apply: extend node_types
1033        self.node_types.extend(ext.node_types.clone());
1034
1035        // Validate edge_type_updates: the edge type must exist, every new
1036        // binding must reference a node type that exists, and a binding that
1037        // is already present would change nothing.
1038        for (edge_name, update) in &ext.edge_type_updates {
1039            let def = self
1040                .edge_types
1041                .get(edge_name)
1042                .ok_or_else(|| MonotonicityError::UnknownEdgeType(edge_name.clone()))?;
1043
1044            for (bindings, existing) in [
1045                (&update.add_source_types, &def.source_types),
1046                (&update.add_target_types, &def.target_types),
1047            ] {
1048                for node_type in bindings {
1049                    // The node type may be arriving in this same extension.
1050                    if !self.node_types.contains_key(node_type)
1051                        && !ext.node_types.contains_key(node_type)
1052                    {
1053                        return Err(MonotonicityError::UnknownBindingType {
1054                            edge_type: edge_name.clone(),
1055                            node_type: node_type.clone(),
1056                        });
1057                    }
1058                    if existing.contains(node_type) {
1059                        return Err(MonotonicityError::DuplicateBinding {
1060                            edge_type: edge_name.clone(),
1061                            node_type: node_type.clone(),
1062                        });
1063                    }
1064                }
1065            }
1066
1067            for prop_name in update.add_properties.keys() {
1068                if def.properties.contains_key(prop_name) {
1069                    return Err(MonotonicityError::DuplicateProperty {
1070                        type_name: edge_name.clone(),
1071                        property: prop_name.clone(),
1072                    });
1073                }
1074            }
1075        }
1076
1077        // Apply: extend edge_types
1078        self.edge_types.extend(ext.edge_types.clone());
1079
1080        // Apply: widen existing edge types
1081        for (edge_name, update) in &ext.edge_type_updates {
1082            let def = self.edge_types.get_mut(edge_name).unwrap(); // validated above
1083            def.source_types.extend(update.add_source_types.clone());
1084            def.target_types.extend(update.add_target_types.clone());
1085            def.properties.extend(update.add_properties.clone());
1086        }
1087
1088        // Apply: update existing node types
1089        for (type_name, update) in &ext.node_type_updates {
1090            let def = self.node_types.get_mut(type_name).unwrap(); // validated above
1091
1092            // Add new properties
1093            def.properties.extend(update.add_properties.clone());
1094
1095            // Relax required → optional
1096            for prop_name in &update.relax_properties {
1097                if let Some(prop_def) = def.properties.get_mut(prop_name) {
1098                    prop_def.required = false;
1099                }
1100            }
1101
1102            // Add subtypes
1103            if !update.add_subtypes.is_empty() {
1104                let subtypes = def.subtypes.get_or_insert_with(BTreeMap::new);
1105                subtypes.extend(update.add_subtypes.clone());
1106            }
1107        }
1108
1109        // Validate the merged ontology is internally consistent
1110        self.validate_self()
1111            .map_err(MonotonicityError::ValidationFailed)?;
1112
1113        Ok(())
1114    }
1115}
1116
1117/// Validate properties against their definitions.
1118fn validate_properties(
1119    type_name: &str,
1120    defs: &BTreeMap<String, PropertyDef>,
1121    values: &BTreeMap<String, Value>,
1122    mode: ValidationMode,
1123) -> Result<(), ValidationError> {
1124    // Check required properties are present. Skipped only for a checkpoint's
1125    // synthetic inner ops, whose values arrive as separate UpdateProperty ops.
1126    if mode == ValidationMode::Full {
1127        for (prop_name, prop_def) in defs {
1128            if prop_def.required && !values.contains_key(prop_name) {
1129                return Err(ValidationError::MissingRequiredProperty {
1130                    type_name: type_name.to_string(),
1131                    property: prop_name.clone(),
1132                });
1133            }
1134        }
1135    }
1136
1137    // Check all provided properties are known and correctly typed
1138    for (prop_name, value) in values {
1139        // D-026: accept unknown properties without validation.
1140        // The ontology defines the minimum, not the maximum.
1141        let prop_def = match defs.get(prop_name) {
1142            Some(def) => def,
1143            None => continue,
1144        };
1145
1146        if prop_def.value_type != ValueType::Any {
1147            let actual_type = value_type_name(value);
1148            let expected = &prop_def.value_type;
1149            if !value_matches_type(value, expected) {
1150                return Err(ValidationError::WrongPropertyType {
1151                    type_name: type_name.to_string(),
1152                    property: prop_name.clone(),
1153                    expected: expected.clone(),
1154                    got: actual_type.to_string(),
1155                });
1156            }
1157        }
1158
1159        // Validate constraints (if any)
1160        if let Some(constraints) = &prop_def.constraints {
1161            validate_constraints(type_name, prop_name, value, constraints)?;
1162        }
1163    }
1164
1165    Ok(())
1166}
1167
1168/// Validate a property value against its constraints.
1169/// Built-in constraints: "enum" (allowed values), "min"/"max" (numeric range).
1170/// Unknown constraint names are silently ignored — enables forward compatibility
1171/// with community-contributed constraint types.
1172fn validate_constraints(
1173    type_name: &str,
1174    prop_name: &str,
1175    value: &Value,
1176    constraints: &BTreeMap<String, serde_json::Value>,
1177) -> Result<(), ValidationError> {
1178    // "enum": list of allowed string values
1179    if let Some(serde_json::Value::Array(allowed)) = constraints.get("enum") {
1180        if let Value::String(s) = value {
1181            let allowed_strs: Vec<&str> = allowed.iter().filter_map(|v| v.as_str()).collect();
1182            if !allowed_strs.contains(&s.as_str()) {
1183                return constraint_err(
1184                    type_name,
1185                    prop_name,
1186                    "enum",
1187                    format!("value '{}' not in allowed set {:?}", s, allowed_strs),
1188                );
1189            }
1190        }
1191    }
1192
1193    // Numeric bounds (4 variants share the same extract-compare pattern)
1194    check_numeric_bound(
1195        type_name,
1196        prop_name,
1197        value,
1198        constraints,
1199        "min",
1200        |n, b| n < b,
1201        |n, b| format!("value {} is less than minimum {}", n, b),
1202    )?;
1203    check_numeric_bound(
1204        type_name,
1205        prop_name,
1206        value,
1207        constraints,
1208        "max",
1209        |n, b| n > b,
1210        |n, b| format!("value {} exceeds maximum {}", n, b),
1211    )?;
1212    check_numeric_bound(
1213        type_name,
1214        prop_name,
1215        value,
1216        constraints,
1217        "min_exclusive",
1218        |n, b| n <= b,
1219        |n, b| format!("value {} must be greater than {}", n, b),
1220    )?;
1221    check_numeric_bound(
1222        type_name,
1223        prop_name,
1224        value,
1225        constraints,
1226        "max_exclusive",
1227        |n, b| n >= b,
1228        |n, b| format!("value {} must be less than {}", n, b),
1229    )?;
1230
1231    // String length bounds
1232    check_string_length(
1233        type_name,
1234        prop_name,
1235        value,
1236        constraints,
1237        "min_length",
1238        |len, bound| len < bound,
1239        |len, bound| format!("string length {} is less than minimum {}", len, bound),
1240    )?;
1241    check_string_length(
1242        type_name,
1243        prop_name,
1244        value,
1245        constraints,
1246        "max_length",
1247        |len, bound| len > bound,
1248        |len, bound| format!("string length {} exceeds maximum {}", len, bound),
1249    )?;
1250
1251    // "pattern": regex match on string values
1252    if let Some(serde_json::Value::String(pattern)) = constraints.get("pattern") {
1253        if let Value::String(s) = value {
1254            match regex::Regex::new(pattern) {
1255                Ok(re) if !re.is_match(s) => {
1256                    return constraint_err(
1257                        type_name,
1258                        prop_name,
1259                        "pattern",
1260                        format!("value '{}' does not match pattern '{}'", s, pattern),
1261                    );
1262                }
1263                Err(e) => {
1264                    return constraint_err(
1265                        type_name,
1266                        prop_name,
1267                        "pattern",
1268                        format!("invalid regex pattern '{}': {}", pattern, e),
1269                    );
1270                }
1271                _ => {}
1272            }
1273        }
1274    }
1275
1276    // Unknown constraint names are silently ignored (forward compat).
1277    Ok(())
1278}
1279
1280/// Helper: extract numeric value from a Value.
1281fn value_as_f64(value: &Value) -> Option<f64> {
1282    match value {
1283        Value::Int(n) => Some(*n as f64),
1284        Value::Float(n) => Some(*n),
1285        _ => None,
1286    }
1287}
1288
1289/// Helper: check a numeric bound constraint.
1290fn check_numeric_bound(
1291    type_name: &str,
1292    prop_name: &str,
1293    value: &Value,
1294    constraints: &BTreeMap<String, serde_json::Value>,
1295    key: &str,
1296    violates: impl Fn(f64, f64) -> bool,
1297    msg: impl Fn(f64, f64) -> String,
1298) -> Result<(), ValidationError> {
1299    if let Some(bound_val) = constraints.get(key) {
1300        if let Some(bound) = bound_val.as_f64() {
1301            if let Some(n) = value_as_f64(value) {
1302                if violates(n, bound) {
1303                    return constraint_err(type_name, prop_name, key, msg(n, bound));
1304                }
1305            }
1306        }
1307    }
1308    Ok(())
1309}
1310
1311/// Helper: check a string length constraint.
1312fn check_string_length(
1313    type_name: &str,
1314    prop_name: &str,
1315    value: &Value,
1316    constraints: &BTreeMap<String, serde_json::Value>,
1317    key: &str,
1318    violates: impl Fn(u64, u64) -> bool,
1319    msg: impl Fn(u64, u64) -> String,
1320) -> Result<(), ValidationError> {
1321    if let Some(serde_json::Value::Number(n)) = constraints.get(key) {
1322        if let (Some(bound), Value::String(s)) = (n.as_u64(), value) {
1323            if violates(s.len() as u64, bound) {
1324                return constraint_err(type_name, prop_name, key, msg(s.len() as u64, bound));
1325            }
1326        }
1327    }
1328    Ok(())
1329}
1330
1331/// Helper: construct a ConstraintViolation error.
1332fn constraint_err(
1333    type_name: &str,
1334    prop_name: &str,
1335    constraint: &str,
1336    message: String,
1337) -> Result<(), ValidationError> {
1338    Err(ValidationError::ConstraintViolation {
1339        type_name: type_name.to_string(),
1340        property: prop_name.to_string(),
1341        constraint: constraint.to_string(),
1342        message,
1343    })
1344}
1345
1346fn value_matches_type(value: &Value, expected: &ValueType) -> bool {
1347    matches!(
1348        (value, expected),
1349        (Value::Null, _)
1350            | (Value::String(_), ValueType::String)
1351            | (Value::Int(_), ValueType::Int)
1352            | (Value::Float(_), ValueType::Float)
1353            | (Value::Bool(_), ValueType::Bool)
1354            | (Value::List(_), ValueType::List)
1355            | (Value::Map(_), ValueType::Map)
1356            | (_, ValueType::Any)
1357    )
1358}
1359
1360fn value_type_name(value: &Value) -> &'static str {
1361    match value {
1362        Value::Null => "null",
1363        Value::Bool(_) => "bool",
1364        Value::Int(_) => "int",
1365        Value::Float(_) => "float",
1366        Value::String(_) => "string",
1367        Value::List(_) => "list",
1368        Value::Map(_) => "map",
1369    }
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use super::*;
1375
1376    /// `OntologyExtension` serializes as a POSITIONAL array, so its field
1377    /// count is the wire format. `edge_type_updates` was appended last; a
1378    /// legacy 3-element extension, as written by every build up to 0.3.0,
1379    /// must still load. Older builds cannot read the 4-element form — that is
1380    /// why PROTOCOL_VERSION moved.
1381    #[test]
1382    fn legacy_three_field_extension_still_deserializes() {
1383        let legacy = (
1384            BTreeMap::<String, NodeTypeDef>::new(),
1385            BTreeMap::<String, EdgeTypeDef>::new(),
1386            BTreeMap::<String, NodeTypeUpdate>::new(),
1387        );
1388        let bytes = rmp_serde::to_vec(&legacy).unwrap();
1389        assert_eq!(bytes[0], 0x93, "legacy fixture is not 3 elements");
1390
1391        let restored: OntologyExtension =
1392            rmp_serde::from_slice(&bytes).expect("legacy extension must load");
1393        assert!(restored.edge_type_updates.is_empty());
1394    }
1395
1396    #[test]
1397    fn extension_wire_format_is_a_positional_array_of_four() {
1398        let ext = OntologyExtension::default();
1399        let bytes = rmp_serde::to_vec(&ext).unwrap();
1400        assert_eq!(
1401            bytes[0], 0x94,
1402            "OntologyExtension is no longer a 4-element positional array. \
1403             Field order and count are the wire format: append new fields at \
1404             the end and move PROTOCOL_VERSION."
1405        );
1406    }
1407
1408    fn devops_ontology() -> Ontology {
1409        Ontology {
1410            node_types: BTreeMap::from([
1411                (
1412                    "signal".into(),
1413                    NodeTypeDef {
1414                        description: Some("Something observed".into()),
1415                        properties: BTreeMap::from([(
1416                            "severity".into(),
1417                            PropertyDef {
1418                                value_type: ValueType::String,
1419                                required: true,
1420                                description: None,
1421                                constraints: None,
1422                            },
1423                        )]),
1424                        subtypes: None,
1425                        parent_type: None,
1426                    },
1427                ),
1428                (
1429                    "entity".into(),
1430                    NodeTypeDef {
1431                        description: Some("Something that exists".into()),
1432                        properties: BTreeMap::from([
1433                            (
1434                                "status".into(),
1435                                PropertyDef {
1436                                    value_type: ValueType::String,
1437                                    required: false,
1438                                    description: None,
1439                                    constraints: None,
1440                                },
1441                            ),
1442                            (
1443                                "port".into(),
1444                                PropertyDef {
1445                                    value_type: ValueType::Int,
1446                                    required: false,
1447                                    description: None,
1448                                    constraints: None,
1449                                },
1450                            ),
1451                        ]),
1452                        subtypes: None,
1453                        parent_type: None,
1454                    },
1455                ),
1456                (
1457                    "rule".into(),
1458                    NodeTypeDef {
1459                        description: None,
1460                        properties: BTreeMap::new(),
1461                        subtypes: None,
1462                        parent_type: None,
1463                    },
1464                ),
1465                (
1466                    "action".into(),
1467                    NodeTypeDef {
1468                        description: None,
1469                        properties: BTreeMap::new(),
1470                        subtypes: None,
1471                        parent_type: None,
1472                    },
1473                ),
1474            ]),
1475            edge_types: BTreeMap::from([
1476                (
1477                    "OBSERVES".into(),
1478                    EdgeTypeDef {
1479                        description: None,
1480                        source_types: vec!["signal".into()],
1481                        target_types: vec!["entity".into()],
1482                        properties: BTreeMap::new(),
1483                    },
1484                ),
1485                (
1486                    "TRIGGERS".into(),
1487                    EdgeTypeDef {
1488                        description: None,
1489                        source_types: vec!["signal".into()],
1490                        target_types: vec!["rule".into()],
1491                        properties: BTreeMap::new(),
1492                    },
1493                ),
1494                (
1495                    "RUNS_ON".into(),
1496                    EdgeTypeDef {
1497                        description: None,
1498                        source_types: vec!["entity".into()],
1499                        target_types: vec!["entity".into()],
1500                        properties: BTreeMap::new(),
1501                    },
1502                ),
1503            ]),
1504        }
1505    }
1506
1507    // --- Node validation ---
1508
1509    #[test]
1510    fn validate_node_valid() {
1511        let ont = devops_ontology();
1512        let props = BTreeMap::from([("severity".into(), Value::String("critical".into()))]);
1513        assert!(ont.validate_node("signal", None, &props).is_ok());
1514    }
1515
1516    #[test]
1517    fn validate_node_unknown_type() {
1518        let ont = devops_ontology();
1519        let err = ont
1520            .validate_node("potato", None, &BTreeMap::new())
1521            .unwrap_err();
1522        assert!(matches!(err, ValidationError::UnknownNodeType(t) if t == "potato"));
1523    }
1524
1525    #[test]
1526    fn validate_node_missing_required() {
1527        let ont = devops_ontology();
1528        let err = ont
1529            .validate_node("signal", None, &BTreeMap::new())
1530            .unwrap_err();
1531        assert!(
1532            matches!(err, ValidationError::MissingRequiredProperty { property, .. } if property == "severity")
1533        );
1534    }
1535
1536    #[test]
1537    fn validate_node_wrong_type() {
1538        let ont = devops_ontology();
1539        let props = BTreeMap::from([("severity".into(), Value::Int(5))]);
1540        let err = ont.validate_node("signal", None, &props).unwrap_err();
1541        assert!(
1542            matches!(err, ValidationError::WrongPropertyType { property, .. } if property == "severity")
1543        );
1544    }
1545
1546    #[test]
1547    fn validate_node_unknown_property_accepted() {
1548        // D-026: unknown properties are accepted without validation
1549        let ont = devops_ontology();
1550        let props = BTreeMap::from([
1551            ("severity".into(), Value::String("warn".into())),
1552            ("bogus".into(), Value::Bool(true)),
1553        ]);
1554        assert!(ont.validate_node("signal", None, &props).is_ok());
1555    }
1556
1557    #[test]
1558    fn validate_node_optional_property_absent() {
1559        let ont = devops_ontology();
1560        // entity has optional "status" — omitting it is fine
1561        assert!(ont.validate_node("entity", None, &BTreeMap::new()).is_ok());
1562    }
1563
1564    #[test]
1565    fn validate_node_null_accepted_for_any_type() {
1566        let ont = devops_ontology();
1567        // Null is accepted for any typed property (represents absence)
1568        let props = BTreeMap::from([("severity".into(), Value::Null)]);
1569        assert!(ont.validate_node("signal", None, &props).is_ok());
1570    }
1571
1572    // --- Edge validation ---
1573
1574    #[test]
1575    fn validate_edge_valid() {
1576        let ont = devops_ontology();
1577        assert!(ont
1578            .validate_edge("OBSERVES", "signal", "entity", &BTreeMap::new())
1579            .is_ok());
1580    }
1581
1582    #[test]
1583    fn validate_edge_unknown_type() {
1584        let ont = devops_ontology();
1585        let err = ont
1586            .validate_edge("FLIES_TO", "signal", "entity", &BTreeMap::new())
1587            .unwrap_err();
1588        assert!(matches!(err, ValidationError::UnknownEdgeType(t) if t == "FLIES_TO"));
1589    }
1590
1591    #[test]
1592    fn validate_edge_invalid_source() {
1593        let ont = devops_ontology();
1594        // OBSERVES requires source=signal, not entity
1595        let err = ont
1596            .validate_edge("OBSERVES", "entity", "entity", &BTreeMap::new())
1597            .unwrap_err();
1598        assert!(matches!(err, ValidationError::InvalidSource { .. }));
1599    }
1600
1601    #[test]
1602    fn validate_edge_invalid_target() {
1603        let ont = devops_ontology();
1604        // OBSERVES requires target=entity, not signal
1605        let err = ont
1606            .validate_edge("OBSERVES", "signal", "signal", &BTreeMap::new())
1607            .unwrap_err();
1608        assert!(matches!(err, ValidationError::InvalidTarget { .. }));
1609    }
1610
1611    // --- Self-validation ---
1612
1613    #[test]
1614    fn validate_self_consistent() {
1615        let ont = devops_ontology();
1616        assert!(ont.validate_self().is_ok());
1617    }
1618
1619    #[test]
1620    fn validate_self_dangling_source() {
1621        let ont = Ontology {
1622            node_types: BTreeMap::from([(
1623                "entity".into(),
1624                NodeTypeDef {
1625                    description: None,
1626                    properties: BTreeMap::new(),
1627                    subtypes: None,
1628                    parent_type: None,
1629                },
1630            )]),
1631            edge_types: BTreeMap::from([(
1632                "OBSERVES".into(),
1633                EdgeTypeDef {
1634                    description: None,
1635                    source_types: vec!["ghost".into()], // doesn't exist
1636                    target_types: vec!["entity".into()],
1637                    properties: BTreeMap::new(),
1638                },
1639            )]),
1640        };
1641        let err = ont.validate_self().unwrap_err();
1642        assert!(
1643            matches!(err, ValidationError::InvalidSource { node_type, .. } if node_type == "ghost")
1644        );
1645    }
1646
1647    #[test]
1648    fn validate_self_dangling_target() {
1649        let ont = Ontology {
1650            node_types: BTreeMap::from([(
1651                "signal".into(),
1652                NodeTypeDef {
1653                    description: None,
1654                    properties: BTreeMap::new(),
1655                    subtypes: None,
1656                    parent_type: None,
1657                },
1658            )]),
1659            edge_types: BTreeMap::from([(
1660                "OBSERVES".into(),
1661                EdgeTypeDef {
1662                    description: None,
1663                    source_types: vec!["signal".into()],
1664                    target_types: vec!["phantom".into()], // doesn't exist
1665                    properties: BTreeMap::new(),
1666                },
1667            )]),
1668        };
1669        let err = ont.validate_self().unwrap_err();
1670        assert!(
1671            matches!(err, ValidationError::InvalidTarget { node_type, .. } if node_type == "phantom")
1672        );
1673    }
1674
1675    // --- Serialization ---
1676
1677    // --- New constraint tests (Step 1: SHACL-inspired vocabulary) ---
1678
1679    fn constrained_ontology() -> Ontology {
1680        Ontology {
1681            node_types: BTreeMap::from([(
1682                "item".into(),
1683                NodeTypeDef {
1684                    description: None,
1685                    properties: BTreeMap::from([
1686                        (
1687                            "slug".into(),
1688                            PropertyDef {
1689                                value_type: ValueType::String,
1690                                required: false,
1691                                description: None,
1692                                constraints: Some(BTreeMap::from([
1693                                    (
1694                                        "pattern".to_string(),
1695                                        serde_json::Value::String("^[a-z0-9-]+$".to_string()),
1696                                    ),
1697                                    (
1698                                        "min_length".to_string(),
1699                                        serde_json::Value::Number(1.into()),
1700                                    ),
1701                                    (
1702                                        "max_length".to_string(),
1703                                        serde_json::Value::Number(63.into()),
1704                                    ),
1705                                ])),
1706                            },
1707                        ),
1708                        (
1709                            "score".into(),
1710                            PropertyDef {
1711                                value_type: ValueType::Float,
1712                                required: false,
1713                                description: None,
1714                                constraints: Some(BTreeMap::from([
1715                                    ("min_exclusive".to_string(), serde_json::json!(0.0)),
1716                                    ("max_exclusive".to_string(), serde_json::json!(100.0)),
1717                                ])),
1718                            },
1719                        ),
1720                    ]),
1721                    subtypes: None,
1722                    parent_type: None,
1723                },
1724            )]),
1725            edge_types: BTreeMap::new(),
1726        }
1727    }
1728
1729    #[test]
1730    fn pattern_valid_slug() {
1731        let ont = constrained_ontology();
1732        let props = BTreeMap::from([("slug".into(), Value::String("my-project-1".into()))]);
1733        assert!(ont.validate_node("item", None, &props).is_ok());
1734    }
1735
1736    #[test]
1737    fn pattern_rejects_uppercase() {
1738        let ont = constrained_ontology();
1739        let props = BTreeMap::from([("slug".into(), Value::String("My-Project".into()))]);
1740        assert!(ont.validate_node("item", None, &props).is_err());
1741    }
1742
1743    #[test]
1744    fn pattern_rejects_spaces() {
1745        let ont = constrained_ontology();
1746        let props = BTreeMap::from([("slug".into(), Value::String("has space".into()))]);
1747        assert!(ont.validate_node("item", None, &props).is_err());
1748    }
1749
1750    #[test]
1751    fn min_length_accepts_valid() {
1752        let ont = constrained_ontology();
1753        let props = BTreeMap::from([("slug".into(), Value::String("a".into()))]);
1754        assert!(ont.validate_node("item", None, &props).is_ok());
1755    }
1756
1757    #[test]
1758    fn min_length_rejects_empty() {
1759        let ont = constrained_ontology();
1760        let props = BTreeMap::from([("slug".into(), Value::String("".into()))]);
1761        let err = ont.validate_node("item", None, &props).unwrap_err();
1762        assert!(
1763            matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_length")
1764        );
1765    }
1766
1767    #[test]
1768    fn max_length_rejects_too_long() {
1769        let ont = constrained_ontology();
1770        let long = "a".repeat(64);
1771        let props = BTreeMap::from([("slug".into(), Value::String(long))]);
1772        let err = ont.validate_node("item", None, &props).unwrap_err();
1773        assert!(
1774            matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_length")
1775        );
1776    }
1777
1778    #[test]
1779    fn max_length_accepts_boundary() {
1780        let ont = constrained_ontology();
1781        let exact = "a".repeat(63);
1782        let props = BTreeMap::from([("slug".into(), Value::String(exact))]);
1783        assert!(ont.validate_node("item", None, &props).is_ok());
1784    }
1785
1786    #[test]
1787    fn min_exclusive_rejects_boundary() {
1788        let ont = constrained_ontology();
1789        let props = BTreeMap::from([("score".into(), Value::Float(0.0))]);
1790        let err = ont.validate_node("item", None, &props).unwrap_err();
1791        assert!(
1792            matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_exclusive")
1793        );
1794    }
1795
1796    #[test]
1797    fn min_exclusive_accepts_above() {
1798        let ont = constrained_ontology();
1799        let props = BTreeMap::from([("score".into(), Value::Float(0.001))]);
1800        assert!(ont.validate_node("item", None, &props).is_ok());
1801    }
1802
1803    #[test]
1804    fn max_exclusive_rejects_boundary() {
1805        let ont = constrained_ontology();
1806        let props = BTreeMap::from([("score".into(), Value::Float(100.0))]);
1807        let err = ont.validate_node("item", None, &props).unwrap_err();
1808        assert!(
1809            matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_exclusive")
1810        );
1811    }
1812
1813    #[test]
1814    fn max_exclusive_accepts_below() {
1815        let ont = constrained_ontology();
1816        let props = BTreeMap::from([("score".into(), Value::Float(99.999))]);
1817        assert!(ont.validate_node("item", None, &props).is_ok());
1818    }
1819
1820    // --- Serialization ---
1821
1822    #[test]
1823    fn ontology_roundtrip_msgpack() {
1824        let ont = devops_ontology();
1825        let bytes = rmp_serde::to_vec(&ont).unwrap();
1826        let decoded: Ontology = rmp_serde::from_slice(&bytes).unwrap();
1827        assert_eq!(ont, decoded);
1828    }
1829
1830    #[test]
1831    fn ontology_roundtrip_json() {
1832        let ont = devops_ontology();
1833        let json = serde_json::to_string(&ont).unwrap();
1834        let decoded: Ontology = serde_json::from_str(&json).unwrap();
1835        assert_eq!(ont, decoded);
1836    }
1837
1838    // --- Step 2: RDFS class hierarchy tests ---
1839
1840    fn hierarchy_ontology() -> Ontology {
1841        // thing → entity → server (two levels)
1842        //       → event
1843        Ontology {
1844            node_types: BTreeMap::from([
1845                (
1846                    "thing".into(),
1847                    NodeTypeDef {
1848                        description: None,
1849                        properties: BTreeMap::from([(
1850                            "name".into(),
1851                            PropertyDef {
1852                                value_type: ValueType::String,
1853                                required: true,
1854                                description: None,
1855                                constraints: None,
1856                            },
1857                        )]),
1858                        subtypes: None,
1859                        parent_type: None, // root
1860                    },
1861                ),
1862                (
1863                    "entity".into(),
1864                    NodeTypeDef {
1865                        description: None,
1866                        properties: BTreeMap::from([(
1867                            "status".into(),
1868                            PropertyDef {
1869                                value_type: ValueType::String,
1870                                required: false,
1871                                description: None,
1872                                constraints: None,
1873                            },
1874                        )]),
1875                        subtypes: None,
1876                        parent_type: Some("thing".into()), // entity extends thing
1877                    },
1878                ),
1879                (
1880                    "server".into(),
1881                    NodeTypeDef {
1882                        description: None,
1883                        properties: BTreeMap::from([(
1884                            "ip".into(),
1885                            PropertyDef {
1886                                value_type: ValueType::String,
1887                                required: false,
1888                                description: None,
1889                                constraints: None,
1890                            },
1891                        )]),
1892                        subtypes: None,
1893                        parent_type: Some("entity".into()), // server extends entity
1894                    },
1895                ),
1896                (
1897                    "event".into(),
1898                    NodeTypeDef {
1899                        description: None,
1900                        properties: BTreeMap::new(),
1901                        subtypes: None,
1902                        parent_type: Some("thing".into()), // event extends thing
1903                    },
1904                ),
1905            ]),
1906            edge_types: BTreeMap::from([(
1907                "RELATES_TO".into(),
1908                EdgeTypeDef {
1909                    description: None,
1910                    source_types: vec!["thing".into()], // accepts any thing descendant
1911                    target_types: vec!["entity".into()], // accepts entity or server
1912                    properties: BTreeMap::new(),
1913                },
1914            )]),
1915        }
1916    }
1917
1918    #[test]
1919    fn ancestors_empty_for_root() {
1920        let ont = hierarchy_ontology();
1921        assert!(ont.ancestors("thing").is_empty());
1922    }
1923
1924    #[test]
1925    fn ancestors_single_parent() {
1926        let ont = hierarchy_ontology();
1927        assert_eq!(ont.ancestors("entity"), vec!["thing"]);
1928    }
1929
1930    #[test]
1931    fn ancestors_transitive() {
1932        let ont = hierarchy_ontology();
1933        // server → entity → thing
1934        assert_eq!(ont.ancestors("server"), vec!["entity", "thing"]);
1935    }
1936
1937    #[test]
1938    fn descendants_of_root() {
1939        let ont = hierarchy_ontology();
1940        let mut desc = ont.descendants("thing");
1941        desc.sort();
1942        assert_eq!(desc, vec!["entity", "event", "server"]);
1943    }
1944
1945    #[test]
1946    fn descendants_of_entity() {
1947        let ont = hierarchy_ontology();
1948        assert_eq!(ont.descendants("entity"), vec!["server"]);
1949    }
1950
1951    #[test]
1952    fn descendants_of_leaf() {
1953        let ont = hierarchy_ontology();
1954        assert!(ont.descendants("server").is_empty());
1955    }
1956
1957    #[test]
1958    fn is_subtype_of_self() {
1959        let ont = hierarchy_ontology();
1960        assert!(ont.is_subtype_of("server", "server"));
1961    }
1962
1963    #[test]
1964    fn is_subtype_of_parent() {
1965        let ont = hierarchy_ontology();
1966        assert!(ont.is_subtype_of("server", "entity"));
1967        assert!(ont.is_subtype_of("server", "thing"));
1968    }
1969
1970    #[test]
1971    fn is_not_subtype_of_sibling() {
1972        let ont = hierarchy_ontology();
1973        assert!(!ont.is_subtype_of("server", "event"));
1974    }
1975
1976    #[test]
1977    fn effective_properties_inherits() {
1978        let ont = hierarchy_ontology();
1979        let props = ont.effective_properties("server");
1980        // server should have: name (from thing), status (from entity), ip (own)
1981        assert!(props.contains_key("name"));
1982        assert!(props.contains_key("status"));
1983        assert!(props.contains_key("ip"));
1984    }
1985
1986    #[test]
1987    fn effective_properties_root_has_own_only() {
1988        let ont = hierarchy_ontology();
1989        let props = ont.effective_properties("thing");
1990        assert!(props.contains_key("name"));
1991        assert!(!props.contains_key("status"));
1992    }
1993
1994    #[test]
1995    fn validate_node_inherits_required_from_ancestor() {
1996        let ont = hierarchy_ontology();
1997        // server requires "name" (inherited from thing)
1998        let err = ont.validate_node("server", None, &BTreeMap::new());
1999        assert!(err.is_err());
2000
2001        let props = BTreeMap::from([("name".into(), Value::String("web-01".into()))]);
2002        assert!(ont.validate_node("server", None, &props).is_ok());
2003    }
2004
2005    #[test]
2006    fn validate_edge_hierarchy_aware() {
2007        let ont = hierarchy_ontology();
2008        // RELATES_TO: source=thing, target=entity
2009        // server is-a thing, server is-a entity → both should pass
2010        let empty = BTreeMap::new();
2011        assert!(ont
2012            .validate_edge("RELATES_TO", "server", "server", &empty)
2013            .is_ok());
2014        assert!(ont
2015            .validate_edge("RELATES_TO", "event", "entity", &empty)
2016            .is_ok());
2017        assert!(ont
2018            .validate_edge("RELATES_TO", "thing", "entity", &empty)
2019            .is_ok());
2020    }
2021
2022    #[test]
2023    fn validate_edge_hierarchy_rejects_wrong_branch() {
2024        let ont = hierarchy_ontology();
2025        // RELATES_TO target must be entity or descendant. event is not entity's descendant.
2026        let empty = BTreeMap::new();
2027        assert!(ont
2028            .validate_edge("RELATES_TO", "thing", "event", &empty)
2029            .is_err());
2030    }
2031
2032    #[test]
2033    fn validate_self_rejects_dangling_parent() {
2034        let ont = Ontology {
2035            node_types: BTreeMap::from([(
2036                "orphan".into(),
2037                NodeTypeDef {
2038                    description: None,
2039                    properties: BTreeMap::new(),
2040                    subtypes: None,
2041                    parent_type: Some("ghost".into()), // doesn't exist
2042                },
2043            )]),
2044            edge_types: BTreeMap::new(),
2045        };
2046        assert!(ont.validate_self().is_err());
2047    }
2048
2049    // -- Ontology hashing and fingerprinting --
2050
2051    fn pet_ontology() -> Ontology {
2052        Ontology {
2053            node_types: BTreeMap::from([
2054                (
2055                    "animal".into(),
2056                    NodeTypeDef {
2057                        description: None,
2058                        properties: BTreeMap::from([(
2059                            "name".into(),
2060                            PropertyDef {
2061                                value_type: ValueType::String,
2062                                required: true,
2063                                description: None,
2064                                constraints: None,
2065                            },
2066                        )]),
2067                        subtypes: None,
2068                        parent_type: None,
2069                    },
2070                ),
2071                (
2072                    "shelter".into(),
2073                    NodeTypeDef {
2074                        description: None,
2075                        properties: BTreeMap::new(),
2076                        subtypes: None,
2077                        parent_type: None,
2078                    },
2079                ),
2080            ]),
2081            edge_types: BTreeMap::from([(
2082                "LIVES_AT".into(),
2083                EdgeTypeDef {
2084                    description: None,
2085                    source_types: vec!["animal".into()],
2086                    target_types: vec!["shelter".into()],
2087                    properties: BTreeMap::new(),
2088                },
2089            )]),
2090        }
2091    }
2092
2093    #[test]
2094    fn content_hash_deterministic() {
2095        let a = pet_ontology();
2096        let b = pet_ontology();
2097        assert_eq!(a.content_hash(), b.content_hash());
2098    }
2099
2100    #[test]
2101    fn content_hash_is_32_bytes() {
2102        let ont = pet_ontology();
2103        let hash = ont.content_hash();
2104        assert_eq!(hash.len(), 32);
2105        assert_ne!(hash, [0u8; 32]); // not all zeros
2106    }
2107
2108    #[test]
2109    fn content_hash_changes_on_new_type() {
2110        let mut ont = pet_ontology();
2111        let hash_before = ont.content_hash();
2112        ont.node_types.insert(
2113            "volunteer".into(),
2114            NodeTypeDef {
2115                description: None,
2116                properties: BTreeMap::new(),
2117                subtypes: None,
2118                parent_type: None,
2119            },
2120        );
2121        let hash_after = ont.content_hash();
2122        assert_ne!(hash_before, hash_after);
2123    }
2124
2125    #[test]
2126    fn content_hash_changes_on_new_property() {
2127        let mut ont = pet_ontology();
2128        let hash_before = ont.content_hash();
2129        ont.node_types.get_mut("animal").unwrap().properties.insert(
2130            "microchip_id".into(),
2131            PropertyDef {
2132                value_type: ValueType::String,
2133                required: false,
2134                description: None,
2135                constraints: None,
2136            },
2137        );
2138        let hash_after = ont.content_hash();
2139        assert_ne!(hash_before, hash_after);
2140    }
2141
2142    #[test]
2143    fn fingerprint_contains_types() {
2144        let ont = pet_ontology();
2145        let fp = ont.fingerprint();
2146        assert!(fp.contains("type:animal"));
2147        assert!(fp.contains("type:shelter"));
2148        assert!(fp.contains("edge:LIVES_AT"));
2149    }
2150
2151    #[test]
2152    fn fingerprint_contains_properties() {
2153        let ont = pet_ontology();
2154        let fp = ont.fingerprint();
2155        assert!(fp.contains("prop:animal:name:string:required"));
2156    }
2157
2158    #[test]
2159    fn fingerprint_contains_edge_constraints() {
2160        let ont = pet_ontology();
2161        let fp = ont.fingerprint();
2162        assert!(fp.contains("edge:LIVES_AT:src:animal"));
2163        assert!(fp.contains("edge:LIVES_AT:tgt:shelter"));
2164    }
2165
2166    #[test]
2167    fn fingerprint_contains_parent_type() {
2168        let ont = Ontology {
2169            node_types: BTreeMap::from([
2170                (
2171                    "entity".into(),
2172                    NodeTypeDef {
2173                        description: None,
2174                        properties: BTreeMap::new(),
2175                        subtypes: None,
2176                        parent_type: None,
2177                    },
2178                ),
2179                (
2180                    "server".into(),
2181                    NodeTypeDef {
2182                        description: None,
2183                        properties: BTreeMap::new(),
2184                        subtypes: None,
2185                        parent_type: Some("entity".into()),
2186                    },
2187                ),
2188            ]),
2189            edge_types: BTreeMap::new(),
2190        };
2191        let fp = ont.fingerprint();
2192        assert!(fp.contains("type:server:parent:entity"));
2193    }
2194
2195    #[test]
2196    fn fingerprint_contains_subtypes() {
2197        let ont = Ontology {
2198            node_types: BTreeMap::from([(
2199                "entity".into(),
2200                NodeTypeDef {
2201                    description: None,
2202                    properties: BTreeMap::new(),
2203                    subtypes: Some(BTreeMap::from([(
2204                        "project".into(),
2205                        SubtypeDef {
2206                            description: None,
2207                            properties: BTreeMap::from([(
2208                                "slug".into(),
2209                                PropertyDef {
2210                                    value_type: ValueType::String,
2211                                    required: true,
2212                                    description: None,
2213                                    constraints: None,
2214                                },
2215                            )]),
2216                        },
2217                    )])),
2218                    parent_type: None,
2219                },
2220            )]),
2221            edge_types: BTreeMap::new(),
2222        };
2223        let fp = ont.fingerprint();
2224        assert!(fp.contains("subtype:entity:project"));
2225        assert!(fp.contains("subprop:entity:project:slug:string:required"));
2226    }
2227
2228    #[test]
2229    fn fingerprint_superset_after_extension() {
2230        let base = pet_ontology();
2231        let base_fp = base.fingerprint();
2232
2233        let mut extended = pet_ontology();
2234        extended.node_types.insert(
2235            "volunteer".into(),
2236            NodeTypeDef {
2237                description: None,
2238                properties: BTreeMap::new(),
2239                subtypes: None,
2240                parent_type: None,
2241            },
2242        );
2243        let ext_fp = extended.fingerprint();
2244
2245        // Extended is strict superset of base
2246        assert!(base_fp.is_subset(&ext_fp));
2247        assert!(!ext_fp.is_subset(&base_fp));
2248    }
2249
2250    #[test]
2251    fn check_compatibility_identical() {
2252        let a = pet_ontology();
2253        let b = pet_ontology();
2254        let verdict = a.check_compatibility(&b.content_hash(), &b.fingerprint());
2255        assert_eq!(verdict, Compatibility::Identical);
2256    }
2257
2258    #[test]
2259    fn check_compatibility_superset() {
2260        let base = pet_ontology();
2261
2262        let mut extended = pet_ontology();
2263        extended.node_types.insert(
2264            "volunteer".into(),
2265            NodeTypeDef {
2266                description: None,
2267                properties: BTreeMap::new(),
2268                subtypes: None,
2269                parent_type: None,
2270            },
2271        );
2272
2273        // Extended checking base: extended is superset
2274        let verdict = extended.check_compatibility(&base.content_hash(), &base.fingerprint());
2275        assert_eq!(verdict, Compatibility::Superset);
2276    }
2277
2278    #[test]
2279    fn check_compatibility_subset() {
2280        let base = pet_ontology();
2281
2282        let mut extended = pet_ontology();
2283        extended.node_types.insert(
2284            "volunteer".into(),
2285            NodeTypeDef {
2286                description: None,
2287                properties: BTreeMap::new(),
2288                subtypes: None,
2289                parent_type: None,
2290            },
2291        );
2292
2293        // Base checking extended: base is subset
2294        let verdict = base.check_compatibility(&extended.content_hash(), &extended.fingerprint());
2295        assert_eq!(verdict, Compatibility::Subset);
2296    }
2297
2298    #[test]
2299    fn check_compatibility_divergent() {
2300        // Two independent extensions from the same base
2301        let mut branch_a = pet_ontology();
2302        branch_a.node_types.insert(
2303            "volunteer".into(),
2304            NodeTypeDef {
2305                description: None,
2306                properties: BTreeMap::new(),
2307                subtypes: None,
2308                parent_type: None,
2309            },
2310        );
2311
2312        let mut branch_b = pet_ontology();
2313        branch_b.node_types.insert(
2314            "adoption".into(),
2315            NodeTypeDef {
2316                description: None,
2317                properties: BTreeMap::new(),
2318                subtypes: None,
2319                parent_type: None,
2320            },
2321        );
2322
2323        let verdict =
2324            branch_a.check_compatibility(&branch_b.content_hash(), &branch_b.fingerprint());
2325        assert_eq!(verdict, Compatibility::Divergent);
2326    }
2327
2328    #[test]
2329    fn fingerprint_contains_enum_constraints() {
2330        let ont = Ontology {
2331            node_types: BTreeMap::from([(
2332                "server".into(),
2333                NodeTypeDef {
2334                    description: None,
2335                    properties: BTreeMap::from([(
2336                        "status".into(),
2337                        PropertyDef {
2338                            value_type: ValueType::String,
2339                            required: true,
2340                            description: None,
2341                            constraints: Some(BTreeMap::from([(
2342                                "enum".into(),
2343                                serde_json::json!(["active", "standby"]),
2344                            )])),
2345                        },
2346                    )]),
2347                    subtypes: None,
2348                    parent_type: None,
2349                },
2350            )]),
2351            edge_types: BTreeMap::new(),
2352        };
2353        let fp = ont.fingerprint();
2354        assert!(fp.contains("constraint:server:status:enum:active"));
2355        assert!(fp.contains("constraint:server:status:enum:standby"));
2356    }
2357}