Skip to content

feat(rdf): add opt-in diff-stable blank-node labels - #24

Open
jdsika wants to merge 3 commits into
mainfrom
feat/diff-stable-serialization
Open

feat(rdf): add opt-in diff-stable blank-node labels#24
jdsika wants to merge 3 commits into
mainfrom
feat/diff-stable-serialization

Conversation

@jdsika

@jdsika jdsika commented Sep 8, 2026

Copy link
Copy Markdown

Add opt-in diff-stable blank-node labels

Background

This is the follow-up to linkml#3295, which was closed with a clear
instruction rather than a rejection:

Regardless of formal correctness of approach, the implementation should live
elsewhere, where others can take advantage of it! Have you considered either
rdflib or some kind of independent rdflib sidecar library?
@cmungall, linkml#3295

For part 1, I'd like to encourage you to identify another RDF library where
this can live [...] this seems like content we should consume from an
upstream not manage ourselves.
@amc-corey-cox, linkml#3295

This should be implemented somewhere upstream of linkml, after which we'll
happily consider a PR to integrate it.
@cmungall, closing linkml#3295

So the algorithm was extracted, published and hardened outside linkml as
diffable-rdf, and this PR is the
thin integration that was invited. linkml gains a flag and a dependency; it
does not gain an algorithm to maintain.

It is also shaped by the constraint attached to linkml#3407:

I would be amenable to a minimal diff serialization form as an opt-in feature
clearly labeled as such, as a separate change, if it is lossless and etc.
@sneakers-the-rat, linkml#3407

Opt-in, separate, and lossless is exactly what this is, and the losslessness
claim is asserted by a test rather than argued.

Problem

