Open research, August 2026
Checking the knowledge graph your pipeline just built
A pipeline that turns documents into RDF is making a claim in a formal language, and it has no way to check its own claim. We ran that check against Semantica, an MIT-licensed Python pipeline with about 9,300 stars, at versions 0.6.5 and 0.6.6, by reading every output back through two independent RDF engines and comparing what each one saw. Seventeen defects came out of it. Four of the fixes are merged upstream and six pull requests are open as of 21 August 2026. The failures share a shape worth knowing about before you buy one of these systems: the export succeeds, a lenient reader accepts it, and the graph is wrong or largely absent anyway.
The short version
- The read that succeeds and returns a fraction: a two-entity JSON-LD export parsed as 2 triples through
Graph()and 21 quads throughDataset(). Neither path raised anything. - The validator that always passes: generated SHACL shapes targeted a namespace the generated data never used, so no shape selected a single node and pySHACL reported
conforms=Trueon data that violated every constraint in the file. - The query that quietly deletes your rows: timestamps written without a UTC offset make a timezone-qualified SPARQL filter indeterminate, which SPARQL turns into an error, so every affected row disappears from the result and the query still returns.
- The negative result: the one module that serialises through a real RDF library rather than by hand, the PROV-O export, had nothing wrong with it. Eleven predicates and five types, none undeclared.
A pipeline cannot check its own output
This is the asymmetry the whole exercise rests on. An extraction pipeline proposes entities, relations and types, and then writes them out in a formal notation. Every part of that sentence is the same program. If the program has a wrong idea about what a valid identifier looks like, it will write invalid identifiers and then read them back with the same wrong idea, and its tests will pass.
The way out is to bring in a reader that had no part in writing the file. That is not a new idea in software, but it is unusual in graph pipelines, and it is remarkably productive. Every finding on this page came from the same move: parse the output with a second engine, and compare.
Two readers, one file, two different answers
The pipeline’s graph builder defaults an entity’s identifier to its surface text and its type to whatever label the extractor produced. On the documented path, that produces Turtle like this:
<Acme Corp> a <ORG> ;
semantica:text "Acme Corp" ;
semantica:confidence 0.91 .A space is not allowed in an IRI. rdflib parses this anyway: it resolves the subject against the current working directory, hands you file:///home/you/project/Acme%20Corp, and logs a warning that nothing in a production pipeline is reading. Oxigraph, through open-ontologies, refuses the file: Invalid IRI code point ' '.
Both behaviours are defensible readings of the standard’s tolerance rules. Only one of them tells you that your entity identifiers now depend on which directory the batch job happened to run in. If you have ever wondered why two loads of the same corpus produced graphs that would not join, this is a candidate explanation.
The load that succeeds and gives you a twentieth of the data
JSON-LD has a feature that is easy to trigger by accident. A top-level @id sitting beside a top-level @graph does not label the document. It names the graph, which converts every member of that array from a triple in the default graph into a quad in a named one. A plain triple reader keeps the default graph and drops everything else.
Measured on version 0.6.6, a knowledge graph of two entities and one relationship exported to JSON-LD parsed as 2 triples through rdflib.Graph.parse() and 21 quads through Dataset(). The 19 statements in the gap were the content. There is no error, no warning, and no count that looks wrong unless you already know what the right count was.
This is the argument for all-or-nothing loading. A partial load that reports success is worse than a refusal, because every dashboard, every query and every downstream model then computes over a fragment and looks entirely healthy doing it.
Validation that cannot fail
The pipeline generates SHACL shapes from its own ontology, which is a genuinely useful feature. The shapes minted their sh:targetClass values under a /shapes/ path, while the data it generated used a /ns# path. The two never met. No shape selected any node, and pySHACL reported conformance on data that broke every constraint in the file.
A vacuous pass and a real pass produce the same verdict, so any acceptance test written as assert the report conforms would have gone on passing for as long as the project lived. The report needs to say how many focus nodes each shape actually selected, and a shape that selected none needs to be an alarm rather than a silence.
There is a second check that catches this class of defect from the other direction. Closed-world vocabulary checking asks which terms appear in the graph and are declared nowhere. Open-world SHACL cannot ask that question, because in the open world an undeclared term is merely unknown rather than wrong, which is precisely why an extractor that invents a plausible-sounding property sails through it.
The timestamp that deletes rows from your answer
Across the codebase there were 106 timestamps written with datetime.now(), spread over 41 files, and 70 written with the deprecated datetime.utcnow() over 23 more. The first means local time. The second means UTC. Once either has been written into an RDF literal, nothing distinguishes them, and eighteen of the first group were in the export module.
Now run an audit query with a proper bound:
FILTER(?t < "2026-08-19T00:00:00Z"^^xsd:dateTime)XSD 1.1 makes the comparison between an offset-bearing value and an offset-free one indeterminate, because the offset-free value could fall on either side of the bound by up to fourteen hours. SPARQL raises that as an error, and an error in a filter is not a false. Oxigraph drops the row. The query returns, the shape of the result looks reasonable, and the records it was asked about are the ones missing from it.
The fix upstream tightened the range of the export timestamp to xsd:dateTimeStamp, which requires the offset rather than merely permitting it. That is the version of the type that a schema can enforce.
What was measured
| Finding | Count | What it means |
|---|---|---|
| Defects found and reported against Semantica 0.6.5 and 0.6.6 between 19 and 21 August 2026, all on the public issue tracker | 17 issues | Four fixes are merged upstream and six pull requests are open. The pipeline is actively maintained and the reports were accepted, which is why this reads as a method rather than an audit. |
| Triples recovered from a two-entity JSON-LD export by a plain graph reader, against the quads the same bytes yield to a dataset reader | 2 of 21 | A top-level @id beside a top-level @graph names the whole payload into a graph the default reader discards. No error is raised on either path, so the loss is invisible to anything downstream. |
| SHACL shapes whose targets were minted in a namespace the data never used, so no shape selected any node | all of them | pySHACL reported conforms=True on data that violated every constraint. Validation that matches nothing is indistinguishable from validation that passes, unless the report states how many nodes were selected. |
| Timezone-naive timestamp call sites, in two idioms that mean different things: datetime.now() means local, datetime.utcnow() means UTC | 106 and 70 | A timezone-qualified SPARQL filter over these values hits XSD 1.1 indeterminate comparison, which SPARQL turns into an error, so Oxigraph silently drops every affected row from the result. |
| Entity IRIs minted from Python’s builtin hash(), which is randomised per process | every unidentified entity | The same entity received a different identifier on every run, so exports could not be diffed, deduplicated against an earlier load, or joined to provenance written by an earlier process. |
| Metadata statements reaching the RDF output before the fix, out of four carried on an entity, across all four serialisation formats | 0 of 4 | The dropped fields are the provenance ones. An entity kept its confidence score and lost the source document, page, extractor and reviewer behind it. The JSON exporter kept all four, so the same graph exported two ways disagreed about what the user had supplied. |
| Undeclared terms in the PROV-O export, measured against the real PROV-O vocabulary | 0 of 11 predicates, 0 of 5 types | The one module that serialises through a real RDF library instead of by hand is also the one module with nothing wrong with it. That is most of the explanation, and it is the cheapest fix available to any pipeline in this position. |
| OWL or RDFS entailment constructs anywhere in the pipeline’s reasoning module | none | It is a rule engine over facts, which is a capability boundary rather than a defect. It is also the clearest statement of what a formal engine adds after extraction ends. |
Four things to write into the specification
If you are commissioning a knowledge graph build, each of these corresponds to a defect above that the pipeline’s own test suite had not caught.
- Read it back with something that did not write it. Acceptance runs the output through an independent, strict parser, and a rejection is a failed acceptance rather than a note for the backlog.
- Assert on the parsed graph and on counts, never on the file. A serialised string that looks correct can mean nothing. State the expected triple count and check it.
- Require validation reports to state their coverage. How many focus nodes did each shape select? A shape matching zero nodes is the single most likely reason a validation report is clean.
- Require stable identifiers and offset-bearing timestamps. Re-run the same input in a fresh process and diff the two graphs. If the identifiers moved, nothing built on them will join, and no amount of downstream cleverness will repair it.
Reproducing this
The probe scripts, their raw output files, the reports as filed, and a verification adapter that runs the strict engine over an export before it reaches disk are all public at semantica-contrib. Everything goes through the pipeline’s documented public API, because a defect reachable only from a private helper is a curiosity, and a defect on the path a user takes is a defect.
A word about the subject. Semantica was chosen because it is good enough to be worth checking, and its maintainer replied to the first report within about an hour, agreed the modelling calls, and asked for the pull request. Reports that arrive with a reproduction, a fix and a test get treated well there, which is why this exercise produced merged code rather than a list of complaints. The method is the transferable part, and it applies to any pipeline that emits RDF, including ones you may already be running.
