"""Graph rewriting for entity disambiguation. This module handles the robust application of entity mappings to RDF graphs, replacing all occurrences of entities according to the mapping. Provenance is tracked using `RDF 1.2 reification `_ together with the `PROV-O `_ vocabulary. For every asserted fact triple a **reifier** blank node is created:: _:r rdf:reifies <<( s p o )>> . _:r prov:wasDerivedFrom . When the same triple originates from multiple chunks the reifier accumulates several ``prov:wasDerivedFrom`` arcs. Chunk metadata (``index``, ``hid``) is recorded as separate triples on the chunk URI. The merged graph is backed by the *oxigraph* store so that RDF 1.2 triple-term syntax (``<<( s p o )>>``) is serialised correctly via ``pyoxigraph``. """ import logging from collections import defaultdict import pyoxigraph as ox from oxrdflib._converter import to_ox from rdflib import Literal, Node, URIRef from rdflib.namespace import OWL, RDF, XSD from ontocast.onto.constants import PROV, RDF_REIFIES, SCHEMA from ontocast.onto.content_unit import ContentUnit from ontocast.onto.rdfgraph import RDFGraph logger = logging.getLogger(__name__) # Local alias for readability _PROV = PROV _SCHEMA = SCHEMA class GraphRewriter: """Rewrites RDF graphs by applying entity mappings. This class handles the robust replacement of entity URIs in RDF graphs according to a mapping, while preserving graph structure and metadata. """ def __init__( self, add_sameas_links: bool = True, blocked_sameas_namespaces: tuple[str, ...] = (), ): """Initialize the graph rewriter. Args: add_sameas_links: Whether to add owl:sameAs links for merged entities blocked_sameas_namespaces: Namespace prefixes that should never appear as subject or object in emitted owl:sameAs links. """ self.add_sameas_links = add_sameas_links self.blocked_sameas_namespaces = blocked_sameas_namespaces @staticmethod def _in_namespace(entity: URIRef, namespace: str) -> bool: entity_str = str(entity) if entity_str.startswith(namespace): return True slash_variant = namespace.rstrip("/") + "/" hash_variant = namespace.rstrip("#") + "#" return entity_str.startswith(slash_variant) or entity_str.startswith( hash_variant ) def should_emit_sameas(self, original: URIRef, canonical: URIRef) -> bool: """Return whether a sameAs link is valid for emission.""" if original == canonical: return False for namespace in self.blocked_sameas_namespaces: if self._in_namespace(original, namespace) or self._in_namespace( canonical, namespace ): return False return True def _emit_sameas_links( self, target_graph: RDFGraph, merged_entities: dict[URIRef, set[URIRef]], ) -> None: if not self.add_sameas_links: return for canonical, originals in merged_entities.items(): for original in originals: if self.should_emit_sameas(original, canonical): target_graph.add((canonical, OWL.sameAs, original)) def apply_mapping_to_triple( self, subject: Node, predicate: Node, obj: Node, mapping: dict[URIRef, URIRef], ) -> tuple[Node, Node, Node]: """Apply entity mapping to a single triple. Args: subject: Triple subject predicate: Triple predicate obj: Triple object mapping: Entity mapping Returns: Mapped triple (subject, predicate, object) """ # Map subject if needed new_subject = ( mapping.get(subject, subject) if isinstance(subject, URIRef) else subject ) # Map predicate if needed new_predicate = ( mapping.get(predicate, predicate) if isinstance(predicate, URIRef) else predicate ) # Map object if needed new_obj = mapping.get(obj, obj) if isinstance(obj, URIRef) else obj return new_subject, new_predicate, new_obj def rewrite_graph(self, graph: RDFGraph, mapping: dict[URIRef, URIRef]) -> RDFGraph: """Rewrite a graph by applying entity mapping. Args: graph: Original RDF graph mapping: Entity mapping (e -> e') Returns: New RDF graph with entities replaced according to mapping """ rewritten = RDFGraph() # Copy namespace bindings for prefix, namespace in graph.namespaces(): rewritten.bind(prefix, namespace) # Track which entities were merged for owl:sameAs links merged_entities: dict[URIRef, set[URIRef]] = defaultdict(set) for original, mapped in mapping.items(): if original != mapped: merged_entities[mapped].add(original) # Rewrite all triples processed_triples = set() for s, p, o in graph: # Apply mapping new_s, new_p, new_o = self.apply_mapping_to_triple(s, p, o, mapping) # Create triple signature to avoid duplicates triple_sig = (new_s, new_p, new_o) # Skip if we've already added this triple if triple_sig in processed_triples: continue # Add rewritten triple rewritten.add(triple_sig) processed_triples.add(triple_sig) # Add owl:sameAs links for merged entities self._emit_sameas_links(rewritten, merged_entities) logger.info( f"Rewrote graph: {len(graph)} -> {len(rewritten)} triples " f"({len(merged_entities)} entities merged)" ) return rewritten @staticmethod def _merge_sameas_links( merged_entities: dict[URIRef, set[URIRef]], extra_sameas_links: dict[URIRef, set[URIRef]] | None, ) -> dict[URIRef, set[URIRef]]: """Merge mapping-derived sameAs links with explicitly provided aliases.""" if not extra_sameas_links: return merged_entities for canonical, originals in extra_sameas_links.items(): merged_entities[canonical].update( original for original in originals if original != canonical ) return merged_entities def merge_graphs( self, graphs: list[RDFGraph], mapping: dict[URIRef, URIRef], base_namespace: str, extra_sameas_links: dict[URIRef, set[URIRef]] | None = None, suppress_sameas_origins: set[URIRef] | None = None, ) -> RDFGraph: """Merge multiple graphs into one, applying entity mapping. Args: graphs: List of RDF graphs to merge. mapping: Entity mapping (e -> e'). base_namespace: Base namespace for the merged graph (bound as ``facts:``). Returns: Single merged and rewritten graph. """ merged = RDFGraph() # Bind base namespace merged.bind("facts", base_namespace) # Collect all namespaces from all graphs all_namespaces = {} for graph in graphs: for prefix, namespace in graph.namespaces(): if prefix not in all_namespaces: all_namespaces[prefix] = namespace elif all_namespaces[prefix] != namespace: # Handle prefix conflicts new_prefix = f"{prefix}_{len(all_namespaces)}" all_namespaces[new_prefix] = namespace # Bind all namespaces for prefix, namespace in all_namespaces.items(): merged.bind(prefix, namespace) # Track processed triples to avoid duplicates processed_triples = set() # Track merged entities for owl:sameAs merged_entities: dict[URIRef, set[URIRef]] = defaultdict(set) suppressed_origins = suppress_sameas_origins or set() for original, mapped in mapping.items(): if original != mapped: if original in suppressed_origins: continue merged_entities[mapped].add(original) # Merge all graphs for graph in graphs: for s, p, o in graph: # Apply mapping new_s, new_p, new_o = self.apply_mapping_to_triple(s, p, o, mapping) triple_sig = (new_s, new_p, new_o) if triple_sig not in processed_triples: merged.add(triple_sig) processed_triples.add(triple_sig) merged_entities = self._merge_sameas_links(merged_entities, extra_sameas_links) # Add owl:sameAs links self._emit_sameas_links(merged, merged_entities) total_original_triples = sum(len(g) for g in graphs) logger.info( f"Merged {len(graphs)} graphs: " f"{total_original_triples} -> {len(merged)} triples " f"({len(merged_entities)} entities merged)" ) return merged # ------------------------------------------------------------------ # provenance helpers # ------------------------------------------------------------------ @staticmethod def _to_ox_term( node: Node, ) -> ox.NamedNode | ox.BlankNode | ox.Literal: """Convert an rdflib term to a pyoxigraph term via oxrdflib. The ``oxrdflib._converter.to_ox`` function has a broad return type, but for RDF *terms* (``URIRef``, ``Literal``, ``BNode``) it always produces the corresponding pyoxigraph type. """ result = to_ox(node) assert isinstance(result, (ox.NamedNode, ox.BlankNode, ox.Literal)) return result def _add_unit_metadata( self, graph: RDFGraph, unit: ContentUnit, ) -> URIRef: """Add source-unit metadata triples and return the source unit URI. Emitted triples:: a prov:Entity, schema:Text ; schema:position ; schema:identifier ; prov:generatedAtTime . """ unit_uri = URIRef(unit.iri_absolute) graph.add((unit_uri, RDF.type, _PROV.Entity)) graph.add((unit_uri, RDF.type, _SCHEMA.text)) graph.add( ( unit_uri, _PROV.generatedAtTime, Literal(f"{unit.generated_at_iso}", datatype=XSD.dateTime), ) ) graph.add( ( unit_uri, _SCHEMA.position, Literal(unit.index, datatype=XSD.integer), ) ) graph.add((unit_uri, _SCHEMA.identifier, Literal(unit.hid))) return unit_uri def _add_reified_provenance( self, graph: RDFGraph, s: Node, p: Node, o: Node, chunk_uri: URIRef, reifier: ox.BlankNode | None = None, ) -> ox.BlankNode: """Attach provenance to a triple using RDF 1.2 reification. Creates (or reuses) a reifier blank node and emits:: _:r rdf:reifies <<( s p o )>> . _:r prov:wasDerivedFrom . The ``rdf:reifies`` quad is only added when a *new* reifier is created. The ``prov:wasDerivedFrom`` quad is always added so that a shared triple accumulates one arc per source chunk. Args: graph: Oxigraph-backed :class:`RDFGraph`. s: Triple subject (rdflib term). p: Triple predicate (rdflib term). o: Triple object (rdflib term). chunk_uri: URI of the source :class:`ContentUnit`. reifier: Existing reifier to reuse. When *None* a fresh blank node is created. Returns: The reifier blank node (for later reuse). """ # Access the underlying pyoxigraph Store and graph context so # that triples added here are visible through the rdflib API. ox_store: ox.Store = graph.store._inner # type: ignore[attr-defined] graph_ctx_raw = to_ox(graph.identifier) assert isinstance(graph_ctx_raw, (ox.NamedNode, ox.BlankNode, ox.DefaultGraph)) graph_ctx: ox.NamedNode | ox.BlankNode | ox.DefaultGraph = graph_ctx_raw # Convert rdflib terms → pyoxigraph terms s_ox = self._to_ox_term(s) p_ox = self._to_ox_term(p) o_ox = self._to_ox_term(o) # Narrow types for ox.Triple (subject: NamedNode|BlankNode|Triple, # predicate: NamedNode, object: any ox term). assert isinstance(s_ox, (ox.NamedNode, ox.BlankNode)) assert isinstance(p_ox, ox.NamedNode) # RDF 1.2 triple term triple_term = ox.Triple(s_ox, p_ox, o_ox) if reifier is None: reifier = ox.BlankNode() # rdf:reifies is emitted only once per reifier ox_store.add( ox.Quad( reifier, ox.NamedNode(str(RDF_REIFIES)), triple_term, graph_ctx, ) ) # prov:wasDerivedFrom — one arc per source chunk ox_store.add( ox.Quad( reifier, ox.NamedNode(str(_PROV.wasDerivedFrom)), ox.NamedNode(str(chunk_uri)), graph_ctx, ) ) return reifier def merge_graphs_with_provenance( self, units: list[ContentUnit], mapping: dict[URIRef, URIRef], extra_sameas_links: dict[URIRef, set[URIRef]] | None = None, suppress_sameas_origins: set[URIRef] | None = None, ) -> RDFGraph: """Merge multiple chunk graphs with per-triple provenance tracking. This method extends :meth:`merge_graphs` by: 1. Recording **chunk metadata** (``index``, ``hid``) as separate triples using ``prov:Entity`` / ``schema:position`` / ``schema:identifier``. 2. Creating an **RDF 1.2 reifier** node for every asserted fact triple using ``rdf:reifies`` with a triple term ``<<( s p o )>>``, and linking it back to its source chunk via ``prov:wasDerivedFrom``. If the same triple is produced by several chunks the reifier accumulates multiple ``prov:wasDerivedFrom`` arcs. The merged graph is backed by the *oxigraph* store so that RDF 1.2 triple-term serialisation is available natively. Args: units: Content units whose graphs are to be merged. mapping: Entity mapping ``e → e'``. Returns: Merged RDF graph with RDF 1.2 provenance annotations. """ merged = RDFGraph(store="oxigraph") # Bind well-known namespaces merged.bind("prov", str(_PROV)) merged.bind("schema", str(_SCHEMA)) # Collect all unique doc_iri namespaces and bind them doc_iris: set[str] = set() for unit in units: if unit.doc_iri: doc_iris.add(unit.doc_iri) for idx, doc_iri in enumerate(sorted(doc_iris)): prefix = f"doc{idx}" if len(doc_iris) > 1 else "doc" merged.bind(prefix, doc_iri.rstrip("/") + "/") # Collect namespaces from all source graphs all_namespaces: dict[str, str] = {} for unit in units: if unit.graph is None: continue for prefix, namespace in unit.graph.namespaces(): if prefix not in all_namespaces and namespace != unit.iri: all_namespaces[prefix] = namespace for prefix, namespace in all_namespaces.items(): merged.bind(prefix, namespace) # Track processed fact triples processed_triples: set[tuple[Node, Node, Node]] = set() # Track reifier blank nodes keyed by triple signature so that a # shared triple accumulates multiple prov:wasDerivedFrom arcs on # the *same* reifier. reifier_map: dict[tuple[Node, Node, Node], ox.BlankNode] = {} # Merged-entity tracking for owl:sameAs merged_entities: dict[URIRef, set[URIRef]] = defaultdict(set) suppressed_origins = suppress_sameas_origins or set() for original, mapped in mapping.items(): if original != mapped: if original in suppressed_origins: continue merged_entities[mapped].add(original) for unit in units: if unit.graph is None: continue # 1. Chunk metadata chunk_uri = self._add_unit_metadata(merged, unit) # 2. Merge triples with provenance for s, p, o in unit.graph: new_s, new_p, new_o = self.apply_mapping_to_triple( s, p, o, mapping, ) triple_sig = (new_s, new_p, new_o) # Assert fact (deduplicated) if triple_sig not in processed_triples: merged.add(triple_sig) processed_triples.add(triple_sig) # Attach RDF 1.2 reified provenance. # Reuse the existing reifier when the triple was already # seen from a previous chunk so that prov:wasDerivedFrom # arcs accumulate on the same blank node. existing_reifier = reifier_map.get(triple_sig) reifier = self._add_reified_provenance( merged, new_s, new_p, new_o, chunk_uri, reifier=existing_reifier, ) if triple_sig not in reifier_map: reifier_map[triple_sig] = reifier merged_entities = self._merge_sameas_links(merged_entities, extra_sameas_links) # owl:sameAs links self._emit_sameas_links(merged, merged_entities) total_original = sum(len(u.graph) for u in units if u.graph is not None) logger.info( f"Merged {len(units)} unit graphs with provenance: " f"{total_original} -> {len(merged)} triples " f"({len(merged_entities)} entities merged)" ) return merged