mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Merge remote-tracking branch 'origin/main' into codex/harden-markdown-import-symlinks
This commit is contained in:
@@ -493,10 +493,59 @@ 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 "")
|
||||
|
||||
# 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(rel_type)
|
||||
.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]:
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
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, `<e1> <employs> <e2>`, 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: <https://semantica.dev/ns#>
|
||||
PREFIX time: <http://www.w3.org/2006/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}"
|
||||
|
||||
|
||||
# ── 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
|
||||
Reference in New Issue
Block a user