Releases: ASCS-eV/diffable-rdf
Release list
v0.4.0
Two changes. If you serialize only standard RDF, neither alters your output.
Fixed: the rdflib fallback no longer drops graph.base
A document that holds relative references and declares no base is not self-describing. RFC 3986 section 5.1.3 hands resolution to the retrieval URI, so the same bytes read from two directories produced two different graphs, and section 5.1.4 places that responsibility on the sender.
The drop had a real cause: rdflib's Serializer.relativize shortens an IRI by string prefix rather than by the component algorithm RFC 3986 section 5.2.2 defines and Turtle section 6.3 requires, so under a base ending in #, in ?, or mid-path-segment it emits a reference that resolves back to a different IRI. But the defence was too broad -- it also discarded path-segment and authority-only bases, which are the ones ordinary tooling emits.
RFC 3986 specifies resolution and never its inverse, so no static test can decide this. The rendering is now re-read and the base kept only if every absolute IRI of the source survives. Only loss counts: a relative source term is outside the RDF abstract syntax (RDF 1.1 Concepts section 3.2) and always resolves to something on re-reading. Each drop logs a warning naming the base and an IRI that forced it.
A base that is not itself a valid absolute IRI is never declared, since Turtle section 6.5 IRIREF admits no space, brace or quote.
Output bytes change for graphs that take the fallback path and carry a base. Regenerate the affected artifact once; subsequent runs are stable.
Added: canonicalize_rdf_graph(..., diff_stable=True)
The same Weisfeiler-Leman blank-node labelling wl_relabel_quads exposes, without having to build a pipeline around it. RDFC-1.0 labels are a function of the whole graph, so a one-triple edit can renumber every blank node in a document; WL labels depend only on a node's neighbourhood. Measured on a six-branch graph, adding one branch changes 6 lines under RDFC-1.0 and 2 under WL.
Opt-in, deterministic either way, and isomorphic to the input in both cases. The fallback path cannot relabel -- WL consumes pyoxigraph quads that path never produces -- so it warns rather than passing silently.
Full detail in CHANGELOG.md and #60.
v0.3.0
Two kinds of change here, and the difference matters when you upgrade.
Calls that used to return now raise. These are breaking, and each replaces
silent data loss with an error naming the term and a format that can carry it:
DatasetandConjunctiveGrapharguments raiseTypeErrorat both entry
points, instead of one arbitrary graph being serialized as if it were the
whole input.ntandnquadsraiseValueErrorfor a graph holding a term N-Triples
cannot write, instead of returning text no parser will read.xmlraisesValueErrorfor a character XML 1.0 cannot represent, and
degraded JSON-LD raises for a generalized term the interoperable subset
cannot express.deterministic_jsonraisesValueErrorfor two dict keys that encode to the
same JSON name.- Output that fails its own round-trip check raises rather than being returned.
If your input is standard RDF and your keys are strings, none of these fire.
Output bytes change on the paths below. Each is a one-time diff: regenerate
the affected artifact, commit it once, and subsequent runs are stable again.
Changed
- Turtle-family output declares a generated prefix only where the serializer
asks for one.deterministic_turtle, and the fallback rendering of
turtle,ttl,n3andtrig, no longer invent a namespace for every IRI
in the graph. A namespace you bound is still used in every position. A
namespace you did not bind is now declared only for the predicates that use
it: subjects, objects and datatypes in an unbound namespace are written as
complete IRIs, so"42"^^ns2:integerbecomes
"42"^^<http://www.w3.org/2001/XMLSchema#integer>and unused@prefixlines
disappear. This is a one-time diff on affected artifacts: regenerate, commit
once, and later runs are stable again. To keep a namespace compact in every
position, bind it —well_known_prefix_map()supplies the standard names.
RDF/XML, JSON-LD, N-Triples and N-Quads output is byte-identical, as is
Turtle whose namespaces are all bound. wl_blank_node_labelsandwl_relabel_quadsnow reject embedded
pyoxigraph.Tripleterms withValueError. They operate on supported
top-level quad terms only; direction-tagged literals remain supported.- Base rendering is accepted only after RDFLib and pyoxigraph preserve direct
and literal-datatype IRI terms. A rendering that does not verify is emitted
once more without its base IRI, retaining valid prefixes. If compact prefix
rendering still does not verify, a final rendering uses complete IRIs without
prefixes or a base. Ordinary valid bindings remain compact. Turtle, TriG,
and N3 prefix bindings equal to the base remain available for compact terms;
RDF/XML keeps its XML namespace selection on the no-base retry. - Degraded JSON-LD rejects a relative subject or object identifier exactly
matching@[A-Za-z]+. JSON-LD reserves these strings, so returning them in an@id
value can change or discard a graph term. - Typed literals in
deterministic_turtlekeep their exact lexical form.
Anxsd:integeris now written"42"^^xsd:integerrather than42, and
likewise for booleans and other typed values. Turtle's numeric short form
renders the value, which merges terms RDF 1.1 keeps distinct —"01"and
"1"are one value but two terms — and can shorten a double's lexical form.
Output is more verbose and no longer loses a triple to that merge.
canonicalize_rdf_graphstill uses the short form; both are exact. - Blank-node labels change once, for graphs whose disconnected blank-node
components previously converged after different numbers of refinement rounds.
Each component now converges independently, so an edit in one region no
longer relabels an unrelated one. - Blank-node labels change once more, where structurally indistinguishable
nodes share a signature and are told apart by a_1,_2, … suffix. Those
suffixes are assigned inc14nNorder, as documented, but the order was read
as text:c14n10sorted betweenc14n1andc14n2. Adding a tenth tied
blank node relabelled eight of the nine already there — the opposite of what
this labelling is for. Numbers now compare as numbers. - Generated
ns1,ns2, … prefix names are allocated in IRI order, so the
same graph gets the same names in every process. Prefixes you bind yourself
are unaffected and still take precedence. - RDF/XML writes a literal carriage return as

