Skip to main content

relay_knowledge/domain/core/
ontology.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use url::Url;
6
7use super::{DomainError, SourceScope};
8
9const MAX_SCHEMA_CLASSES: usize = 256;
10const MAX_SCHEMA_PROPERTIES: usize = 256;
11const MAX_SCHEMA_RELATION_SHAPES: usize = 256;
12const MAX_SCHEMA_TEXT_BYTES: usize = 512;
13
14/// Whether an ontology class has identity across snapshots or represents one occurrence.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum OntologyClassIdentity {
18    Stable,
19    Occurrence,
20}
21
22/// One OWL class in a bounded, versioned ontology schema.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
24pub struct OntologyClassDefinition {
25    pub id: &'static str,
26    pub rdf_local_name: &'static str,
27    pub identity: OntologyClassIdentity,
28}
29
30/// A subject-class constraint for an ontology object property.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "snake_case", tag = "kind", content = "classes")]
33pub enum OntologyDomainConstraint {
34    Any,
35    OneOf(&'static [&'static str]),
36}
37
38/// An object-class constraint evaluated relative to the statement subject.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "snake_case", tag = "kind", content = "classes")]
41pub enum OntologyRangeConstraint {
42    Any,
43    OneOf(&'static [&'static str]),
44    SameAsSubject,
45    DifferentFromSubject,
46}
47
48/// One SHACL-like domain/range shape attached to an OWL object property.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50pub struct OntologyRelationShape {
51    pub domain: OntologyDomainConstraint,
52    pub range: OntologyRangeConstraint,
53}
54
55/// One RDF/OWL object property and its executable relation shapes.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
57pub struct OntologyObjectPropertyDefinition {
58    pub id: &'static str,
59    pub rdf_local_name: &'static str,
60    pub relation_shapes: &'static [OntologyRelationShape],
61}
62
63/// A reusable ontology schema independent from graph storage and repository projections.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
65pub struct OntologySchema {
66    pub id: &'static str,
67    pub version: &'static str,
68    pub namespace_iri: &'static str,
69    pub classes: &'static [OntologyClassDefinition],
70    pub object_properties: &'static [OntologyObjectPropertyDefinition],
71}
72
73impl OntologySchema {
74    /// Validates bounded schema identity, RDF names, uniqueness, and class references.
75    pub fn validate(&self) -> Result<(), DomainError> {
76        validate_schema_text("ontology_schema_id", self.id)?;
77        validate_semantic_version(self.version)?;
78        validate_namespace_iri(self.namespace_iri)?;
79        validate_schema_capacity("ontology_classes", self.classes.len(), MAX_SCHEMA_CLASSES)?;
80        validate_schema_capacity(
81            "ontology_object_properties",
82            self.object_properties.len(),
83            MAX_SCHEMA_PROPERTIES,
84        )?;
85        if self.classes.is_empty() {
86            return Err(DomainError::invalid(
87                "ontology_classes",
88                "must contain at least one ontology class",
89            ));
90        }
91
92        let mut class_ids = BTreeSet::new();
93        let mut class_names = BTreeSet::new();
94        for class in self.classes {
95            validate_rdf_local_name("ontology_class_id", class.id)?;
96            validate_rdf_local_name("ontology_class_rdf_name", class.rdf_local_name)?;
97            if !class_ids.insert(class.id) {
98                return Err(DomainError::invalid(
99                    "ontology_class_id",
100                    format!("duplicate ontology class '{}'", class.id),
101                ));
102            }
103            if !class_names.insert(class.rdf_local_name) {
104                return Err(DomainError::invalid(
105                    "ontology_class_rdf_name",
106                    format!("duplicate RDF class name '{}'", class.rdf_local_name),
107                ));
108            }
109        }
110
111        let mut property_ids = BTreeSet::new();
112        let mut property_names = BTreeSet::new();
113        for property in self.object_properties {
114            validate_rdf_local_name("ontology_property_id", property.id)?;
115            validate_rdf_local_name("ontology_property_rdf_name", property.rdf_local_name)?;
116            validate_schema_capacity(
117                "ontology_relation_shapes",
118                property.relation_shapes.len(),
119                MAX_SCHEMA_RELATION_SHAPES,
120            )?;
121            if property.relation_shapes.is_empty() {
122                return Err(DomainError::invalid(
123                    "ontology_relation_shapes",
124                    format!(
125                        "ontology property '{}' requires at least one shape",
126                        property.id
127                    ),
128                ));
129            }
130            if !property_ids.insert(property.id) {
131                return Err(DomainError::invalid(
132                    "ontology_property_id",
133                    format!("duplicate ontology property '{}'", property.id),
134                ));
135            }
136            if !property_names.insert(property.rdf_local_name) {
137                return Err(DomainError::invalid(
138                    "ontology_property_rdf_name",
139                    format!("duplicate RDF property name '{}'", property.rdf_local_name),
140                ));
141            }
142            for shape in property.relation_shapes {
143                validate_domain_constraint(shape.domain, &class_ids)?;
144                validate_range_constraint(shape.range, &class_ids)?;
145            }
146        }
147        Ok(())
148    }
149
150    /// Returns whether a property admits the given subject class.
151    pub fn allows_subject(&self, property_id: &str, subject_class_id: &str) -> bool {
152        if !self
153            .classes
154            .iter()
155            .any(|class| class.id == subject_class_id)
156        {
157            return false;
158        }
159        self.object_properties
160            .iter()
161            .find(|property| property.id == property_id)
162            .is_some_and(|property| {
163                property
164                    .relation_shapes
165                    .iter()
166                    .any(|shape| domain_matches(shape.domain, subject_class_id))
167            })
168    }
169
170    /// Returns whether an RDF object relation conforms to a declared domain/range shape.
171    pub fn allows_relation(
172        &self,
173        property_id: &str,
174        subject_class_id: &str,
175        object_class_id: &str,
176    ) -> bool {
177        if !self
178            .classes
179            .iter()
180            .any(|class| class.id == subject_class_id)
181            || !self.classes.iter().any(|class| class.id == object_class_id)
182        {
183            return false;
184        }
185        self.object_properties
186            .iter()
187            .find(|property| property.id == property_id)
188            .is_some_and(|property| {
189                property.relation_shapes.iter().any(|shape| {
190                    domain_matches(shape.domain, subject_class_id)
191                        && range_matches(shape.range, subject_class_id, object_class_id)
192                })
193            })
194    }
195}
196
197fn validate_schema_capacity(
198    field: &'static str,
199    actual: usize,
200    maximum: usize,
201) -> Result<(), DomainError> {
202    if actual > maximum {
203        return Err(DomainError::invalid(
204            field,
205            format!("must contain {maximum} entries or fewer"),
206        ));
207    }
208    Ok(())
209}
210
211fn validate_schema_text(field: &'static str, value: &str) -> Result<(), DomainError> {
212    if value.is_empty() {
213        return Err(DomainError::invalid(field, "must not be empty"));
214    }
215    if value.len() > MAX_SCHEMA_TEXT_BYTES {
216        return Err(DomainError::invalid(
217            field,
218            format!("must be {MAX_SCHEMA_TEXT_BYTES} bytes or less"),
219        ));
220    }
221    if value.trim() != value || value.contains('\0') {
222        return Err(DomainError::invalid(
223            field,
224            "must be trimmed and contain no NUL bytes",
225        ));
226    }
227    Ok(())
228}
229
230fn validate_semantic_version(version: &str) -> Result<(), DomainError> {
231    validate_schema_text("ontology_schema_version", version)?;
232    if version.split('.').count() != 3
233        || version
234            .split('.')
235            .any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()))
236    {
237        return Err(DomainError::invalid(
238            "ontology_schema_version",
239            "must be a numeric major.minor.patch version",
240        ));
241    }
242    Ok(())
243}
244
245fn validate_namespace_iri(namespace_iri: &str) -> Result<(), DomainError> {
246    validate_schema_text("ontology_namespace_iri", namespace_iri)?;
247    let parsed = Url::parse(namespace_iri).map_err(|_| {
248        DomainError::invalid(
249            "ontology_namespace_iri",
250            "must be an absolute HTTP(S) IRI with a host and ending in '#' or '/'",
251        )
252    })?;
253    if !matches!(parsed.scheme(), "http" | "https")
254        || parsed.host().is_none()
255        || !(namespace_iri.ends_with('#') || namespace_iri.ends_with('/'))
256    {
257        return Err(DomainError::invalid(
258            "ontology_namespace_iri",
259            "must be an absolute HTTP(S) IRI with a host and ending in '#' or '/'",
260        ));
261    }
262    Ok(())
263}
264
265fn validate_rdf_local_name(field: &'static str, value: &str) -> Result<(), DomainError> {
266    validate_schema_text(field, value)?;
267    let mut bytes = value.bytes();
268    if !bytes
269        .next()
270        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
271        || !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
272    {
273        return Err(DomainError::invalid(
274            field,
275            "must be a bounded RDF local name",
276        ));
277    }
278    Ok(())
279}
280
281fn validate_domain_constraint(
282    constraint: OntologyDomainConstraint,
283    class_ids: &BTreeSet<&str>,
284) -> Result<(), DomainError> {
285    if let OntologyDomainConstraint::OneOf(classes) = constraint {
286        validate_class_list("ontology_property_domain", classes, class_ids)?;
287    }
288    Ok(())
289}
290
291fn validate_range_constraint(
292    constraint: OntologyRangeConstraint,
293    class_ids: &BTreeSet<&str>,
294) -> Result<(), DomainError> {
295    if let OntologyRangeConstraint::OneOf(classes) = constraint {
296        validate_class_list("ontology_property_range", classes, class_ids)?;
297    }
298    Ok(())
299}
300
301fn validate_class_list(
302    field: &'static str,
303    classes: &[&str],
304    class_ids: &BTreeSet<&str>,
305) -> Result<(), DomainError> {
306    if classes.is_empty() {
307        return Err(DomainError::invalid(field, "must not be empty"));
308    }
309    validate_schema_capacity(field, classes.len(), MAX_SCHEMA_CLASSES)?;
310    let mut unique = BTreeSet::new();
311    for class in classes {
312        if !class_ids.contains(class) {
313            return Err(DomainError::invalid(
314                field,
315                format!("references unknown ontology class '{class}'"),
316            ));
317        }
318        if !unique.insert(*class) {
319            return Err(DomainError::invalid(
320                field,
321                format!("contains duplicate ontology class '{class}'"),
322            ));
323        }
324    }
325    Ok(())
326}
327
328fn domain_matches(constraint: OntologyDomainConstraint, subject_class_id: &str) -> bool {
329    match constraint {
330        OntologyDomainConstraint::Any => true,
331        OntologyDomainConstraint::OneOf(classes) => classes.contains(&subject_class_id),
332    }
333}
334
335fn range_matches(
336    constraint: OntologyRangeConstraint,
337    subject_class_id: &str,
338    object_class_id: &str,
339) -> bool {
340    match constraint {
341        OntologyRangeConstraint::Any => true,
342        OntologyRangeConstraint::OneOf(classes) => classes.contains(&object_class_id),
343        OntologyRangeConstraint::SameAsSubject => subject_class_id == object_class_id,
344        OntologyRangeConstraint::DifferentFromSubject => subject_class_id != object_class_id,
345    }
346}
347
348/// Typed ontology node identity. Untyped legacy graph entities keep their label-derived ids.
349#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub enum OntologyEntityKind {
352    #[default]
353    Untyped,
354    BusinessDomain,
355    BusinessTerm,
356}
357
358impl OntologyEntityKind {
359    /// Stable storage and wire representation.
360    pub const fn as_str(self) -> &'static str {
361        match self {
362            Self::Untyped => "untyped",
363            Self::BusinessDomain => "business_domain",
364            Self::BusinessTerm => "business_term",
365        }
366    }
367}
368
369/// Immutable ontology identity independent of a display label.
370#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
371pub struct OntologyIdentity {
372    pub source_scope: SourceScope,
373    pub domain_id: String,
374    pub entity_id: String,
375    pub entity_kind: OntologyEntityKind,
376}
377
378impl OntologyIdentity {
379    /// Validates the scoped identity used to create stable typed entity ids.
380    pub fn new(
381        source_scope: SourceScope,
382        domain_id: impl Into<String>,
383        entity_id: impl Into<String>,
384        entity_kind: OntologyEntityKind,
385    ) -> Result<Self, DomainError> {
386        let domain_id = validate_identity_text("domain_id", domain_id.into())?;
387        let entity_id = validate_identity_text("entity_id", entity_id.into())?;
388        if entity_kind == OntologyEntityKind::Untyped {
389            return Err(DomainError::invalid(
390                "entity_kind",
391                "scoped ontology identities must be typed",
392            ));
393        }
394        Ok(Self {
395            source_scope,
396            domain_id,
397            entity_id,
398            entity_kind,
399        })
400    }
401
402    /// Returns a deterministic id that does not depend on the display name.
403    pub fn stable_entity_id(&self) -> String {
404        let mut digest = Sha256::new();
405        for part in [
406            self.source_scope.as_str(),
407            self.domain_id.as_str(),
408            self.entity_id.as_str(),
409            self.entity_kind.as_str(),
410        ] {
411            digest.update((part.len() as u64).to_be_bytes());
412            digest.update(part.as_bytes());
413        }
414        format!("ontology:{:x}", digest.finalize())
415    }
416}
417
418fn validate_identity_text(field: &'static str, value: String) -> Result<String, DomainError> {
419    let value = value.trim();
420    if value.is_empty() {
421        return Err(DomainError::invalid(field, "must not be empty"));
422    }
423    if value.len() > 128 {
424        return Err(DomainError::invalid(field, "must be 128 bytes or less"));
425    }
426    if value.contains('\0') {
427        return Err(DomainError::invalid(field, "must not contain NUL bytes"));
428    }
429    Ok(value.to_owned())
430}
431
432#[cfg(test)]
433#[path = "ontology_tests.rs"]
434mod tests;