Files
semantica/tests/export/test_rdf_exporter_iri_minting.py
T
KaifAhmad1andfabio-rovai 2d75952476 fix: close remaining review gaps in vocabulary/deterministic-IRI PR
serialize_to_rdfxml still defaulted entity_type to the bare string
"semantica:Entity" written into an rdf:resource attribute, which isn't
namespace-expanded the way a Turtle angle-bracket or XML element name is -
the same #1101 failure mode, just on the path the original tests didn't
cover. Now uses the full-IRI DEFAULT_ENTITY_TYPE like the Turtle path.

json_exporter.py emits semantica:format and @type: "semantica:KnowledgeGraph",
neither of which was declared in the vocabulary or included in
EMITTED_TERMS, so the "undeclared terms fail the build" guarantee didn't
actually cover them. Both are now declared with rdfs:label/comment and
added to the guard set.

MANIFEST.in didn't mirror the pyproject.toml package-data addition, so a
source-distribution install could ship without the vocabulary file.

The cross-process minting-stability test replaced the subprocess's entire
environment with a POSIX-only PATH, breaking it on Windows and any host
needing other inherited env vars; now overrides only PYTHONHASHSEED on top
of the inherited environment.

Also folds mint_entity_iri/mint_relationship_iri's hand-rolled
hashlib.sha256(...).hexdigest() into the existing hash_data() helper this
file already imports alongside.

229 export and ontology tests pass, including a new regression test for
the RDF/XML default-type fix.

Co-Authored-By: fabio-rovai <fabio@thetesseractacademy.com>
2026-08-19 19:09:02 +05:30

142 lines
5.3 KiB
Python

"""Minted IRIs must be stable and must sit in the declared namespace (issue #1101).
An entity that arrives without an id gets one minted for it. That identifier was
built from Python's builtin ``hash()``, which is randomised per process, so the
same entity received a different IRI on every run and exports could not be
diffed, deduplicated against an earlier load, or joined to a provenance record
written by an earlier process.
It was also written as ``semantica:entity_N`` inside angle brackets, which is an
IRI in the scheme ``semantica`` rather than the expansion of the declared
``semantica:`` prefix, so it never joined with anything written through it.
"""
import os
import subprocess
import sys
from semantica.export.rdf_exporter import (
DEFAULT_ENTITY_TYPE,
DEFAULT_RELATION_TYPE,
RDFExporter,
SEMANTICA_NS,
mint_entity_iri,
mint_relationship_iri,
)
UNIDENTIFIED = {
"entities": [{"text": "Acme Corp", "type": "https://example.org/Org"}],
"relationships": [],
}
def test_minted_entity_iri_is_stable_within_a_process():
assert mint_entity_iri("Acme Corp") == mint_entity_iri("Acme Corp")
def test_minted_entity_iri_is_stable_across_processes():
"""The regression that matters: identity must survive a restart."""
script = (
"from semantica.export.rdf_exporter import mint_entity_iri;"
"print(mint_entity_iri('Acme Corp'))"
)
runs = {
subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=True,
env={**os.environ, "PYTHONHASHSEED": seed},
).stdout.strip()
for seed in ("0", "1", "random")
}
assert len(runs) == 1, f"minted IRI differs between processes: {runs}"
def test_minted_iris_are_in_the_declared_namespace():
assert mint_entity_iri("Acme Corp").startswith(SEMANTICA_NS)
assert mint_relationship_iri(0, "a", "b").startswith(SEMANTICA_NS)
def test_distinct_entities_get_distinct_iris():
assert mint_entity_iri("Acme Corp") != mint_entity_iri("Acme Corporation")
def test_turtle_export_writes_a_resolvable_minted_iri():
turtle = RDFExporter().export_to_rdf(UNIDENTIFIED, format="turtle")
assert f"<{SEMANTICA_NS}entity_" in turtle
assert "<semantica:entity_" not in turtle, "scheme 'semantica' is not the prefix"
def test_ntriples_export_agrees_with_turtle_on_the_minted_iri():
exporter = RDFExporter()
minted = mint_entity_iri("Acme Corp")
assert minted in exporter.export_to_rdf(UNIDENTIFIED, format="turtle")
assert minted in exporter.export_to_rdf(UNIDENTIFIED, format="ntriples")
def test_default_types_are_written_as_full_iris_in_turtle():
untyped = {"entities": [{"id": "https://example.org/e1", "text": "A"}],
"relationships": [{"source": "https://example.org/e1",
"target": "https://example.org/e2"}]}
turtle = RDFExporter().export_to_rdf(untyped, format="turtle")
assert f"<{DEFAULT_ENTITY_TYPE}>" in turtle
assert f"<{DEFAULT_RELATION_TYPE}>" in turtle
assert "<semantica:Entity>" not in turtle
assert "<semantica:related_to>" not in turtle
def test_default_entity_type_is_a_full_iri_in_rdfxml():
"""RDF/XML's rdf:resource is an attribute value, not a QName context, so a
prefixed default there (``semantica:Entity``) resolves to the scheme
``semantica`` rather than the declared namespace — the same failure mode
fixed for Turtle in #1101, missed here because the original tests only
checked Turtle output.
"""
untyped = {"entities": [{"id": "https://example.org/e1", "text": "A"}],
"relationships": []}
rdfxml = RDFExporter().export_to_rdf(untyped, format="rdfxml")
assert f'rdf:resource="{DEFAULT_ENTITY_TYPE}"' in rdfxml
assert 'rdf:resource="semantica:Entity"' not in rdfxml
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"})