feat(rdf): add opt-in diff-stable blank-node labels - #24
Open
jdsika wants to merge 3 commits into
Open
Conversation
jdsika
force-pushed
the
feat/diff-stable-serialization
branch
from
September 11, 2026 09:22
d687949 to
a3e3af4
Compare
This was referenced Sep 11, 2026
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
jdsika
force-pushed
the
feat/diff-stable-serialization
branch
from
September 11, 2026 11:50
a3e3af4 to
a4d5253
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
So the algorithm was extracted, published and hardened outside linkml as
diffable-rdf, and this PR is thethin 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:
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 globalordering 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_stableargument oncanonicalize_rdf_graph(), and a matching--diff-stable / --no-diff-stableflag 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:
--diff-stableowlgenshexgenshaclgenrdfgenIn 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 fallsback to rdflib when pyoxigraph cannot parse a graph, and on that path there are
no canonical quads to relabel, so
diff_stablecannot be honoured. It now saysso with a warning instead of returning unchanged output. This is reachable
today:
gen-shacl --include-annotationsemits a literal in predicate positionfor any annotation tag without a
:, which forces the fallback.No new transitive dependencies. The refinement lives in
diffable-rdf, asmall pure-Python library. Its only two dependencies —
rdflibandpyoxigraph— are alreadylinkml-runtimedependencies at higher versionfloors, 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 tobefore, so nothing changes for existing users.
test_diff_stable_preserves_semantics— relabelled output is isomorphic tothe default output.
test_diff_stable_is_deterministic— repeated runs produce one distinctresult.
test_diff_stable_confines_an_insertion_to_the_lines_it_touches— thebehavioural 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
c14nNlabels survive in the output), so it fails if a generator acceptsthe flag but forgets to thread it into
canonicalize_rdf_graph().test_diff_stable_warns_instead_of_silently_no_opping_on_the_fallback— thecase 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
_:cb0labels rather than thec14nNlabels 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 apyproject.tomlconflict withthe dependabot floor bumps for
pyoxigraphandpydantic— main's higherfloors 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
sortedstability — becauseWL 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 fordiffable-rdfis included. The repo setsexclude-newer = "7 days"in[tool.uv], so a full re-resolution will notpick up a release younger than that;
uv lock --checkanduv sync --frozenboth pass against the committed lock, which is what CI runs — the
Validate Dependenciesjob is green on this PR.A separate finding.
rdf_canonicalize.pyand the extracted library are thesame 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
@baseending in#rewrites every IRI in a way that stillparses, and a shared
rdf:Listtail is written twice, so nine triples inbecome eleven out. One gap runs the other way: the library drops
@baseon itsfallback 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 isalso why the library is not yet used for the default path: it needs
base_irisupport first.
Deliberately out of scope
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.
shexgendefault format —--diff-stableonly has an effect when--format rdfis used; the default ShExC output is text, not RDF, so thereare no blank-node labels to stabilize.
Dependency
Pins
diffable-rdf>=0.4.0. 0.4.0 fixes the last correctness property thatlinkml held and the library did not —
@basesurviving 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
rdflibandpyoxigraph(both already linkml dependencies), and is published to PyPI witha 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