diff --git a/semantica/export/rdf_exporter.py b/semantica/export/rdf_exporter.py index 40cc4341..0d8dcdee 100644 --- a/semantica/export/rdf_exporter.py +++ b/semantica/export/rdf_exporter.py @@ -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: diff --git a/semantica/ontology/vocabulary/semantica-ns.ttl b/semantica/ontology/vocabulary/semantica-ns.ttl index b5695b08..19943eac 100644 --- a/semantica/ontology/vocabulary/semantica-ns.ttl +++ b/semantica/ontology/vocabulary/semantica-ns.ttl @@ -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 . sem:metadata a owl:AnnotationProperty ; diff --git a/tests/export/test_rdf_exporter_iri_minting.py b/tests/export/test_rdf_exporter_iri_minting.py index 5734db73..fc56b74e 100644 --- a/tests/export/test_rdf_exporter_iri_minting.py +++ b/tests/export/test_rdf_exporter_iri_minting.py @@ -86,3 +86,39 @@ def test_default_types_are_written_as_full_iris_in_turtle(): assert f"<{DEFAULT_RELATION_TYPE}>" in turtle assert "" not in turtle assert "" 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"}) diff --git a/tests/ontology/test_vocabulary.py b/tests/ontology/test_vocabulary.py index 49d2e794..e59d0d77 100644 --- a/tests/ontology/test_vocabulary.py +++ b/tests/ontology/test_vocabulary.py @@ -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)}" + )