376 lines
12 KiB
Python
376 lines
12 KiB
Python
|
|
"""Graph diff generation and application system.
|
||
|
|
|
||
|
|
This module provides functionality for generating and applying diffs between
|
||
|
|
graph versions, enabling incremental updates and efficient context passing.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import logging
|
||
|
|
from datetime import datetime
|
||
|
|
from enum import StrEnum
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from pydantic import BaseModel, Field
|
||
|
|
|
||
|
|
from ontocast.onto.rdfgraph import RDFGraph
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
class DiffOperation(StrEnum):
|
||
|
|
"""Enumeration of diff operations."""
|
||
|
|
|
||
|
|
ADD = "add"
|
||
|
|
REMOVE = "remove"
|
||
|
|
MODIFY = "modify"
|
||
|
|
UNCHANGED = "unchanged"
|
||
|
|
|
||
|
|
|
||
|
|
class TripleDiff(BaseModel):
|
||
|
|
"""Represents a diff for a single triple."""
|
||
|
|
|
||
|
|
subject: str = Field(description="Subject of the triple")
|
||
|
|
predicate: str = Field(description="Predicate of the triple")
|
||
|
|
object: str = Field(description="Object of the triple")
|
||
|
|
operation: DiffOperation = Field(description="Operation performed on this triple")
|
||
|
|
old_value: str | None = Field(
|
||
|
|
default=None, description="Old value for modifications"
|
||
|
|
)
|
||
|
|
new_value: str | None = Field(
|
||
|
|
default=None, description="New value for modifications"
|
||
|
|
)
|
||
|
|
metadata: dict[str, Any] = Field(
|
||
|
|
default_factory=dict, description="Additional metadata"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class GraphDiff(BaseModel):
|
||
|
|
"""Represents differences between two graph versions."""
|
||
|
|
|
||
|
|
# Diff metadata
|
||
|
|
diff_id: str = Field(description="Unique identifier for this diff")
|
||
|
|
source_version_id: str = Field(description="ID of the source version")
|
||
|
|
target_version_id: str = Field(description="ID of the target version")
|
||
|
|
created_at: datetime = Field(
|
||
|
|
default_factory=datetime.now, description="When this diff was created"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Diff content
|
||
|
|
triple_diffs: list[TripleDiff] = Field(
|
||
|
|
default_factory=list, description="List of triple differences"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Summary statistics
|
||
|
|
added_triples: int = Field(default=0, description="Number of added triples")
|
||
|
|
removed_triples: int = Field(default=0, description="Number of removed triples")
|
||
|
|
modified_triples: int = Field(default=0, description="Number of modified triples")
|
||
|
|
unchanged_triples: int = Field(default=0, description="Number of unchanged triples")
|
||
|
|
|
||
|
|
# Context information
|
||
|
|
context_metadata: dict[str, Any] = Field(
|
||
|
|
default_factory=dict, description="Context metadata for this diff"
|
||
|
|
)
|
||
|
|
|
||
|
|
def get_summary(self) -> str:
|
||
|
|
"""Get a summary of this diff.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
str: Human-readable summary of the diff
|
||
|
|
"""
|
||
|
|
return f"""
|
||
|
|
Graph Diff Summary:
|
||
|
|
- Diff ID: {self.diff_id}
|
||
|
|
- Source: {self.source_version_id} → Target: {self.target_version_id}
|
||
|
|
- Created: {self.created_at.isoformat()}
|
||
|
|
- Added: {self.added_triples} triples
|
||
|
|
- Removed: {self.removed_triples} triples
|
||
|
|
- Modified: {self.modified_triples} triples
|
||
|
|
- Unchanged: {self.unchanged_triples} triples
|
||
|
|
- Total changes: {self.added_triples + self.removed_triples + self.modified_triples}
|
||
|
|
"""
|
||
|
|
|
||
|
|
def get_sparql_operations(self) -> list[str]:
|
||
|
|
"""Get SPARQL operations for applying this diff.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
list[str]: List of SPARQL queries to apply the diff
|
||
|
|
"""
|
||
|
|
operations = []
|
||
|
|
|
||
|
|
for triple_diff in self.triple_diffs:
|
||
|
|
if triple_diff.operation == DiffOperation.ADD:
|
||
|
|
# Generate INSERT operation
|
||
|
|
sparql = f"INSERT DATA {{ {triple_diff.subject} {triple_diff.predicate} {triple_diff.object} . }}"
|
||
|
|
operations.append(sparql)
|
||
|
|
|
||
|
|
elif triple_diff.operation == DiffOperation.REMOVE:
|
||
|
|
# Generate DELETE operation
|
||
|
|
sparql = f"DELETE DATA {{ {triple_diff.subject} {triple_diff.predicate} {triple_diff.object} . }}"
|
||
|
|
operations.append(sparql)
|
||
|
|
|
||
|
|
elif triple_diff.operation == DiffOperation.MODIFY:
|
||
|
|
# Generate DELETE + INSERT for modifications
|
||
|
|
if triple_diff.old_value:
|
||
|
|
delete_sparql = f"DELETE DATA {{ {triple_diff.subject} {triple_diff.predicate} {triple_diff.old_value} . }}"
|
||
|
|
operations.append(delete_sparql)
|
||
|
|
if triple_diff.new_value:
|
||
|
|
insert_sparql = f"INSERT DATA {{ {triple_diff.subject} {triple_diff.predicate} {triple_diff.new_value} . }}"
|
||
|
|
operations.append(insert_sparql)
|
||
|
|
|
||
|
|
return operations
|
||
|
|
|
||
|
|
def is_empty(self) -> bool:
|
||
|
|
"""Check if this diff is empty (no changes).
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
bool: True if no changes, False otherwise
|
||
|
|
"""
|
||
|
|
return (
|
||
|
|
self.added_triples == 0
|
||
|
|
and self.removed_triples == 0
|
||
|
|
and self.modified_triples == 0
|
||
|
|
)
|
||
|
|
|
||
|
|
def get_changed_subjects(self) -> set[str]:
|
||
|
|
"""Get all subjects that have changes.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
set[str]: Set of subject URIs that have changes
|
||
|
|
"""
|
||
|
|
return {
|
||
|
|
triple_diff.subject
|
||
|
|
for triple_diff in self.triple_diffs
|
||
|
|
if triple_diff.operation != DiffOperation.UNCHANGED
|
||
|
|
}
|
||
|
|
|
||
|
|
def get_changed_predicates(self) -> set[str]:
|
||
|
|
"""Get all predicates that have changes.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
set[str]: Set of predicate URIs that have changes
|
||
|
|
"""
|
||
|
|
return {
|
||
|
|
triple_diff.predicate
|
||
|
|
for triple_diff in self.triple_diffs
|
||
|
|
if triple_diff.operation != DiffOperation.UNCHANGED
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class DiffTool:
|
||
|
|
"""Tool for generating and applying graph diffs."""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
"""Initialize the diff tool."""
|
||
|
|
self.logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
def generate_diff(
|
||
|
|
self,
|
||
|
|
source_graph: RDFGraph,
|
||
|
|
target_graph: RDFGraph,
|
||
|
|
source_version_id: str,
|
||
|
|
target_version_id: str,
|
||
|
|
context_metadata: dict[str, Any] | None = None,
|
||
|
|
) -> GraphDiff:
|
||
|
|
"""Generate a diff between two graphs.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
source_graph: The source graph to compare from
|
||
|
|
target_graph: The target graph to compare to
|
||
|
|
source_version_id: ID of the source version
|
||
|
|
target_version_id: ID of the target version
|
||
|
|
context_metadata: Optional context metadata
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
GraphDiff: The generated diff
|
||
|
|
"""
|
||
|
|
self.logger.info(
|
||
|
|
f"Generating diff from {source_version_id} to {target_version_id}"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Get triples from both graphs
|
||
|
|
source_triples = self._get_triples_set(source_graph)
|
||
|
|
target_triples = self._get_triples_set(target_graph)
|
||
|
|
|
||
|
|
# Find differences
|
||
|
|
triple_diffs = []
|
||
|
|
added_triples = 0
|
||
|
|
removed_triples = 0
|
||
|
|
modified_triples = 0
|
||
|
|
unchanged_triples = 0
|
||
|
|
|
||
|
|
# Find added triples
|
||
|
|
for triple in target_triples - source_triples:
|
||
|
|
triple_diff = TripleDiff(
|
||
|
|
subject=triple[0],
|
||
|
|
predicate=triple[1],
|
||
|
|
object=triple[2],
|
||
|
|
operation=DiffOperation.ADD,
|
||
|
|
)
|
||
|
|
triple_diffs.append(triple_diff)
|
||
|
|
added_triples += 1
|
||
|
|
|
||
|
|
# Find removed triples
|
||
|
|
for triple in source_triples - target_triples:
|
||
|
|
triple_diff = TripleDiff(
|
||
|
|
subject=triple[0],
|
||
|
|
predicate=triple[1],
|
||
|
|
object=triple[2],
|
||
|
|
operation=DiffOperation.REMOVE,
|
||
|
|
)
|
||
|
|
triple_diffs.append(triple_diff)
|
||
|
|
removed_triples += 1
|
||
|
|
|
||
|
|
# Find unchanged triples
|
||
|
|
for triple in source_triples & target_triples:
|
||
|
|
triple_diff = TripleDiff(
|
||
|
|
subject=triple[0],
|
||
|
|
predicate=triple[1],
|
||
|
|
object=triple[2],
|
||
|
|
operation=DiffOperation.UNCHANGED,
|
||
|
|
)
|
||
|
|
triple_diffs.append(triple_diff)
|
||
|
|
unchanged_triples += 1
|
||
|
|
|
||
|
|
# Create diff
|
||
|
|
diff = GraphDiff(
|
||
|
|
diff_id=f"diff_{source_version_id}_{target_version_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
||
|
|
source_version_id=source_version_id,
|
||
|
|
target_version_id=target_version_id,
|
||
|
|
triple_diffs=triple_diffs,
|
||
|
|
added_triples=added_triples,
|
||
|
|
removed_triples=removed_triples,
|
||
|
|
modified_triples=modified_triples,
|
||
|
|
unchanged_triples=unchanged_triples,
|
||
|
|
context_metadata=context_metadata or {},
|
||
|
|
)
|
||
|
|
|
||
|
|
self.logger.info(
|
||
|
|
f"Generated diff with {added_triples} additions, {removed_triples} removals, {modified_triples} modifications"
|
||
|
|
)
|
||
|
|
return diff
|
||
|
|
|
||
|
|
def apply_diff(self, graph: RDFGraph, diff: GraphDiff) -> RDFGraph:
|
||
|
|
"""Apply a diff to a graph.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
graph: The graph to apply the diff to
|
||
|
|
diff: The diff to apply
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
RDFGraph: The updated graph
|
||
|
|
"""
|
||
|
|
self.logger.info(f"Applying diff {diff.diff_id} to graph")
|
||
|
|
|
||
|
|
# Create a copy of the graph
|
||
|
|
updated_graph = RDFGraph()
|
||
|
|
updated_graph += graph
|
||
|
|
|
||
|
|
# Apply each triple diff
|
||
|
|
for triple_diff in diff.triple_diffs:
|
||
|
|
if triple_diff.operation == DiffOperation.ADD:
|
||
|
|
# Add the triple
|
||
|
|
updated_graph.add_triple(
|
||
|
|
triple_diff.subject,
|
||
|
|
triple_diff.predicate,
|
||
|
|
triple_diff.object,
|
||
|
|
)
|
||
|
|
|
||
|
|
elif triple_diff.operation == DiffOperation.REMOVE:
|
||
|
|
# Remove the triple
|
||
|
|
updated_graph.remove_triple(
|
||
|
|
triple_diff.subject,
|
||
|
|
triple_diff.predicate,
|
||
|
|
triple_diff.object,
|
||
|
|
)
|
||
|
|
|
||
|
|
elif triple_diff.operation == DiffOperation.MODIFY:
|
||
|
|
# Remove old, add new
|
||
|
|
if triple_diff.old_value:
|
||
|
|
updated_graph.remove_triple(
|
||
|
|
triple_diff.subject,
|
||
|
|
triple_diff.predicate,
|
||
|
|
triple_diff.old_value,
|
||
|
|
)
|
||
|
|
if triple_diff.new_value:
|
||
|
|
updated_graph.add_triple(
|
||
|
|
triple_diff.subject,
|
||
|
|
triple_diff.predicate,
|
||
|
|
triple_diff.new_value,
|
||
|
|
)
|
||
|
|
|
||
|
|
self.logger.info(
|
||
|
|
f"Applied diff to graph, new triple count: {len(updated_graph)}"
|
||
|
|
)
|
||
|
|
return updated_graph
|
||
|
|
|
||
|
|
def _get_triples_set(self, graph: RDFGraph) -> set[tuple[str, str, str]]:
|
||
|
|
"""Get a set of triples from a graph.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
graph: The graph to extract triples from
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
set[tuple[str, str, str]]: Set of (subject, predicate, object) tuples
|
||
|
|
"""
|
||
|
|
triples = set()
|
||
|
|
for triple in graph:
|
||
|
|
triples.add((str(triple[0]), str(triple[1]), str(triple[2])))
|
||
|
|
return triples
|
||
|
|
|
||
|
|
def get_diff_summary(self, diff: GraphDiff) -> str:
|
||
|
|
"""Get a human-readable summary of a diff.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
diff: The diff to summarize
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
str: Human-readable summary
|
||
|
|
"""
|
||
|
|
return f"""
|
||
|
|
Diff Summary:
|
||
|
|
- ID: {diff.diff_id}
|
||
|
|
- Source: {diff.source_version_id} → Target: {diff.target_version_id}
|
||
|
|
- Created: {diff.created_at.isoformat()}
|
||
|
|
- Changes: {diff.added_triples} added, {diff.removed_triples} removed, {diff.modified_triples} modified
|
||
|
|
- Total triples: {len(diff.triple_diffs)}
|
||
|
|
- Changed subjects: {len(diff.get_changed_subjects())}
|
||
|
|
- Changed predicates: {len(diff.get_changed_predicates())}
|
||
|
|
"""
|
||
|
|
|
||
|
|
def merge_diffs(self, diffs: list[GraphDiff]) -> GraphDiff:
|
||
|
|
"""Merge multiple diffs into a single diff.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
diffs: List of diffs to merge
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
GraphDiff: Merged diff
|
||
|
|
"""
|
||
|
|
if not diffs:
|
||
|
|
raise ValueError("Cannot merge empty list of diffs")
|
||
|
|
|
||
|
|
if len(diffs) == 1:
|
||
|
|
return diffs[0]
|
||
|
|
|
||
|
|
# Start with the first diff
|
||
|
|
merged_diff = diffs[0]
|
||
|
|
|
||
|
|
# Merge each subsequent diff
|
||
|
|
for diff in diffs[1:]:
|
||
|
|
# Combine triple diffs
|
||
|
|
merged_diff.triple_diffs.extend(diff.triple_diffs)
|
||
|
|
|
||
|
|
# Update statistics
|
||
|
|
merged_diff.added_triples += diff.added_triples
|
||
|
|
merged_diff.removed_triples += diff.removed_triples
|
||
|
|
merged_diff.modified_triples += diff.modified_triples
|
||
|
|
merged_diff.unchanged_triples += diff.unchanged_triples
|
||
|
|
|
||
|
|
# Update target version
|
||
|
|
merged_diff.target_version_id = diff.target_version_id
|
||
|
|
|
||
|
|
# Update diff ID
|
||
|
|
merged_diff.diff_id = f"merged_{merged_diff.source_version_id}_{merged_diff.target_version_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||
|
|
|
||
|
|
self.logger.info(f"Merged {len(diffs)} diffs into single diff")
|
||
|
|
return merged_diff
|