Files
AI/참고/ontocast-main/ontocast/onto/ontology.py
2026-05-12 19:40:31 +09:00

1282 lines
53 KiB
Python

import logging
import pathlib
import re
from collections import defaultdict
from datetime import datetime
from typing import Annotated, Union
from pydantic import BaseModel, ConfigDict, Field
from rdflib import DCTERMS, OWL, RDF, RDFS, XSD, Literal, URIRef
from ontocast.onto.constants import DEFAULT_DOMAIN, ONTOLOGY_NULL_IRI, PROV
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.onto.sparql_models import GraphUpdate, TripleOp
from ontocast.onto.util import derive_ontology_id
from ontocast.util import iri2namespace
logger = logging.getLogger(__name__)
# Semantic version pattern: MAJOR.MINOR.PATCH (e.g., 1.2.3)
SemanticVersion = Annotated[
str,
Field(
pattern=r"^\d+\.\d+\.\d+$",
description="Semantic version in MAJOR.MINOR.PATCH format (e.g., 1.2.3)",
),
]
class OntologyProperties(BaseModel):
"""Properties of an ontology.
Attributes:
ontology_id: Ontology identifier.
title: Ontology title.
description: A concise description of the ontology.
version: Version of the ontology.
iri: Ontology IRI (Internationalized Resource Identifier).
"""
ontology_id: str | None = Field(
default=None,
description="Ontology identifier, an human readable lower case abbreviation.",
)
title: str | None = Field(default=None, description="Ontology title.")
description: str | None = Field(
default=None,
description="A concise description (3-4 sentences) of the ontology "
"(domain, purpose, applicability, etc.)",
)
version: SemanticVersion | None = Field(
default=None,
description="Version of the ontology (use semantic versioning)",
)
iri: str = Field(
default=ONTOLOGY_NULL_IRI,
description="Ontology IRI (Internationalized Resource Identifier)",
)
initial_version: SemanticVersion | None = Field(
default=None,
description=(
"The initial version of the ontology when it was first loaded "
"in this session"
),
)
@property
def namespace(self):
"""Get the namespace for this ontology.
Returns:
str: The namespace string.
"""
return iri2namespace(self.iri, ontology=True)
class OntologyPropertiesWithLineage(OntologyProperties):
"""Properties of an ontology with versioning lineage information.
This class extends OntologyProperties with hash-based versioning support,
similar to git-style versioning. Each ontology has a hash of its graph
and optionally multiple parent hashes to support parallel branches and merges.
Attributes:
hash: Hash of the ontology graph (computed from canonicalized graph).
parent_hashes: List of hashes of parent ontologies. Supports multiple
parents for parallel branches and merges. Can be empty, indicating
this is a root ontology with no parents.
created_at: Timestamp when the ontology version was created (UTC).
This is set deterministically when a version is created, not by LLM.
"""
hash: str | None = Field(
default=None,
description="Hash of the ontology graph (SHA256 of canonicalized graph)",
)
parent_hashes: list[str] = Field(
default_factory=list,
description=(
"List of hashes of parent ontologies. Supports multiple parents "
"for parallel branches and merges. Can be empty, indicating "
"this is a root ontology with no parents."
),
)
created_at: datetime | None = Field(
default=None,
description="Timestamp when the ontology version was created (UTC). "
"Set deterministically when a version is created, not by LLM.",
)
@property
def versioned_iri(self) -> str:
"""Get the versioned URI for this ontology (for storage purposes).
This creates a versioned URI using hash-based fragments for git-style
versioning. Format: <base_iri>#<hash>. Falls back to semantic version
fragment (#v1.2.3) or base IRI if hash is not available.
This allows multiple versions of the same ontology to coexist in storage
(e.g., Fuseki named graphs). The semantic ontology IRI in the graph
remains unchanged; this is only used for storage organization.
Returns:
str: The versioned URI with hash fragment, or semantic version fragment,
or base IRI if neither is available.
Examples:
>>> ont = Ontology(iri="https://growgraph.dev/fcaont", hash="abc123...")
>>> ont.versioned_iri
'https://growgraph.dev/fcaont#abc123...'
>>> ont2 = Ontology(iri="http://example.org/ontology", version="1.0.0")
>>> ont2.versioned_iri
'http://example.org/ontology#v1.0.0'
"""
if self.hash:
# Use hash-based fragment for git-style versioning
return f"{self.iri}#{self.hash}"
elif self.version:
# Fall back to semantic version fragment for backward compatibility
return f"{self.iri}#v{self.version}"
return self.iri
class Ontology(OntologyPropertiesWithLineage):
"""A Pydantic model representing an ontology with its RDF graph and description.
Attributes:
graph: The RDF graph containing the ontology data.
current_domain: The domain used to construct the ontology IRI
if ontology_id is set.
"""
graph: RDFGraph = Field(
default_factory=RDFGraph,
description="RDF triples that define an ontology "
"in turtle format: use prefixes for namespaces, do NOT add comments.",
)
current_domain: str = Field(
default=DEFAULT_DOMAIN, description="Domain for ontology IRI construction."
)
model_config = ConfigDict(arbitrary_types_allowed=True)
def __init__(self, **kwargs):
# Pop current_domain if provided, else use DEFAULT_DOMAIN
current_domain = kwargs.pop("current_domain", DEFAULT_DOMAIN)
super().__init__(**kwargs)
self.current_domain = current_domain
# Check if this is explicitly a null ontology (only if both IRI is null AND no graph provided)
# Don't return early if graph is provided - graph might contain ontology information
is_explicitly_null = (
self.iri == ONTOLOGY_NULL_IRI
and self.ontology_id is None
and (not self.graph or len(self.graph) == 0)
)
if is_explicitly_null:
# This is explicitly a null ontology - don't derive ontology_id, don't compute hash, etc.
return
# Parse IRI fragment for hash-based or version-based identifiers
if self.iri and "#" in self.iri:
base_iri, fragment = self.iri.rsplit("#", 1)
# Check if fragment is a hash (long hex string) or version (v1.2.3)
if len(fragment) > 20 and all(c in "0123456789abcdef" for c in fragment):
# Looks like a hash - extract it
if self.hash is None:
self.hash = fragment
self.iri = base_iri # Remove fragment from IRI
logger.debug(f"Extracted hash from IRI fragment: {fragment}")
elif fragment.startswith("v") and re.match(r"^v\d+\.\d+\.\d+$", fragment):
# Semantic version fragment - extract version
version_str = fragment[1:] # Remove 'v' prefix
if self.version is None:
self.version = version_str
self.iri = base_iri # Remove fragment from IRI
logger.debug(f"Extracted version from IRI fragment: {version_str}")
# Try to sync from graph first (this is the primary source of truth)
graph_had_ontology = False
iri_from_graph = None
if self.graph:
# Try to extract from graph
self.sync_properties_from_graph()
# Check if graph provided valid ontology information
# IRI should be set and not null, ontology_id should be set
if self.iri and self.iri != ONTOLOGY_NULL_IRI:
iri_from_graph = self.iri # Remember that IRI came from graph
if self.ontology_id:
graph_had_ontology = True
else:
# IRI is set but ontology_id is missing - try to derive it
self.ontology_id = (
self._extract_ontology_id_from_prefixes()
or derive_ontology_id(self.iri)
)
if self.ontology_id:
graph_had_ontology = True
# Only apply fallback if graph did not provide a valid pair
if not graph_had_ontology:
# Try to extract ontology_id from prefixes if IRI is available
if self.iri and self.iri != ONTOLOGY_NULL_IRI and not self.ontology_id:
# Prefer derivation from IRI over prefix
derived_id = derive_ontology_id(self.iri)
prefix_id = self._extract_ontology_id_from_prefixes()
if derived_id:
self.ontology_id = derived_id
# If prefix exists but doesn't match ontology_id, rebind it
if prefix_id and prefix_id != derived_id:
self._rebind_prefix_to_ontology_id(prefix_id, derived_id)
elif prefix_id:
# Fallback to prefix if IRI derivation fails
self.ontology_id = prefix_id
# Fallback logic: construct IRI from ontology_id or vice versa
# BUT: Never override IRI that came from graph
if self.ontology_id and (not self.iri or self.iri == ONTOLOGY_NULL_IRI):
self.iri = f"{self.current_domain}/{self.ontology_id}"
elif self.ontology_id and self.iri and self.iri != ONTOLOGY_NULL_IRI:
# IRI is set - check if it came from graph
if iri_from_graph and self.iri == iri_from_graph:
# IRI came from graph - don't override, just log if pattern doesn't match
expected_iri = f"{self.current_domain}/{self.ontology_id}"
if (
not self.iri.endswith(f"/{self.ontology_id}")
and self.iri != expected_iri
):
logger.debug(
f"Ontology IRI '{self.iri}' from graph does not match expected pattern "
f"'{expected_iri}', but keeping IRI from graph (authoritative)"
)
else:
# IRI didn't come from graph - check if it matches expected pattern
expected_iri = f"{self.current_domain}/{self.ontology_id}"
if (
not self.iri.endswith(f"/{self.ontology_id}")
and self.iri != expected_iri
):
logger.warning(
f"Ontology IRI '{self.iri}' does not match expected "
f"'{expected_iri}', correcting IRI"
)
self.iri = expected_iri
elif not self.ontology_id and self.iri and self.iri != ONTOLOGY_NULL_IRI:
# Extract ontology_id: prefer IRI derivation, rebind prefix if needed
derived_id = derive_ontology_id(self.iri)
prefix_id = self._extract_ontology_id_from_prefixes()
if derived_id:
self.ontology_id = derived_id
# If prefix exists but doesn't match ontology_id, rebind it
if prefix_id and prefix_id != derived_id:
self._rebind_prefix_to_ontology_id(prefix_id, derived_id)
elif prefix_id:
# Fallback to prefix if IRI derivation fails
self.ontology_id = prefix_id
# Set default values for fields that are still None
if self.version is None:
self.version = "1.0.0"
self._compute_and_set_hash()
# Always ensure graph is up to date with properties (including hash/parent_hashes)
self.sync_properties_to_graph()
# Set initial_version if not already set
if self.initial_version is None and self.version:
# Normalize version to ensure semantic versioning
self.initial_version = self._normalize_version(self.version)
@property
def prefix(self) -> str | None:
"""Get the namespace prefix for this ontology.
Returns:
str | None: The namespace prefix if found, None otherwise.
"""
prefixes = [
prefix
for prefix, iri in self.graph.namespaces()
if iri == URIRef(self.namespace)
]
if len(prefixes) == 0:
return None
else:
return prefixes[0]
def is_null(self) -> bool:
"""Check if this ontology is the null ontology.
Returns:
bool: True if this is NULL_ONTOLOGY or has null characteristics.
"""
from ontocast.onto.null import NULL_ONTOLOGY
# Check identity first (fastest)
if self is NULL_ONTOLOGY:
return True
# Check characteristics
return self.iri == ONTOLOGY_NULL_IRI and self.ontology_id is None
def set_properties(self, **kwargs):
"""Set ontology properties from keyword arguments and sync to graph.
Only update properties if they are missing (None or empty).
Also enforces ontology_id/iri consistency as in __init__, but only
if graph does not provide a valid pair.
"""
for k, v in kwargs.items():
if hasattr(self, k):
current = getattr(self, k)
if not current and v:
setattr(self, k, v)
# Try to sync from graph first
graph_had_ontology = False
if self.graph:
self.sync_properties_from_graph()
if self.iri and not self.is_null() and self.ontology_id:
graph_had_ontology = True
if not graph_had_ontology:
if self.ontology_id and (not self.iri or self.is_null()):
self.iri = f"{self.current_domain}/{self.ontology_id}"
elif self.ontology_id and self.iri:
expected_iri = f"{self.current_domain}/{self.ontology_id}"
if not self.iri.endswith(f"/{self.ontology_id}"):
logger.warning(
f"Ontology IRI '{self.iri}' does not match expected "
f"'{expected_iri}'"
)
elif not self.ontology_id and self.iri and not self.is_null():
self.ontology_id = derive_ontology_id(self.iri)
self.sync_properties_to_graph()
def sync_properties_to_graph(self):
"""
Update the RDF graph with the Ontology's properties.
Only sync properties for the entity that is explicitly typed as owl:Ontology.
Only add property triples if they do not already exist in the graph.
Optimized to avoid multiple loops over triples.
"""
# Early return for NULL_ONTOLOGY - don't sync anything
if self.is_null():
return
if self.ontology_id is not None:
if not self.iri or self.is_null():
self.iri = f"{self.current_domain}/{self.ontology_id}"
elif self.iri:
expected_iri = f"{self.current_domain}/{self.ontology_id}"
# Only fix IRI if it doesn't match expected pattern AND it's not from an external source
# Don't override IRIs that came from the graph or were explicitly provided
if (
not self.iri.endswith(f"/{self.ontology_id}")
and self.iri != expected_iri
):
# Check if IRI looks like it came from an external source (not our default domain)
if self.current_domain not in self.iri:
# IRI is from external source (e.g., graph) - don't override
logger.debug(
f"Ontology IRI '{self.iri}' does not match expected pattern "
f"'{expected_iri}', but keeping IRI (likely from graph or external source)"
)
else:
# IRI is from our domain but doesn't match - fix it
logger.warning(
f"Ontology IRI '{self.iri}' does not match expected "
f"'{expected_iri}', fixing"
)
self.iri = expected_iri
elif self.iri and not self.is_null():
# Only derive ontology_id if this is not a null ontology
self.ontology_id = derive_ontology_id(self.iri)
onto_iri = URIRef(self.iri)
g = self.graph
onto_triple = [
subj
for subj, _, o in g.triples((None, RDF.type, None))
if o == OWL.Ontology
]
if not onto_triple:
if onto_iri is not None:
# iri set as a property, but not in ontology
g.add((onto_iri, RDF.type, OWL.Ontology))
else:
onto_iri_graph = onto_triple[0]
onto_iri = onto_iri_graph
# Collect all predicates for this subject in one pass
existing_preds = set(p for _, p, _ in g.triples((onto_iri, None, None)))
def add_if_missing(p, v):
if p not in existing_preds:
g.add((onto_iri, p, Literal(v)))
# Add label/title
if self.title:
add_if_missing(RDFS.label, self.title)
if self.ontology_id:
add_if_missing(DCTERMS.title, self.ontology_id)
# Add description
if self.description:
add_if_missing(DCTERMS.description, self.description)
add_if_missing(RDFS.comment, self.description)
# Add version (update if exists)
if self.version:
# Remove existing version triples to update them
for _, _, obj in g.triples((onto_iri, OWL.versionInfo, None)):
g.remove((onto_iri, OWL.versionInfo, obj))
# Add new version
g.add((onto_iri, OWL.versionInfo, Literal(self.version)))
# Add created_at if set (only if not already present in graph)
if self.created_at:
# Check if created_at already exists in graph - don't overwrite if present
existing_created = [
str(obj) for _, _, obj in g.triples((onto_iri, DCTERMS.created, None))
]
if not existing_created:
# Add new created_at with datetime type
g.add(
(
onto_iri,
DCTERMS.created,
Literal(self.created_at.isoformat(), datatype=XSD.dateTime),
)
)
# Add hash (only if not already present in graph)
# Use dcterms:identifier for hash (with "hash:" prefix to distinguish from other identifiers)
if self.hash:
# Check if hash already exists in graph
existing_hash = [
str(obj)
for _, _, obj in g.triples((onto_iri, DCTERMS.identifier, None))
if str(obj).startswith("hash:")
]
if not existing_hash:
g.add((onto_iri, DCTERMS.identifier, Literal(f"hash:{self.hash}")))
# Add parent_hashes (multiple parents supported)
# Use prov:wasDerivedFrom for each parent hash (standard PROV predicate)
if self.parent_hashes:
# Get existing parent hashes to avoid duplicates
existing_parent_uris = {
str(obj)
for _, _, obj in g.triples((onto_iri, PROV.wasDerivedFrom, None))
}
# Add each parent hash as a URIRef if not already present
for parent_hash in self.parent_hashes:
parent_hash_uri = URIRef(f"urn:hash:{parent_hash}")
if str(parent_hash_uri) not in existing_parent_uris:
g.add((onto_iri, PROV.wasDerivedFrom, parent_hash_uri))
def _resolve_ontology_subject(self) -> URIRef | None:
"""Resolve ontology subject used for metadata triples."""
onto_subjects = [
subj for subj, _, obj in self.graph.triples((None, RDF.type, OWL.Ontology))
]
if onto_subjects:
first_subject = onto_subjects[0]
if isinstance(first_subject, URIRef):
return first_subject
if self.iri and self.iri != ONTOLOGY_NULL_IRI:
return URIRef(self.iri)
return None
def _clear_lineage_metadata_triples(self) -> None:
"""Remove lineage metadata triples before writing a new version."""
onto_iri = self._resolve_ontology_subject()
if onto_iri is None:
return
for _, _, obj in list(self.graph.triples((onto_iri, DCTERMS.created, None))):
self.graph.remove((onto_iri, DCTERMS.created, obj))
for _, _, obj in list(
self.graph.triples((onto_iri, PROV.wasDerivedFrom, None))
):
self.graph.remove((onto_iri, PROV.wasDerivedFrom, obj))
for _, _, obj in list(self.graph.triples((onto_iri, DCTERMS.identifier, None))):
if isinstance(obj, Literal) and str(obj).startswith("hash:"):
self.graph.remove((onto_iri, DCTERMS.identifier, obj))
def derive_updated_version(self, updated_graph: RDFGraph) -> "Ontology":
"""Create a new ontology version from an updated graph snapshot."""
from copy import deepcopy
from datetime import datetime, timezone
updated_ontology = deepcopy(self)
updated_ontology.graph = updated_graph
updated_ontology.parent_hashes = [self.hash] if self.hash else []
updated_ontology.created_at = datetime.now(timezone.utc)
updated_ontology.hash = None
updated_ontology._clear_lineage_metadata_triples()
updated_ontology._compute_and_set_hash()
if not updated_ontology.hash and updated_ontology.parent_hashes:
updated_ontology.hash = updated_ontology.parent_hashes[0]
updated_ontology.sync_properties_to_graph()
return updated_ontology
def _compute_and_set_hash(self) -> None:
"""Compute the hash of the ontology graph and set it.
The hash is computed from the canonicalized graph using SHA256.
The hash is computed from the graph WITHOUT hash/parent_hash triples,
as these are metadata about the graph, not part of the graph content.
This method should only be called if hash is not already set.
"""
if self.graph and len(self.graph) > 0:
try:
# Find the ontology IRI from the graph if not set
onto_iri = None
if self.iri and not self.is_null():
onto_iri = URIRef(self.iri)
else:
# Try to find ontology IRI from graph
onto_triples = [
subj
for subj, _, o in self.graph.triples((None, RDF.type, None))
if o == OWL.Ontology
]
if onto_triples:
onto_iri = onto_triples[0]
# Create a temporary graph without hash/parent_hash triples for hashing
temp_graph = RDFGraph()
# Copy all triples except metadata triples - these are metadata, not content
# Metadata to exclude: hash, parent_hash, created_at, version, title, description
for s, p, o in self.graph:
# Skip metadata triples for the ontology IRI
if onto_iri and s == onto_iri:
if (
p == DCTERMS.identifier
and isinstance(o, Literal)
and str(o).startswith("hash:")
):
continue # Skip hash identifier
if p == PROV.wasDerivedFrom:
continue # Skip parent hash
if p == DCTERMS.created:
continue # Skip created_at
if p == OWL.versionInfo:
continue # Skip version
if p == RDFS.label:
continue # Skip title/label
if p == DCTERMS.title:
continue # Skip title
if p == DCTERMS.description:
continue # Skip description
if p == RDFS.comment:
continue # Skip description (comment)
temp_graph.add((s, p, o))
# Copy namespace bindings
for prefix, uri in self.graph.namespaces():
temp_graph.bind(prefix, uri)
# Use RDFGraph.hash() directly
self.hash = temp_graph.hash()
logger.debug(
f"Computed hash for ontology {self.ontology_id}: {self.hash}"
)
except Exception as e:
logger.warning(
f"Failed to compute hash for ontology {self.ontology_id}: {e}"
)
# Set a placeholder hash if computation fails
self.hash = None
def _normalize_version(self, version: str) -> str:
"""Normalize version string to semantic versioning format.
Handles various version formats and converts them to MAJOR.MINOR.PATCH:
- "3.5.1" -> "3.5.1" (already valid)
- "3.5" -> "3.5.0" (adds missing PATCH)
- "3" -> "3.0.0" (adds missing MINOR and PATCH)
- Invalid formats -> "1.0.0"
Args:
version: The version string to normalize
Returns:
A valid semantic version string (MAJOR.MINOR.PATCH)
"""
# Already valid semantic version
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version)
if match:
return version
# Try to parse as MAJOR.MINOR (missing PATCH)
match = re.match(r"^(\d+)\.(\d+)$", version)
if match:
major, minor = match.groups()
normalized = f"{major}.{minor}.0"
logger.info(
f"Version '{version}' missing PATCH component, normalized to '{normalized}'"
)
return normalized
# Try to parse as just MAJOR (missing MINOR and PATCH)
match = re.match(r"^(\d+)$", version)
if match:
major = match.group(1)
normalized = f"{major}.0.0"
logger.info(
f"Version '{version}' missing MINOR and PATCH components, normalized to '{normalized}'"
)
return normalized
# Invalid format, use default
logger.warning(
f"Version '{version}' does not match any recognized format, "
f"normalizing to '1.0.0'"
)
return "1.0.0"
def _analyze_version_increment_type(
self, updates: list[GraphUpdate]
) -> tuple[str, str]:
"""Analyze the updates to determine the appropriate version increment type.
Args:
updates: List of GraphUpdate objects that were applied to the ontology
Returns:
Tuple of (increment_type, reason) where increment_type is
'major', 'minor', or 'patch' and reason explains the decision
"""
if not updates:
return ("patch", "No updates to analyze")
# Count operations by type
total_deletes = 0
total_inserts = 0
# Track specific types of changes
class_changes = 0
property_changes = 0
instance_changes = 0
for update in updates:
for op in update.triple_operations:
if isinstance(op, TripleOp):
if op.type == "delete":
total_deletes += len(op.graph)
# Check if deleting core ontology constructs
for subject, predicate, object_ in op.graph:
predicate_str = str(predicate)
object_str = str(object_)
if "rdf:type" in predicate_str:
if any(
cls in object_str.lower()
for cls in ["class", "property", "ontology"]
):
if (
"owl:class" in object_str
or "rdfs:class" in object_str
):
class_changes += 1
elif "owl:ontology" in object_str:
class_changes += 1
else: # insert
total_inserts += len(op.graph)
# Check if adding core ontology constructs
for subject, predicate, object_ in op.graph:
predicate_str = str(predicate)
object_str = str(object_)
if "rdf:type" in predicate_str:
if (
"owl:class" in object_str
or "rdfs:class" in object_str
):
class_changes += 1
elif "owl:ontology" in object_str:
class_changes += 1
elif (
"owl:objectproperty" in object_str
or "owl:datatypeproperty" in object_str
or "rdf:property" in object_str
):
property_changes += 1
else:
instance_changes += 1
# Decision logic - conservative approach, favor PATCH
# Check for substantial breaking changes first (MAJOR)
if total_deletes > 5 and (class_changes > 2 or property_changes > 3):
reason = (
f"MAJOR: Deleted {total_deletes} triples including "
f"{class_changes} classes and {property_changes} properties "
"(significant breaking change)"
)
return ("major", reason)
# Any deletions trigger MINOR (even small ones indicate changes)
if total_deletes > 0:
reason = (
f"MINOR: Deleted {total_deletes} triples "
f"({class_changes} classes, {property_changes} properties removed)"
)
return ("minor", reason)
# Only increment MINOR for substantial new features (>=5 classes or properties)
if class_changes >= 5 or property_changes >= 5:
reason = (
f"MINOR: Added {total_inserts} triples including "
f"{class_changes} classes and {property_changes} properties "
"(substantial new features)"
)
return ("minor", reason)
# Default to PATCH for most additions
# This includes: instances, descriptions, small numbers of classes/properties
reason = f"PATCH: Added {total_inserts} triples"
if class_changes > 0 or property_changes > 0:
reason += f" ({class_changes} classes, {property_changes} properties)"
reason += " (updates to existing structures)"
return ("patch", reason)
def _increment_version(self, increment_type: str = "patch") -> None:
"""Increment the ontology version using semantic versioning.
Args:
increment_type: Type of increment - 'major', 'minor', or 'patch'
"""
# If version is None, set to default
if self.version is None:
self.version = "1.0.0"
return
# Normalize to ensure semantic versioning
normalized_version = self._normalize_version(self.version)
if normalized_version != self.version:
logger.warning(
f"Version '{self.version}' normalized to '{normalized_version}' "
"before incrementing"
)
self.version = normalized_version
# Parse and increment version string based on increment_type
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", self.version)
if match:
major, minor, patch = map(int, match.groups())
if increment_type == "major":
major += 1
minor = 0
patch = 0
logger.info(
f"Incrementing MAJOR version from {self.version} to {major}.{minor}.{patch}"
)
elif increment_type == "minor":
minor += 1
patch = 0
logger.info(
f"Incrementing MINOR version from {self.version} to {major}.{minor}.{patch}"
)
else: # patch
patch += 1
logger.info(
f"Incrementing PATCH version from {self.version} to {major}.{minor}.{patch}"
)
self.version = f"{major}.{minor}.{patch}"
else:
# Should never reach here after normalization, but handle gracefully
logger.error(f"Version '{self.version}' still invalid after normalization")
self.version = "1.0.1"
logger.info(f"Incremented ontology version to {self.version}")
def mark_as_updated(self, updates: list[GraphUpdate] | None = None) -> None:
"""Mark the ontology version and update semantic version.
Note: Ontologies are immutable - modifications create new versions.
This method only updates the semantic version number, not the creation timestamp.
The creation timestamp is set when a new version is created.
Analyzes the updates to determine appropriate version increment type.
Args:
updates: Optional list of GraphUpdate objects that were applied.
If provided, analyzes them to determine MAJOR/MINOR/PATCH increment.
"""
# Analyze updates to determine increment type
if updates:
increment_type, reason = self._analyze_version_increment_type(updates)
logger.info(f"Version increment analysis: {reason}")
self._increment_version(increment_type)
else:
# Default to patch increment if no updates provided
self._increment_version("patch")
logger.info(
f"Updated semantic version for ontology {self.ontology_id} to {self.version}"
)
def _extract_ontology_id_from_prefixes(self) -> str | None:
"""Extract ontology_id from namespace prefixes that match the ontology IRI.
Looks for prefixes where the namespace URI matches the ontology IRI or namespace.
For example, if IRI is 'https://growgraph.dev/fcaont' and there's a prefix
'fca' with namespace 'https://growgraph.dev/fcaont#', returns 'fca'.
Returns:
str | None: The prefix name if found, None otherwise.
"""
if not self.graph or not self.iri or self.iri == ONTOLOGY_NULL_IRI:
return None
# Try exact IRI match first
ontology_namespace = iri2namespace(self.iri, ontology=True)
for prefix, namespace_uri in self.graph.namespaces():
namespace_str = str(namespace_uri)
# Check if namespace matches ontology IRI or namespace
if namespace_str == self.iri or namespace_str == ontology_namespace:
if prefix and prefix not in [
"rdf",
"rdfs",
"owl",
"xsd",
"dc",
"dcterms",
"skos",
"foaf",
"schema",
"prov",
]:
logger.debug(f"Found prefix '{prefix}' matching IRI '{self.iri}'")
return prefix
return None
def _rebind_prefix_to_ontology_id(self, old_prefix: str, ontology_id: str) -> None:
"""Rebind a prefix to match the ontology_id.
If a prefix exists that matches the ontology IRI but has a different name
than the ontology_id, rebind it to use the ontology_id as the prefix name.
This ensures consistency between the prefix name and ontology_id.
Args:
old_prefix: The existing prefix name that needs to be rebound.
ontology_id: The ontology_id that should be used as the new prefix name.
"""
if not self.graph or not self.iri or self.iri == ONTOLOGY_NULL_IRI:
return
ontology_namespace = iri2namespace(self.iri, ontology=True)
# Find the namespace URI for the old prefix
old_namespace_uri = None
for prefix, namespace_uri in self.graph.namespaces():
if prefix == old_prefix:
old_namespace_uri = str(namespace_uri)
break
if old_namespace_uri and old_namespace_uri == ontology_namespace:
# Only rebind if the namespace matches
# Bind the new prefix with ontology_id (this will override if it exists)
from rdflib import Namespace
ns = Namespace(ontology_namespace)
self.graph.namespace_manager.bind(ontology_id, ns, override=True)
# If old prefix is different, we can optionally remove it
# But keep it for now to avoid breaking existing references in the graph
# The new prefix will be used going forward
logger.debug(
f"Rebound prefix: '{old_prefix}' -> '{ontology_id}' "
f"for namespace '{ontology_namespace}'"
)
def sync_properties_from_graph(self):
"""
Update Ontology properties from the RDF graph if present,
but only if missing, and only for entities explicitly typed as owl:Ontology.
Optimized to avoid multiple loops over triples.
"""
g = self.graph
if not g or len(g) == 0:
return
# Only proceed if this subject is explicitly typed as owl:Ontology
onto_triple = [
subj
for subj, _, o in g.triples((None, RDF.type, None))
if o == OWL.Ontology
]
if not onto_triple:
# No owl:Ontology found - try to extract IRI from prefixes as fallback
if not self.iri or self.iri == ONTOLOGY_NULL_IRI:
# Look for prefixes that might indicate the ontology IRI
for prefix, namespace_uri in g.namespaces():
namespace_str = str(namespace_uri).rstrip("#/")
# Skip standard prefixes
if prefix and prefix not in [
"rdf",
"rdfs",
"owl",
"xsd",
"dc",
"dcterms",
"skos",
"foaf",
"schema",
"prov",
]:
# Use this namespace as potential IRI
self.iri = namespace_str
self.ontology_id = prefix
logger.debug(
f"No owl:Ontology found, extracted IRI '{self.iri}' and "
f"ontology_id '{self.ontology_id}' from prefix '{prefix}'"
)
return
return
onto_iri = onto_triple[0]
iri_str = str(onto_iri)
# Strip hash fragment from IRI to ensure simplified representation
# Hash fragments are long hex strings (64+ chars) used for versioning
if "#" in iri_str:
base_iri, fragment = iri_str.rsplit("#", 1)
# Check if fragment is a hash (long hex string) or version (v1.2.3)
if len(fragment) > 20 and all(
c in "0123456789abcdef" for c in fragment.lower()
):
# Looks like a hash - use base IRI only
iri_str = base_iri
logger.debug(
f"Stripped hash fragment from IRI in graph: {fragment[:20]}..."
)
elif fragment.startswith("v") and re.match(r"^v\d+\.\d+\.\d+$", fragment):
# Semantic version fragment - use base IRI only
iri_str = base_iri
logger.debug(f"Stripped version fragment from IRI in graph: {fragment}")
# Set IRI from graph (this is authoritative)
if not self.iri or self.iri == ONTOLOGY_NULL_IRI:
self.iri = iri_str
elif self.iri != iri_str:
# Graph has different IRI - prefer graph IRI but log the difference
logger.debug(
f"Graph IRI '{iri_str}' differs from provided IRI '{self.iri}', "
f"using graph IRI"
)
self.iri = iri_str
# Extract ontology_id: prefer derivation from IRI over prefix
# If both exist, use IRI-derived ontology_id and rebind prefix to match
if not self.ontology_id:
# First try to derive from IRI (preferred)
derived_id = derive_ontology_id(self.iri)
prefix_id = self._extract_ontology_id_from_prefixes()
if derived_id:
self.ontology_id = derived_id
# If prefix exists but doesn't match ontology_id, rebind it
if prefix_id and prefix_id != derived_id:
self._rebind_prefix_to_ontology_id(prefix_id, derived_id)
elif prefix_id:
# Fallback to prefix if IRI derivation fails
self.ontology_id = prefix_id
# Collect all predicates and objects for this subject in one pass
pred_map = defaultdict(list)
for _, p, o in g.triples((onto_iri, None, None)):
pred_map[p].append(o)
# Title: try rdfs:label, dcterms:title
if self.title is None:
title = None
if RDFS.label in pred_map:
title = str(pred_map[RDFS.label][0])
elif DCTERMS.title in pred_map:
title = str(pred_map[DCTERMS.title][0])
if title:
self.title = title
# Description: try dcterms:description, rdfs:comment
if self.description is None:
description = None
if DCTERMS.description in pred_map:
description = str(pred_map[DCTERMS.description][0])
elif RDFS.comment in pred_map:
description = str(pred_map[RDFS.comment][0])
if description:
self.description = description
# Version
if self.version is None:
if OWL.versionInfo in pred_map:
version_str = str(pred_map[OWL.versionInfo][0])
self.version = self._normalize_version(version_str)
# Created at - only read if not already set (preserve existing value)
if not getattr(self, "created_at", None):
if DCTERMS.created in pred_map:
# Get the first created date
created_str = str(pred_map[DCTERMS.created][0])
# Try to parse as datetime
try:
self.created_at = datetime.fromisoformat(
created_str.replace("Z", "+00:00")
)
except (ValueError, AttributeError):
# If parsing fails, keep it as None
pass
# Short name: try dcterms:title if not already used for title
if not getattr(self, "ontology_id", None):
if DCTERMS.title in pred_map:
self.ontology_id = str(pred_map[DCTERMS.title][0])
# Hash: read from dcterms:identifier with "hash:" prefix if present
if self.hash is None:
if DCTERMS.identifier in pred_map:
for obj in pred_map[DCTERMS.identifier]:
obj_str = str(obj)
if obj_str.startswith("hash:"):
self.hash = obj_str[5:] # Remove "hash:" prefix
break
# Parent_hashes: read all from prov:wasDerivedFrom if present
if len(self.parent_hashes) == 0:
if PROV.wasDerivedFrom in pred_map:
for parent_uri_obj in pred_map[PROV.wasDerivedFrom]:
parent_uri = str(parent_uri_obj)
# Extract hash from URN format: urn:hash:<hash>
if parent_uri.startswith("urn:hash:"):
parent_hash = parent_uri[9:] # Remove "urn:hash:" prefix
self.parent_hashes.append(parent_hash)
def __iadd__(self, other: Union["Ontology", RDFGraph]) -> "Ontology":
"""In-place addition operator for Ontology instances.
Merges the RDF graphs and takes properties from the right-hand operand.
Args:
other: The ontology or graph to add to this one.
Returns:
Ontology: self after modification.
"""
if isinstance(other, Ontology):
self.graph += other.graph
self.title = other.title
self.ontology_id = other.ontology_id
self.description = other.description
self.iri = other.iri
self.version = other.version
self.created_at = other.created_at
self.initial_version = other.initial_version
self.hash = other.hash
self.parent_hashes = other.parent_hashes
else:
self.graph += other
return self
@classmethod
def from_file(cls, file_path: pathlib.Path, format: str = "turtle", **kwargs):
"""Create an Ontology instance by loading a graph from a file.
Args:
file_path: Path to the ontology file.
format: Format of the input file (default: "turtle").
**kwargs: Additional arguments to pass to the constructor.
Returns:
Ontology: A new Ontology instance.
"""
graph: RDFGraph = RDFGraph()
graph.parse(file_path, format=format)
return cls(graph=graph, **kwargs)
def describe(self) -> str:
"""Get a human-readable description of the ontology.
Returns:
str: A formatted description string.
"""
return (
f"Ontology id: {self.ontology_id}\n"
f"Description: {self.description}\n"
f"Ontology IRI: {self.iri}\n"
)
def to_lineage_node(self) -> dict:
"""Convert ontology to a lineage node representation.
Returns a dictionary suitable for constructing a meta-graph representing
the ontology lineage. This representation can be used to build the full
ontology lineage graph.
Returns:
dict: Lineage node with hash, parents, and metadata.
Example:
>>> ont = Ontology(iri="https://example.org/ont", hash="abc123", parent_hashes=["def456"])
>>> node = ont.to_lineage_node()
>>> node["hash"]
'abc123'
>>> node["parents"]
['def456']
"""
return {
"hash": self.hash,
"parents": self.parent_hashes,
"iri": self.iri,
"title": self.title,
"version": self.version,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
@staticmethod
def build_lineage_graph(ontologies: list["Ontology"]):
"""Build a NetworkX directed graph representing the lineage of all given ontologies.
Constructs a directed graph where nodes represent ontologies (by their hash)
and edges represent parent-child relationships. Each node includes metadata
as node attributes (iri, title, version, created_at, etc.).
Args:
ontologies: List of Ontology instances to include in the lineage graph.
Returns:
networkx.DiGraph: A directed graph representing the full ontology lineage.
Nodes are identified by hash strings, with edges from children to parents.
Each node has attributes: iri, title, ontology_id, version, created_at.
Example:
>>> import networkx as nx
>>> ont1 = Ontology(iri="https://example.org/ont1", hash="abc123")
>>> ont2 = Ontology(iri="https://example.org/ont2", hash="def456", parent_hashes=["abc123"])
>>> lineage = Ontology.build_lineage_graph([ont1, ont2])
>>> isinstance(lineage, nx.DiGraph)
True
>>> "def456" in lineage.nodes()
True
>>> "abc123" in lineage["def456"] # Check if edge exists
True
"""
import networkx as nx
lineage_graph = nx.DiGraph()
for ontology in ontologies:
if not ontology.hash:
logger.warning(
f"Skipping ontology {ontology.iri} in lineage graph: no hash"
)
continue
# Add node with metadata attributes
lineage_graph.add_node(
ontology.hash,
iri=ontology.iri,
title=ontology.title,
ontology_id=ontology.ontology_id,
version=ontology.version,
created_at=ontology.created_at.isoformat()
if ontology.created_at
else None,
)
# Add edges from this ontology to its parents
if ontology.parent_hashes:
for parent_hash in ontology.parent_hashes:
# Ensure parent node exists (even if not in the ontologies list)
if parent_hash not in lineage_graph:
lineage_graph.add_node(parent_hash)
lineage_graph.add_edge(ontology.hash, parent_hash)
return lineage_graph
def add_parent_hash(self, parent_hash: str) -> None:
"""Add a parent hash to the ontology's parent list.
Appends the given hash to parent_hashes if not already present,
and updates the RDF graph accordingly by adding a new prov:wasDerivedFrom triple.
Args:
parent_hash: The hash of the parent ontology to add.
Example:
>>> ont = Ontology(iri="https://example.org/ont", hash="abc123")
>>> ont.add_parent_hash("def456")
>>> "def456" in ont.parent_hashes
True
"""
if parent_hash not in self.parent_hashes:
self.parent_hashes.append(parent_hash)
# Update graph
if self.iri and not self.is_null():
onto_iri = URIRef(self.iri)
parent_hash_uri = URIRef(f"urn:hash:{parent_hash}")
self.graph.add((onto_iri, PROV.wasDerivedFrom, parent_hash_uri))
logger.debug(
f"Added parent hash {parent_hash} to ontology {self.ontology_id}"
)
def validate_lineage(self) -> list[str]:
"""Validate the ontology lineage for integrity issues.
Checks for cycles and ensures that self.hash is not in its own parent_hashes.
Returns a list of warning messages if any issues are found.
Returns:
list[str]: List of warning messages describing any lineage issues found.
Empty list if lineage is valid.
Example:
>>> ont = Ontology(iri="https://example.org/ont", hash="abc123", parent_hashes=["abc123"])
>>> warnings = ont.validate_lineage()
>>> len(warnings) > 0
True
"""
warnings = []
if not self.hash:
return warnings
# Check if hash is in its own parent_hashes
if self.parent_hashes and self.hash in self.parent_hashes:
warnings.append(
f"Ontology {self.ontology_id} (hash: {self.hash[:8]}...) "
"has itself as a parent, which may indicate a cycle"
)
# Check for cycles using a simple depth-first search
visited = set()
to_visit = [(self.hash, [self.hash])]
while to_visit:
current_hash, path = to_visit.pop()
if current_hash in visited:
continue
visited.add(current_hash)
# Find ontology with this hash in the graph
# This is a simplified check - in practice, you'd need access to all ontologies
# For now, we just check immediate parents
if self.parent_hashes:
for parent_hash in self.parent_hashes:
if parent_hash == current_hash and len(path) > 1:
warnings.append(
f"Potential cycle detected in lineage: "
f"{' -> '.join(path)} -> {parent_hash}"
)
elif parent_hash not in visited:
to_visit.append((parent_hash, path + [parent_hash]))
if warnings:
for warning in warnings:
logger.warning(warning)
return warnings