mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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:
co-authored by
Claude Opus 5
parent
e1092ac507
commit
e55c03bd39
@@ -445,10 +445,14 @@ class RDFSerializer:
|
|||||||
if time_axis in ("transaction", "both"):
|
if time_axis in ("transaction", "both"):
|
||||||
axes.append(("tx", rel.get("recorded_at"), rel.get("superseded_at")))
|
axes.append(("tx", rel.get("recorded_at"), rel.get("superseded_at")))
|
||||||
|
|
||||||
rel_base_id = (
|
# Resolve endpoints the same way serialize_to_turtle does: both
|
||||||
rel.get("id")
|
# representations are accepted upstream, and minting from source_id
|
||||||
or mint_relationship_iri(idx, rel.get('source_id', ''), rel.get('target_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
|
lines = [""] # blank separator
|
||||||
for axis_name, from_val, until_val in axes:
|
for axis_name, from_val, until_val in axes:
|
||||||
|
|||||||
@@ -48,8 +48,14 @@ sem:confidence a owl:DatatypeProperty ;
|
|||||||
rdfs:label "confidence" ;
|
rdfs:label "confidence" ;
|
||||||
rdfs:comment """Extractor confidence in the assertion, on the unit interval.
|
rdfs:comment """Extractor confidence in the assertion, on the unit interval.
|
||||||
Emitted for both entities and relationships, so the domain is left open rather
|
Emitted for both entities and relationships, so the domain is left open rather
|
||||||
than tied to sem:Entity.""" ;
|
than tied to sem:Entity.
|
||||||
rdfs:range xsd:decimal ;
|
|
||||||
|
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> .
|
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||||
|
|
||||||
sem:metadata a owl:AnnotationProperty ;
|
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 f"<{DEFAULT_RELATION_TYPE}>" in turtle
|
||||||
assert "<semantica:Entity>" not in turtle
|
assert "<semantica:Entity>" not in turtle
|
||||||
assert "<semantica:related_to>" 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"})
|
||||||
|
|||||||
@@ -83,3 +83,32 @@ def test_every_declared_term_carries_a_label_and_a_comment(graph):
|
|||||||
continue
|
continue
|
||||||
assert graph.value(subject, rdflib.RDFS.label), f"{subject} has no rdfs:label"
|
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"
|
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)}"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user