Skip to content

Releases: ASCS-eV/diffable-rdf

v0.4.0

Choose a tag to compare

@jdsika jdsika released this 11 Sep 11:41
a05e53e

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

Choose a tag to compare

@jdsika jdsika released this 11 Sep 07:58
v0.3.0
6304dbe

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:

  • Dataset and ConjunctiveGraph arguments raise TypeError at both entry
    points, instead of one arbitrary graph being serialized as if it were the
    whole input.
  • nt and nquads raise ValueError for a graph holding a term N-Triples
    cannot write, instead of returning text no parser will read.
  • xml raises ValueError for a character XML 1.0 cannot represent, and
    degraded JSON-LD raises for a generalized term the interoperable subset
    cannot express.
  • deterministic_json raises ValueError for 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, n3 and trig, 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:integer becomes
    "42"^^<http://www.w3.org/2001/XMLSchema#integer> and unused @prefix lines
    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_labels and wl_relabel_quads now reject embedded
    pyoxigraph.Triple terms with ValueError. 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_turtle keep their exact lexical form.
    An xsd:integer is now written "42"^^xsd:integer rather than 42, 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_graph still 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 in c14nN order, as documented, but the order was read
    as text: c14n10 sorted between c14n1 and c14n2. 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 &#xD;. 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_json keeps 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-ld is 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 and deterministic_json renders 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_json now sorts @graph and @set arrays. 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 raise ValueError.
  • 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 its rdf:Description elements 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.
  • nt and nquads now 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, xml and json-ld all work, since Turtle permits relative IRIs.
  • deterministic_json raises 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.dumps does. One object cannot carry a name twice: json.loads keeps
    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#b were 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 raises ValueError rather than returning a plausible
    but lossy file.
  • Explicit rejection of input a format cannot represent, in place of silent
    loss: Dataset and ConjunctiveGraph containers raise TypeError, 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 claims Operating 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.typed is checked explicitly,
    because without it a type checker silently ignores every annotation shipped.
  • A scheduled job against the newest permitted dependency versions....
Read more

v0.2.0

Choose a tag to compare

@jdsika jdsika released this 08 Sep 15:59
1f1c559

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

Choose a tag to compare

@jdsika jdsika released this 08 Sep 14:35
c21110b

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 plain graph.serialize(),
    which labels blank nodes from run-local state, so output differed between
    processes — on exactly the inputs that need reproducibility most.
  • json-ld bypassed 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 against graph.base
    by naive string prefixing, which is not RFC-3986-correct: http://ex.org/d#a
    under base http://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_json rejected non-string dict keys, raising TypeError
    on input that json.dumps serializes 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 line

Format-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

Choose a tag to compare

@jdsika jdsika released this 05 Aug 05:51
v0.0.2
31bfb8d

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

Choose a tag to compare

@jdsika jdsika released this 29 Jul 11:58
2ed0e19

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.