From f60ca6a52943292d4b0fe38bc9848d1abc1169de Mon Sep 17 00:00:00 2001 From: FABIOTESS Date: Wed, 19 Aug 2026 17:25:18 +0100 Subject: [PATCH 1/2] fix(export): give the OWL-Time interval a subject the graph can reach (#1106) include_temporal=True emitted a well formed OWL-Time interval hanging off a relationship IRI that appears nowhere else in the graph. A relationship is written as a single triple, , so there is no node for the time to attach to: <...#rel_0_0940a860> time:hasTime <...#rel_0_0940a860__valid_interval> . Counting inbound arcs to that subject gives zero. The timestamps parse, they validate, and no query can reach them from the relationship they describe, which is the only thing they are for. The JSON-LD path already reifies relationships as sem:Relationship with sem:source, sem:target and sem:type, and the vocabulary declares all four terms. Turtle now emits the same shape when it has temporal data to attach, so the two serializations describe relationships the same way and the interval has a reachable subject. The direct triple is unchanged, and nothing is reified when a relationship carries no temporal data, so default output is untouched. 7 tests in tests/export/test_owl_time_reachability.py, including a SPARQL walk from the edge to its validity interval, which is what the dangling node made impossible, and a check that every emitted term is declared in the shipped vocabulary. Export and ontology suites pass at 228 tests. --- semantica/export/rdf_exporter.py | 46 +++++++ tests/export/test_owl_time_reachability.py | 143 +++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 tests/export/test_owl_time_reachability.py diff --git a/semantica/export/rdf_exporter.py b/semantica/export/rdf_exporter.py index 63eac4fe..d5ee173f 100644 --- a/semantica/export/rdf_exporter.py +++ b/semantica/export/rdf_exporter.py @@ -414,10 +414,56 @@ class RDFSerializer: if include_temporal: owl_lines = self._owl_time_triples_for_rel(rel, idx, time_axis) if owl_lines: + # The interval hangs off the relationship's own IRI, and a + # relationship written as a single triple has no such node + # in the graph. Without this the timestamps are well formed + # and unreachable: no query can get from the edge to its + # validity interval (#1106). The shape matches the JSON-LD + # export, and every term is declared in the vocabulary. + lines.extend( + self._reified_relationship_triples( + rel, idx, source_id, target_id, rel_type + ) + ) lines.extend(owl_lines) return "\n".join(lines) + def _reified_relationship_triples( + self, + rel: Dict[str, Any], + idx: int, + source_id: str, + target_id: str, + rel_type: str, + ) -> List[str]: + """ + Emit the reified relationship node that OWL-Time triples hang off. + + The direct triple stays. This adds a subject the interval can attach + to, using the same sem:Relationship shape the JSON-LD export already + writes, so the two serializations describe relationships the same way. + """ + rel_id = rel.get("id") or mint_relationship_iri(idx, source_id or "", target_id or "") + + type_label = rel_type.rsplit("#", 1)[-1].rsplit("/", 1)[-1] + escaped = ( + str(type_label) + .replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + ) + + predicates = [f"a semantica:Relationship"] + if source_id: + predicates.append(f"semantica:source <{source_id}>") + if target_id: + predicates.append(f"semantica:target <{target_id}>") + predicates.append(f'semantica:type "{escaped}"') + + return ["", f"<{rel_id}> " + " ;\n ".join(predicates) + " ."] + def _owl_time_triples_for_rel( self, rel: Dict[str, Any], idx: int, time_axis: str ) -> List[str]: diff --git a/tests/export/test_owl_time_reachability.py b/tests/export/test_owl_time_reachability.py new file mode 100644 index 00000000..cdcb97b2 --- /dev/null +++ b/tests/export/test_owl_time_reachability.py @@ -0,0 +1,143 @@ +""" +Regression test for #1106. + +`include_temporal=True` emitted a well formed OWL-Time interval hanging off a +relationship IRI that appears nowhere else in the graph. The relationship +itself is written as a single triple, ` `, so there is no +node to carry the time and no path from the edge to its validity interval. +The timestamps parsed, validated and meant nothing: no query could reach them +from the relationship they describe. + +The JSON-LD path already reifies relationships as sem:Relationship with +sem:source, sem:target and sem:type, and the shipped vocabulary declares all +four terms. Turtle now emits the same shape when it has temporal data to +attach, so the interval hangs off a node the graph can actually reach. +""" + +import pytest + +rdflib = pytest.importorskip("rdflib") +from rdflib import Graph, RDF, URIRef # noqa: E402 + +from semantica.export.rdf_exporter import RDFSerializer # noqa: E402 + +NS = "https://semantica.dev/ns#" +TIME = "http://www.w3.org/2006/time#" + +E1, E2 = NS + "e1", NS + "e2" +EMPLOYS = NS + "employs" + +KG = { + "entities": [ + {"id": E1, "text": "Acme", "type": NS + "ORG"}, + {"id": E2, "text": "Globex", "type": NS + "ORG"}, + ], + "relationships": [ + { + "source_id": E1, + "target_id": E2, + "type": EMPLOYS, + "valid_from": "2024-01-01T00:00:00Z", + "valid_until": "2025-01-01T00:00:00Z", + } + ], +} + + +def _graph(**options): + turtle = RDFSerializer().serialize_to_turtle( + {k: [dict(v) for v in vs] for k, vs in KG.items()}, **options + ) + graph = Graph() + graph.parse(data=turtle, format="turtle") + return graph + + +def test_the_interval_holder_is_reachable_from_the_graph(): + """The heart of #1106: the node carrying time had no inbound arc at all.""" + graph = _graph(include_temporal=True) + holders = [s for s, p, _ in graph if str(p) == TIME + "hasTime"] + assert holders, "no OWL-Time interval was emitted" + + for holder in holders: + inbound = [(s, p) for s, p, o in graph if o == holder] + outbound = [(p, o) for s, p, o in graph if s == holder and str(p) != TIME + "hasTime"] + assert inbound or outbound, ( + f"{holder} carries an interval but nothing else in the graph mentions it" + ) + + +def test_the_relationship_is_reified_so_time_has_a_subject(): + graph = _graph(include_temporal=True) + relationships = list(graph.subjects(RDF.type, URIRef(NS + "Relationship"))) + assert len(relationships) == 1, f"expected one reified relationship, got {relationships}" + + node = relationships[0] + assert (node, URIRef(NS + "source"), URIRef(E1)) in graph + assert (node, URIRef(NS + "target"), URIRef(E2)) in graph + assert list(graph.objects(node, URIRef(TIME + "hasTime"))), ( + "the reified relationship does not carry the interval" + ) + + +def test_a_query_can_walk_from_the_edge_to_its_interval(): + """What the dangling node made impossible.""" + graph = _graph(include_temporal=True) + rows = list(graph.query( + """ + PREFIX sem: + PREFIX time: + SELECT ?begin WHERE { + ?s ?p ?o . + ?rel sem:source ?s ; + sem:target ?o ; + time:hasTime/time:hasBeginning/time:inXSDDateTimeStamp ?begin . + } + """ + )) + assert rows, "no path from the relationship to its validity interval" + assert str(rows[0][0]) == "2024-01-01T00:00:00Z" + + +def test_the_direct_triple_is_still_written(): + """Reification is added alongside the edge, it does not replace it.""" + graph = _graph(include_temporal=True) + assert (URIRef(E1), URIRef(EMPLOYS), URIRef(E2)) in graph + + +def test_default_output_is_unchanged(): + """Nothing is reified when there is no temporal data to attach.""" + graph = _graph() + assert list(graph.subjects(RDF.type, URIRef(NS + "Relationship"))) == [] + assert (URIRef(E1), URIRef(EMPLOYS), URIRef(E2)) in graph + + +def test_a_relationship_without_temporal_data_is_not_reified(): + kg = { + "entities": KG["entities"], + "relationships": [ + {"source_id": E1, "target_id": E2, "type": EMPLOYS}, + dict(KG["relationships"][0]), + ], + } + turtle = RDFSerializer().serialize_to_turtle(kg, include_temporal=True) + graph = Graph() + graph.parse(data=turtle, format="turtle") + + assert len(list(graph.subjects(RDF.type, URIRef(NS + "Relationship")))) == 1 + + +def test_the_reified_terms_are_declared_in_the_shipped_vocabulary(): + """A reification nobody declared would just move the problem.""" + from semantica.ontology.vocabulary import vocabulary_turtle + + vocabulary = Graph() + vocabulary.parse(data=vocabulary_turtle(), format="turtle") + declared = {str(s) for s in vocabulary.subjects()} + + graph = _graph(include_temporal=True) + node = next(iter(graph.subjects(RDF.type, URIRef(NS + "Relationship")))) + emitted = {str(p) for p in graph.predicates(node, None)} | {NS + "Relationship"} + + undeclared = {t for t in emitted if t.startswith(NS) and t not in declared} + assert not undeclared, f"emitted but not declared in the vocabulary: {undeclared}" From d7ee22cf1f751756b475311a928932269fa23167 Mon Sep 17 00:00:00 2001 From: FABIOTESS Date: Wed, 19 Aug 2026 17:39:05 +0100 Subject: [PATCH 2/2] fix(export): keep the full predicate on the reified relationship Review finding, reproduced. The reified node reduced the relationship type to its last fragment or path component, so https://a.example/ns#employs and https://b.example/ns#employs both became semantica:type "employs". The temporal node no longer said which predicate it described, and it disagreed with the direct triple written beside it, which carries the full IRI. The full predicate is written instead. I had flagged the local-name form as a deliberate simplification in the PR description; the collision case shows it was the wrong call. 2 further tests. --- semantica/export/rdf_exporter.py | 7 +++- tests/export/test_owl_time_reachability.py | 46 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/semantica/export/rdf_exporter.py b/semantica/export/rdf_exporter.py index d5ee173f..f07964e7 100644 --- a/semantica/export/rdf_exporter.py +++ b/semantica/export/rdf_exporter.py @@ -446,9 +446,12 @@ class RDFSerializer: """ rel_id = rel.get("id") or mint_relationship_iri(idx, source_id or "", target_id or "") - type_label = rel_type.rsplit("#", 1)[-1].rsplit("/", 1)[-1] + # The full predicate, not its local name. Truncating to the fragment + # made https://a.example/ns#employs and https://b.example/ns#employs the + # same literal, so the temporal node no longer said which predicate it + # described, and it disagreed with the direct triple beside it. escaped = ( - str(type_label) + str(rel_type) .replace("\\", "\\\\") .replace('"', '\\"') .replace("\n", "\\n") diff --git a/tests/export/test_owl_time_reachability.py b/tests/export/test_owl_time_reachability.py index cdcb97b2..92473390 100644 --- a/tests/export/test_owl_time_reachability.py +++ b/tests/export/test_owl_time_reachability.py @@ -141,3 +141,49 @@ def test_the_reified_terms_are_declared_in_the_shipped_vocabulary(): undeclared = {t for t in emitted if t.startswith(NS) and t not in declared} assert not undeclared, f"emitted but not declared in the vocabulary: {undeclared}" + + +# ── Review finding on the first revision of this fix ───────────────────────── + +def test_the_reified_type_keeps_the_full_predicate(): + """ + Truncating to the local name made two predicates from different namespaces + indistinguishable, and disagreed with the direct triple beside it. + """ + from rdflib import Literal + + def reified_type(rel_type): + kg = { + "entities": KG["entities"], + "relationships": [ + { + "source_id": E1, + "target_id": E2, + "type": rel_type, + "valid_from": "2024-01-01T00:00:00Z", + } + ], + } + graph = Graph() + graph.parse( + data=RDFSerializer().serialize_to_turtle(kg, include_temporal=True), + format="turtle", + ) + node = next(iter(graph.subjects(RDF.type, URIRef(NS + "Relationship")))) + return set(graph.objects(node, URIRef(NS + "type"))) + + a = reified_type("https://a.example/ns#employs") + b = reified_type("https://b.example/ns#employs") + + assert a == {Literal("https://a.example/ns#employs")}, a + assert a != b, "two distinct predicates produced the same reified type" + + +def test_the_reified_type_matches_the_direct_triples_predicate(): + from rdflib import Literal + + graph = _graph(include_temporal=True) + node = next(iter(graph.subjects(RDF.type, URIRef(NS + "Relationship")))) + + assert set(graph.objects(node, URIRef(NS + "type"))) == {Literal(EMPLOYS)} + assert (URIRef(E1), URIRef(EMPLOYS), URIRef(E2)) in graph