RDFC-1.0 canonicalization (merged in linkml#3407) makes RDF output
deterministic: two isomorphic graphs always serialize to identical bytes.
That solves reproducibility, but it does not make output diffable.

RDFC-1.0 assigns blank-node labels c14n0, c14n1, … from a single global
ordering over the whole graph. So a blank node's name depends on every other
blank node in the file. Adding one class near the top of a schema shifts that
ordering and renumbers every blank node after it.

The practical effect: a one-line semantic change produces a whole-file diff.
linkml#3481 demonstrates it on a real schema — one slot added, roughly
1,100 changed lines. For generated OWL and SHACL that are checked into version
control, this makes review effectively impossible, because you cannot see what
actually changed when everything changed. linkml#3702 is the open issue
for this specific property.

What this changes

A new diff_stable argument on canonicalize_rdf_graph(), and a matching
--diff-stable / --no-diff-stable flag on the four generators that emit RDF
(owlgen, rdfgen, shaclgen, shexgen).

When enabled, blank-node labels are derived from each node's own
neighbourhood
using Weisfeiler-Lehman refinement instead of from a global
ordering. A blank node's label then depends only on the subgraph around it, so
editing one part of a schema leaves the labels in unrelated parts untouched.

This runs after RDFC-1.0, not instead of it. Determinism is unchanged and
still comes from RDFC-1.0; this only changes which label each blank node ends
up with.

Effect

Adding a single class to a real schema, counting changed lines in the output:

generator default --diff-stable reduction
owlgen 2091 17 123×
shexgen 796 50 16×
shaclgen 291 13 22×
rdfgen 115 25 4.6×

In every case the output was verified to be isomorphic to the default
output and byte-identical across repeated runs.

Design notes

Off by default. Enabling it relabels every blank node in existing output,
which is a one-time churn event for anyone with generated files in git. That
should be an explicit choice, not something a version bump does to you.

Determinism is not traded away. Diff stability is the weaker property;
determinism is the stronger one. The output is still fully deterministic with
the flag on — there is a test asserting exactly this.

It refuses rather than pretending. canonicalize_rdf_graph() already falls
back to rdflib when pyoxigraph cannot parse a graph, and on that path there are
no canonical quads to relabel, so diff_stable cannot be honoured. It now says
so with a warning instead of returning unchanged output. This is reachable
today: gen-shacl --include-annotations emits a literal in predicate position
for any annotation tag without a :, which forces the fallback.

No new transitive dependencies. The refinement lives in diffable-rdf, a
small pure-Python library. Its only two dependencies — rdflib and
pyoxigraph — are already linkml-runtime dependencies at higher version
floors, so the dependency tree does not grow.

Testing

Six new tests in tests/linkml_runtime/test_utils/test_rdf_canonicalize.py:

  • test_diff_stable_is_opt_in — the default output is byte-identical to
    before, so nothing changes for existing users.
  • test_diff_stable_preserves_semantics — relabelled output is isomorphic to
    the default output.
  • test_diff_stable_is_deterministic — repeated runs produce one distinct
    result.
  • test_diff_stable_confines_an_insertion_to_the_lines_it_touches — the
    behavioural claim: inserting a subject must churn far fewer lines than the
    baseline. Asserts on the ratio, not an absolute count, so it is not brittle
    across rdflib versions.
  • test_diff_stable_reaches_every_rdf_generator[owlgen|rdfgen|shaclgen|shexgen]
    — parametrized over all four generators. Asserts the observable consequence
    (no c14nN labels survive in the output), so it fails if a generator accepts
    the flag but forgets to thread it into canonicalize_rdf_graph().
  • test_diff_stable_warns_instead_of_silently_no_opping_on_the_fallback — the
    case above, where the request cannot be honoured and must be reported.

Each new assertion was validated by reverting the change and confirming the
test fails
— including unwiring each generator individually, which is what
caught an earlier version of the generator test passing vacuously. The
fallback test came out of the same exercise: the generator test could not have
caught that bug, because the fallback emits _:cb0 labels rather than the
c14nN labels it looks for.

Full suite: green on CI across Ubuntu and Windows for Python 3.10–3.14,
including Validate Dependencies.

Rebased onto current main, which resolved a pyproject.toml conflict with
the dependabot floor bumps for pyoxigraph and pydantic — main's higher
floors are kept.

Relation to the open PRs

linkml#3704 and linkml#3754 (@amc-corey-cox) target the same
property with an in-tree implementation. I am not proposing this instead of
that work, and the two are not mutually exclusive — linkml#3754 covers dumpers, which
this PR explicitly does not.

The reason to prefer an external implementation for the algorithm itself is the
one @cmungall and @amc-corey-cox gave on linkml#3295: it is general-purpose RDF
tooling and other projects want it too. It also happens to answer the three
review objections @matentzn raised on linkml#3704 — recursion depth at large N, the
24-hex-prefix collision grouping, and reliance on sorted stability — because
WL refinement is iterative rather than recursive, compares full labels, and
does not depend on sort stability for correctness. Those are properties of the
algorithm, not of who maintains it, so whichever route is chosen they should be
covered by tests either way.

Happy to fold this into either PR, or to close it, if that is the shorter path.

Notes for reviewers

uv.lock. The lock entry for diffable-rdf is included. The repo sets
exclude-newer = "7 days" in [tool.uv], so a full re-resolution will not
pick up a release younger than that; uv lock --check and uv sync --frozen
both pass against the committed lock, which is what CI runs — the
Validate Dependencies job is green on this PR.

A separate finding. rdf_canonicalize.py and the extracted library are the
same code, and while integrating I compared them property by property. Nine
correctness gaps in linkml's copy showed up, two of which are silent data
corruption — a @base ending in # rewrites every IRI in a way that still
parses, and a shared rdf:List tail is written twice, so nine triples in
become eleven out. One gap runs the other way: the library drops @base on its
fallback path and linkml does not.

That is out of scope here and is not folded into this PR. It is written up as
linkml#3986 (mirrored on the fork as #25), which adds a
conformance test file asserting each property against both implementations with
the failing side marked xfail(strict=True). It changes no behaviour. It is
also why the library is not yet used for the default path: it needs base_iri
support first.

Deliberately out of scope

  • Dumpers (linkml_runtime.dumpers.rdflib_dumper) — same argument applies,
    but instance data is a separate surface with separate compatibility concerns.
    feat(rdf): enable stable blank-node labels across gen-rdf and RDFDumper (#3721) linkml/linkml#3754 already covers this.
  • shexgen default format--diff-stable only has an effect when
    --format rdf is used; the default ShExC output is text, not RDF, so there
    are no blank-node labels to stabilize.
  • Making it the default — see "Off by default" above.

Dependency

Pins diffable-rdf>=0.4.0. 0.4.0 fixes the last correctness property that
linkml held and the library did not — @base surviving the rdflib fallback —
which is what unblocks linkml#3987 from deleting the in-tree
canonicalizer entirely. See ASCS-eV/diffable-rdf#60 and the
v0.4.0 release.

The library is Apache-2.0, has no dependencies beyond rdflib and
pyoxigraph (both already linkml dependencies), and is published to PyPI with
a Trusted Publisher via OIDC.

Related

Fulfils: linkml#3295 · Addresses: linkml#3702, linkml#3212,
linkml#696 · Demonstrated by: linkml#3481 · Tracking:
linkml#3721
Builds on: linkml#3407, linkml#3703, linkml#3696,
linkml#3518, linkml#3524

RDFC-1.0 canonicalization already makes RDF output deterministic:
isomorphic graphs always serialize identically. It does not make output
diffable. Blank nodes are numbered `c14nN` in a single global order, so
inserting one class can renumber every blank node after it and rewrite
most of the file. A one-line semantic change lands as a whole-file diff,
which makes generated OWL/SHACL hard to review and noisy to keep under
version control.

Add a `diff_stable` argument to `canonicalize_rdf_graph()` and a
`--diff-stable/--no-diff-stable` flag to the four RDF generators. When
enabled, blank-node labels are derived from each node's own neighbourhood
via Weisfeiler-Lehman refinement, so an edit relabels only the blank
nodes it actually touches.

Measured churn on a real schema (add one class, count changed lines):

    generator   default   --diff-stable
    owlgen         2091              17
    shexgen         796              50
    shaclgen        291              13
    rdfgen          115              25

Output stays deterministic and isomorphic either way; only the choice of
label changes. Off by default, because enabling it relabels existing
output.

The refinement itself lives in `diffable-rdf`, whose only dependencies
(rdflib, pyoxigraph) are already linkml-runtime dependencies at higher
versions, so this adds no new transitive dependencies.
…-opping

Bump the floor to diffable-rdf 0.3.0 and add the missing uv.lock entry: the
dependency was declared in pyproject.toml but never locked, so "uv lock --check"
and the "uv sync --frozen" anti-malware gate would both have failed CI.

0.3.0 also fixes two defects in the Weisfeiler-Lehman labelling this feature
relies on. Disconnected blank-node components now converge independently, so an
edit in one region no longer relabels an unrelated one. And the suffix used to
tell structurally indistinguishable nodes apart was assigned in c14nN *text*
order, so c14n10 sorted between c14n1 and c14n2 -- adding a tenth tied blank
node relabelled eight of the nine already there, the exact opposite of what this
labelling is for.

Separately, diff_stable=True was silently ignored whenever pyoxigraph refused
the graph and canonicalize_rdf_graph degraded to rdflib. Weisfeiler-Lehman
refinement consumes canonical pyoxigraph quads, and that path exists precisely
because there are none, so the argument could not be honoured -- but the caller
was never told. "shaclgen --include-annotations --diff-stable" reaches it, via
the literal predicate an annotation tag without a ':' produces, and returned
output byte-identical to --no-diff-stable. It now warns, with a regression test
asserting the warning and the byte-identical output that makes silence
misleading.
0.4.0 carries graph.base through the library's rdflib fallback, verifying
that every absolute IRI of the source survives a re-read rather than
dropping the directive outright, and adds a diff_stable parameter to
canonicalize_rdf_graph.

The lock entry is written by hand because the workspace sets
exclude-newer = "7 days", which filters any release younger than that from
resolution; 0.3.0 was pinned the same way for the same reason, and both
become resolvable normally on 2026-09-18. uv lock --check and
uv sync --all-groups both accept the entry.

https://github.com/ASCS-eV/diffable-rdf/releases/tag/v0.4.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant