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, and nothing the
134    /// remote already holds stops validating. Safe to merge.
135    Superset,
136    /// Local is a superset on the declarations, but the extra facts include a
137    /// requirement the remote's data was never held to. Adopting this ontology
138    /// would quarantine entries the remote considers valid.
139    ///
140    /// This is reported instead of `Superset` so a consumer that gates an
141    /// automatic migration on `"superset"` falls through to its refuse branch
142    /// rather than migrating into data loss.
143    DestructiveSuperset,
144    /// Remote has types/properties local doesn't have yet. ExtendOntology
145    /// entries in the sync payload will resolve the gap.
146    Subset,
147    /// Neither is a superset. Incompatible fork, cannot be resolved
148    /// by additive evolution alone.
149    Divergent,
150}
151
152impl Ontology {
153    /// BLAKE3 hash of the canonical JSON representation.
154    ///
155    /// Two ontologies with identical resolved state produce the same hash,
156    /// regardless of how they got there (genesis path, extension order).
157    /// BTreeMap gives deterministic key ordering.
158    pub fn content_hash(&self) -> [u8; 32] {
159        let json = serde_json::to_string(self).expect("ontology serialization should not fail");
160        *blake3::hash(json.as_bytes()).as_bytes()
161    }
162
163    /// Set of atomic facts about this ontology's structure.
164    ///
165    /// Each fact is a string: "type:Animal", "prop:Animal:name:string:required",
166    /// "edge:LIVES_AT", "edge:LIVES_AT:src:Animal", "subtype:Entity:Project", etc.
167    ///
168    /// Under additive-only evolution, a newer ontology's fingerprint is a strict
169    /// superset of an older one's. Set comparison gives the compatibility verdict.
170    pub fn fingerprint(&self) -> HashSet<String> {
171        let mut facts = HashSet::new();
172
173        // S10: identify the emitter's formula, so an upgrade is distinguishable
174        // from a real fork.
175        facts.insert(format!("fingerprint_version:{FINGERPRINT_VERSION}"));
176
177        for (type_name, type_def) in &self.node_types {
178            facts.insert(format!("type:{type_name}"));
179
180            if let Some(parent) = &type_def.parent_type {
181                facts.insert(format!("type:{type_name}:parent:{parent}"));
182            }
183
184            // H3: emit membership from the RESOLVED table, not the declaration
185            // syntax, so a slot attached by any route (own, parent chain) is a
186            // fact on the type that actually carries it.
187            for (prop_name, prop_def) in &self.effective_properties(type_name) {
188                let req = if prop_def.required {
189                    "required"
190                } else {
191                    "optional"
192                };
193                let vt = format!("{:?}", prop_def.value_type).to_lowercase();
194                facts.insert(format!("prop:{type_name}:{prop_name}:{vt}:{req}"));
195                Self::fingerprint_constraints(&mut facts, type_name, prop_name, prop_def);
196            }
197
198            // Subtypes
199            if let Some(subtypes) = &type_def.subtypes {
200                for (sub_name, sub_def) in subtypes {
201                    facts.insert(format!("subtype:{type_name}:{sub_name}"));
202                    for (prop_name, prop_def) in &sub_def.properties {
203                        let req = if prop_def.required {
204                            "required"
205                        } else {
206                            "optional"
207                        };
208                        let vt = format!("{:?}", prop_def.value_type).to_lowercase();
209                        facts.insert(format!(
210                            "subprop:{type_name}:{sub_name}:{prop_name}:{vt}:{req}"
211                        ));
212                        Self::fingerprint_constraints(
213                            &mut facts,
214                            &format!("{type_name}:{sub_name}"),
215                            prop_name,
216                            prop_def,
217                        );
218                    }
219                }
220            }
221        }
222
223        for (edge_name, edge_def) in &self.edge_types {
224            facts.insert(format!("edge:{edge_name}"));
225            for src in &edge_def.source_types {
226                facts.insert(format!("edge:{edge_name}:src:{src}"));
227            }
228            for tgt in &edge_def.target_types {
229                facts.insert(format!("edge:{edge_name}:tgt:{tgt}"));
230            }
231            // H3: edge properties are validated (validate_edge ->
232            // validate_properties) and so must be fingerprinted.
233            for (prop_name, prop_def) in &edge_def.properties {
234                let req = if prop_def.required {
235                    "required"
236                } else {
237                    "optional"
238                };
239                let vt = format!("{:?}", prop_def.value_type).to_lowercase();
240                facts.insert(format!("edgeprop:{edge_name}:{prop_name}:{vt}:{req}"));
241                Self::fingerprint_constraints(
242                    &mut facts,
243                    &format!("edge:{edge_name}"),
244                    prop_name,
245                    prop_def,
246                );
247            }
248        }
249
250        facts
251    }
252
253    /// Facts held here that would invalidate data the foreign peer considers
254    /// valid, as human-readable lines.
255    ///
256    /// Works on fingerprints alone, so it needs no access to the peer's oplog.
257    /// That is also its limit: a change whose destructiveness depends on
258    /// values rather than declarations is invisible here. Declaring a property
259    /// that existing nodes already carry as an undeclared extra, under a type
260    /// that does not match, is the known case. `preview_extension` sees that
261    /// one because it replays.
262    pub fn destructive_changes(&self, foreign_fingerprint: &HashSet<String>) -> Vec<String> {
263        Self::destructive_facts(&self.fingerprint(), foreign_fingerprint)
264    }
265
266    /// Shared by `check_compatibility` and `destructive_changes`.
267    ///
268    /// Three fact shapes matter, mirroring what `merge_extension` refuses:
269    /// a required property the peer does not hold as required, on a type the
270    /// peer knows; and the first subtype on a type the peer holds without any.
271    fn destructive_facts(mine: &HashSet<String>, foreign: &HashSet<String>) -> Vec<String> {
272        let mut out = Vec::new();
273
274        // `prop:T:P:vt:required`, `edgeprop:E:P:vt:required`,
275        // `subprop:T:S:P:vt:required`. Strip the "required" suffix and the
276        // value type to get the slot, then ask whether the peer holds that
277        // same slot as required. Absent or optional both mean the peer's data
278        // was never held to the requirement.
279        for fact in mine.difference(foreign) {
280            let Some(slot) = fact.strip_suffix(":required") else {
281                continue;
282            };
283            let (kind, prefix, rest) = match slot.split_once(':') {
284                Some(("prop", rest)) => ("node type", "prop", rest),
285                Some(("edgeprop", rest)) => ("edge type", "edgeprop", rest),
286                Some(("subprop", rest)) => ("subtype", "subprop", rest),
287                _ => continue,
288            };
289            // `rest` is "T:P:vt", or "T:S:P:vt" for subprop. Drop the value
290            // type, then split the owner from the property name.
291            let Some((owner_and_prop, _value_type)) = rest.rsplit_once(':') else {
292                continue;
293            };
294            let Some((owner, property)) = owner_and_prop.rsplit_once(':') else {
295                continue;
296            };
297
298            // Only a type the peer already knows can hold peer data.
299            let peer_knows_owner = foreign.contains(&format!("type:{owner}"))
300                || foreign.contains(&format!("edge:{owner}"))
301                || owner
302                    .split_once(':')
303                    .is_some_and(|(t, _)| foreign.contains(&format!("type:{t}")));
304            if !peer_knows_owner {
305                continue;
306            }
307
308            // Same slot, any value type, required on the peer too.
309            let head = format!("{prefix}:{owner_and_prop}");
310            let peer_requires_it = foreign.iter().any(|f| {
311                f.strip_suffix(":required")
312                    .and_then(|s| s.rsplit_once(':'))
313                    .is_some_and(|(h, _)| h == head)
314            });
315            if peer_requires_it {
316                continue;
317            }
318
319            out.push(format!(
320                "required property '{property}' on {kind} '{owner}', which the peer \
321                 does not require: its entries written without it would quarantine"
322            ));
323        }
324
325        // The first subtype on a type the peer holds with none.
326        let mut flipped: Vec<&str> = Vec::new();
327        for fact in mine.difference(foreign) {
328            let Some((type_name, _sub)) = fact
329                .strip_prefix("subtype:")
330                .and_then(|rest| rest.split_once(':'))
331            else {
332                continue;
333            };
334            if !foreign.contains(&format!("type:{type_name}")) || flipped.contains(&type_name) {
335                continue;
336            }
337            let peer_has_subtypes = foreign
338                .iter()
339                .any(|f| f.starts_with(&format!("subtype:{type_name}:")));
340            if !peer_has_subtypes {
341                flipped.push(type_name);
342            }
343        }
344        for type_name in flipped {
345            out.push(format!(
346                "node type '{type_name}' declares subtypes and the peer's does not: \
347                 every node the peer wrote without one would quarantine"
348            ));
349        }
350
351        out.sort();
352        out
353    }
354
355    /// Compare this ontology against a foreign peer's hash and fingerprint.
356    pub fn check_compatibility(
357        &self,
358        foreign_hash: &[u8; 32],
359        foreign_fingerprint: &HashSet<String>,
360    ) -> Compatibility {
361        if &self.content_hash() == foreign_hash {
362            return Compatibility::Identical;
363        }
364
365        let my_fp = self.fingerprint();
366
367        // H3: ordered superset-then-subset. Equal fact sets with different
368        // hashes used to fall into a `(true, true) => Identical` arm commented
369        // "shouldn't happen" — which is exactly what fired whenever the
370        // emitter was blind to a constraint the validator enforced. With the
371        // emitter complete, equal facts and a differing hash is a genuine
372        // divergence and must be reported as one.
373        if my_fp == *foreign_fingerprint {
374            return Compatibility::Divergent;
375        }
376        if foreign_fingerprint.is_subset(&my_fp) {
377            if Self::destructive_facts(&my_fp, foreign_fingerprint).is_empty() {
378                return Compatibility::Superset;
379            }
380            return Compatibility::DestructiveSuperset;
381        }
382        if my_fp.is_subset(foreign_fingerprint) {
383            return Compatibility::Subset;
384        }
385        Compatibility::Divergent
386    }
387
388    /// Emit one fact per declared constraint, walking the constraint map the
389    /// validator consults rather than a hand-written parallel list (H3).
390    /// Values are serialized canonically so differing bounds differ as facts.
391    fn fingerprint_constraints(
392        facts: &mut HashSet<String>,
393        type_name: &str,
394        prop_name: &str,
395        prop_def: &PropertyDef,
396    ) {
397        let Some(constraints) = &prop_def.constraints else {
398            return;
399        };
400        for (cname, cvalue) in constraints {
401            match cvalue {
402                // Enum members are emitted individually so that adding a member
403                // is a superset rather than a divergence.
404                serde_json::Value::Array(items) if cname == "enum" => {
405                    for val in items {
406                        let rendered = match val.as_str() {
407                            Some(s) => s.to_string(),
408                            None => val.to_string(),
409                        };
410                        facts.insert(format!(
411                            "constraint:{type_name}:{prop_name}:enum:{rendered}"
412                        ));
413                    }
414                }
415                other => {
416                    facts.insert(format!(
417                        "constraint:{type_name}:{prop_name}:{cname}:{other}"
418                    ));
419                }
420            }
421        }
422    }
423}
424
425/// Validation errors returned when an operation violates the ontology.
426#[derive(Debug, Clone, PartialEq)]
427pub enum ValidationError {
428    UnknownNodeType(String),
429    UnknownEdgeType(String),
430    InvalidSource {
431        edge_type: String,
432        node_type: String,
433        allowed: Vec<String>,
434    },
435    InvalidTarget {
436        edge_type: String,
437        node_type: String,
438        allowed: Vec<String>,
439    },
440    MissingRequiredProperty {
441        type_name: String,
442        property: String,
443    },
444    WrongPropertyType {
445        type_name: String,
446        property: String,
447        expected: ValueType,
448        got: String,
449    },
450    UnknownProperty {
451        type_name: String,
452        property: String,
453    },
454    MissingSubtype {
455        node_type: String,
456        allowed: Vec<String>,
457    },
458    UnknownSubtype {
459        node_type: String,
460        subtype: String,
461        allowed: Vec<String>,
462    },
463    UnexpectedSubtype {
464        node_type: String,
465        subtype: String,
466    },
467    /// A property value violates a constraint (enum, range, etc.)
468    ConstraintViolation {
469        type_name: String,
470        property: String,
471        constraint: String,
472        message: String,
473    },
474    /// A constraint name no validator enforces (S3): silently inert otherwise.
475    UnknownConstraint {
476        type_name: String,
477        property: String,
478        constraint: String,
479        known: Vec<String>,
480    },
481}
482
483impl std::fmt::Display for ValidationError {
484    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485        match self {
486            ValidationError::UnknownNodeType(t) => write!(f, "unknown node type: '{t}'"),
487            ValidationError::UnknownEdgeType(t) => write!(f, "unknown edge type: '{t}'"),
488            ValidationError::InvalidSource {
489                edge_type,
490                node_type,
491                allowed,
492            } => write!(
493                f,
494                "edge '{edge_type}' cannot have source type '{node_type}' (allowed: {allowed:?})"
495            ),
496            ValidationError::InvalidTarget {
497                edge_type,
498                node_type,
499                allowed,
500            } => write!(
501                f,
502                "edge '{edge_type}' cannot have target type '{node_type}' (allowed: {allowed:?})"
503            ),
504            ValidationError::MissingRequiredProperty {
505                type_name,
506                property,
507            } => write!(f, "'{type_name}' requires property '{property}'"),
508            ValidationError::WrongPropertyType {
509                type_name,
510                property,
511                expected,
512                got,
513            } => write!(
514                f,
515                "'{type_name}'.'{property}' expects {expected:?}, got {got}"
516            ),
517            ValidationError::UnknownProperty {
518                type_name,
519                property,
520            } => write!(f, "'{type_name}' has no property '{property}' in ontology"),
521            ValidationError::MissingSubtype { node_type, allowed } => {
522                write!(f, "'{node_type}' requires a subtype (allowed: {allowed:?})")
523            }
524            ValidationError::UnknownSubtype {
525                node_type,
526                subtype,
527                allowed,
528            } => write!(
529                f,
530                "'{node_type}' has no subtype '{subtype}' (allowed: {allowed:?})"
531            ),
532            ValidationError::UnexpectedSubtype { node_type, subtype } => write!(
533                f,
534                "'{node_type}' does not define subtypes, but got subtype '{subtype}'"
535            ),
536            ValidationError::ConstraintViolation {
537                type_name,
538                property,
539                constraint,
540                message,
541            } => write!(
542                f,
543                "'{type_name}'.'{property}' violates constraint '{constraint}': {message}"
544            ),
545            ValidationError::UnknownConstraint {
546                type_name,
547                property,
548                constraint,
549                known,
550            } => write!(
551                f,
552                "'{type_name}'.'{property}' declares unknown constraint '{constraint}' \
553                 (enforced: {}); nothing would check it. Prefix it '{}' to declare it \
554                 deliberately unenforced.",
555                known.join(", "),
556                UNENFORCED_CONSTRAINT_PREFIX
557            ),
558        }
559    }
560}
561
562/// An additive ontology extension — monotonic evolution only (R-03).
563#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
564pub struct OntologyExtension {
565    /// New node types to add.
566    #[serde(default)]
567    pub node_types: BTreeMap<String, NodeTypeDef>,
568    /// New edge types to add.
569    #[serde(default)]
570    pub edge_types: BTreeMap<String, EdgeTypeDef>,
571    /// Updates to existing node types (add properties, subtypes, relax required).
572    #[serde(default)]
573    pub node_type_updates: BTreeMap<String, NodeTypeUpdate>,
574    /// Updates to existing edge types (widen endpoint bindings, add properties).
575    ///
576    /// Added after a downstream outage: binding a new source or target type to
577    /// an edge type that already exists had no vocabulary at all, and the
578    /// attempt was silently accepted as a no-op. Widening is monotonic — the
579    /// edge type only ever accepts more — so it is safe for convergence.
580    ///
581    /// NOTE: this field is appended LAST. `OntologyExtension` serializes as a
582    /// positional array, so field order is the wire format; a new field must
583    /// go at the end, and older builds cannot read extensions that carry it.
584    #[serde(default)]
585    pub edge_type_updates: BTreeMap<String, EdgeTypeUpdate>,
586}
587
588/// Monotonic update to an existing edge type.
589#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
590pub struct EdgeTypeUpdate {
591    /// Node types to add to `source_types` (widening only).
592    #[serde(default)]
593    pub add_source_types: Vec<String>,
594    /// Node types to add to `target_types` (widening only).
595    #[serde(default)]
596    pub add_target_types: Vec<String>,
597    /// New optional properties on the edge type.
598    #[serde(default)]
599    pub add_properties: BTreeMap<String, PropertyDef>,
600}
601
602impl EdgeTypeUpdate {
603    fn is_empty(&self) -> bool {
604        self.add_source_types.is_empty()
605            && self.add_target_types.is_empty()
606            && self.add_properties.is_empty()
607    }
608}
609
610/// Additive update to an existing node type.
611#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
612pub struct NodeTypeUpdate {
613    /// New optional properties to add.
614    #[serde(default)]
615    pub add_properties: BTreeMap<String, PropertyDef>,
616    /// Properties to relax from required to optional.
617    #[serde(default)]
618    pub relax_properties: Vec<String>,
619    /// New subtypes to add.
620    #[serde(default)]
621    pub add_subtypes: BTreeMap<String, SubtypeDef>,
622}
623
624/// Errors from monotonic ontology extension (R-03).
625#[derive(Debug, Clone, PartialEq)]
626pub enum MonotonicityError {
627    DuplicateNodeType(String),
628    DuplicateEdgeType(String),
629    UnknownNodeType(String),
630    /// An `edge_type_updates` entry names an edge type that does not exist.
631    UnknownEdgeType(String),
632    /// A new endpoint binding references a node type that does not exist.
633    UnknownBindingType {
634        edge_type: String,
635        node_type: String,
636    },
637    /// A binding that is already present — the extension would change nothing.
638    DuplicateBinding {
639        edge_type: String,
640        node_type: String,
641    },
642    /// The extension parses but expresses no change (S-noop). Accepting it
643    /// writes a no-op entry to the replicated log and tells the caller the
644    /// schema evolved when it has not.
645    EmptyExtension,
646    DuplicateProperty {
647        type_name: String,
648        property: String,
649    },
650    UnknownProperty {
651        type_name: String,
652        property: String,
653    },
654    /// A required property introduced onto a type that already exists. Every
655    /// entry of that type written without it was valid when written and would
656    /// quarantine on the next materialization.
657    RequiredPropertyOnExistingType {
658        kind: &'static str,
659        type_name: String,
660        property: String,
661    },
662    /// Subtypes introduced onto a node type that had none. Once a type
663    /// declares subtypes, a node without one fails validation, so every node
664    /// of that type written so far would quarantine.
665    SubtypesOnSubtypelessType {
666        type_name: String,
667        subtypes: Vec<String>,
668    },
669    /// Wraps a ValidationError from validate_self() after merge.
670    ValidationFailed(ValidationError),
671}
672
673impl std::fmt::Display for MonotonicityError {
674    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
675        match self {
676            MonotonicityError::DuplicateNodeType(t) => {
677                write!(f, "node type '{t}' already exists")
678            }
679            MonotonicityError::DuplicateEdgeType(t) => {
680                write!(f, "edge type '{t}' already exists")
681            }
682            MonotonicityError::UnknownNodeType(t) => {
683                write!(f, "cannot update unknown node type '{t}'")
684            }
685            MonotonicityError::UnknownEdgeType(t) => {
686                write!(f, "cannot update unknown edge type '{t}'")
687            }
688            MonotonicityError::UnknownBindingType {
689                edge_type,
690                node_type,
691            } => write!(
692                f,
693                "edge type '{edge_type}' cannot bind to unknown node type '{node_type}'"
694            ),
695            MonotonicityError::DuplicateBinding {
696                edge_type,
697                node_type,
698            } => write!(
699                f,
700                "edge type '{edge_type}' already binds '{node_type}'; this extension \
701                 would change nothing"
702            ),
703            MonotonicityError::RequiredPropertyOnExistingType {
704                kind,
705                type_name,
706                property,
707            } => write!(
708                f,
709                "cannot add required property '{property}' to {kind} '{type_name}': \
710                 entries written before this extension did not have to carry it, \
711                 and every one of them would be quarantined on the next \
712                 materialization, silently. Add it as optional (required: false) \
713                 instead, or declare a new type."
714            ),
715            MonotonicityError::SubtypesOnSubtypelessType {
716                type_name,
717                subtypes,
718            } => write!(
719                f,
720                "cannot add subtypes {subtypes:?} to node type '{type_name}', which \
721                 declares none: once a type has subtypes every node of it must name \
722                 one, so every '{type_name}' node written so far would be quarantined \
723                 on the next materialization, silently. Subtypes have to be declared \
724                 when the type is created."
725            ),
726            MonotonicityError::EmptyExtension => write!(
727                f,
728                "extension expresses no change; nothing would be added. An extension \
729                 that changes nothing must not be written to the log"
730            ),
731            MonotonicityError::DuplicateProperty {
732                type_name,
733                property,
734            } => {
735                write!(f, "property '{property}' already exists on '{type_name}'")
736            }
737            MonotonicityError::UnknownProperty {
738                type_name,
739                property,
740            } => {
741                write!(
742                    f,
743                    "property '{property}' does not exist on '{type_name}' (cannot relax)"
744                )
745            }
746            MonotonicityError::ValidationFailed(e) => {
747                write!(f, "ontology validation failed after merge: {e}")
748            }
749        }
750    }
751}
752
753impl Ontology {
754    // -- RDFS-level class hierarchy (Step 2) --
755
756    /// Return all ancestor types of `node_type` (transitive parent_type chain).
757    /// Does not include `node_type` itself. Returns empty vec if no parent.
758    pub fn ancestors(&self, node_type: &str) -> Vec<&str> {
759        let mut result = Vec::new();
760        let mut current = node_type;
761        // Guard against cycles (max 100 levels — no real ontology is deeper)
762        for _ in 0..100 {
763            match self
764                .node_types
765                .get(current)
766                .and_then(|d| d.parent_type.as_deref())
767            {
768                Some(parent) => {
769                    result.push(parent);
770                    current = parent;
771                }
772                None => break,
773            }
774        }
775        result
776    }
777
778    /// Return all descendant types of `node_type` (types whose ancestor chain includes it).
779    /// Does not include `node_type` itself.
780    pub fn descendants(&self, node_type: &str) -> Vec<&str> {
781        // Collect all types that have node_type anywhere in their ancestor chain.
782        self.node_types
783            .iter()
784            .filter(|(name, _)| {
785                name.as_str() != node_type && self.ancestors(name).contains(&node_type)
786            })
787            .map(|(name, _)| name.as_str())
788            .collect()
789    }
790
791    /// Check if `child_type` is the same as or a descendant of `parent_type`.
792    pub fn is_subtype_of(&self, child_type: &str, parent_type: &str) -> bool {
793        child_type == parent_type || self.ancestors(child_type).contains(&parent_type)
794    }
795
796    /// Get all properties for a type, including those inherited from ancestors.
797    /// Ancestors' properties are applied first (most general), then overridden
798    /// by more specific types. Same order as Python MRO: parent first, child overrides.
799    pub fn effective_properties(&self, node_type: &str) -> BTreeMap<String, PropertyDef> {
800        let mut chain: Vec<&str> = self.ancestors(node_type);
801        chain.reverse(); // most general first
802        chain.push(node_type);
803
804        let mut props = BTreeMap::new();
805        for t in chain {
806            if let Some(def) = self.node_types.get(t) {
807                for (k, v) in &def.properties {
808                    props.insert(k.clone(), v.clone());
809                }
810            }
811        }
812        props
813    }
814
815    /// Validate that a node type exists and its properties conform.
816    ///
817    /// If the type defines subtypes (D-024), `subtype` must be `Some` and
818    /// properties are validated against the subtype's definition.
819    /// If the type does not define subtypes, `subtype` must be `None`.
820    pub fn validate_node(
821        &self,
822        node_type: &str,
823        subtype: Option<&str>,
824        properties: &BTreeMap<String, Value>,
825    ) -> Result<(), ValidationError> {
826        self.validate_node_mode(node_type, subtype, properties, ValidationMode::Full)
827    }
828
829    /// `validate_node` with an explicit enforcement mode (S2).
830    pub fn validate_node_mode(
831        &self,
832        node_type: &str,
833        subtype: Option<&str>,
834        properties: &BTreeMap<String, Value>,
835        mode: ValidationMode,
836    ) -> Result<(), ValidationError> {
837        let def = self
838            .node_types
839            .get(node_type)
840            .ok_or_else(|| ValidationError::UnknownNodeType(node_type.to_string()))?;
841
842        // Step 2: use effective_properties (includes inherited from ancestors)
843        let base_props = self.effective_properties(node_type);
844
845        match (&def.subtypes, subtype) {
846            // Type has subtypes and caller provided one
847            (Some(subtypes), Some(st)) => {
848                match subtypes.get(st) {
849                    Some(st_def) => {
850                        // Known subtype — merge inherited + type-level + subtype-level
851                        let mut merged = base_props;
852                        merged.extend(st_def.properties.clone());
853                        validate_properties(node_type, &merged, properties, mode)
854                    }
855                    None => {
856                        // D-026: unknown subtype — validate inherited + type-level only
857                        validate_properties(node_type, &base_props, properties, mode)
858                    }
859                }
860            }
861            // Type has subtypes but caller didn't provide one — error
862            (Some(subtypes), None) => Err(ValidationError::MissingSubtype {
863                node_type: node_type.to_string(),
864                allowed: subtypes.keys().cloned().collect(),
865            }),
866            // D-026: accept subtypes even if type doesn't declare any
867            (None, Some(_st)) => validate_properties(node_type, &base_props, properties, mode),
868            // Type has no subtypes and caller didn't provide one — validate as before
869            (None, None) => validate_properties(node_type, &base_props, properties, mode),
870        }
871    }
872
873    /// Validate that an edge type exists, source/target types are allowed,
874    /// and properties conform.
875    pub fn validate_edge(
876        &self,
877        edge_type: &str,
878        source_node_type: &str,
879        target_node_type: &str,
880        properties: &BTreeMap<String, Value>,
881    ) -> Result<(), ValidationError> {
882        self.validate_edge_mode(
883            edge_type,
884            source_node_type,
885            target_node_type,
886            properties,
887            ValidationMode::Full,
888        )
889    }
890
891    /// `validate_edge` with an explicit enforcement mode (S2).
892    pub fn validate_edge_mode(
893        &self,
894        edge_type: &str,
895        source_node_type: &str,
896        target_node_type: &str,
897        properties: &BTreeMap<String, Value>,
898        mode: ValidationMode,
899    ) -> Result<(), ValidationError> {
900        let def = self
901            .edge_types
902            .get(edge_type)
903            .ok_or_else(|| ValidationError::UnknownEdgeType(edge_type.to_string()))?;
904
905        // Hierarchy-aware: accept if actual type IS one of the allowed types
906        // OR is a descendant of any allowed type (RDFS rdfs9).
907        if !def
908            .source_types
909            .iter()
910            .any(|t| self.is_subtype_of(source_node_type, t))
911        {
912            return Err(ValidationError::InvalidSource {
913                edge_type: edge_type.to_string(),
914                node_type: source_node_type.to_string(),
915                allowed: def.source_types.clone(),
916            });
917        }
918
919        if !def
920            .target_types
921            .iter()
922            .any(|t| self.is_subtype_of(target_node_type, t))
923        {
924            return Err(ValidationError::InvalidTarget {
925                edge_type: edge_type.to_string(),
926                node_type: target_node_type.to_string(),
927                allowed: def.target_types.clone(),
928            });
929        }
930
931        validate_properties(edge_type, &def.properties, properties, mode)
932    }
933
934    /// Validate a single property update on an EDGE (H2). The node-shaped
935    /// `validate_property_update` cannot serve here: callers that looked up
936    /// only nodes silently skipped validation for every edge property.
937    pub fn validate_edge_property_update(
938        &self,
939        edge_type: &str,
940        key: &str,
941        value: &Value,
942    ) -> Result<(), ValidationError> {
943        let def = match self.edge_types.get(edge_type) {
944            Some(d) => d,
945            None => return Ok(()), // Unknown edge type — can't validate
946        };
947        // D-026: unknown properties accepted without validation.
948        let prop_def = match def.properties.get(key) {
949            Some(d) => d,
950            None => return Ok(()),
951        };
952        if prop_def.value_type != ValueType::Any && !value_matches_type(value, &prop_def.value_type)
953        {
954            return Err(ValidationError::WrongPropertyType {
955                type_name: edge_type.to_string(),
956                property: key.to_string(),
957                expected: prop_def.value_type.clone(),
958                got: value_type_name(value).to_string(),
959            });
960        }
961        if let Some(constraints) = &prop_def.constraints {
962            validate_constraints(edge_type, key, value, constraints)?;
963        }
964        Ok(())
965    }
966
967    /// Validate a single property update against the ontology.
968    /// Checks that the value type matches the property definition.
969    /// Unknown properties are accepted (D-026: ontology defines minimum, not maximum).
970    pub fn validate_property_update(
971        &self,
972        node_type: &str,
973        subtype: Option<&str>,
974        key: &str,
975        value: &Value,
976    ) -> Result<(), ValidationError> {
977        let def = match self.node_types.get(node_type) {
978            Some(d) => d,
979            None => return Ok(()), // Unknown node type — can't validate
980        };
981
982        // Merge type-level + subtype-level property definitions
983        let mut merged = def.properties.clone();
984        if let (Some(subtypes), Some(st)) = (&def.subtypes, subtype) {
985            if let Some(st_def) = subtypes.get(st) {
986                merged.extend(st_def.properties.clone());
987            }
988        }
989
990        // D-026: unknown properties accepted without validation
991        let prop_def = match merged.get(key) {
992            Some(d) => d,
993            None => return Ok(()),
994        };
995
996        // Type check
997        if prop_def.value_type != ValueType::Any && !value_matches_type(value, &prop_def.value_type)
998        {
999            return Err(ValidationError::WrongPropertyType {
1000                type_name: node_type.to_string(),
1001                property: key.to_string(),
1002                expected: prop_def.value_type.clone(),
1003                got: value_type_name(value).to_string(),
1004            });
1005        }
1006
1007        // Constraint check
1008        if let Some(constraints) = &prop_def.constraints {
1009            validate_constraints(node_type, key, value, constraints)?;
1010        }
1011
1012        Ok(())
1013    }
1014
1015    /// Validate that the ontology itself is internally consistent.
1016    /// All source_types/target_types in edge defs must reference existing node types.
1017    pub fn validate_self(&self) -> Result<(), ValidationError> {
1018        // Validate edge source/target references
1019        for (edge_name, edge_def) in &self.edge_types {
1020            for src in &edge_def.source_types {
1021                if !self.node_types.contains_key(src) {
1022                    return Err(ValidationError::InvalidSource {
1023                        edge_type: edge_name.clone(),
1024                        node_type: src.clone(),
1025                        allowed: self.node_types.keys().cloned().collect(),
1026                    });
1027                }
1028            }
1029            for tgt in &edge_def.target_types {
1030                if !self.node_types.contains_key(tgt) {
1031                    return Err(ValidationError::InvalidTarget {
1032                        edge_type: edge_name.clone(),
1033                        node_type: tgt.clone(),
1034                        allowed: self.node_types.keys().cloned().collect(),
1035                    });
1036                }
1037            }
1038        }
1039        // Validate parent_type references (Step 2: class hierarchy)
1040        for (type_name, type_def) in &self.node_types {
1041            if let Some(ref parent) = type_def.parent_type {
1042                if !self.node_types.contains_key(parent) {
1043                    return Err(ValidationError::UnknownNodeType(format!(
1044                        "{}: parent_type '{}' does not exist",
1045                        type_name, parent
1046                    )));
1047                }
1048            }
1049        }
1050        // S3: an unknown constraint name is inert forever and invisible to the
1051        // fingerprint, so a typo silently disables the rule it was meant to
1052        // impose. Reject it here, which covers both ontology entry points
1053        // (construction and extension). `x_` stays available for constraints
1054        // this validator deliberately does not enforce.
1055        for (type_name, type_def) in &self.node_types {
1056            for (prop_name, prop_def) in &type_def.properties {
1057                Self::check_constraint_names(type_name, prop_name, prop_def)?;
1058            }
1059            if let Some(subtypes) = &type_def.subtypes {
1060                for (sub_name, sub_def) in subtypes {
1061                    for (prop_name, prop_def) in &sub_def.properties {
1062                        Self::check_constraint_names(
1063                            &format!("{type_name}:{sub_name}"),
1064                            prop_name,
1065                            prop_def,
1066                        )?;
1067                    }
1068                }
1069            }
1070        }
1071        for (edge_name, edge_def) in &self.edge_types {
1072            for (prop_name, prop_def) in &edge_def.properties {
1073                Self::check_constraint_names(edge_name, prop_name, prop_def)?;
1074            }
1075        }
1076        Ok(())
1077    }
1078
1079    fn check_constraint_names(
1080        type_name: &str,
1081        prop_name: &str,
1082        prop_def: &PropertyDef,
1083    ) -> Result<(), ValidationError> {
1084        let Some(constraints) = &prop_def.constraints else {
1085            return Ok(());
1086        };
1087        for cname in constraints.keys() {
1088            if ENFORCED_CONSTRAINTS.contains(&cname.as_str())
1089                || cname.starts_with(UNENFORCED_CONSTRAINT_PREFIX)
1090            {
1091                continue;
1092            }
1093            return Err(ValidationError::UnknownConstraint {
1094                type_name: type_name.to_string(),
1095                property: prop_name.to_string(),
1096                constraint: cname.clone(),
1097                known: ENFORCED_CONSTRAINTS.iter().map(|s| s.to_string()).collect(),
1098            });
1099        }
1100        Ok(())
1101    }
1102
1103    /// R-03: Merge an additive extension into this ontology.
1104    /// Only monotonic (additive) changes are allowed:
1105    /// - New node types (must not already exist)
1106    /// - New edge types (must not already exist)
1107    /// - Updates to existing node types: add OPTIONAL properties, relax
1108    ///   required→optional, add subtypes to a type that already declares some
1109    ///
1110    /// "Additive" means no entry already in the oplog stops validating. A
1111    /// change that is additive on the declarations but retroactively
1112    /// invalidates data is refused: see `RequiredPropertyOnExistingType` and
1113    /// `SubtypesOnSubtypelessType`.
1114    pub fn merge_extension(&mut self, ext: &OntologyExtension) -> Result<(), MonotonicityError> {
1115        // An extension that expresses no change must not be accepted: it
1116        // returns a hash, appends to the replicated log, and tells the caller
1117        // the schema evolved when nothing did.
1118        if ext.node_types.is_empty()
1119            && ext.edge_types.is_empty()
1120            && ext.node_type_updates.values().all(|u| {
1121                u.add_properties.is_empty()
1122                    && u.relax_properties.is_empty()
1123                    && u.add_subtypes.is_empty()
1124            })
1125            && ext.edge_type_updates.values().all(|u| u.is_empty())
1126        {
1127            return Err(MonotonicityError::EmptyExtension);
1128        }
1129
1130        // Validate: new node types don't already exist
1131        for name in ext.node_types.keys() {
1132            if self.node_types.contains_key(name) {
1133                return Err(MonotonicityError::DuplicateNodeType(name.clone()));
1134            }
1135        }
1136
1137        // Validate: new edge types don't already exist
1138        for name in ext.edge_types.keys() {
1139            if self.edge_types.contains_key(name) {
1140                return Err(MonotonicityError::DuplicateEdgeType(name.clone()));
1141            }
1142        }
1143
1144        // Validate node_type_updates reference existing types
1145        for (type_name, update) in &ext.node_type_updates {
1146            let def = self
1147                .node_types
1148                .get(type_name)
1149                .ok_or_else(|| MonotonicityError::UnknownNodeType(type_name.clone()))?;
1150
1151            // Validate: add_properties don't already exist
1152            for prop_name in update.add_properties.keys() {
1153                if def.properties.contains_key(prop_name) {
1154                    return Err(MonotonicityError::DuplicateProperty {
1155                        type_name: type_name.clone(),
1156                        property: prop_name.clone(),
1157                    });
1158                }
1159            }
1160
1161            // Validate: relax_properties exist and are currently required
1162            for prop_name in &update.relax_properties {
1163                match def.properties.get(prop_name) {
1164                    Some(prop_def) if prop_def.required => {} // ok
1165                    Some(_) => {} // already optional — idempotent, allow it
1166                    None => {
1167                        return Err(MonotonicityError::UnknownProperty {
1168                            type_name: type_name.clone(),
1169                            property: prop_name.clone(),
1170                        });
1171                    }
1172                }
1173            }
1174
1175            // A required property on a type that already exists retroactively
1176            // invalidates every entry written without it. Syntactically
1177            // additive, semantically a delete.
1178            for (prop_name, prop_def) in &update.add_properties {
1179                if prop_def.required {
1180                    return Err(MonotonicityError::RequiredPropertyOnExistingType {
1181                        kind: "node type",
1182                        type_name: type_name.clone(),
1183                        property: prop_name.clone(),
1184                    });
1185                }
1186            }
1187
1188            // Validate: add_subtypes don't already exist (if subtypes are defined)
1189            if !update.add_subtypes.is_empty() {
1190                match def.subtypes {
1191                    // Going from no subtypes to some makes `subtype` mandatory
1192                    // where it was forbidden, so every existing node of the
1193                    // type stops validating.
1194                    None => {
1195                        return Err(MonotonicityError::SubtypesOnSubtypelessType {
1196                            type_name: type_name.clone(),
1197                            subtypes: update.add_subtypes.keys().cloned().collect(),
1198                        });
1199                    }
1200                    Some(ref existing) => {
1201                        for st_name in update.add_subtypes.keys() {
1202                            if existing.contains_key(st_name) {
1203                                return Err(MonotonicityError::DuplicateProperty {
1204                                    type_name: type_name.clone(),
1205                                    property: format!("subtype:{st_name}"),
1206                                });
1207                            }
1208                        }
1209                    }
1210                }
1211
1212                // D-026 accepts a node naming a subtype the ontology has not
1213                // declared, validating type-level properties only. Declaring
1214                // that subtype with a required property quarantines it.
1215                for (st_name, st_def) in &update.add_subtypes {
1216                    for (prop_name, prop_def) in &st_def.properties {
1217                        if prop_def.required {
1218                            return Err(MonotonicityError::RequiredPropertyOnExistingType {
1219                                kind: "subtype",
1220                                type_name: format!("{type_name}:{st_name}"),
1221                                property: prop_name.clone(),
1222                            });
1223                        }
1224                    }
1225                }
1226            }
1227        }
1228
1229        // Apply: extend node_types
1230        self.node_types.extend(ext.node_types.clone());
1231
1232        // Validate edge_type_updates: the edge type must exist, every new
1233        // binding must reference a node type that exists, and a binding that
1234        // is already present would change nothing.
1235        for (edge_name, update) in &ext.edge_type_updates {
1236            let def = self
1237                .edge_types
1238                .get(edge_name)
1239                .ok_or_else(|| MonotonicityError::UnknownEdgeType(edge_name.clone()))?;
1240
1241            for (bindings, existing) in [
1242                (&update.add_source_types, &def.source_types),
1243                (&update.add_target_types, &def.target_types),
1244            ] {
1245                for node_type in bindings {
1246                    // The node type may be arriving in this same extension.
1247                    if !self.node_types.contains_key(node_type)
1248                        && !ext.node_types.contains_key(node_type)
1249                    {
1250                        return Err(MonotonicityError::UnknownBindingType {
1251                            edge_type: edge_name.clone(),
1252                            node_type: node_type.clone(),
1253                        });
1254                    }
1255                    if existing.contains(node_type) {
1256                        return Err(MonotonicityError::DuplicateBinding {
1257                            edge_type: edge_name.clone(),
1258                            node_type: node_type.clone(),
1259                        });
1260                    }
1261                }
1262            }
1263
1264            for (prop_name, prop_def) in &update.add_properties {
1265                if def.properties.contains_key(prop_name) {
1266                    return Err(MonotonicityError::DuplicateProperty {
1267                        type_name: edge_name.clone(),
1268                        property: prop_name.clone(),
1269                    });
1270                }
1271                if prop_def.required {
1272                    return Err(MonotonicityError::RequiredPropertyOnExistingType {
1273                        kind: "edge type",
1274                        type_name: edge_name.clone(),
1275                        property: prop_name.clone(),
1276                    });
1277                }
1278            }
1279        }
1280
1281        // Apply: extend edge_types
1282        self.edge_types.extend(ext.edge_types.clone());
1283
1284        // Apply: widen existing edge types
1285        for (edge_name, update) in &ext.edge_type_updates {
1286            let def = self.edge_types.get_mut(edge_name).unwrap(); // validated above
1287            def.source_types.extend(update.add_source_types.clone());
1288            def.target_types.extend(update.add_target_types.clone());
1289            def.properties.extend(update.add_properties.clone());
1290        }
1291
1292        // Apply: update existing node types
1293        for (type_name, update) in &ext.node_type_updates {
1294            let def = self.node_types.get_mut(type_name).unwrap(); // validated above
1295
1296            // Add new properties
1297            def.properties.extend(update.add_properties.clone());
1298
1299            // Relax required → optional
1300            for prop_name in &update.relax_properties {
1301                if let Some(prop_def) = def.properties.get_mut(prop_name) {
1302                    prop_def.required = false;
1303                }
1304            }
1305
1306            // Add subtypes
1307            if !update.add_subtypes.is_empty() {
1308                let subtypes = def.subtypes.get_or_insert_with(BTreeMap::new);
1309                subtypes.extend(update.add_subtypes.clone());
1310            }
1311        }
1312
1313        // Validate the merged ontology is internally consistent
1314        self.validate_self()
1315            .map_err(MonotonicityError::ValidationFailed)?;
1316
1317        Ok(())
1318    }
1319}
1320
1321/// Validate properties against their definitions.
1322fn validate_properties(
1323    type_name: &str,
1324    defs: &BTreeMap<String, PropertyDef>,
1325    values: &BTreeMap<String, Value>,
1326    mode: ValidationMode,
1327) -> Result<(), ValidationError> {
1328    // Check required properties are present. Skipped only for a checkpoint's
1329    // synthetic inner ops, whose values arrive as separate UpdateProperty ops.
1330    if mode == ValidationMode::Full {
1331        for (prop_name, prop_def) in defs {
1332            if prop_def.required && !values.contains_key(prop_name) {
1333                return Err(ValidationError::MissingRequiredProperty {
1334                    type_name: type_name.to_string(),
1335                    property: prop_name.clone(),
1336                });
1337            }
1338        }
1339    }
1340
1341    // Check all provided properties are known and correctly typed
1342    for (prop_name, value) in values {
1343        // D-026: accept unknown properties without validation.
1344        // The ontology defines the minimum, not the maximum.
1345        let prop_def = match defs.get(prop_name) {
1346            Some(def) => def,
1347            None => continue,
1348        };
1349
1350        if prop_def.value_type != ValueType::Any {
1351            let actual_type = value_type_name(value);
1352            let expected = &prop_def.value_type;
1353            if !value_matches_type(value, expected) {
1354                return Err(ValidationError::WrongPropertyType {
1355                    type_name: type_name.to_string(),
1356                    property: prop_name.clone(),
1357                    expected: expected.clone(),
1358                    got: actual_type.to_string(),
1359                });
1360            }
1361        }
1362
1363        // Validate constraints (if any)
1364        if let Some(constraints) = &prop_def.constraints {
1365            validate_constraints(type_name, prop_name, value, constraints)?;
1366        }
1367    }
1368
1369    Ok(())
1370}
1371
1372/// Validate a property value against its constraints.
1373/// Built-in constraints: "enum" (allowed values), "min"/"max" (numeric range).
1374/// Unknown constraint names are silently ignored — enables forward compatibility
1375/// with community-contributed constraint types.
1376fn validate_constraints(
1377    type_name: &str,
1378    prop_name: &str,
1379    value: &Value,
1380    constraints: &BTreeMap<String, serde_json::Value>,
1381) -> Result<(), ValidationError> {
1382    // "enum": list of allowed string values
1383    if let Some(serde_json::Value::Array(allowed)) = constraints.get("enum") {
1384        if let Value::String(s) = value {
1385            let allowed_strs: Vec<&str> = allowed.iter().filter_map(|v| v.as_str()).collect();
1386            if !allowed_strs.contains(&s.as_str()) {
1387                return constraint_err(
1388                    type_name,
1389                    prop_name,
1390                    "enum",
1391                    format!("value '{}' not in allowed set {:?}", s, allowed_strs),
1392                );
1393            }
1394        }
1395    }
1396
1397    // Numeric bounds (4 variants share the same extract-compare pattern)
1398    check_numeric_bound(
1399        type_name,
1400        prop_name,
1401        value,
1402        constraints,
1403        "min",
1404        |n, b| n < b,
1405        |n, b| format!("value {} is less than minimum {}", n, b),
1406    )?;
1407    check_numeric_bound(
1408        type_name,
1409        prop_name,
1410        value,
1411        constraints,
1412        "max",
1413        |n, b| n > b,
1414        |n, b| format!("value {} exceeds maximum {}", n, b),
1415    )?;
1416    check_numeric_bound(
1417        type_name,
1418        prop_name,
1419        value,
1420        constraints,
1421        "min_exclusive",
1422        |n, b| n <= b,
1423        |n, b| format!("value {} must be greater than {}", n, b),
1424    )?;
1425    check_numeric_bound(
1426        type_name,
1427        prop_name,
1428        value,
1429        constraints,
1430        "max_exclusive",
1431        |n, b| n >= b,
1432        |n, b| format!("value {} must be less than {}", n, b),
1433    )?;
1434
1435    // String length bounds
1436    check_string_length(
1437        type_name,
1438        prop_name,
1439        value,
1440        constraints,
1441        "min_length",
1442        |len, bound| len < bound,
1443        |len, bound| format!("string length {} is less than minimum {}", len, bound),
1444    )?;
1445    check_string_length(
1446        type_name,
1447        prop_name,
1448        value,
1449        constraints,
1450        "max_length",
1451        |len, bound| len > bound,
1452        |len, bound| format!("string length {} exceeds maximum {}", len, bound),
1453    )?;
1454
1455    // "pattern": regex match on string values
1456    if let Some(serde_json::Value::String(pattern)) = constraints.get("pattern") {
1457        if let Value::String(s) = value {
1458            match regex::Regex::new(pattern) {
1459                Ok(re) if !re.is_match(s) => {
1460                    return constraint_err(
1461                        type_name,
1462                        prop_name,
1463                        "pattern",
1464                        format!("value '{}' does not match pattern '{}'", s, pattern),
1465                    );
1466                }
1467                Err(e) => {
1468                    return constraint_err(
1469                        type_name,
1470                        prop_name,
1471                        "pattern",
1472                        format!("invalid regex pattern '{}': {}", pattern, e),
1473                    );
1474                }
1475                _ => {}
1476            }
1477        }
1478    }
1479
1480    // Unknown constraint names are silently ignored (forward compat).
1481    Ok(())
1482}
1483
1484/// Helper: extract numeric value from a Value.
1485fn value_as_f64(value: &Value) -> Option<f64> {
1486    match value {
1487        Value::Int(n) => Some(*n as f64),
1488        Value::Float(n) => Some(*n),
1489        _ => None,
1490    }
1491}
1492
1493/// Helper: check a numeric bound constraint.
1494fn check_numeric_bound(
1495    type_name: &str,
1496    prop_name: &str,
1497    value: &Value,
1498    constraints: &BTreeMap<String, serde_json::Value>,
1499    key: &str,
1500    violates: impl Fn(f64, f64) -> bool,
1501    msg: impl Fn(f64, f64) -> String,
1502) -> Result<(), ValidationError> {
1503    if let Some(bound_val) = constraints.get(key) {
1504        if let Some(bound) = bound_val.as_f64() {
1505            if let Some(n) = value_as_f64(value) {
1506                if violates(n, bound) {
1507                    return constraint_err(type_name, prop_name, key, msg(n, bound));
1508                }
1509            }
1510        }
1511    }
1512    Ok(())
1513}
1514
1515/// Helper: check a string length constraint.
1516fn check_string_length(
1517    type_name: &str,
1518    prop_name: &str,
1519    value: &Value,
1520    constraints: &BTreeMap<String, serde_json::Value>,
1521    key: &str,
1522    violates: impl Fn(u64, u64) -> bool,
1523    msg: impl Fn(u64, u64) -> String,
1524) -> Result<(), ValidationError> {
1525    if let Some(serde_json::Value::Number(n)) = constraints.get(key) {
1526        if let (Some(bound), Value::String(s)) = (n.as_u64(), value) {
1527            if violates(s.len() as u64, bound) {
1528                return constraint_err(type_name, prop_name, key, msg(s.len() as u64, bound));
1529            }
1530        }
1531    }
1532    Ok(())
1533}
1534
1535/// Helper: construct a ConstraintViolation error.
1536fn constraint_err(
1537    type_name: &str,
1538    prop_name: &str,
1539    constraint: &str,
1540    message: String,
1541) -> Result<(), ValidationError> {
1542    Err(ValidationError::ConstraintViolation {
1543        type_name: type_name.to_string(),
1544        property: prop_name.to_string(),
1545        constraint: constraint.to_string(),
1546        message,
1547    })
1548}
1549
1550fn value_matches_type(value: &Value, expected: &ValueType) -> bool {
1551    matches!(
1552        (value, expected),
1553        (Value::Null, _)
1554            | (Value::String(_), ValueType::String)
1555            | (Value::Int(_), ValueType::Int)
1556            | (Value::Float(_), ValueType::Float)
1557            | (Value::Bool(_), ValueType::Bool)
1558            | (Value::List(_), ValueType::List)
1559            | (Value::Map(_), ValueType::Map)
1560            | (_, ValueType::Any)
1561    )
1562}
1563
1564fn value_type_name(value: &Value) -> &'static str {
1565    match value {
1566        Value::Null => "null",
1567        Value::Bool(_) => "bool",
1568        Value::Int(_) => "int",
1569        Value::Float(_) => "float",
1570        Value::String(_) => "string",
1571        Value::List(_) => "list",
1572        Value::Map(_) => "map",
1573    }
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578    use super::*;
1579
1580    /// `OntologyExtension` serializes as a POSITIONAL array, so its field
1581    /// count is the wire format. `edge_type_updates` was appended last; a
1582    /// legacy 3-element extension, as written by every build up to 0.3.0,
1583    /// must still load. Older builds cannot read the 4-element form — that is
1584    /// why PROTOCOL_VERSION moved.
1585    #[test]
1586    fn legacy_three_field_extension_still_deserializes() {
1587        let legacy = (
1588            BTreeMap::<String, NodeTypeDef>::new(),
1589            BTreeMap::<String, EdgeTypeDef>::new(),
1590            BTreeMap::<String, NodeTypeUpdate>::new(),
1591        );
1592        let bytes = rmp_serde::to_vec(&legacy).unwrap();
1593        assert_eq!(bytes[0], 0x93, "legacy fixture is not 3 elements");
1594
1595        let restored: OntologyExtension =
1596            rmp_serde::from_slice(&bytes).expect("legacy extension must load");
1597        assert!(restored.edge_type_updates.is_empty());
1598    }
1599
1600    #[test]
1601    fn extension_wire_format_is_a_positional_array_of_four() {
1602        let ext = OntologyExtension::default();
1603        let bytes = rmp_serde::to_vec(&ext).unwrap();
1604        assert_eq!(
1605            bytes[0], 0x94,
1606            "OntologyExtension is no longer a 4-element positional array. \
1607             Field order and count are the wire format: append new fields at \
1608             the end and move PROTOCOL_VERSION."
1609        );
1610    }
1611
1612    fn devops_ontology() -> Ontology {
1613        Ontology {
1614            node_types: BTreeMap::from([
1615                (
1616                    "signal".into(),
1617                    NodeTypeDef {
1618                        description: Some("Something observed".into()),
1619                        properties: BTreeMap::from([(
1620                            "severity".into(),
1621                            PropertyDef {
1622                                value_type: ValueType::String,
1623                                required: true,
1624                                description: None,
1625                                constraints: None,
1626                            },
1627                        )]),
1628                        subtypes: None,
1629                        parent_type: None,
1630                    },
1631                ),
1632                (
1633                    "entity".into(),
1634                    NodeTypeDef {
1635                        description: Some("Something that exists".into()),
1636                        properties: BTreeMap::from([
1637                            (
1638                                "status".into(),
1639                                PropertyDef {
1640                                    value_type: ValueType::String,
1641                                    required: false,
1642                                    description: None,
1643                                    constraints: None,
1644                                },
1645                            ),
1646                            (
1647                                "port".into(),
1648                                PropertyDef {
1649                                    value_type: ValueType::Int,
1650                                    required: false,
1651                                    description: None,
1652                                    constraints: None,
1653                                },
1654                            ),
1655                        ]),
1656                        subtypes: None,
1657                        parent_type: None,
1658                    },
1659                ),
1660                (
1661                    "rule".into(),
1662                    NodeTypeDef {
1663                        description: None,
1664                        properties: BTreeMap::new(),
1665                        subtypes: None,
1666                        parent_type: None,
1667                    },
1668                ),
1669                (
1670                    "action".into(),
1671                    NodeTypeDef {
1672                        description: None,
1673                        properties: BTreeMap::new(),
1674                        subtypes: None,
1675                        parent_type: None,
1676                    },
1677                ),
1678            ]),
1679            edge_types: BTreeMap::from([
1680                (
1681                    "OBSERVES".into(),
1682                    EdgeTypeDef {
1683                        description: None,
1684                        source_types: vec!["signal".into()],
1685                        target_types: vec!["entity".into()],
1686                        properties: BTreeMap::new(),
1687                    },
1688                ),
1689                (
1690                    "TRIGGERS".into(),
1691                    EdgeTypeDef {
1692                        description: None,
1693                        source_types: vec!["signal".into()],
1694                        target_types: vec!["rule".into()],
1695                        properties: BTreeMap::new(),
1696                    },
1697                ),
1698                (
1699                    "RUNS_ON".into(),
1700                    EdgeTypeDef {
1701                        description: None,
1702                        source_types: vec!["entity".into()],
1703                        target_types: vec!["entity".into()],
1704                        properties: BTreeMap::new(),
1705                    },
1706                ),
1707            ]),
1708        }
1709    }
1710
1711    // --- Node validation ---
1712
1713    #[test]
1714    fn validate_node_valid() {
1715        let ont = devops_ontology();
1716        let props = BTreeMap::from([("severity".into(), Value::String("critical".into()))]);
1717        assert!(ont.validate_node("signal", None, &props).is_ok());
1718    }
1719
1720    #[test]
1721    fn validate_node_unknown_type() {
1722        let ont = devops_ontology();
1723        let err = ont
1724            .validate_node("potato", None, &BTreeMap::new())
1725            .unwrap_err();
1726        assert!(matches!(err, ValidationError::UnknownNodeType(t) if t == "potato"));
1727    }
1728
1729    #[test]
1730    fn validate_node_missing_required() {
1731        let ont = devops_ontology();
1732        let err = ont
1733            .validate_node("signal", None, &BTreeMap::new())
1734            .unwrap_err();
1735        assert!(
1736            matches!(err, ValidationError::MissingRequiredProperty { property, .. } if property == "severity")
1737        );
1738    }
1739
1740    #[test]
1741    fn validate_node_wrong_type() {
1742        let ont = devops_ontology();
1743        let props = BTreeMap::from([("severity".into(), Value::Int(5))]);
1744        let err = ont.validate_node("signal", None, &props).unwrap_err();
1745        assert!(
1746            matches!(err, ValidationError::WrongPropertyType { property, .. } if property == "severity")
1747        );
1748    }
1749
1750    #[test]
1751    fn validate_node_unknown_property_accepted() {
1752        // D-026: unknown properties are accepted without validation
1753        let ont = devops_ontology();
1754        let props = BTreeMap::from([
1755            ("severity".into(), Value::String("warn".into())),
1756            ("bogus".into(), Value::Bool(true)),
1757        ]);
1758        assert!(ont.validate_node("signal", None, &props).is_ok());
1759    }
1760
1761    #[test]
1762    fn validate_node_optional_property_absent() {
1763        let ont = devops_ontology();
1764        // entity has optional "status" — omitting it is fine
1765        assert!(ont.validate_node("entity", None, &BTreeMap::new()).is_ok());
1766    }
1767
1768    #[test]
1769    fn validate_node_null_accepted_for_any_type() {
1770        let ont = devops_ontology();
1771        // Null is accepted for any typed property (represents absence)
1772        let props = BTreeMap::from([("severity".into(), Value::Null)]);
1773        assert!(ont.validate_node("signal", None, &props).is_ok());
1774    }
1775
1776    // --- Edge validation ---
1777
1778    #[test]
1779    fn validate_edge_valid() {
1780        let ont = devops_ontology();
1781        assert!(ont
1782            .validate_edge("OBSERVES", "signal", "entity", &BTreeMap::new())
1783            .is_ok());
1784    }
1785
1786    #[test]
1787    fn validate_edge_unknown_type() {
1788        let ont = devops_ontology();
1789        let err = ont
1790            .validate_edge("FLIES_TO", "signal", "entity", &BTreeMap::new())
1791            .unwrap_err();
1792        assert!(matches!(err, ValidationError::UnknownEdgeType(t) if t == "FLIES_TO"));
1793    }
1794
1795    #[test]
1796    fn validate_edge_invalid_source() {
1797        let ont = devops_ontology();
1798        // OBSERVES requires source=signal, not entity
1799        let err = ont
1800            .validate_edge("OBSERVES", "entity", "entity", &BTreeMap::new())
1801            .unwrap_err();
1802        assert!(matches!(err, ValidationError::InvalidSource { .. }));
1803    }
1804
1805    #[test]
1806    fn validate_edge_invalid_target() {
1807        let ont = devops_ontology();
1808        // OBSERVES requires target=entity, not signal
1809        let err = ont
1810            .validate_edge("OBSERVES", "signal", "signal", &BTreeMap::new())
1811            .unwrap_err();
1812        assert!(matches!(err, ValidationError::InvalidTarget { .. }));
1813    }
1814
1815    // --- Self-validation ---
1816
1817    #[test]
1818    fn validate_self_consistent() {
1819        let ont = devops_ontology();
1820        assert!(ont.validate_self().is_ok());
1821    }
1822
1823    #[test]
1824    fn validate_self_dangling_source() {
1825        let ont = Ontology {
1826            node_types: BTreeMap::from([(
1827                "entity".into(),
1828                NodeTypeDef {
1829                    description: None,
1830                    properties: BTreeMap::new(),
1831                    subtypes: None,
1832                    parent_type: None,
1833                },
1834            )]),
1835            edge_types: BTreeMap::from([(
1836                "OBSERVES".into(),
1837                EdgeTypeDef {
1838                    description: None,
1839                    source_types: vec!["ghost".into()], // doesn't exist
1840                    target_types: vec!["entity".into()],
1841                    properties: BTreeMap::new(),
1842                },
1843            )]),
1844        };
1845        let err = ont.validate_self().unwrap_err();
1846        assert!(
1847            matches!(err, ValidationError::InvalidSource { node_type, .. } if node_type == "ghost")
1848        );
1849    }
1850
1851    #[test]
1852    fn validate_self_dangling_target() {
1853        let ont = Ontology {
1854            node_types: BTreeMap::from([(
1855                "signal".into(),
1856                NodeTypeDef {
1857                    description: None,
1858                    properties: BTreeMap::new(),
1859                    subtypes: None,
1860                    parent_type: None,
1861                },
1862            )]),
1863            edge_types: BTreeMap::from([(
1864                "OBSERVES".into(),
1865                EdgeTypeDef {
1866                    description: None,
1867                    source_types: vec!["signal".into()],
1868                    target_types: vec!["phantom".into()], // doesn't exist
1869                    properties: BTreeMap::new(),
1870                },
1871            )]),
1872        };
1873        let err = ont.validate_self().unwrap_err();
1874        assert!(
1875            matches!(err, ValidationError::InvalidTarget { node_type, .. } if node_type == "phantom")
1876        );
1877    }
1878
1879    // --- Serialization ---
1880
1881    // --- New constraint tests (Step 1: SHACL-inspired vocabulary) ---
1882
1883    fn constrained_ontology() -> Ontology {
1884        Ontology {
1885            node_types: BTreeMap::from([(
1886                "item".into(),
1887                NodeTypeDef {
1888                    description: None,
1889                    properties: BTreeMap::from([
1890                        (
1891                            "slug".into(),
1892                            PropertyDef {
1893                                value_type: ValueType::String,
1894                                required: false,
1895                                description: None,
1896                                constraints: Some(BTreeMap::from([
1897                                    (
1898                                        "pattern".to_string(),
1899                                        serde_json::Value::String("^[a-z0-9-]+$".to_string()),
1900                                    ),
1901                                    (
1902                                        "min_length".to_string(),
1903                                        serde_json::Value::Number(1.into()),
1904                                    ),
1905                                    (
1906                                        "max_length".to_string(),
1907                                        serde_json::Value::Number(63.into()),
1908                                    ),
1909                                ])),
1910                            },
1911                        ),
1912                        (
1913                            "score".into(),
1914                            PropertyDef {
1915                                value_type: ValueType::Float,
1916                                required: false,
1917                                description: None,
1918                                constraints: Some(BTreeMap::from([
1919                                    ("min_exclusive".to_string(), serde_json::json!(0.0)),
1920                                    ("max_exclusive".to_string(), serde_json::json!(100.0)),
1921                                ])),
1922                            },
1923                        ),
1924                    ]),
1925                    subtypes: None,
1926                    parent_type: None,
1927                },
1928            )]),
1929            edge_types: BTreeMap::new(),
1930        }
1931    }
1932
1933    #[test]
1934    fn pattern_valid_slug() {
1935        let ont = constrained_ontology();
1936        let props = BTreeMap::from([("slug".into(), Value::String("my-project-1".into()))]);
1937        assert!(ont.validate_node("item", None, &props).is_ok());
1938    }
1939
1940    #[test]
1941    fn pattern_rejects_uppercase() {
1942        let ont = constrained_ontology();
1943        let props = BTreeMap::from([("slug".into(), Value::String("My-Project".into()))]);
1944        assert!(ont.validate_node("item", None, &props).is_err());
1945    }
1946
1947    #[test]
1948    fn pattern_rejects_spaces() {
1949        let ont = constrained_ontology();
1950        let props = BTreeMap::from([("slug".into(), Value::String("has space".into()))]);
1951        assert!(ont.validate_node("item", None, &props).is_err());
1952    }
1953
1954    #[test]
1955    fn min_length_accepts_valid() {
1956        let ont = constrained_ontology();
1957        let props = BTreeMap::from([("slug".into(), Value::String("a".into()))]);
1958        assert!(ont.validate_node("item", None, &props).is_ok());
1959    }
1960
1961    #[test]
1962    fn min_length_rejects_empty() {
1963        let ont = constrained_ontology();
1964        let props = BTreeMap::from([("slug".into(), Value::String("".into()))]);
1965        let err = ont.validate_node("item", None, &props).unwrap_err();
1966        assert!(
1967            matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_length")
1968        );
1969    }
1970
1971    #[test]
1972    fn max_length_rejects_too_long() {
1973        let ont = constrained_ontology();
1974        let long = "a".repeat(64);
1975        let props = BTreeMap::from([("slug".into(), Value::String(long))]);
1976        let err = ont.validate_node("item", None, &props).unwrap_err();
1977        assert!(
1978            matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_length")
1979        );
1980    }
1981
1982    #[test]
1983    fn max_length_accepts_boundary() {
1984        let ont = constrained_ontology();
1985        let exact = "a".repeat(63);
1986        let props = BTreeMap::from([("slug".into(), Value::String(exact))]);
1987        assert!(ont.validate_node("item", None, &props).is_ok());
1988    }
1989
1990    #[test]
1991    fn min_exclusive_rejects_boundary() {
1992        let ont = constrained_ontology();
1993        let props = BTreeMap::from([("score".into(), Value::Float(0.0))]);
1994        let err = ont.validate_node("item", None, &props).unwrap_err();
1995        assert!(
1996            matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "min_exclusive")
1997        );
1998    }
1999
2000    #[test]
2001    fn min_exclusive_accepts_above() {
2002        let ont = constrained_ontology();
2003        let props = BTreeMap::from([("score".into(), Value::Float(0.001))]);
2004        assert!(ont.validate_node("item", None, &props).is_ok());
2005    }
2006
2007    #[test]
2008    fn max_exclusive_rejects_boundary() {
2009        let ont = constrained_ontology();
2010        let props = BTreeMap::from([("score".into(), Value::Float(100.0))]);
2011        let err = ont.validate_node("item", None, &props).unwrap_err();
2012        assert!(
2013            matches!(err, ValidationError::ConstraintViolation { constraint, .. } if constraint == "max_exclusive")
2014        );
2015    }
2016
2017    #[test]
2018    fn max_exclusive_accepts_below() {
2019        let ont = constrained_ontology();
2020        let props = BTreeMap::from([("score".into(), Value::Float(99.999))]);
2021        assert!(ont.validate_node("item", None, &props).is_ok());
2022    }
2023
2024    // --- Serialization ---
2025
2026    #[test]
2027    fn ontology_roundtrip_msgpack() {
2028        let ont = devops_ontology();
2029        let bytes = rmp_serde::to_vec(&ont).unwrap();
2030        let decoded: Ontology = rmp_serde::from_slice(&bytes).unwrap();
2031        assert_eq!(ont, decoded);
2032    }
2033
2034    #[test]
2035    fn ontology_roundtrip_json() {
2036        let ont = devops_ontology();
2037        let json = serde_json::to_string(&ont).unwrap();
2038        let decoded: Ontology = serde_json::from_str(&json).unwrap();
2039        assert_eq!(ont, decoded);
2040    }
2041
2042    // --- Step 2: RDFS class hierarchy tests ---
2043
2044    fn hierarchy_ontology() -> Ontology {
2045        // thing → entity → server (two levels)
2046        //       → event
2047        Ontology {
2048            node_types: BTreeMap::from([
2049                (
2050                    "thing".into(),
2051                    NodeTypeDef {
2052                        description: None,
2053                        properties: BTreeMap::from([(
2054                            "name".into(),
2055                            PropertyDef {
2056                                value_type: ValueType::String,
2057                                required: true,
2058                                description: None,
2059                                constraints: None,
2060                            },
2061                        )]),
2062                        subtypes: None,
2063                        parent_type: None, // root
2064                    },
2065                ),
2066                (
2067                    "entity".into(),
2068                    NodeTypeDef {
2069                        description: None,
2070                        properties: BTreeMap::from([(
2071                            "status".into(),
2072                            PropertyDef {
2073                                value_type: ValueType::String,
2074                                required: false,
2075                                description: None,
2076                                constraints: None,
2077                            },
2078                        )]),
2079                        subtypes: None,
2080                        parent_type: Some("thing".into()), // entity extends thing
2081                    },
2082                ),
2083                (
2084                    "server".into(),
2085                    NodeTypeDef {
2086                        description: None,
2087                        properties: BTreeMap::from([(
2088                            "ip".into(),
2089                            PropertyDef {
2090                                value_type: ValueType::String,
2091                                required: false,
2092                                description: None,
2093                                constraints: None,
2094                            },
2095                        )]),
2096                        subtypes: None,
2097                        parent_type: Some("entity".into()), // server extends entity
2098                    },
2099                ),
2100                (
2101                    "event".into(),
2102                    NodeTypeDef {
2103                        description: None,
2104                        properties: BTreeMap::new(),
2105                        subtypes: None,
2106                        parent_type: Some("thing".into()), // event extends thing
2107                    },
2108                ),
2109            ]),
2110            edge_types: BTreeMap::from([(
2111                "RELATES_TO".into(),
2112                EdgeTypeDef {
2113                    description: None,
2114                    source_types: vec!["thing".into()], // accepts any thing descendant
2115                    target_types: vec!["entity".into()], // accepts entity or server
2116                    properties: BTreeMap::new(),
2117                },
2118            )]),
2119        }
2120    }
2121
2122    #[test]
2123    fn ancestors_empty_for_root() {
2124        let ont = hierarchy_ontology();
2125        assert!(ont.ancestors("thing").is_empty());
2126    }
2127
2128    #[test]
2129    fn ancestors_single_parent() {
2130        let ont = hierarchy_ontology();
2131        assert_eq!(ont.ancestors("entity"), vec!["thing"]);
2132    }
2133
2134    #[test]
2135    fn ancestors_transitive() {
2136        let ont = hierarchy_ontology();
2137        // server → entity → thing
2138        assert_eq!(ont.ancestors("server"), vec!["entity", "thing"]);
2139    }
2140
2141    #[test]
2142    fn descendants_of_root() {
2143        let ont = hierarchy_ontology();
2144        let mut desc = ont.descendants("thing");
2145        desc.sort();
2146        assert_eq!(desc, vec!["entity", "event", "server"]);
2147    }
2148
2149    #[test]
2150    fn descendants_of_entity() {
2151        let ont = hierarchy_ontology();
2152        assert_eq!(ont.descendants("entity"), vec!["server"]);
2153    }
2154
2155    #[test]
2156    fn descendants_of_leaf() {
2157        let ont = hierarchy_ontology();
2158        assert!(ont.descendants("server").is_empty());
2159    }
2160
2161    #[test]
2162    fn is_subtype_of_self() {
2163        let ont = hierarchy_ontology();
2164        assert!(ont.is_subtype_of("server", "server"));
2165    }
2166
2167    #[test]
2168    fn is_subtype_of_parent() {
2169        let ont = hierarchy_ontology();
2170        assert!(ont.is_subtype_of("server", "entity"));
2171        assert!(ont.is_subtype_of("server", "thing"));
2172    }
2173
2174    #[test]
2175    fn is_not_subtype_of_sibling() {
2176        let ont = hierarchy_ontology();
2177        assert!(!ont.is_subtype_of("server", "event"));
2178    }
2179
2180    #[test]
2181    fn effective_properties_inherits() {
2182        let ont = hierarchy_ontology();
2183        let props = ont.effective_properties("server");
2184        // server should have: name (from thing), status (from entity), ip (own)
2185        assert!(props.contains_key("name"));
2186        assert!(props.contains_key("status"));
2187        assert!(props.contains_key("ip"));
2188    }
2189
2190    #[test]
2191    fn effective_properties_root_has_own_only() {
2192        let ont = hierarchy_ontology();
2193        let props = ont.effective_properties("thing");
2194        assert!(props.contains_key("name"));
2195        assert!(!props.contains_key("status"));
2196    }
2197
2198    #[test]
2199    fn validate_node_inherits_required_from_ancestor() {
2200        let ont = hierarchy_ontology();
2201        // server requires "name" (inherited from thing)
2202        let err = ont.validate_node("server", None, &BTreeMap::new());
2203        assert!(err.is_err());
2204
2205        let props = BTreeMap::from([("name".into(), Value::String("web-01".into()))]);
2206        assert!(ont.validate_node("server", None, &props).is_ok());
2207    }
2208
2209    #[test]
2210    fn validate_edge_hierarchy_aware() {
2211        let ont = hierarchy_ontology();
2212        // RELATES_TO: source=thing, target=entity
2213        // server is-a thing, server is-a entity → both should pass
2214        let empty = BTreeMap::new();
2215        assert!(ont
2216            .validate_edge("RELATES_TO", "server", "server", &empty)
2217            .is_ok());
2218        assert!(ont
2219            .validate_edge("RELATES_TO", "event", "entity", &empty)
2220            .is_ok());
2221        assert!(ont
2222            .validate_edge("RELATES_TO", "thing", "entity", &empty)
2223            .is_ok());
2224    }
2225
2226    #[test]
2227    fn validate_edge_hierarchy_rejects_wrong_branch() {
2228        let ont = hierarchy_ontology();
2229        // RELATES_TO target must be entity or descendant. event is not entity's descendant.
2230        let empty = BTreeMap::new();
2231        assert!(ont
2232            .validate_edge("RELATES_TO", "thing", "event", &empty)
2233            .is_err());
2234    }
2235
2236    #[test]
2237    fn validate_self_rejects_dangling_parent() {
2238        let ont = Ontology {
2239            node_types: BTreeMap::from([(
2240                "orphan".into(),
2241                NodeTypeDef {
2242                    description: None,
2243                    properties: BTreeMap::new(),
2244                    subtypes: None,
2245                    parent_type: Some("ghost".into()), // doesn't exist
2246                },
2247            )]),
2248            edge_types: BTreeMap::new(),
2249        };
2250        assert!(ont.validate_self().is_err());
2251    }
2252
2253    // -- Ontology hashing and fingerprinting --
2254
2255    fn pet_ontology() -> Ontology {
2256        Ontology {
2257            node_types: BTreeMap::from([
2258                (
2259                    "animal".into(),
2260                    NodeTypeDef {
2261                        description: None,
2262                        properties: BTreeMap::from([(
2263                            "name".into(),
2264                            PropertyDef {
2265                                value_type: ValueType::String,
2266                                required: true,
2267                                description: None,
2268                                constraints: None,
2269                            },
2270                        )]),
2271                        subtypes: None,
2272                        parent_type: None,
2273                    },
2274                ),
2275                (
2276                    "shelter".into(),
2277                    NodeTypeDef {
2278                        description: None,
2279                        properties: BTreeMap::new(),
2280                        subtypes: None,
2281                        parent_type: None,
2282                    },
2283                ),
2284            ]),
2285            edge_types: BTreeMap::from([(
2286                "LIVES_AT".into(),
2287                EdgeTypeDef {
2288                    description: None,
2289                    source_types: vec!["animal".into()],
2290                    target_types: vec!["shelter".into()],
2291                    properties: BTreeMap::new(),
2292                },
2293            )]),
2294        }
2295    }
2296
2297    fn prop(required: bool) -> PropertyDef {
2298        PropertyDef {
2299            value_type: ValueType::String,
2300            required,
2301            description: None,
2302            constraints: None,
2303        }
2304    }
2305
2306    fn subtype(props: BTreeMap<String, PropertyDef>) -> SubtypeDef {
2307        SubtypeDef {
2308            description: None,
2309            properties: props,
2310        }
2311    }
2312
2313    // -- A: extensions that would invalidate existing entries are refused --
2314
2315    #[test]
2316    fn refuses_required_property_on_an_existing_node_type() {
2317        let mut ont = pet_ontology();
2318        let ext = OntologyExtension {
2319            node_type_updates: BTreeMap::from([(
2320                "animal".into(),
2321                NodeTypeUpdate {
2322                    add_properties: BTreeMap::from([("owner".into(), prop(true))]),
2323                    ..Default::default()
2324                },
2325            )]),
2326            ..Default::default()
2327        };
2328        assert_eq!(
2329            ont.merge_extension(&ext),
2330            Err(MonotonicityError::RequiredPropertyOnExistingType {
2331                kind: "node type",
2332                type_name: "animal".into(),
2333                property: "owner".into(),
2334            })
2335        );
2336        // Refused means unchanged, not half-applied.
2337        assert_eq!(ont, pet_ontology());
2338    }
2339
2340    #[test]
2341    fn refuses_required_property_on_an_existing_edge_type() {
2342        let mut ont = pet_ontology();
2343        let ext = OntologyExtension {
2344            edge_type_updates: BTreeMap::from([(
2345                "LIVES_AT".into(),
2346                EdgeTypeUpdate {
2347                    add_properties: BTreeMap::from([("since".into(), prop(true))]),
2348                    ..Default::default()
2349                },
2350            )]),
2351            ..Default::default()
2352        };
2353        assert!(matches!(
2354            ont.merge_extension(&ext),
2355            Err(MonotonicityError::RequiredPropertyOnExistingType {
2356                kind: "edge type",
2357                ..
2358            })
2359        ));
2360        assert_eq!(ont, pet_ontology());
2361    }
2362
2363    #[test]
2364    fn refuses_the_first_subtype_on_a_subtypeless_type() {
2365        let mut ont = pet_ontology();
2366        let ext = OntologyExtension {
2367            node_type_updates: BTreeMap::from([(
2368                "animal".into(),
2369                NodeTypeUpdate {
2370                    add_subtypes: BTreeMap::from([("dog".into(), subtype(BTreeMap::new()))]),
2371                    ..Default::default()
2372                },
2373            )]),
2374            ..Default::default()
2375        };
2376        assert_eq!(
2377            ont.merge_extension(&ext),
2378            Err(MonotonicityError::SubtypesOnSubtypelessType {
2379                type_name: "animal".into(),
2380                subtypes: vec!["dog".into()],
2381            })
2382        );
2383        assert_eq!(ont, pet_ontology());
2384    }
2385
2386    #[test]
2387    fn refuses_required_property_on_a_new_subtype() {
2388        // D-026 lets a node name a subtype the ontology has not declared, so
2389        // declaring it with a required property can quarantine that node.
2390        let mut ont = pet_ontology();
2391        ont.node_types.get_mut("animal").unwrap().subtypes =
2392            Some(BTreeMap::from([("cat".into(), subtype(BTreeMap::new()))]));
2393        let before = ont.clone();
2394
2395        let ext = OntologyExtension {
2396            node_type_updates: BTreeMap::from([(
2397                "animal".into(),
2398                NodeTypeUpdate {
2399                    add_subtypes: BTreeMap::from([(
2400                        "dog".into(),
2401                        subtype(BTreeMap::from([("breed".into(), prop(true))])),
2402                    )]),
2403                    ..Default::default()
2404                },
2405            )]),
2406            ..Default::default()
2407        };
2408        assert!(matches!(
2409            ont.merge_extension(&ext),
2410            Err(MonotonicityError::RequiredPropertyOnExistingType {
2411                kind: "subtype",
2412                ..
2413            })
2414        ));
2415        assert_eq!(ont, before);
2416    }
2417
2418    #[test]
2419    fn allows_an_optional_property_and_a_subtype_on_a_subtyped_type() {
2420        let mut ont = pet_ontology();
2421        ont.node_types.get_mut("animal").unwrap().subtypes =
2422            Some(BTreeMap::from([("cat".into(), subtype(BTreeMap::new()))]));
2423
2424        let ext = OntologyExtension {
2425            node_type_updates: BTreeMap::from([(
2426                "animal".into(),
2427                NodeTypeUpdate {
2428                    add_properties: BTreeMap::from([("owner".into(), prop(false))]),
2429                    add_subtypes: BTreeMap::from([(
2430                        "dog".into(),
2431                        subtype(BTreeMap::from([("breed".into(), prop(false))])),
2432                    )]),
2433                    ..Default::default()
2434                },
2435            )]),
2436            ..Default::default()
2437        };
2438        assert_eq!(ont.merge_extension(&ext), Ok(()));
2439    }
2440
2441    #[test]
2442    fn allows_a_required_property_on_a_brand_new_type() {
2443        let mut ont = pet_ontology();
2444        let ext = OntologyExtension {
2445            node_types: BTreeMap::from([(
2446                "vet".into(),
2447                NodeTypeDef {
2448                    description: None,
2449                    properties: BTreeMap::from([("license".into(), prop(true))]),
2450                    subtypes: None,
2451                    parent_type: None,
2452                },
2453            )]),
2454            ..Default::default()
2455        };
2456        assert_eq!(ont.merge_extension(&ext), Ok(()));
2457    }
2458
2459    // -- D: naming a destructive superset from fingerprints alone --
2460
2461    #[test]
2462    fn destructive_changes_flags_a_newly_required_property() {
2463        let old = pet_ontology();
2464        let mut new = pet_ontology();
2465        new.node_types
2466            .get_mut("shelter")
2467            .unwrap()
2468            .properties
2469            .insert("capacity".into(), prop(true));
2470
2471        let changes = new.destructive_changes(&old.fingerprint());
2472        assert_eq!(changes.len(), 1, "{changes:?}");
2473        assert!(changes[0].contains("capacity") && changes[0].contains("shelter"));
2474    }
2475
2476    #[test]
2477    fn destructive_changes_flags_a_property_the_peer_holds_as_optional() {
2478        // Absent and optional are the same thing here: neither peer entry had
2479        // to carry it.
2480        let mut old = pet_ontology();
2481        old.node_types
2482            .get_mut("shelter")
2483            .unwrap()
2484            .properties
2485            .insert("capacity".into(), prop(false));
2486        let mut new = pet_ontology();
2487        new.node_types
2488            .get_mut("shelter")
2489            .unwrap()
2490            .properties
2491            .insert("capacity".into(), prop(true));
2492
2493        assert_eq!(new.destructive_changes(&old.fingerprint()).len(), 1);
2494    }
2495
2496    #[test]
2497    fn destructive_changes_flags_a_newly_required_edge_property() {
2498        let old = pet_ontology();
2499        let mut new = pet_ontology();
2500        new.edge_types
2501            .get_mut("LIVES_AT")
2502            .unwrap()
2503            .properties
2504            .insert("since".into(), prop(true));
2505
2506        let changes = new.destructive_changes(&old.fingerprint());
2507        assert_eq!(changes.len(), 1, "{changes:?}");
2508        assert!(changes[0].contains("since") && changes[0].contains("LIVES_AT"));
2509    }
2510
2511    #[test]
2512    fn destructive_changes_flags_a_newly_required_subtype_property() {
2513        let mut old = pet_ontology();
2514        old.node_types.get_mut("animal").unwrap().subtypes =
2515            Some(BTreeMap::from([("cat".into(), subtype(BTreeMap::new()))]));
2516        let mut new = old.clone();
2517        new.node_types
2518            .get_mut("animal")
2519            .unwrap()
2520            .subtypes
2521            .as_mut()
2522            .unwrap()
2523            .insert(
2524                "dog".into(),
2525                subtype(BTreeMap::from([("breed".into(), prop(true))])),
2526            );
2527
2528        let changes = new.destructive_changes(&old.fingerprint());
2529        assert_eq!(changes.len(), 1, "{changes:?}");
2530        assert!(changes[0].contains("breed"), "{changes:?}");
2531    }
2532
2533    #[test]
2534    fn destructive_changes_flags_the_subtype_flip_once_per_type() {
2535        let old = pet_ontology();
2536        let mut new = pet_ontology();
2537        new.node_types.get_mut("animal").unwrap().subtypes = Some(BTreeMap::from([
2538            ("cat".into(), subtype(BTreeMap::new())),
2539            ("dog".into(), subtype(BTreeMap::new())),
2540        ]));
2541
2542        let changes = new.destructive_changes(&old.fingerprint());
2543        assert_eq!(changes.len(), 1, "two subtypes, one flip: {changes:?}");
2544        assert!(changes[0].contains("animal"));
2545    }
2546
2547    #[test]
2548    fn destructive_changes_ignores_a_type_the_peer_never_had() {
2549        // A brand new type with required properties holds no peer data.
2550        let old = pet_ontology();
2551        let mut new = pet_ontology();
2552        new.node_types.insert(
2553            "vet".into(),
2554            NodeTypeDef {
2555                description: None,
2556                properties: BTreeMap::from([("license".into(), prop(true))]),
2557                subtypes: Some(BTreeMap::from([(
2558                    "surgeon".into(),
2559                    subtype(BTreeMap::new()),
2560                )])),
2561                parent_type: None,
2562            },
2563        );
2564        assert_eq!(
2565            new.destructive_changes(&old.fingerprint()),
2566            Vec::<String>::new()
2567        );
2568    }
2569
2570    #[test]
2571    fn destructive_changes_ignores_an_optional_property() {
2572        let old = pet_ontology();
2573        let mut new = pet_ontology();
2574        new.node_types
2575            .get_mut("shelter")
2576            .unwrap()
2577            .properties
2578            .insert("capacity".into(), prop(false));
2579        assert_eq!(
2580            new.destructive_changes(&old.fingerprint()),
2581            Vec::<String>::new()
2582        );
2583    }
2584
2585    #[test]
2586    fn check_compatibility_separates_destructive_from_safe_supersets() {
2587        let old = pet_ontology();
2588        let (old_hash, old_fp) = (old.content_hash(), old.fingerprint());
2589
2590        let mut safe = pet_ontology();
2591        safe.node_types
2592            .get_mut("shelter")
2593            .unwrap()
2594            .properties
2595            .insert("capacity".into(), prop(false));
2596        assert_eq!(
2597            safe.check_compatibility(&old_hash, &old_fp),
2598            Compatibility::Superset
2599        );
2600
2601        let mut destructive = pet_ontology();
2602        destructive
2603            .node_types
2604            .get_mut("shelter")
2605            .unwrap()
2606            .properties
2607            .insert("capacity".into(), prop(true));
2608        assert_eq!(
2609            destructive.check_compatibility(&old_hash, &old_fp),
2610            Compatibility::DestructiveSuperset
2611        );
2612    }
2613
2614    #[test]
2615    fn check_compatibility_subset_and_divergent_are_unaffected() {
2616        let old = pet_ontology();
2617        let mut newer = pet_ontology();
2618        newer
2619            .node_types
2620            .get_mut("shelter")
2621            .unwrap()
2622            .properties
2623            .insert("capacity".into(), prop(true));
2624
2625        assert_eq!(
2626            old.check_compatibility(&newer.content_hash(), &newer.fingerprint()),
2627            Compatibility::Subset
2628        );
2629    }
2630
2631    #[test]
2632    fn content_hash_deterministic() {
2633        let a = pet_ontology();
2634        let b = pet_ontology();
2635        assert_eq!(a.content_hash(), b.content_hash());
2636    }
2637
2638    #[test]
2639    fn content_hash_is_32_bytes() {
2640        let ont = pet_ontology();
2641        let hash = ont.content_hash();
2642        assert_eq!(hash.len(), 32);
2643        assert_ne!(hash, [0u8; 32]); // not all zeros
2644    }
2645
2646    #[test]
2647    fn content_hash_changes_on_new_type() {
2648        let mut ont = pet_ontology();
2649        let hash_before = ont.content_hash();
2650        ont.node_types.insert(
2651            "volunteer".into(),
2652            NodeTypeDef {
2653                description: None,
2654                properties: BTreeMap::new(),
2655                subtypes: None,
2656                parent_type: None,
2657            },
2658        );
2659        let hash_after = ont.content_hash();
2660        assert_ne!(hash_before, hash_after);
2661    }
2662
2663    #[test]
2664    fn content_hash_changes_on_new_property() {
2665        let mut ont = pet_ontology();
2666        let hash_before = ont.content_hash();
2667        ont.node_types.get_mut("animal").unwrap().properties.insert(
2668            "microchip_id".into(),
2669            PropertyDef {
2670                value_type: ValueType::String,
2671                required: false,
2672                description: None,
2673                constraints: None,
2674            },
2675        );
2676        let hash_after = ont.content_hash();
2677        assert_ne!(hash_before, hash_after);
2678    }
2679
2680    #[test]
2681    fn fingerprint_contains_types() {
2682        let ont = pet_ontology();
2683        let fp = ont.fingerprint();
2684        assert!(fp.contains("type:animal"));
2685        assert!(fp.contains("type:shelter"));
2686        assert!(fp.contains("edge:LIVES_AT"));
2687    }
2688
2689    #[test]
2690    fn fingerprint_contains_properties() {
2691        let ont = pet_ontology();
2692        let fp = ont.fingerprint();
2693        assert!(fp.contains("prop:animal:name:string:required"));
2694    }
2695
2696    #[test]
2697    fn fingerprint_contains_edge_constraints() {
2698        let ont = pet_ontology();
2699        let fp = ont.fingerprint();
2700        assert!(fp.contains("edge:LIVES_AT:src:animal"));
2701        assert!(fp.contains("edge:LIVES_AT:tgt:shelter"));
2702    }
2703
2704    #[test]
2705    fn fingerprint_contains_parent_type() {
2706        let ont = Ontology {
2707            node_types: BTreeMap::from([
2708                (
2709                    "entity".into(),
2710                    NodeTypeDef {
2711                        description: None,
2712                        properties: BTreeMap::new(),
2713                        subtypes: None,
2714                        parent_type: None,
2715                    },
2716                ),
2717                (
2718                    "server".into(),
2719                    NodeTypeDef {
2720                        description: None,
2721                        properties: BTreeMap::new(),
2722                        subtypes: None,
2723                        parent_type: Some("entity".into()),
2724                    },
2725                ),
2726            ]),
2727            edge_types: BTreeMap::new(),
2728        };
2729        let fp = ont.fingerprint();
2730        assert!(fp.contains("type:server:parent:entity"));
2731    }
2732
2733    #[test]
2734    fn fingerprint_contains_subtypes() {
2735        let ont = Ontology {
2736            node_types: BTreeMap::from([(
2737                "entity".into(),
2738                NodeTypeDef {
2739                    description: None,
2740                    properties: BTreeMap::new(),
2741                    subtypes: Some(BTreeMap::from([(
2742                        "project".into(),
2743                        SubtypeDef {
2744                            description: None,
2745                            properties: BTreeMap::from([(
2746                                "slug".into(),
2747                                PropertyDef {
2748                                    value_type: ValueType::String,
2749                                    required: true,
2750                                    description: None,
2751                                    constraints: None,
2752                                },
2753                            )]),
2754                        },
2755                    )])),
2756                    parent_type: None,
2757                },
2758            )]),
2759            edge_types: BTreeMap::new(),
2760        };
2761        let fp = ont.fingerprint();
2762        assert!(fp.contains("subtype:entity:project"));
2763        assert!(fp.contains("subprop:entity:project:slug:string:required"));
2764    }
2765
2766    #[test]
2767    fn fingerprint_superset_after_extension() {
2768        let base = pet_ontology();
2769        let base_fp = base.fingerprint();
2770
2771        let mut extended = pet_ontology();
2772        extended.node_types.insert(
2773            "volunteer".into(),
2774            NodeTypeDef {
2775                description: None,
2776                properties: BTreeMap::new(),
2777                subtypes: None,
2778                parent_type: None,
2779            },
2780        );
2781        let ext_fp = extended.fingerprint();
2782
2783        // Extended is strict superset of base
2784        assert!(base_fp.is_subset(&ext_fp));
2785        assert!(!ext_fp.is_subset(&base_fp));
2786    }
2787
2788    #[test]
2789    fn check_compatibility_identical() {
2790        let a = pet_ontology();
2791        let b = pet_ontology();
2792        let verdict = a.check_compatibility(&b.content_hash(), &b.fingerprint());
2793        assert_eq!(verdict, Compatibility::Identical);
2794    }
2795
2796    #[test]
2797    fn check_compatibility_superset() {
2798        let base = pet_ontology();
2799
2800        let mut extended = pet_ontology();
2801        extended.node_types.insert(
2802            "volunteer".into(),
2803            NodeTypeDef {
2804                description: None,
2805                properties: BTreeMap::new(),
2806                subtypes: None,
2807                parent_type: None,
2808            },
2809        );
2810
2811        // Extended checking base: extended is superset
2812        let verdict = extended.check_compatibility(&base.content_hash(), &base.fingerprint());
2813        assert_eq!(verdict, Compatibility::Superset);
2814    }
2815
2816    #[test]
2817    fn check_compatibility_subset() {
2818        let base = pet_ontology();
2819
2820        let mut extended = pet_ontology();
2821        extended.node_types.insert(
2822            "volunteer".into(),
2823            NodeTypeDef {
2824                description: None,
2825                properties: BTreeMap::new(),
2826                subtypes: None,
2827                parent_type: None,
2828            },
2829        );
2830
2831        // Base checking extended: base is subset
2832        let verdict = base.check_compatibility(&extended.content_hash(), &extended.fingerprint());
2833        assert_eq!(verdict, Compatibility::Subset);
2834    }
2835
2836    #[test]
2837    fn check_compatibility_divergent() {
2838        // Two independent extensions from the same base
2839        let mut branch_a = pet_ontology();
2840        branch_a.node_types.insert(
2841            "volunteer".into(),
2842            NodeTypeDef {
2843                description: None,
2844                properties: BTreeMap::new(),
2845                subtypes: None,
2846                parent_type: None,
2847            },
2848        );
2849
2850        let mut branch_b = pet_ontology();
2851        branch_b.node_types.insert(
2852            "adoption".into(),
2853            NodeTypeDef {
2854                description: None,
2855                properties: BTreeMap::new(),
2856                subtypes: None,
2857                parent_type: None,
2858            },
2859        );
2860
2861        let verdict =
2862            branch_a.check_compatibility(&branch_b.content_hash(), &branch_b.fingerprint());
2863        assert_eq!(verdict, Compatibility::Divergent);
2864    }
2865
2866    #[test]
2867    fn fingerprint_contains_enum_constraints() {
2868        let ont = Ontology {
2869            node_types: BTreeMap::from([(
2870                "server".into(),
2871                NodeTypeDef {
2872                    description: None,
2873                    properties: BTreeMap::from([(
2874                        "status".into(),
2875                        PropertyDef {
2876                            value_type: ValueType::String,
2877                            required: true,
2878                            description: None,
2879                            constraints: Some(BTreeMap::from([(
2880                                "enum".into(),
2881                                serde_json::json!(["active", "standby"]),
2882                            )])),
2883                        },
2884                    )]),
2885                    subtypes: None,
2886                    parent_type: None,
2887                },
2888            )]),
2889            edge_types: BTreeMap::new(),
2890        };
2891        let fp = ont.fingerprint();
2892        assert!(fp.contains("constraint:server:status:enum:active"));
2893        assert!(fp.contains("constraint:server:status:enum:standby"));
2894    }
2895}