ontocore-diff 0.14.0

Semantic ontology diff for OntoCore
Documentation
use crate::model::{
    AnnotationChange, AxiomChange, BreakingChange, BreakingReason, DiffResult, EntityChange,
    EntityChangeKind, ImportChange,
};
use ontocore_catalog::{IndexBuilder, OntologyCatalog};
use ontocore_core::{
    Annotation, Axiom, Entity, Import, AXIOM_KIND_DOMAIN, AXIOM_KIND_RANGE, AXIOM_KIND_SUB_CLASS_OF,
};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum DiffError {
    #[error("catalog error: {0}")]
    Catalog(#[from] ontocore_catalog::CatalogError),

    #[error("{0}")]
    Message(String),
}

pub type Result<T> = std::result::Result<T, DiffError>;

pub fn diff_directories(left: &Path, right: &Path) -> Result<DiffResult> {
    let left_cat = IndexBuilder::new().workspace(left).build()?;
    let right_cat = IndexBuilder::new().workspace(right).build()?;
    Ok(diff_catalogs(&left_cat, &right_cat))
}

pub fn diff_catalogs(base: &OntologyCatalog, head: &OntologyCatalog) -> DiffResult {
    let mut result = DiffResult::default();
    diff_entities(base, head, &mut result);
    diff_annotations(base, head, &mut result);
    detect_renames(&mut result);
    diff_axioms(base, head, &mut result);
    diff_imports(base, head, &mut result);
    detect_breaking(&mut result);
    result
}

fn diff_entities(base: &OntologyCatalog, head: &OntologyCatalog, result: &mut DiffResult) {
    let base_map: BTreeMap<&str, &Entity> =
        base.data().entities.iter().map(|e| (e.iri.as_str(), e)).collect();
    let head_map: BTreeMap<&str, &Entity> =
        head.data().entities.iter().map(|e| (e.iri.as_str(), e)).collect();

    for (iri, entity) in &head_map {
        if !base_map.contains_key(iri) {
            result.entity_changes.push(EntityChange {
                kind: EntityChangeKind::Added,
                iri: iri.to_string(),
                previous_iri: None,
                labels: entity.labels.clone(),
            });
        }
    }

    for (iri, entity) in &base_map {
        if !head_map.contains_key(iri) {
            result.entity_changes.push(EntityChange {
                kind: EntityChangeKind::Removed,
                iri: iri.to_string(),
                previous_iri: None,
                labels: entity.labels.clone(),
            });
        }
    }

    for (iri, head_entity) in &head_map {
        if let Some(base_entity) = base_map.get(iri) {
            if head_entity.deprecated && !base_entity.deprecated {
                result.entity_changes.push(EntityChange {
                    kind: EntityChangeKind::Deprecated,
                    iri: iri.to_string(),
                    previous_iri: None,
                    labels: head_entity.labels.clone(),
                });
            }
        }
    }
}

fn detect_renames(result: &mut DiffResult) {
    let added: Vec<EntityChange> = result
        .entity_changes
        .iter()
        .filter(|c| c.kind == EntityChangeKind::Added)
        .cloned()
        .collect();
    let removed: Vec<EntityChange> = result
        .entity_changes
        .iter()
        .filter(|c| c.kind == EntityChangeKind::Removed)
        .cloned()
        .collect();
    let mut drop_removed = BTreeSet::new();
    let mut drop_added = BTreeSet::new();
    for r in &removed {
        for a in &added {
            if drop_added.contains(&a.iri) {
                continue;
            }
            let label_match = !r.labels.is_empty() && r.labels == a.labels;
            let sameas = result.annotation_changes.iter().any(|ann| {
                (ann.predicate.contains("sameAs") || ann.predicate.ends_with("#sameAs"))
                    && ((ann.subject == r.iri && ann.object == a.iri)
                        || (ann.subject == a.iri && ann.object == r.iri))
            });
            if sameas || label_match {
                result.entity_changes.push(EntityChange {
                    kind: EntityChangeKind::Renamed,
                    iri: a.iri.clone(),
                    previous_iri: Some(r.iri.clone()),
                    labels: a.labels.clone(),
                });
                drop_removed.insert(r.iri.clone());
                drop_added.insert(a.iri.clone());
                break;
            }
        }
    }
    result.entity_changes.retain(|c| {
        !((c.kind == EntityChangeKind::Removed && drop_removed.contains(&c.iri))
            || (c.kind == EntityChangeKind::Added && drop_added.contains(&c.iri)))
    });
}

#[derive(Eq, PartialEq, Ord, PartialOrd, Clone)]
struct AnnotationKey(String, String, String);

fn annotation_key(a: &Annotation) -> AnnotationKey {
    AnnotationKey(a.subject.clone(), a.predicate.clone(), a.object.clone())
}

fn diff_annotations(base: &OntologyCatalog, head: &OntologyCatalog, result: &mut DiffResult) {
    let base_set: BTreeSet<AnnotationKey> =
        base.data().annotations.iter().map(annotation_key).collect();
    let head_set: BTreeSet<AnnotationKey> =
        head.data().annotations.iter().map(annotation_key).collect();

    for ann in &head.data().annotations {
        if !base_set.contains(&annotation_key(ann)) {
            result.annotation_changes.push(AnnotationChange {
                change: "added".to_string(),
                subject: ann.subject.clone(),
                predicate: ann.predicate.clone(),
                object: ann.object.clone(),
            });
        }
    }
    for ann in &base.data().annotations {
        if !head_set.contains(&annotation_key(ann)) {
            result.annotation_changes.push(AnnotationChange {
                change: "removed".to_string(),
                subject: ann.subject.clone(),
                predicate: ann.predicate.clone(),
                object: ann.object.clone(),
            });
        }
    }
}

#[derive(Eq, PartialEq, Ord, PartialOrd, Clone)]
struct AxiomKey(String, String, String, String);

fn axiom_key(a: &Axiom) -> AxiomKey {
    AxiomKey(a.axiom_kind.clone(), a.subject.clone(), a.predicate.clone(), a.object.clone())
}

fn diff_axioms(base: &OntologyCatalog, head: &OntologyCatalog, result: &mut DiffResult) {
    let base_set: BTreeSet<AxiomKey> = base.data().axioms.iter().map(axiom_key).collect();
    let head_set: BTreeSet<AxiomKey> = head.data().axioms.iter().map(axiom_key).collect();

    for ax in &head.data().axioms {
        if !base_set.contains(&axiom_key(ax)) {
            result.axiom_changes.push(AxiomChange {
                change: "added".to_string(),
                subject: ax.subject.clone(),
                predicate: ax.predicate.clone(),
                object: ax.object.clone(),
                axiom_kind: ax.axiom_kind.clone(),
            });
        }
    }
    for ax in &base.data().axioms {
        if !head_set.contains(&axiom_key(ax)) {
            result.axiom_changes.push(AxiomChange {
                change: "removed".to_string(),
                subject: ax.subject.clone(),
                predicate: ax.predicate.clone(),
                object: ax.object.clone(),
                axiom_kind: ax.axiom_kind.clone(),
            });
            if ax.axiom_kind == AXIOM_KIND_SUB_CLASS_OF {
                result.breaking_changes.push(BreakingChange {
                    reason: BreakingReason::RemovedSuperclass,
                    message: format!(
                        "removed subclass axiom: {} subClassOf {}",
                        ax.subject, ax.object
                    ),
                    entity_iri: Some(ax.subject.clone()),
                });
            }
            if ax.axiom_kind == AXIOM_KIND_DOMAIN || ax.axiom_kind == AXIOM_KIND_RANGE {
                result.breaking_changes.push(BreakingChange {
                    reason: BreakingReason::DomainRangeChange,
                    message: format!("removed {} axiom on {}", ax.axiom_kind, ax.subject),
                    entity_iri: Some(ax.subject.clone()),
                });
            }
        }
    }
}

#[derive(Eq, PartialEq, Ord, PartialOrd, Clone)]
struct ImportKey(String, String);

fn import_key(i: &Import) -> ImportKey {
    ImportKey(i.ontology_id.clone(), i.import_iri.clone())
}

fn diff_imports(base: &OntologyCatalog, head: &OntologyCatalog, result: &mut DiffResult) {
    let base_set: BTreeSet<ImportKey> = base.data().imports.iter().map(import_key).collect();
    let head_set: BTreeSet<ImportKey> = head.data().imports.iter().map(import_key).collect();

    for imp in &head.data().imports {
        if !base_set.contains(&import_key(imp)) {
            result.import_changes.push(ImportChange {
                change: "added".to_string(),
                ontology_id: imp.ontology_id.clone(),
                import_iri: imp.import_iri.clone(),
            });
        }
    }
    for imp in &base.data().imports {
        if !head_set.contains(&import_key(imp)) {
            result.import_changes.push(ImportChange {
                change: "removed".to_string(),
                ontology_id: imp.ontology_id.clone(),
                import_iri: imp.import_iri.clone(),
            });
            result.breaking_changes.push(BreakingChange {
                reason: BreakingReason::RemovedImport,
                message: format!("removed import: {}", imp.import_iri),
                entity_iri: None,
            });
        }
    }
}

fn detect_breaking(result: &mut DiffResult) {
    let mut seen = BTreeSet::new();
    for change in &result.entity_changes {
        match change.kind {
            EntityChangeKind::Removed => {
                let key = format!("removed:{}", change.iri);
                if seen.insert(key) {
                    result.breaking_changes.push(BreakingChange {
                        reason: BreakingReason::RemovedEntity,
                        message: format!("removed entity: {}", change.iri),
                        entity_iri: Some(change.iri.clone()),
                    });
                }
            }
            EntityChangeKind::Renamed => {
                let key = format!("renamed:{}", change.iri);
                if seen.insert(key) {
                    result.breaking_changes.push(BreakingChange {
                        reason: BreakingReason::RenamedIri,
                        message: format!(
                            "renamed entity: {} -> {}",
                            change.previous_iri.as_deref().unwrap_or("?"),
                            change.iri
                        ),
                        entity_iri: Some(change.iri.clone()),
                    });
                }
            }
            _ => {}
        }
    }
}

/// Merge unsatisfiability sets into inference + breaking sections.
pub fn apply_unsat_diff(result: &mut DiffResult, base_unsat: &[String], head_unsat: &[String]) {
    let base: BTreeSet<&str> = base_unsat.iter().map(String::as_str).collect();
    let head: BTreeSet<&str> = head_unsat.iter().map(String::as_str).collect();
    for iri in head.difference(&base) {
        result.inference_changes.push(crate::model::InferenceChange {
            class_iri: (*iri).to_string(),
            change: "became_unsatisfiable".to_string(),
            detail: "class is unsatisfiable in head but not base".to_string(),
        });
        result.breaking_changes.push(BreakingChange {
            reason: BreakingReason::UnsatisfiableClass,
            message: format!("class became unsatisfiable: {iri}"),
            entity_iri: Some((*iri).to_string()),
        });
    }
    for iri in base.difference(&head) {
        result.inference_changes.push(crate::model::InferenceChange {
            class_iri: (*iri).to_string(),
            change: "became_satisfiable".to_string(),
            detail: "class is satisfiable in head but was unsatisfiable in base".to_string(),
        });
    }
}

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

    fn fixtures() -> PathBuf {
        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures")
    }

    #[test]
    fn diff_same_directory_is_empty() {
        let path = fixtures();
        let cat = IndexBuilder::new().workspace(&path).build().expect("index");
        let diff = diff_catalogs(&cat, &cat);
        assert!(diff.is_empty());
    }
}