fix: resolve temporal endpoints both ways, and stop declaring a range the exporters contradict

Both from review on #1109.

The temporal fallback minted from source_id only, while the main serializer
accepts source_id or source. Relationships using the second form therefore
hashed two empty strings, and once the IRI became deterministic that turned a
latent problem into an active one: unrelated relationships at the same list
index collided on the same IRI across exports, so their temporal data aliased
when loaded together. Endpoints are now resolved the way serialize_to_turtle
resolves them, before minting.

The vocabulary declared sem:confidence with range xsd:decimal, which the
N-Triples serializer contradicts by typing the same value xsd:float. Neither is
safe to declare while the two serializers disagree, since the Turtle path writes
the value bare and the Turtle grammar reads that as xsd:decimal. The range is
dropped with the reasoning recorded on the term and a pointer to #1100, which
tracks the disagreement itself.

Extends the drift guard rather than only fixing the instance: a new test asserts
that any range this vocabulary declares matches the datatype the serializers
actually emit, so the class of contradiction that review caught fails the build
next time.

228 export and ontology tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
FABIOTESS
2026-08-19 13:35:39 +01:00
co-authored by Claude Opus 5
parent e1092ac507
commit e55c03bd39
4 changed files with 81 additions and 6 deletions
+8 -4
View File
@@ -445,10 +445,14 @@ class RDFSerializer:
if time_axis in ("transaction", "both"):
axes.append(("tx", rel.get("recorded_at"), rel.get("superseded_at")))
rel_base_id = (
rel.get("id")
or mint_relationship_iri(idx, rel.get('source_id', ''), rel.get('target_id', ''))
)
# Resolve endpoints the same way serialize_to_turtle does: both
# representations are accepted upstream, and minting from source_id
# alone hashes empty strings for every relationship that uses source,
# so unrelated relationships at the same index would collide on a
# deterministic IRI.
source_id = rel.get("source_id") or rel.get("source") or ""
target_id = rel.get("target_id") or rel.get("target") or ""
rel_base_id = rel.get("id") or mint_relationship_iri(idx, source_id, target_id)
lines = [""] # blank separator
for axis_name, from_val, until_val in axes:
@@ -48,8 +48,14 @@ sem:confidence a owl:DatatypeProperty ;
rdfs:label "confidence" ;
rdfs:comment """Extractor confidence in the assertion, on the unit interval.
Emitted for both entities and relationships, so the domain is left open rather
than tied to sem:Entity.""" ;
rdfs:range xsd:decimal ;
than tied to sem:Entity.
No rdfs:range is declared, deliberately. The Turtle serializer writes the value
bare, which the Turtle grammar reads as xsd:decimal, while the N-Triples
serializer types it xsd:float explicitly, and those two datatypes are disjoint.
Declaring either one would make the vocabulary contradict one of the exporters.
Issue #1100 tracks the disagreement; a range belongs here once the serializers
agree on one.""" ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:metadata a owl:AnnotationProperty ;
@@ -86,3 +86,39 @@ def test_default_types_are_written_as_full_iris_in_turtle():
assert f"<{DEFAULT_RELATION_TYPE}>" in turtle
assert "<semantica:Entity>" not in turtle
assert "<semantica:related_to>" not in turtle
def test_temporal_minting_uses_either_endpoint_representation():
"""Relationships may carry source/target or source_id/target_id (#1109 review).
Minting from source_id alone hashed empty strings for every relationship
that used the other representation, so once the IRI became deterministic,
unrelated relationships at the same index collided on it and their temporal
data aliased when the exports were loaded together.
"""
def temporal(rel):
return RDFExporter().export_to_rdf(
{"entities": [], "relationships": [rel]},
format="turtle",
include_temporal=True,
)
a = temporal({"source": "https://example.org/a", "target": "https://example.org/b",
"type": "https://example.org/worksFor", "valid_from": "2020-01-01T00:00:00Z"})
b = temporal({"source": "https://example.org/c", "target": "https://example.org/d",
"type": "https://example.org/worksFor", "valid_from": "2020-01-01T00:00:00Z"})
assert f"<{SEMANTICA_NS}rel_" in a
assert a != b, "different endpoints must not mint the same temporal IRI"
def test_temporal_minting_agrees_across_the_two_representations():
"""The same relationship written either way is the same relationship."""
def mint(rel):
return mint_relationship_iri(
0,
rel.get("source_id") or rel.get("source") or "",
rel.get("target_id") or rel.get("target") or "",
)
assert mint({"source": "a", "target": "b"}) == mint({"source_id": "a", "target_id": "b"})
+29
View File
@@ -83,3 +83,32 @@ def test_every_declared_term_carries_a_label_and_a_comment(graph):
continue
assert graph.value(subject, rdflib.RDFS.label), f"{subject} has no rdfs:label"
assert graph.value(subject, rdflib.RDFS.comment), f"{subject} has no rdfs:comment"
def test_declared_ranges_do_not_contradict_what_the_exporters_emit(graph):
"""A declared range must match the datatype the serializers actually write.
Caught by review on #1109: sem:confidence was declared xsd:decimal while the
N-Triples serializer types the same value xsd:float. A vocabulary that
contradicts the code is worse than no vocabulary, so any range declared here
has to be one the exporters really emit.
"""
import re
from semantica.export.rdf_exporter import RDFExporter
sample = {
"entities": [{"id": "https://example.org/e1", "text": "A",
"type": "https://example.org/T", "confidence": 0.5}],
"relationships": [],
}
emitted = RDFExporter().export_to_rdf(sample, format="ntriples")
for subject, _, range_ in graph.triples((None, rdflib.RDFS.range, None)):
if not str(subject).startswith(NAMESPACE):
continue
local = str(subject)[len(NAMESPACE):]
for match in re.finditer(rf'<{NAMESPACE}{local}> "[^"]*"\^\^<([^>]+)>', emitted):
assert match.group(1) == str(range_), (
f"{local}: vocabulary declares {range_}, N-Triples emits {match.group(1)}"
)