Skip to main content

termivar_core/
ontology.rs

1//! Domain ontology contracts for semantic reasoning.
2//!
3//! A knowledge graph records instance relationships. An [`Ontology`] defines
4//! what conceptual relationships mean: whether they are transitive,
5//! symmetric, reflexive, acyclic, or have a named inverse. This distinction
6//! prevents a decision engine from treating every connected path as the same
7//! kind of inference.
8
9use serde::{Deserialize, Deserializer, Serialize};
10use std::collections::{BTreeMap, BTreeSet, VecDeque};
11use std::fmt;
12
13/// Validation and consistency errors for ontology operations.
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum OntologyError {
17    /// A required identifier or label was empty.
18    EmptyValue {
19        /// Name of the invalid field.
20        field: &'static str,
21    },
22    /// An identity was reused with a different definition.
23    IdentityConflict {
24        /// Kind of ontology record that conflicted.
25        kind: OntologyRecordKind,
26        /// Reused identifier.
27        id: String,
28    },
29    /// An axiom referenced a concept that has not been registered.
30    UnknownConcept(ConceptId),
31    /// An axiom or query referenced an unknown relation type.
32    UnknownRelationType(RelationTypeId),
33    /// An axiom would create a cycle in an acyclic relation.
34    CycleDetected {
35        /// Relation whose acyclic invariant would be broken.
36        relation: RelationTypeId,
37        /// Proposed axiom subject.
38        subject: ConceptId,
39        /// Proposed axiom object.
40        object: ConceptId,
41    },
42}
43
44impl fmt::Display for OntologyError {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::EmptyValue { field } => write!(formatter, "{field} must not be empty"),
48            Self::IdentityConflict { kind, id } => {
49                write!(
50                    formatter,
51                    "{kind} identity {id} already has a different definition"
52                )
53            },
54            Self::UnknownConcept(id) => write!(formatter, "unknown ontology concept {id}"),
55            Self::UnknownRelationType(id) => {
56                write!(formatter, "unknown ontology relation type {id}")
57            },
58            Self::CycleDetected {
59                relation,
60                subject,
61                object,
62            } => write!(
63                formatter,
64                "ontology axiom {subject} {relation} {object} would create a cycle"
65            ),
66        }
67    }
68}
69
70impl std::error::Error for OntologyError {}
71
72fn non_empty(value: impl Into<String>, field: &'static str) -> Result<String, OntologyError> {
73    let value = value.into();
74    if value.trim().is_empty() {
75        return Err(OntologyError::EmptyValue { field });
76    }
77    Ok(value)
78}
79
80fn deserialize_non_empty_string<'de, D>(deserializer: D) -> Result<String, D::Error>
81where
82    D: Deserializer<'de>,
83{
84    let value = String::deserialize(deserializer)?;
85    non_empty(value, "value").map_err(serde::de::Error::custom)
86}
87
88/// Stable identifier for a domain concept such as `framework` or `laravel`.
89#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
90#[serde(transparent)]
91pub struct ConceptId(String);
92
93impl ConceptId {
94    /// Creates a validated concept identifier.
95    pub fn new(value: impl Into<String>) -> Result<Self, OntologyError> {
96        Ok(Self(non_empty(value, "concept id")?))
97    }
98
99    /// Returns the identifier as a string slice.
100    pub fn as_str(&self) -> &str {
101        &self.0
102    }
103}
104
105impl fmt::Display for ConceptId {
106    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
107        formatter.write_str(&self.0)
108    }
109}
110
111impl<'de> Deserialize<'de> for ConceptId {
112    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
113    where
114        D: Deserializer<'de>,
115    {
116        let value = String::deserialize(deserializer)?;
117        Self::new(value).map_err(serde::de::Error::custom)
118    }
119}
120
121/// Stable identifier for a semantic relation type such as `is_a`.
122#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
123#[serde(transparent)]
124pub struct RelationTypeId(String);
125
126impl RelationTypeId {
127    /// Creates a validated relation-type identifier.
128    pub fn new(value: impl Into<String>) -> Result<Self, OntologyError> {
129        Ok(Self(non_empty(value, "relation type id")?))
130    }
131
132    /// Returns the identifier as a string slice.
133    pub fn as_str(&self) -> &str {
134        &self.0
135    }
136}
137
138impl fmt::Display for RelationTypeId {
139    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140        formatter.write_str(&self.0)
141    }
142}
143
144impl<'de> Deserialize<'de> for RelationTypeId {
145    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
146    where
147        D: Deserializer<'de>,
148    {
149        let value = String::deserialize(deserializer)?;
150        Self::new(value).map_err(serde::de::Error::custom)
151    }
152}
153
154/// A named concept in the domain ontology.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct OntologyConcept {
157    id: ConceptId,
158    #[serde(deserialize_with = "deserialize_non_empty_string")]
159    label: String,
160}
161
162impl OntologyConcept {
163    /// Creates a concept with a stable ID and human-readable label.
164    pub fn new(id: ConceptId, label: impl Into<String>) -> Result<Self, OntologyError> {
165        Ok(Self {
166            id,
167            label: non_empty(label, "concept label")?,
168        })
169    }
170
171    /// Returns the stable concept identifier.
172    pub fn id(&self) -> &ConceptId {
173        &self.id
174    }
175
176    /// Returns the display label.
177    pub fn label(&self) -> &str {
178        &self.label
179    }
180}
181
182/// Formal behavior attached to an ontology relation type.
183#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
184pub struct RelationSemantics {
185    /// Whether `a R b` and `b R c` imply `a R c`.
186    pub transitive: bool,
187    /// Whether `a R b` also implies `b R a`.
188    pub symmetric: bool,
189    /// Whether every concept relates to itself.
190    pub reflexive: bool,
191    /// Whether adding a cycle must be rejected.
192    pub acyclic: bool,
193}
194
195/// Definition of a semantic relationship used by ontology axioms.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct OntologyRelationType {
198    id: RelationTypeId,
199    #[serde(deserialize_with = "deserialize_non_empty_string")]
200    label: String,
201    semantics: RelationSemantics,
202    inverse: Option<RelationTypeId>,
203}
204
205impl OntologyRelationType {
206    /// Creates a directional relation with no inference properties.
207    pub fn new(id: RelationTypeId, label: impl Into<String>) -> Result<Self, OntologyError> {
208        Ok(Self {
209            id,
210            label: non_empty(label, "relation type label")?,
211            semantics: RelationSemantics::default(),
212            inverse: None,
213        })
214    }
215
216    /// Assigns explicit semantic behavior.
217    pub fn with_semantics(mut self, semantics: RelationSemantics) -> Self {
218        self.semantics = semantics;
219        self
220    }
221
222    /// Names the inverse relation type.
223    pub fn with_inverse(mut self, inverse: RelationTypeId) -> Self {
224        self.inverse = Some(inverse);
225        self
226    }
227
228    /// Returns the stable relation-type identifier.
229    pub fn id(&self) -> &RelationTypeId {
230        &self.id
231    }
232
233    /// Returns the display label.
234    pub fn label(&self) -> &str {
235        &self.label
236    }
237
238    /// Returns the formal inference behavior.
239    pub fn semantics(&self) -> RelationSemantics {
240        self.semantics
241    }
242
243    /// Returns the optional inverse relation type.
244    pub fn inverse(&self) -> Option<&RelationTypeId> {
245        self.inverse.as_ref()
246    }
247}
248
249/// One semantic statement between two registered concepts.
250#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
251pub struct OntologyAxiom {
252    subject: ConceptId,
253    relation: RelationTypeId,
254    object: ConceptId,
255}
256
257impl OntologyAxiom {
258    /// Creates an axiom. Registration validates referenced concepts and type.
259    pub fn new(subject: ConceptId, relation: RelationTypeId, object: ConceptId) -> Self {
260        Self {
261            subject,
262            relation,
263            object,
264        }
265    }
266
267    /// Returns the source concept.
268    pub fn subject(&self) -> &ConceptId {
269        &self.subject
270    }
271
272    /// Returns the semantic relation type.
273    pub fn relation(&self) -> &RelationTypeId {
274        &self.relation
275    }
276
277    /// Returns the destination concept.
278    pub fn object(&self) -> &ConceptId {
279        &self.object
280    }
281}
282
283/// Result of an idempotent ontology write.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285#[non_exhaustive]
286pub enum OntologyWrite {
287    /// A new definition or axiom was registered.
288    Inserted,
289    /// The exact definition or axiom already existed.
290    Unchanged,
291}
292
293/// Ontology record categories used by conflict diagnostics.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295#[non_exhaustive]
296pub enum OntologyRecordKind {
297    /// Domain concept.
298    Concept,
299    /// Semantic relation type.
300    RelationType,
301}
302
303impl fmt::Display for OntologyRecordKind {
304    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
305        match self {
306            Self::Concept => formatter.write_str("ontology concept"),
307            Self::RelationType => formatter.write_str("ontology relation type"),
308        }
309    }
310}
311
312/// Counts of definitions held by an [`Ontology`].
313#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
314#[non_exhaustive]
315pub struct OntologyStats {
316    /// Number of registered concepts.
317    pub concepts: usize,
318    /// Number of registered relation types.
319    pub relation_types: usize,
320    /// Number of semantic axioms.
321    pub axioms: usize,
322}
323
324/// Validated domain ontology with deterministic semantic traversal.
325///
326/// The default ontology contains standard relation definitions but no domain
327/// concepts or axioms. Callers explicitly register their product vocabulary.
328///
329/// # Examples
330///
331/// ```rust
332/// use termivar_core::{ConceptId, Ontology, OntologyAxiom, OntologyConcept};
333///
334/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
335/// let mut ontology = Ontology::new();
336/// let framework = ConceptId::new("framework")?;
337/// let technology = ConceptId::new("technology")?;
338/// let laravel = ConceptId::new("laravel")?;
339/// ontology.add_concept(OntologyConcept::new(framework.clone(), "Framework")?)?;
340/// ontology.add_concept(OntologyConcept::new(technology.clone(), "Technology")?)?;
341/// ontology.add_concept(OntologyConcept::new(laravel.clone(), "Laravel")?)?;
342/// ontology.add_axiom(OntologyAxiom::new(
343///     laravel.clone(),
344///     Ontology::relation_id(Ontology::IS_A)?,
345///     framework.clone(),
346/// ))?;
347/// ontology.add_axiom(OntologyAxiom::new(
348///     framework,
349///     Ontology::relation_id(Ontology::IS_A)?,
350///     technology.clone(),
351/// ))?;
352///
353/// assert!(ontology.is_a(&laravel, &technology)?);
354/// # Ok(())
355/// # }
356/// ```
357#[derive(Debug, Clone)]
358pub struct Ontology {
359    concepts: BTreeMap<ConceptId, OntologyConcept>,
360    relation_types: BTreeMap<RelationTypeId, OntologyRelationType>,
361    axioms: BTreeSet<OntologyAxiom>,
362}
363
364impl Ontology {
365    /// Canonical transitive type hierarchy relation.
366    pub const IS_A: &'static str = "is_a";
367    /// Canonical transitive containment relation.
368    pub const PART_OF: &'static str = "part_of";
369    /// Inverse of [`Self::PART_OF`].
370    pub const HAS_PART: &'static str = "has_part";
371    /// Directional dependency relation.
372    pub const DEPENDS_ON: &'static str = "depends_on";
373    /// Inverse of [`Self::DEPENDS_ON`].
374    pub const REQUIRED_BY: &'static str = "required_by";
375    /// Logical implication relation.
376    pub const IMPLIES: &'static str = "implies";
377    /// Symmetric non-hierarchical association.
378    pub const ASSOCIATED_WITH: &'static str = "associated_with";
379    /// Implementation-language relation.
380    pub const IMPLEMENTED_IN: &'static str = "implemented_in";
381    /// Capability or category provision relation.
382    pub const PROVIDES: &'static str = "provides";
383
384    /// Creates an empty domain vocabulary with standard relation definitions.
385    pub fn new() -> Self {
386        let mut ontology = Self {
387            concepts: BTreeMap::new(),
388            relation_types: BTreeMap::new(),
389            axioms: BTreeSet::new(),
390        };
391        for relation in standard_relation_types() {
392            ontology
393                .relation_types
394                .insert(relation.id().clone(), relation);
395        }
396        ontology
397    }
398
399    /// Builds a validated relation-type ID for a canonical or custom name.
400    pub fn relation_id(value: impl Into<String>) -> Result<RelationTypeId, OntologyError> {
401        RelationTypeId::new(value)
402    }
403
404    /// Registers a concept idempotently.
405    pub fn add_concept(
406        &mut self,
407        concept: OntologyConcept,
408    ) -> Result<OntologyWrite, OntologyError> {
409        if let Some(existing) = self.concepts.get(concept.id()) {
410            return if existing == &concept {
411                Ok(OntologyWrite::Unchanged)
412            } else {
413                Err(OntologyError::IdentityConflict {
414                    kind: OntologyRecordKind::Concept,
415                    id: concept.id().to_string(),
416                })
417            };
418        }
419        self.concepts.insert(concept.id().clone(), concept);
420        Ok(OntologyWrite::Inserted)
421    }
422
423    /// Registers a semantic relation type idempotently.
424    pub fn add_relation_type(
425        &mut self,
426        relation_type: OntologyRelationType,
427    ) -> Result<OntologyWrite, OntologyError> {
428        if let Some(existing) = self.relation_types.get(relation_type.id()) {
429            return if existing == &relation_type {
430                Ok(OntologyWrite::Unchanged)
431            } else {
432                Err(OntologyError::IdentityConflict {
433                    kind: OntologyRecordKind::RelationType,
434                    id: relation_type.id().to_string(),
435                })
436            };
437        }
438        self.relation_types
439            .insert(relation_type.id().clone(), relation_type);
440        Ok(OntologyWrite::Inserted)
441    }
442
443    /// Registers a validated semantic axiom idempotently.
444    pub fn add_axiom(&mut self, axiom: OntologyAxiom) -> Result<OntologyWrite, OntologyError> {
445        self.ensure_concept(axiom.subject())?;
446        self.ensure_concept(axiom.object())?;
447        let relation_type = self.ensure_relation_type(axiom.relation())?;
448
449        if relation_type.semantics().acyclic
450            && (axiom.subject() == axiom.object()
451                || self.is_related(axiom.object(), axiom.relation(), axiom.subject())?)
452        {
453            return Err(OntologyError::CycleDetected {
454                relation: axiom.relation().clone(),
455                subject: axiom.subject().clone(),
456                object: axiom.object().clone(),
457            });
458        }
459
460        if self.is_related(axiom.subject(), axiom.relation(), axiom.object())? {
461            return Ok(OntologyWrite::Unchanged);
462        }
463
464        if self.axioms.insert(axiom) {
465            Ok(OntologyWrite::Inserted)
466        } else {
467            Ok(OntologyWrite::Unchanged)
468        }
469    }
470
471    /// Returns a concept definition by ID.
472    pub fn concept(&self, id: &ConceptId) -> Option<&OntologyConcept> {
473        self.concepts.get(id)
474    }
475
476    /// Returns a semantic relation definition by ID.
477    pub fn relation_type(&self, id: &RelationTypeId) -> Option<&OntologyRelationType> {
478        self.relation_types.get(id)
479    }
480
481    /// Returns all concepts in stable ID order.
482    pub fn concepts(&self) -> Vec<OntologyConcept> {
483        self.concepts.values().cloned().collect()
484    }
485
486    /// Returns all relation types in stable ID order.
487    pub fn relation_types(&self) -> Vec<OntologyRelationType> {
488        self.relation_types.values().cloned().collect()
489    }
490
491    /// Returns all axioms in stable tuple order.
492    pub fn axioms(&self) -> Vec<OntologyAxiom> {
493        self.axioms.iter().cloned().collect()
494    }
495
496    /// Evaluates a relationship using its declared semantic behavior.
497    pub fn is_related(
498        &self,
499        subject: &ConceptId,
500        relation: &RelationTypeId,
501        object: &ConceptId,
502    ) -> Result<bool, OntologyError> {
503        self.ensure_concept(subject)?;
504        self.ensure_concept(object)?;
505        let relation_type = self.ensure_relation_type(relation)?;
506        let semantics = relation_type.semantics();
507
508        if semantics.reflexive && subject == object {
509            return Ok(true);
510        }
511
512        let direct = self.neighbors(subject, relation_type);
513        if direct.contains(object) {
514            return Ok(true);
515        }
516        if !semantics.transitive {
517            return Ok(false);
518        }
519
520        let mut visited = BTreeSet::from([subject.clone()]);
521        let mut queue: VecDeque<_> = direct.into_iter().collect();
522        while let Some(current) = queue.pop_front() {
523            if &current == object {
524                return Ok(true);
525            }
526            if !visited.insert(current.clone()) {
527                continue;
528            }
529            queue.extend(
530                self.neighbors(&current, relation_type)
531                    .into_iter()
532                    .filter(|neighbor| !visited.contains(neighbor)),
533            );
534        }
535        Ok(false)
536    }
537
538    /// Evaluates the canonical transitive `is_a` hierarchy.
539    pub fn is_a(&self, child: &ConceptId, ancestor: &ConceptId) -> Result<bool, OntologyError> {
540        self.is_related(child, &Self::relation_id(Self::IS_A)?, ancestor)
541    }
542
543    /// Returns axioms whose subject matches a concept, in stable order.
544    pub fn axioms_from(&self, subject: &ConceptId) -> Result<Vec<OntologyAxiom>, OntologyError> {
545        self.ensure_concept(subject)?;
546        Ok(self
547            .axioms
548            .iter()
549            .filter(|axiom| axiom.subject() == subject)
550            .cloned()
551            .collect())
552    }
553
554    /// Returns a count snapshot.
555    pub fn stats(&self) -> OntologyStats {
556        OntologyStats {
557            concepts: self.concepts.len(),
558            relation_types: self.relation_types.len(),
559            axioms: self.axioms.len(),
560        }
561    }
562
563    fn ensure_concept(&self, id: &ConceptId) -> Result<&OntologyConcept, OntologyError> {
564        self.concepts
565            .get(id)
566            .ok_or_else(|| OntologyError::UnknownConcept(id.clone()))
567    }
568
569    fn ensure_relation_type(
570        &self,
571        id: &RelationTypeId,
572    ) -> Result<&OntologyRelationType, OntologyError> {
573        self.relation_types
574            .get(id)
575            .ok_or_else(|| OntologyError::UnknownRelationType(id.clone()))
576    }
577
578    fn neighbors(
579        &self,
580        subject: &ConceptId,
581        relation_type: &OntologyRelationType,
582    ) -> BTreeSet<ConceptId> {
583        self.axioms
584            .iter()
585            .filter_map(|axiom| {
586                if axiom.relation() == relation_type.id() && axiom.subject() == subject {
587                    Some(axiom.object().clone())
588                } else if axiom.object() == subject
589                    && ((axiom.relation() == relation_type.id()
590                        && relation_type.semantics().symmetric)
591                        || relation_type.inverse() == Some(axiom.relation()))
592                {
593                    Some(axiom.subject().clone())
594                } else {
595                    None
596                }
597            })
598            .collect()
599    }
600}
601
602impl Default for Ontology {
603    fn default() -> Self {
604        Self::new()
605    }
606}
607
608fn standard_relation_types() -> Vec<OntologyRelationType> {
609    let relation = |id: &'static str, label: &'static str| {
610        OntologyRelationType::new(
611            RelationTypeId::new(id).expect("standard relation ID is valid"),
612            label,
613        )
614        .expect("standard relation label is valid")
615    };
616    let id = |value: &'static str| {
617        RelationTypeId::new(value).expect("standard inverse relation ID is valid")
618    };
619
620    vec![
621        relation(Ontology::IS_A, "is a").with_semantics(RelationSemantics {
622            transitive: true,
623            reflexive: true,
624            acyclic: true,
625            ..RelationSemantics::default()
626        }),
627        relation(Ontology::PART_OF, "part of")
628            .with_semantics(RelationSemantics {
629                transitive: true,
630                acyclic: true,
631                ..RelationSemantics::default()
632            })
633            .with_inverse(id(Ontology::HAS_PART)),
634        relation(Ontology::HAS_PART, "has part")
635            .with_semantics(RelationSemantics {
636                transitive: true,
637                acyclic: true,
638                ..RelationSemantics::default()
639            })
640            .with_inverse(id(Ontology::PART_OF)),
641        relation(Ontology::DEPENDS_ON, "depends on").with_inverse(id(Ontology::REQUIRED_BY)),
642        relation(Ontology::REQUIRED_BY, "required by").with_inverse(id(Ontology::DEPENDS_ON)),
643        relation(Ontology::IMPLIES, "implies").with_semantics(RelationSemantics {
644            transitive: true,
645            reflexive: true,
646            ..RelationSemantics::default()
647        }),
648        relation(Ontology::ASSOCIATED_WITH, "associated with").with_semantics(RelationSemantics {
649            symmetric: true,
650            ..RelationSemantics::default()
651        }),
652        relation(Ontology::IMPLEMENTED_IN, "implemented in"),
653        relation(Ontology::PROVIDES, "provides"),
654    ]
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    fn concept(id: &str) -> OntologyConcept {
662        OntologyConcept::new(ConceptId::new(id).unwrap(), id).unwrap()
663    }
664
665    fn concept_id(id: &str) -> ConceptId {
666        ConceptId::new(id).unwrap()
667    }
668
669    fn axiom(subject: &str, relation: &str, object: &str) -> OntologyAxiom {
670        OntologyAxiom::new(
671            concept_id(subject),
672            Ontology::relation_id(relation).unwrap(),
673            concept_id(object),
674        )
675    }
676
677    #[test]
678    fn standard_relations_publish_explicit_semantics() {
679        let ontology = Ontology::new();
680        let is_a = ontology
681            .relation_type(&Ontology::relation_id(Ontology::IS_A).unwrap())
682            .unwrap();
683        assert!(is_a.semantics().transitive);
684        assert!(is_a.semantics().reflexive);
685        assert!(is_a.semantics().acyclic);
686
687        let associated = ontology
688            .relation_type(&Ontology::relation_id(Ontology::ASSOCIATED_WITH).unwrap())
689            .unwrap();
690        assert!(associated.semantics().symmetric);
691        assert!(!associated.semantics().transitive);
692        assert_eq!(ontology.stats().relation_types, 9);
693    }
694
695    #[test]
696    fn is_a_hierarchy_is_transitive_and_reflexive() {
697        let mut ontology = Ontology::new();
698        for id in ["laravel", "framework", "technology"] {
699            ontology.add_concept(concept(id)).unwrap();
700        }
701        ontology
702            .add_axiom(axiom("laravel", Ontology::IS_A, "framework"))
703            .unwrap();
704        ontology
705            .add_axiom(axiom("framework", Ontology::IS_A, "technology"))
706            .unwrap();
707
708        assert!(ontology
709            .is_a(&concept_id("laravel"), &concept_id("technology"))
710            .unwrap());
711        assert!(ontology
712            .is_a(&concept_id("laravel"), &concept_id("laravel"))
713            .unwrap());
714    }
715
716    #[test]
717    fn different_relation_meanings_do_not_collapse_into_reachability() {
718        let mut ontology = Ontology::new();
719        for id in ["laravel", "framework", "php", "language"] {
720            ontology.add_concept(concept(id)).unwrap();
721        }
722        ontology
723            .add_axiom(axiom("laravel", Ontology::IS_A, "framework"))
724            .unwrap();
725        ontology
726            .add_axiom(axiom("laravel", Ontology::IMPLEMENTED_IN, "php"))
727            .unwrap();
728        ontology
729            .add_axiom(axiom("php", Ontology::IS_A, "language"))
730            .unwrap();
731
732        assert!(ontology
733            .is_a(&concept_id("laravel"), &concept_id("framework"))
734            .unwrap());
735        assert!(!ontology
736            .is_a(&concept_id("laravel"), &concept_id("language"))
737            .unwrap());
738        assert!(ontology
739            .is_related(
740                &concept_id("laravel"),
741                &Ontology::relation_id(Ontology::IMPLEMENTED_IN).unwrap(),
742                &concept_id("php"),
743            )
744            .unwrap());
745    }
746
747    #[test]
748    fn symmetric_relation_is_queryable_in_both_directions() {
749        let mut ontology = Ontology::new();
750        ontology.add_concept(concept("laravel")).unwrap();
751        ontology.add_concept(concept("livewire")).unwrap();
752        ontology
753            .add_axiom(axiom("livewire", Ontology::ASSOCIATED_WITH, "laravel"))
754            .unwrap();
755        let relation = Ontology::relation_id(Ontology::ASSOCIATED_WITH).unwrap();
756
757        assert!(ontology
758            .is_related(&concept_id("laravel"), &relation, &concept_id("livewire"))
759            .unwrap());
760    }
761
762    #[test]
763    fn inverse_relation_semantics_are_evaluated_without_duplicate_axioms() {
764        let mut ontology = Ontology::new();
765        ontology.add_concept(concept("livewire")).unwrap();
766        ontology.add_concept(concept("laravel")).unwrap();
767        ontology
768            .add_axiom(axiom("livewire", Ontology::PART_OF, "laravel"))
769            .unwrap();
770
771        assert!(ontology
772            .is_related(
773                &concept_id("laravel"),
774                &Ontology::relation_id(Ontology::HAS_PART).unwrap(),
775                &concept_id("livewire"),
776            )
777            .unwrap());
778        assert_eq!(ontology.stats().axioms, 1);
779    }
780
781    #[test]
782    fn acyclic_hierarchy_rejects_cycles_without_mutation() {
783        let mut ontology = Ontology::new();
784        ontology.add_concept(concept("framework")).unwrap();
785        ontology.add_concept(concept("laravel")).unwrap();
786        ontology
787            .add_axiom(axiom("laravel", Ontology::IS_A, "framework"))
788            .unwrap();
789
790        let result = ontology.add_axiom(axiom("framework", Ontology::IS_A, "laravel"));
791        assert!(matches!(result, Err(OntologyError::CycleDetected { .. })));
792        assert_eq!(ontology.stats().axioms, 1);
793    }
794
795    #[test]
796    fn unknown_references_are_rejected() {
797        let mut ontology = Ontology::new();
798        ontology.add_concept(concept("laravel")).unwrap();
799        assert_eq!(
800            ontology.add_axiom(axiom("laravel", Ontology::IS_A, "framework")),
801            Err(OntologyError::UnknownConcept(concept_id("framework")))
802        );
803        assert!(matches!(
804            ontology.is_related(
805                &concept_id("laravel"),
806                &RelationTypeId::new("unknown").unwrap(),
807                &concept_id("laravel"),
808            ),
809            Err(OntologyError::UnknownRelationType(_))
810        ));
811    }
812
813    #[test]
814    fn definitions_are_idempotent_but_conflicts_are_rejected() {
815        let mut ontology = Ontology::new();
816        let framework = concept("framework");
817        assert_eq!(
818            ontology.add_concept(framework.clone()).unwrap(),
819            OntologyWrite::Inserted
820        );
821        assert_eq!(
822            ontology.add_concept(framework).unwrap(),
823            OntologyWrite::Unchanged
824        );
825        let conflicting = OntologyConcept::new(concept_id("framework"), "Different label").unwrap();
826        assert!(matches!(
827            ontology.add_concept(conflicting),
828            Err(OntologyError::IdentityConflict {
829                kind: OntologyRecordKind::Concept,
830                ..
831            })
832        ));
833    }
834
835    #[test]
836    fn wire_format_preserves_identifier_and_label_invariants() {
837        assert!(serde_json::from_str::<ConceptId>("\" \"").is_err());
838        let invalid = serde_json::json!({ "id": "framework", "label": " " });
839        assert!(serde_json::from_value::<OntologyConcept>(invalid).is_err());
840    }
841}