Skip to main content

silk/
ontology.rs

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