ontologos-core 0.2.0

In-memory OWL ontology data model — interned IRIs, typed axioms, and JSON v2 snapshots
Documentation
use std::path::Path;

use crate::axiom::{Axiom, AxiomId};
use crate::entity::{EntityId, EntityKind, EntityRecord, EntityRegistry};
use crate::error::{Error, Result};
use crate::graph::{AxiomIndex, AxiomStore};
use crate::iri::{validate_iri, InternPool, IriId};
use crate::parse_meta::ParseMeta;

/// In-memory ontology with interned IRIs, typed entities, and indexed axioms.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ontology {
    pub(crate) iris: InternPool,
    pub(crate) entities: EntityRegistry,
    pub(crate) axioms: AxiomStore,
    pub(crate) index: AxiomIndex,
    #[doc(hidden)]
    pub parse_meta: Option<ParseMeta>,
}

impl Default for Ontology {
    fn default() -> Self {
        Self::new()
    }
}

impl Ontology {
    /// Create an empty ontology.
    #[must_use]
    pub fn new() -> Self {
        Self {
            iris: InternPool::new(),
            entities: EntityRegistry::new(),
            axioms: AxiomStore::new(),
            index: AxiomIndex::new(),
            parse_meta: None,
        }
    }

    /// Create a builder for programmatic ontology construction.
    #[must_use]
    pub fn builder() -> OntologyBuilder {
        OntologyBuilder::new()
    }

    /// Load an ontology from a file path.
    ///
    /// Use [`ontologos_parser::load_ontology`] for OWL/RDF file loading.
    pub fn from_file(_path: impl AsRef<Path>) -> Result<Self> {
        Err(Error::ParseNotAvailable)
    }

    /// Parse metadata from the last file load (not present for JSON/builder ontologies).
    #[must_use]
    pub fn parse_meta(&self) -> Option<&ParseMeta> {
        self.parse_meta.as_ref()
    }

    /// Attach parse metadata (used by `ontologos-parser`).
    pub fn set_parse_meta(&mut self, meta: ParseMeta) {
        self.parse_meta = Some(meta);
    }

    /// Number of registered entities.
    #[must_use]
    pub fn entity_count(&self) -> usize {
        self.entities.len()
    }

    /// Number of stored axioms.
    #[must_use]
    pub fn axiom_count(&self) -> usize {
        self.axioms.len()
    }

    /// Number of unique interned IRIs.
    #[must_use]
    pub fn iri_count(&self) -> usize {
        self.iris.len()
    }

    /// Access the IRI intern pool.
    #[must_use]
    pub fn iris(&self) -> &InternPool {
        &self.iris
    }

    /// Access the entity registry.
    #[must_use]
    pub fn entities(&self) -> &EntityRegistry {
        &self.entities
    }

    /// Access the axiom store.
    #[must_use]
    pub fn axioms(&self) -> &AxiomStore {
        &self.axioms
    }

    /// Access axiom indexes.
    #[must_use]
    pub fn index(&self) -> &AxiomIndex {
        &self.index
    }

    /// Resolve an interned IRI to its string value.
    pub fn resolve_iri(&self, id: IriId) -> Result<&str> {
        self.iris.resolve(id)
    }

    /// Look up an entity by IRI string, registering it if absent.
    pub fn entity_id(&mut self, iri: &str, kind: EntityKind) -> Result<EntityId> {
        let iri_id = self.iris.intern(iri)?;
        let iri_str = self.iris.resolve(iri_id)?;
        self.entities.get_or_register(iri_id, iri_str, kind)
    }

    /// Look up an entity id by IRI string, validating the IRI format.
    ///
    /// Returns `Err([Error::InvalidIri](crate::Error::InvalidIri))` for malformed IRIs,
    /// `Ok(None)` if the IRI is valid but not registered, or `Ok(Some(id))` on success.
    pub fn try_lookup_entity(&self, iri: &str) -> Result<Option<EntityId>> {
        validate_iri(iri)?;
        Ok(self
            .iris
            .get(iri)
            .and_then(|iri_id| self.entities.entity_by_iri(iri_id)))
    }

    /// Look up an entity id by IRI string without registering.
    ///
    /// Returns `None` for invalid IRIs or unknown entities.
    #[must_use]
    pub fn lookup_entity(&self, iri: &str) -> Option<EntityId> {
        self.try_lookup_entity(iri).ok().flatten()
    }

    /// Get an entity record by id.
    pub fn entity(&self, id: EntityId) -> Result<&EntityRecord> {
        self.entities.entity(id)
    }

    /// Get an axiom by id.
    pub fn axiom(&self, id: AxiomId) -> Result<&Axiom> {
        self.axioms.get(id)
    }

    /// Direct declared superclasses of a class.
    #[must_use]
    pub fn direct_superclasses(&self, class: EntityId) -> &[EntityId] {
        self.index.direct_superclasses(class)
    }

    /// Direct declared subclasses of a class.
    #[must_use]
    pub fn direct_subclasses(&self, class: EntityId) -> &[EntityId] {
        self.index.direct_subclasses(class)
    }

    /// Direct declared super-properties of a property.
    #[must_use]
    pub fn direct_superproperties(&self, property: EntityId) -> &[EntityId] {
        self.index.direct_superproperties(property)
    }

