mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
#1100 — the four serializers rendered the same confidence four different ways. Turtle wrote it bare, which the Turtle grammar reads as xsd:decimal. N-Triples typed it xsd:float. RDF/XML wrote a plain literal with no datatype. JSON-LD wrote a native JSON number, which expands to xsd:double. For confidence 0.9 that is four distinct RDF terms, so a FILTER matches at most one of them, and merging two exports of one graph gives an entity two different confidence values. N-Triples also omitted the triple entirely when confidence was absent, while the other three wrote the 1.0 default, so the two serializations differed in the number of triples as well as in their datatype. `normalize_confidence` now produces one canonical lexical form and every path writes it with CONFIDENCE_DATATYPE. xsd:decimal is the choice because it is what the Turtle path already produced, so the most used output is unchanged, and because it is exact: xsd:float is 32 bit binary and cannot represent 0.9 at all. Values that arrive in exponent notation are reformatted, since 1e-05 is not a valid xsd:decimal. #1102 — the Turtle path interpolated the value with no type check, so a confidence of "high" produced `semantica:confidence high .` and made the entire document unparseable. One bad field cost the whole export. A value that cannot be a decimal is now omitted with a warning naming the entity, rather than written as something the vocabulary contradicts. Numeric strings are still accepted. Booleans are not, since bool subclasses int and True would otherwise become a confidence of 1. sem:confidence in the shipped vocabulary declared no rdfs:range, deliberately, because declaring one would have contradicted three of the four exporters. It now declares xsd:decimal, and a drift guard asserts the vocabulary and the serializers agree. 20 tests in tests/export/test_confidence_literal_typing.py, comparing the parsed graphs of all four formats rather than their text. Export and ontology suites pass at 240 tests.
This commit is contained in:
@@ -30,6 +30,7 @@ License: MIT
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Dict, List, Optional, Set, Union
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
@@ -49,6 +50,58 @@ DEFAULT_ENTITY_TYPE = f"{SEMANTICA_NS}Entity"
|
||||
#: Written when a relationship carries no type of its own. Same reasoning.
|
||||
DEFAULT_RELATION_TYPE = f"{SEMANTICA_NS}related_to"
|
||||
|
||||
#: The one datatype every serializer writes confidence in.
|
||||
#:
|
||||
#: The four paths used to disagree: Turtle wrote the value bare, which the
|
||||
#: Turtle grammar reads as xsd:decimal, N-Triples typed it xsd:float, RDF/XML
|
||||
#: emitted a plain literal with no datatype, and JSON-LD emitted a native
|
||||
#: number, which becomes xsd:double. Those are four distinct RDF terms for one
|
||||
#: value (issue #1100).
|
||||
#:
|
||||
#: xsd:decimal is the choice because it is what the Turtle path already
|
||||
#: produced, so the most used output is unchanged, and because it is exact:
|
||||
#: xsd:float is 32 bit binary, and cannot represent 0.9 or 0.95 at all.
|
||||
CONFIDENCE_DATATYPE = "http://www.w3.org/2001/XMLSchema#decimal"
|
||||
|
||||
|
||||
def normalize_confidence(value: Any) -> Optional[str]:
|
||||
"""
|
||||
Return the canonical xsd:decimal lexical form of a confidence value.
|
||||
|
||||
Returns None when the value cannot be a decimal, so callers omit the triple
|
||||
rather than writing something the vocabulary contradicts. The Turtle path
|
||||
used to interpolate the raw value, so a confidence of "high" produced
|
||||
`semantica:confidence high .` and made the whole document unparseable
|
||||
(issue #1102).
|
||||
|
||||
Booleans are rejected. `bool` is a subclass of `int` in Python, so True
|
||||
would otherwise silently become a confidence of 1.
|
||||
"""
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
if not isinstance(value, (int, float, str, Decimal)):
|
||||
return None
|
||||
|
||||
try:
|
||||
decimal_value = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
# NaN and the infinities are Decimal values with no xsd:decimal form.
|
||||
if not decimal_value.is_finite():
|
||||
return None
|
||||
|
||||
# `str(Decimal("0.00001"))` gives "0.00001", but a float that has already
|
||||
# been through repr can arrive as "1e-05", which xsd:decimal does not allow.
|
||||
formatted = format(decimal_value, "f")
|
||||
if "." in formatted:
|
||||
formatted = formatted.rstrip("0").rstrip(".") or "0"
|
||||
return formatted
|
||||
|
||||
|
||||
def mint_entity_iri(text: str) -> str:
|
||||
"""Mint a stable IRI for an entity that arrived without an id.
|
||||
@@ -395,11 +448,20 @@ class RDFSerializer:
|
||||
|
||||
entity_type = entity.get("type", DEFAULT_ENTITY_TYPE)
|
||||
text = entity.get("text") or entity.get("label", "")
|
||||
confidence = entity.get("confidence", 1.0)
|
||||
confidence = normalize_confidence(entity.get("confidence", 1.0))
|
||||
|
||||
lines.append(f"<{entity_id}> a <{entity_type}> ;")
|
||||
lines.append(f' semantica:text "{text}" ;')
|
||||
lines.append(f" semantica:confidence {confidence} .")
|
||||
if confidence is None:
|
||||
self.logger.warning(
|
||||
f"Entity {entity_id} has a confidence that is not a number "
|
||||
f"({entity.get('confidence')!r}), so no confidence is written"
|
||||
)
|
||||
lines.append(f' semantica:text "{text}" .')
|
||||
else:
|
||||
lines.append(f' semantica:text "{text}" ;')
|
||||
lines.append(
|
||||
f' semantica:confidence "{confidence}"^^<{CONFIDENCE_DATATYPE}> .'
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Convert relationships to RDF triplets
|
||||
@@ -527,15 +589,22 @@ class RDFSerializer:
|
||||
|
||||
entity_type = entity.get("type", DEFAULT_ENTITY_TYPE)
|
||||
text = entity.get("text") or entity.get("label", "")
|
||||
confidence = entity.get("confidence", 1.0)
|
||||
confidence = normalize_confidence(entity.get("confidence", 1.0))
|
||||
|
||||
# RDF/XML syntax: rdf:Description with rdf:about
|
||||
lines.append(f' <rdf:Description rdf:about="{entity_id}">')
|
||||
lines.append(f' <rdf:type rdf:resource="{entity_type}"/>')
|
||||
lines.append(f" <semantica:text>{text}</semantica:text>")
|
||||
lines.append(
|
||||
f" <semantica:confidence>{confidence}</semantica:confidence>"
|
||||
)
|
||||
if confidence is None:
|
||||
self.logger.warning(
|
||||
f"Entity {entity_id} has a confidence that is not a number "
|
||||
f"({entity.get('confidence')!r}), so no confidence is written"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f' <semantica:confidence rdf:datatype="{CONFIDENCE_DATATYPE}">'
|
||||
f"{confidence}</semantica:confidence>"
|
||||
)
|
||||
lines.append(" </rdf:Description>")
|
||||
lines.append("")
|
||||
|
||||
@@ -606,14 +675,25 @@ class RDFSerializer:
|
||||
entity_text = entity.get("text", "")
|
||||
entity_id = f"semantica:entity/{entity_text}"
|
||||
|
||||
jsonld["@graph"].append(
|
||||
{
|
||||
"@id": entity_id,
|
||||
"@type": entity.get("type", "semantica:Entity"),
|
||||
"semantica:text": entity.get("text") or entity.get("label", ""),
|
||||
"semantica:confidence": entity.get("confidence", 1.0),
|
||||
node = {
|
||||
"@id": entity_id,
|
||||
"@type": entity.get("type", "semantica:Entity"),
|
||||
"semantica:text": entity.get("text") or entity.get("label", ""),
|
||||
}
|
||||
confidence = normalize_confidence(entity.get("confidence", 1.0))
|
||||
if confidence is None:
|
||||
self.logger.warning(
|
||||
f"Entity {entity_id} has a confidence that is not a number "
|
||||
f"({entity.get('confidence')!r}), so no confidence is written"
|
||||
)
|
||||
else:
|
||||
# A native JSON number becomes xsd:double once expanded, so the
|
||||
# value is written as a typed literal instead.
|
||||
node["semantica:confidence"] = {
|
||||
"@value": confidence,
|
||||
"@type": CONFIDENCE_DATATYPE,
|
||||
}
|
||||
)
|
||||
jsonld["@graph"].append(node)
|
||||
|
||||
# Convert relationships to JSON-LD
|
||||
relationships = rdf_data.get("relationships", [])
|
||||
@@ -697,11 +777,20 @@ class RDFSerializer:
|
||||
f'{subject} {expand_uri("semantica:text")} "{safe_text}" .'
|
||||
)
|
||||
|
||||
# Confidence property
|
||||
confidence = entity.get("confidence")
|
||||
if confidence is not None:
|
||||
# Confidence property. The default matches the other serializers,
|
||||
# which have always written one; omitting it here was half of why
|
||||
# Turtle and N-Triples of one KG were different graphs (#1100).
|
||||
raw_confidence = entity.get("confidence", 1.0)
|
||||
confidence = normalize_confidence(raw_confidence)
|
||||
if confidence is None:
|
||||
self.logger.warning(
|
||||
f"Entity {entity.get('id')} has a confidence that is not a "
|
||||
f"number ({raw_confidence!r}), so no confidence is written"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f'{subject} {expand_uri("semantica:confidence")} "{confidence}"^^<http://www.w3.org/2001/XMLSchema#float> .'
|
||||
f'{subject} {expand_uri("semantica:confidence")} '
|
||||
f'"{confidence}"^^<{CONFIDENCE_DATATYPE}> .'
|
||||
)
|
||||
|
||||
# Convert relationships
|
||||
|
||||
@@ -53,16 +53,17 @@ this IRI.""" ;
|
||||
|
||||
sem:confidence a owl:DatatypeProperty ;
|
||||
rdfs:label "confidence" ;
|
||||
rdfs:range xsd:decimal ;
|
||||
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.
|
||||
|
||||
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.""" ;
|
||||
The range was left undeclared when this vocabulary first shipped, because the
|
||||
four serializers disagreed: Turtle wrote the value bare, which the Turtle
|
||||
grammar reads as xsd:decimal, N-Triples typed it xsd:float, RDF/XML wrote a
|
||||
plain literal, and JSON-LD wrote a native number, which expands to xsd:double.
|
||||
Declaring any one of them would have contradicted three exporters. Issue #1100
|
||||
settled that on xsd:decimal, which every serializer now writes.""" ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
sem:metadata a owl:AnnotationProperty ;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Regression tests for #1100 and #1102.
|
||||
|
||||
#1100: the four RDF serializers rendered the same confidence value four
|
||||
different ways. Turtle wrote it bare, which the Turtle grammar reads as
|
||||
xsd:decimal. N-Triples typed it xsd:float. RDF/XML wrote a plain literal with
|
||||
no datatype at all. JSON-LD emitted a native JSON number, which becomes
|
||||
xsd:double. Those are four distinct RDF terms, so a FILTER matches at most one
|
||||
of them and merging two exports of one graph yields two confidence values for
|
||||
the same entity.
|
||||
|
||||
#1102: the Turtle path interpolated the value with no type check, so a
|
||||
non-numeric confidence produced `semantica:confidence high .`, which is not
|
||||
parseable Turtle. One bad field made the whole export unreadable.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
rdflib = pytest.importorskip("rdflib")
|
||||
from rdflib import Graph, URIRef # noqa: E402
|
||||
from rdflib.compare import isomorphic # noqa: E402
|
||||
|
||||
from semantica.export.rdf_exporter import RDFSerializer # noqa: E402
|
||||
|
||||
NS = "https://semantica.dev/ns#"
|
||||
CONFIDENCE = URIRef(NS + "confidence")
|
||||
XSD_DECIMAL = URIRef("http://www.w3.org/2001/XMLSchema#decimal")
|
||||
|
||||
|
||||
def _kg(confidence):
|
||||
entity = {"id": NS + "e1", "text": "Acme", "type": NS + "ORG"}
|
||||
if confidence is not _ABSENT:
|
||||
entity["confidence"] = confidence
|
||||
return {"entities": [entity], "relationships": []}
|
||||
|
||||
|
||||
_ABSENT = object()
|
||||
|
||||
|
||||
def _graphs(kg):
|
||||
"""Parse every serialization of one KG into a graph, keyed by format."""
|
||||
serializer = RDFSerializer()
|
||||
out = {}
|
||||
|
||||
out["turtle"] = Graph()
|
||||
out["turtle"].parse(data=serializer.serialize_to_turtle(json.loads(json.dumps(kg))),
|
||||
format="turtle")
|
||||
|
||||
out["ntriples"] = Graph()
|
||||
out["ntriples"].parse(data=serializer.serialize_to_ntriples(json.loads(json.dumps(kg))),
|
||||
format="nt")
|
||||
|
||||
out["rdfxml"] = Graph()
|
||||
out["rdfxml"].parse(data=serializer.serialize_to_rdfxml(json.loads(json.dumps(kg))),
|
||||
format="xml")
|
||||
|
||||
jsonld = serializer.serialize_to_jsonld(json.loads(json.dumps(kg)))
|
||||
out["jsonld"] = Graph()
|
||||
out["jsonld"].parse(
|
||||
data=jsonld if isinstance(jsonld, str) else json.dumps(jsonld), format="json-ld"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _confidence_terms(graph):
|
||||
return [o for _, p, o in graph if p == CONFIDENCE]
|
||||
|
||||
|
||||
def test_every_serializer_agrees_on_the_confidence_term():
|
||||
"""The heart of #1100. One value, one RDF term, whatever the format."""
|
||||
terms = {}
|
||||
for name, graph in _graphs(_kg(0.9)).items():
|
||||
values = _confidence_terms(graph)
|
||||
assert len(values) == 1, f"{name} emitted {len(values)} confidence triples"
|
||||
terms[name] = values[0]
|
||||
|
||||
distinct = set(terms.values())
|
||||
assert len(distinct) == 1, (
|
||||
"the same confidence serialised as different RDF terms: "
|
||||
+ ", ".join(f"{k}={v!r} ({v.datatype})" for k, v in terms.items())
|
||||
)
|
||||
|
||||
|
||||
def test_the_agreed_term_is_a_typed_decimal():
|
||||
for name, graph in _graphs(_kg(0.9)).items():
|
||||
term = _confidence_terms(graph)[0]
|
||||
assert term.datatype == XSD_DECIMAL, f"{name} typed it {term.datatype}"
|
||||
assert str(term) == "0.9", f"{name} wrote the lexical form {str(term)!r}"
|
||||
|
||||
|
||||
def test_turtle_and_ntriples_are_the_same_graph():
|
||||
"""#1100 as filed: two serializations of one KG must not be two graphs."""
|
||||
graphs = _graphs(_kg(0.9))
|
||||
assert isomorphic(graphs["turtle"], graphs["ntriples"]), (
|
||||
"Turtle only:\n"
|
||||
+ "\n".join(str(t) for t in set(graphs["turtle"]) - set(graphs["ntriples"]))
|
||||
+ "\nN-Triples only:\n"
|
||||
+ "\n".join(str(t) for t in set(graphs["ntriples"]) - set(graphs["turtle"]))
|
||||
)
|
||||
|
||||
|
||||
def test_a_numeric_string_is_accepted():
|
||||
for name, graph in _graphs(_kg("0.85")).items():
|
||||
terms = _confidence_terms(graph)
|
||||
assert terms, f"{name} dropped a usable numeric string"
|
||||
assert terms[0].datatype == XSD_DECIMAL
|
||||
assert str(terms[0]) == "0.85"
|
||||
|
||||
|
||||
def test_an_integer_confidence_is_accepted():
|
||||
for name, graph in _graphs(_kg(1)).items():
|
||||
terms = _confidence_terms(graph)
|
||||
assert terms, f"{name} dropped an integer confidence"
|
||||
assert terms[0].datatype == XSD_DECIMAL
|
||||
|
||||
|
||||
def test_a_small_value_is_not_written_in_exponent_notation():
|
||||
"""1e-05 is a valid Python repr and an invalid xsd:decimal lexical form."""
|
||||
for name, graph in _graphs(_kg(0.00001)).items():
|
||||
term = _confidence_terms(graph)[0]
|
||||
assert "e" not in str(term).lower(), f"{name} wrote {str(term)!r}"
|
||||
assert term.value is not None, f"{name} produced an ill-typed literal"
|
||||
|
||||
|
||||
# ── #1102 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad", ["high", "", "not a number", None, True, False, [0.9], {"v": 0.9},
|
||||
float("nan"), float("inf")],
|
||||
)
|
||||
def test_an_unusable_confidence_never_breaks_the_export(bad):
|
||||
"""`semantica:confidence high .` made the whole Turtle document unparseable."""
|
||||
serializer = RDFSerializer()
|
||||
kg = _kg(bad)
|
||||
|
||||
turtle = serializer.serialize_to_turtle(json.loads(json.dumps(kg, default=str)))
|
||||
graph = Graph()
|
||||
graph.parse(data=turtle, format="turtle") # must not raise
|
||||
|
||||
assert _confidence_terms(graph) == [], (
|
||||
f"{bad!r} was emitted as a confidence value: {_confidence_terms(graph)}"
|
||||
)
|
||||
# The rest of the entity must survive.
|
||||
assert (URIRef(NS + "e1"), URIRef(NS + "text"), rdflib.Literal("Acme")) in graph
|
||||
|
||||
|
||||
def test_an_unusable_confidence_is_dropped_consistently_everywhere():
|
||||
for name, graph in _graphs(_kg("high")).items():
|
||||
assert _confidence_terms(graph) == [], f"{name} still emitted it"
|
||||
|
||||
|
||||
def test_all_serializers_stay_parseable_with_an_unusable_confidence():
|
||||
graphs = _graphs(_kg("high")) # each parse would raise on malformed output
|
||||
assert isomorphic(graphs["turtle"], graphs["ntriples"])
|
||||
|
||||
|
||||
def test_the_emitted_datatype_matches_the_shipped_vocabulary():
|
||||
"""
|
||||
Drift guard. The vocabulary shipped with no rdfs:range on sem:confidence
|
||||
precisely because the serializers disagreed. Now that they agree, the range
|
||||
is declared, and the two must not drift apart again.
|
||||
"""
|
||||
from rdflib import RDFS
|
||||
|
||||
from semantica.export.rdf_exporter import CONFIDENCE_DATATYPE
|
||||
from semantica.ontology.vocabulary import vocabulary_turtle
|
||||
|
||||
vocabulary = Graph()
|
||||
vocabulary.parse(data=vocabulary_turtle(), format="turtle")
|
||||
|
||||
declared = list(vocabulary.objects(URIRef(NS + "confidence"), RDFS.range))
|
||||
assert declared, "sem:confidence declares no rdfs:range"
|
||||
assert str(declared[0]) == CONFIDENCE_DATATYPE, (
|
||||
f"vocabulary says {declared[0]}, serializers write {CONFIDENCE_DATATYPE}"
|
||||
)
|
||||
Reference in New Issue
Block a user