1use serde::{Deserialize, Deserializer, Serialize};
10use std::collections::{BTreeMap, BTreeSet, VecDeque};
11use std::fmt;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum OntologyError {
17 EmptyValue {
19 field: &'static str,
21 },
22 IdentityConflict {
24 kind: OntologyRecordKind,
26 id: String,
28 },
29 UnknownConcept(ConceptId),
31 UnknownRelationType(RelationTypeId),
33 CycleDetected {
35 relation: RelationTypeId,
37 subject: ConceptId,
39 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
90#[serde(transparent)]
91pub struct ConceptId(String);
92
93impl ConceptId {
94 pub fn new(value: impl Into<String>) -> Result<Self, OntologyError> {
96 Ok(Self(non_empty(value, "concept id")?))
97 }
98
99 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
123#[serde(transparent)]
124pub struct RelationTypeId(String);
125
126impl RelationTypeId {
127 pub fn new(value: impl Into<String>) -> Result<Self, OntologyError> {
129 Ok(Self(non_empty(value, "relation type id")?))
130 }
131
132 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#[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 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 pub fn id(&self) -> &ConceptId {
173 &self.id
174 }
175
176 pub fn label(&self) -> &str {
178 &self.label
179 }
180}
181
182#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
184pub struct RelationSemantics {
185 pub transitive: bool,
187 pub symmetric: bool,
189 pub reflexive: bool,
191 pub acyclic: bool,
193}
194
195#[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 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 pub fn with_semantics(mut self, semantics: RelationSemantics) -> Self {
218 self.semantics = semantics;
219 self
220 }
221
222 pub fn with_inverse(mut self, inverse: RelationTypeId) -> Self {
224 self.inverse = Some(inverse);
225 self
226 }
227
228 pub fn id(&self) -> &RelationTypeId {
230 &self.id
231 }
232
233 pub fn label(&self) -> &str {
235 &self.label
236 }
237
238 pub fn semantics(&self) -> RelationSemantics {
240 self.semantics
241 }
242
243 pub fn inverse(&self) -> Option<&RelationTypeId> {
245 self.inverse.as_ref()
246 }
247}
248
249#[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 pub fn new(subject: ConceptId, relation: RelationTypeId, object: ConceptId) -> Self {
260 Self {
261 subject,
262 relation,
263 object,
264 }
265 }
266
267 pub fn subject(&self) -> &ConceptId {
269 &self.subject
270 }
271
272 pub fn relation(&self) -> &RelationTypeId {
274 &self.relation
275 }
276
277 pub fn object(&self) -> &ConceptId {
279 &self.object
280 }
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285#[non_exhaustive]
286pub enum OntologyWrite {
287 Inserted,
289 Unchanged,
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295#[non_exhaustive]
296pub enum OntologyRecordKind {
297 Concept,
299 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
314#[non_exhaustive]
315pub struct OntologyStats {
316 pub concepts: usize,
318 pub relation_types: usize,
320 pub axioms: usize,
322}
323
324#[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 pub const IS_A: &'static str = "is_a";
367 pub const PART_OF: &'static str = "part_of";
369 pub const HAS_PART: &'static str = "has_part";
371 pub const DEPENDS_ON: &'static str = "depends_on";
373 pub const REQUIRED_BY: &'static str = "required_by";
375 pub const IMPLIES: &'static str = "implies";
377 pub const ASSOCIATED_WITH: &'static str = "associated_with";
379 pub const IMPLEMENTED_IN: &'static str = "implemented_in";
381 pub const PROVIDES: &'static str = "provides";
383
384 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 pub fn relation_id(value: impl Into<String>) -> Result<RelationTypeId, OntologyError> {
401 RelationTypeId::new(value)
402 }
403
404 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 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 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 pub fn concept(&self, id: &ConceptId) -> Option<&OntologyConcept> {
473 self.concepts.get(id)
474 }
475
476 pub fn relation_type(&self, id: &RelationTypeId) -> Option<&OntologyRelationType> {
478 self.relation_types.get(id)
479 }
480
481 pub fn concepts(&self) -> Vec<OntologyConcept> {
483 self.concepts.values().cloned().collect()
484 }
485
486 pub fn relation_types(&self) -> Vec<OntologyRelationType> {
488 self.relation_types.values().cloned().collect()
489 }
490
491 pub fn axioms(&self) -> Vec<OntologyAxiom> {
493 self.axioms.iter().cloned().collect()
494 }
495
496 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 ¤t == object {
524 return Ok(true);
525 }
526 if !visited.insert(current.clone()) {
527 continue;
528 }
529 queue.extend(
530 self.neighbors(¤t, relation_type)
531 .into_iter()
532 .filter(|neighbor| !visited.contains(neighbor)),
533 );
534 }
535 Ok(false)
536 }
537
538 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 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 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}