    /// Direct declared sub-properties of a property.
    #[must_use]
    pub fn direct_subproperties(&self, property: EntityId) -> &[EntityId] {
        self.index.direct_subproperties(property)
    }

    /// Declared equivalent classes for a class.
    #[must_use]
    pub fn equivalents_of(&self, class: EntityId) -> Option<&std::collections::HashSet<EntityId>> {
        self.index.equivalents_of(class)
    }

    /// Declared disjoint classes for a class.
    #[must_use]
    pub fn disjoint_with(&self, class: EntityId) -> Option<&std::collections::HashSet<EntityId>> {
        self.index.disjoint_with(class)
    }

    /// Declared inverse object property, if any.
    #[must_use]
    pub fn inverse_of(&self, property: EntityId) -> Option<EntityId> {
        self.index.inverse_of(property)
    }

    /// Existential restrictions declared for a subclass (`property`, `filler` pairs).
    #[must_use]
    pub fn existentials_of(&self, subclass: EntityId) -> &[(EntityId, EntityId)] {
        self.index.existentials_of(subclass)
    }

    /// Add a validated axiom, updating indexes.
    pub fn add_axiom(&mut self, axiom: Axiom) -> Result<AxiomId> {
        self.validate_inverse_pair(&axiom)?;
        let id = self.axioms.push(axiom, &self.entities)?;
        let stored = self.axioms.get(id)?;
        self.index.insert(id, stored);
        Ok(id)
    }

    /// Intern an IRI without registering an entity.
    pub fn intern_iri(&mut self, iri: &str) -> Result<IriId> {
        self.iris.intern(iri)
    }

    fn validate_inverse_pair(&self, axiom: &Axiom) -> Result<()> {
        let Axiom::InverseObjectProperties { left, right } = axiom else {
            return Ok(());
        };
        if let Some(existing) = self.index.inverse_of(*left) {
            if existing != *right {
                return Err(Error::InvalidAxiom(format!(
                    "property {left:?} already has inverse {existing:?}, cannot add inverse {right:?}"
                )));
            }
        }
        if let Some(existing) = self.index.inverse_of(*right) {
            if existing != *left {
                return Err(Error::InvalidAxiom(format!(
                    "property {right:?} already has inverse {existing:?}, cannot add inverse {left:?}"
                )));
            }
        }
        Ok(())
    }
}

/// Fluent builder for constructing ontologies in memory.
#[derive(Debug, Default)]
pub struct OntologyBuilder {
    ontology: Ontology,
}

impl OntologyBuilder {
    /// Create a new builder with an empty ontology.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Register a class entity.
    pub fn class(mut self, iri: &str) -> Result<Self> {
        self.ontology.entity_id(iri, EntityKind::Class)?;
        Ok(self)
    }

    /// Register an individual entity.
    pub fn individual(mut self, iri: &str) -> Result<Self> {
        self.ontology.entity_id(iri, EntityKind::Individual)?;
        Ok(self)
    }

    /// Register an object property entity.
    pub fn object_property(mut self, iri: &str) -> Result<Self> {
        self.ontology.entity_id(iri, EntityKind::ObjectProperty)?;
        Ok(self)
    }

    /// Add a `SubClassOf` axiom.
    pub fn subclass_of(mut self, subclass: &str, superclass: &str) -> Result<Self> {
        let sub = self.ontology.entity_id(subclass, EntityKind::Class)?;
        let sup = self.ontology.entity_id(superclass, EntityKind::Class)?;
        self.ontology.add_axiom(Axiom::SubClassOf {
            subclass: sub,
            superclass: sup,
        })?;
        Ok(self)
    }

    /// Add a `SubObjectPropertyOf` axiom.
    pub fn subproperty_of(mut self, sub: &str, sup: &str) -> Result<Self> {
        let sub_id = self.ontology.entity_id(sub, EntityKind::ObjectProperty)?;
        let sup_id = self.ontology.entity_id(sup, EntityKind::ObjectProperty)?;
        self.ontology.add_axiom(Axiom::SubObjectPropertyOf {
            sub_property: sub_id,
            super_property: sup_id,
        })?;
        Ok(self)
    }

    /// Build the ontology.
    pub fn build(self) -> Result<Ontology> {
        Ok(self.ontology)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builder_constructs_taxonomy() {
        let ontology = Ontology::builder()
            .class("http://example.org/A")
            .expect("class A")
            .class("http://example.org/B")
            .expect("class B")
            .subclass_of("http://example.org/A", "http://example.org/B")
            .expect("subclass")
            .build()
            .expect("build");

        let a = ontology.lookup_entity("http://example.org/A").expect("A");
        let b = ontology.lookup_entity("http://example.org/B").expect("B");
        assert_eq!(ontology.direct_superclasses(a), &[b]);
        assert_eq!(ontology.direct_subclasses(b), &[a]);
    }

    #[test]
    fn from_file_returns_parse_not_available() {
        let err = Ontology::from_file("any.owl").expect_err("should fail");
        assert_eq!(err, Error::ParseNotAvailable);
    }

    #[test]
    fn try_lookup_entity_rejects_invalid_iri() {
        let ontology = Ontology::new();
        let err = ontology
            .try_lookup_entity("relative/path")
            .expect_err("invalid");
        assert!(matches!(err, Error::InvalidIri(_)));
    }
}