. XML parsers
normalize a raw CR to LF before parsing, which silently changed the literal. - Blank-node labels now depend on which graph a statement is in, for quad
input. A dataset with only a default graph is labelled as before. deterministic_jsonkeeps ordered JSON-LD values —@list,@json
payloads, and terms a local context declares ordered — and sorts lists whose
dictionaries have mixed key types, which previously left the whole enclosing
list unsorted.json-ldis produced by pyoxigraph and re-indented, not delegated to
rdflib. In 0.2.0 the name was not mapped, so it fell through to rdflib's
JSON-LD serializer with a warning and carried no determinism guarantee. It is
now a first-class format: pyoxigraph writes expanded JSON-LD from the
canonicalized dataset anddeterministic_jsonrenders it line by line. The
bytes are entirely different, and the output is now stable across processes.- Every output ends with exactly one trailing newline. 0.2.0 passed through
whatever the serializer produced — sometimes none, sometimes two — so a
committed artifact could differ from the same graph written by another path,
and POSIX text tools disagreed about the last line. deterministic_jsonnow sorts@graphand@setarrays. JSON-LD leaves
both unordered; protecting them defeated the determinism the function is for.
An ordered construct nested inside them still keeps its order.- Degraded JSON-LD is written as expanded node objects with explicit
@id
references, never@list. The previous output duplicated a shared list
tail. Terms the interoperable subset cannot represent now raiseValueError. - N-Quads and TriG work on the degraded path, emitting N-Triples and
collection-free Turtle respectively — each a valid document in its own format
for a single graph, and neither inventing a graph name the input did not have. - Degraded RDF/XML element order is now deterministic. rdflib's RDF/XML
serializer orders both itsrdf:Descriptionelements and the property
elements inside them by its own graph traversal, so the same graph produced
different bytes in different processes. Both are sorted now, as the
line-oriented formats already sort their lines. Descriptions with an IRI
subject come before blank-node subjects, and property elements are ordered
by predicate -- previously by the object's blank-node label where it had
one, so a label change anywhere reshuffled unrelated properties. ntandnquadsnow raise for a graph they cannot represent instead of
returning text no parser will read. Both accept only absolute IRIs, and a
graph takes the fallback precisely because it holds a term that is not one.
The error names the term and the formats that can carry the graph:turtle,
trig,xmlandjson-ldall work, since Turtle permits relative IRIs.deterministic_jsonraises for two dict keys that encode to the same JSON
name, such as{1: "a", "1": "b"}, instead of writing that name twice as
json.dumpsdoes. One object cannot carry a name twice:json.loadskeeps
only the last entry, so the text could not be read back, and the two items
tie under the sort, so the equal dicts{1: "a", "1": "b"}and
{"1": "b", 1: "a"}rendered differently — the one guarantee this function
makes. Use string keys.- A namespace with an embedded fragment is now used as a prefix. IRIs under
http://ex/a#bwere written in full because the filter treated any#
before the last character as unusable. pyoxigraph accepts such a prefix and
its CURIEs round-trip exactly, so the output is simply more compact than it
was. Only a namespace that is not a valid IRI is skipped now.
Added
docs/api.md, a full API reference with exact signatures, error cases,
per-format behavior and runnable examples. Shipped in the sdist.- Output verification on every path that can be verified. Turtle, TriG, N3
and RDF/XML output is re-parsed and compared with the input before it is
returned; a mismatch raisesValueErrorrather than returning a plausible
but lossy file. - Explicit rejection of input a format cannot represent, in place of silent
loss:DatasetandConjunctiveGraphcontainers raiseTypeError, RDF/XML
raises for characters XML 1.0 forbids, and degraded JSON-LD raises for
generalized terms. - Every advertised format name is handled on the degraded path, and two
names for one format behave identically — producing the same bytes where the
format can carry the graph, and refusing for the same reason where it cannot. - Lint, type-check and coverage gates; a job that resolves the declared
dependency floors and runs the suite against them; a check that the committed
lockfile is not stale. - Python 3.14 in the tested matrix, and the suite run on Windows and macOS
as well as Linux — the package claimsOperating System :: OS Independent,
which nothing was checking. - The built wheel is installed and tested, rather than only built and
metadata-checked. The suite runs against the installed package with the
src/import path cleared, so a module or data file missing from the wheel
fails here instead of at an install;py.typedis checked explicitly,
because without it a type checker silently ignores every annotation shipped. - A scheduled job against the newest permitted dependency versions....
v0.2.0
Fixed: unbounded memory growth in WL refinement
wl_blank_node_labels built each refinement round's signature by concatenating a node's own signature with those of all its neighbours, and never hashed in between. Signature length therefore grew by roughly a factor of the average degree every round. On a 100-blank-node, OWL-restriction-shaped graph:
| round | total signature bytes |
|---|---|
| 0 | 14,690 |
| 4 | 1,040,270 |
| 8 | 58,179,070 |
| 10 | 434,280,830 |
| 11 | MemoryError |
Any caller raising iterations past ~8 exhausted memory on an otherwise small graph. The final label is hashed either way, so label width could not reveal this — the symptom was memory alone.
Each round is now hashed, which bounds every signature to a constant size while inducing exactly the same partition of the blank nodes.
Changed: iterations now refines to the fixpoint
With growth bounded, the round count no longer has to be rationed — and it should not be guessed either. Stopping early leaves structurally distinct blank nodes sharing a signature, and those ties are broken by a counter assigned in RDFC-1.0 c14nN order, reintroducing precisely the global instability the labels exist to remove.
iterations now defaults to None, meaning refine until the partition stops changing. Refinement is monotone, so a stable class count proves a stable partition, and a partition of n nodes can refine at most n times.
Impact
Adding one class to a 78-class LinkML schema, counting changed lines:
| generator | file size | RDFC-1.0 only | + WL (0.1.0) | + WL (0.2.0) |
|---|---|---|---|---|
| SHACL | 497 lines | 291 changed | 13 | 13 (22x) |
| OWL | 2245 lines | 2091 changed | 253 | 17 (123x) |
Compatibility
Blank-node labels differ from 0.1.0, both from the per-round hash and the deeper refinement, so consumers see a one-time re-labelling. Output remains deterministic and isomorphic; only the choice of label changes.
Notes
Labels derive from a node's whole connected blank-node structure, so an edit inside one large connected structure can relabel all of it. Diff stability isolates unrelated regions of a graph from each other, not parts of a single interconnected one.
Full details in #7.
v0.1.0
First release with the determinism guarantee actually enforced end to end.
0.0.2 was extracted from an early prototype and had drifted behind the
hardened copy that upstream LinkML merged and reviewed. This release closes
that gap and adds a public primitive so tools that already canonicalize can
gain diff stability without replacing their own pipeline.
Fixed
- The pyoxigraph fallback was not deterministic. Graphs pyoxigraph rejects
(literal predicates, relative IRIs) fell back to a plaingraph.serialize(),
which labels blank nodes from run-local state, so output differed between
processes — on exactly the inputs that need reproducibility most. json-ldbypassed canonicalization entirely. Any format outside
pyoxigraph's reach returned raw rdflib output, leaking run-local blank-node
labels and rdflib's set-iteration node ordering.- Hash bases raised
ValueError. rdflib relativizes againstgraph.base
by naive string prefixing, which is not RFC-3986-correct:http://ex.org/d#a
under basehttp://ex.org/d#was emitted as<a>and re-resolved to a
different IRI, tripping the round-trip guard. Hash namespaces are the most
common OWL style. The base is no longer carried over. - A relative base crashed the serializer. Relative bases are legal in
rdflib;"Invalid base IRI"is now recovered from, like"Invalid prefix". deterministic_jsonrejected non-string dict keys, raisingTypeError
on input thatjson.dumpsserializes fine.- Unused prefix declarations are filtered to the namespaces actually used.
Added
wl_blank_node_labels() and wl_relabel_quads() expose the Weisfeiler-Lehman
relabelling step on its own. A tool that already runs RDFC-1.0 does not need
deterministic_turtle — that would replace its own prefix, base and fallback
handling. It can insert one step instead:
dataset.canonicalize(CanonicalizationAlgorithm.RDFC_1_0)
quads = wl_relabel_quads(list(dataset)) # <- the only added lineFormat-agnostic, and measures the same: on a 20-shape graph, adding one class
changes 62 lines to 4.
wl_relabel_quads also remaps blank nodes used as graph names; leaving them
unmapped splits one node in two and silently breaks the dataset.
Changed
The diff-stability guarantee is now stated accurately: WL bounds relabelling to
roughly iterations hops around a change, not to the directly involved blank
nodes, and a node referenced from many subjects re-labels whenever any of those
references changes.
Canonicalization normalizations are documented: language-tag lowercasing
(case-insensitive per RDF 1.1), xsd:string identity, prefix filtering, and the
dropped base.
Testing
188 to 210 tests, on Python 3.10-3.13. Determinism is asserted across separate
interpreter processes for all seven formats — run-local blank-node ids are
stable within one process, so same-process tests cannot catch this bug class.
Every regression test was verified to fail without its fix.
v0.0.2
Fixed
RDF collections could be lost or duplicated by canonicalization (#1, #2)
deterministic_turtle relied on rdflib's Turtle serializer to render rdf:List
structures using the compact ( ... ) syntax. That syntax can only express a list whose
tail is referenced once. When canonicalization produced a graph where several lists shared
a tail — common in OWL ontologies, where owl:unionOf and owl:oneOf lists overlap — the
compact form silently dropped triples or re-stated a shared tail under a fresh blank node.
The output parsed cleanly and looked plausible, so the loss was not detectable by eye.
The canonical form is now verified to round-trip before it is returned: the rendered
Turtle is re-parsed and compared to the input graph, and if it does not match, a serializer
that emits list structures explicitly is used instead. If neither renders faithfully the
function raises rather than returning a lossy result.
Added
A property-based test harness (tests/test_canonicalization_properties.py) asserting four
invariants over seeded random graphs and six hand-built collection-sharing arrangements:
| Property | |
|---|---|
| P1 | Lossless — the output parses back to a graph isomorphic to the input |
| P2 | Idempotent — canonicalizing the output reproduces it byte-for-byte |
| P3 | Label-independent — renaming blank nodes does not change the output |
| P4 | Order-independent — shuffling input triples does not change the output |
Plus checks for dangling list references, list cell counts, and byte-level drift across ten
repeated passes.
Upgrading
Anyone canonicalizing RDF that contains rdf:List structures — owl:unionOf,
owl:intersectionOf, owl:oneOf, sh:in, sh:or — should upgrade. Output for graphs
without shared list tails is unchanged, so this is a safe drop-in.
v0.0.1
First release of diffable-rdf — deterministic, diff-stable serialization for rdflib graphs.
Pipeline: RDFC-1.0 canonicalization (pyoxigraph) -> Weisfeiler-Lehman blank-node hashing -> idiomatic rdflib re-serialization.
API: deterministic_turtle, canonicalize_rdf_graph, deterministic_json, well_known_prefix_map.