Guarantee the canonical form round-trips when RDF collections are shared - #2
Merged
Conversation
…e shared
deterministic_turtle could silently rewrite a graph. rdflib's Turtle serializer
decides where to use inline ( ... ) collection syntax with isValidList, which
checks that every cell of an rdf:List carries exactly two predicates but not how
many statements point *into* the chain. Two consequences, both silent.
A cell referenced from elsewhere is consumed by the inline form and the other
reference is left undefined, so a list is simply lost:
_:tail rdf:first "b" ; rdf:rest rdf:nil .
_:l1 rdf:first "a" ; rdf:rest _:tail .
ex:s1 sh:in _:l1 .
ex:s2 sh:in _:tail .
became
ex:s1 sh:in ( "a" "b" ) .
ex:s2 sh:in _:tail . # _:tail is never defined
A shared head is written inline at every reference instead, so re-parsing yields
one private copy per reference and the triple count grows. On a real SHACL shapes
graph of 4963 triples - 81 sh:in constraints over 51 collections, 16 of them
multiply referenced - every pass added 76 rdf:first and 76 rdf:rest triples and
canonicalization never reached a fixed point. Reported as issue #1.
Serialization is now verified rather than trusted. The output is re-parsed and
compared with the graph it was produced from; a graph whose collections the
inline form cannot represent falls back to explicit rdf:first/rdf:rest
statements, which are always faithful. Two serializers support that: one that
declines inline syntax for a chain any other statement points into, and one that
declines it entirely. If neither round-trips, the call raises rather than
returning a file that does not say what it was given.
RDFC-1.0 and the Weisfeiler-Lehman relabelling were not at fault: on the graph
above both preserve all 4963 triples and map 276 blank nodes to 276 distinct
labels. The defect was entirely in the final serialization.
Effect on that graph: 4963 triples in, 4963 out, no orphaned cells, fixed point
on the first pass, isomorphic to the input, and all 81 sh:in constraints still
resolving to their 452 literals.
tests/test_canonicalization_properties.py asserts the four promises the library
makes, over 40 seeded graphs containing blank node cycles, nested collections,
shared heads, shared interior cells and the full range of literal forms:
P1 lossless re-parsing the output is isomorphic to the input
P2 idempotent canonicalizing the output reproduces it byte for byte
P3 label-independent inputs differing only in blank node ids give equal bytes
P4 order-independent insertion order does not affect the output
P3 is what makes the form canonical and had no coverage before. Comparison for P1
is modulo RDF 1.1 literal identity - "a"^^xsd:string and "a" are the same literal
(Concepts, Sec. 3.3) and numeric lexical forms may differ - with triple counts
asserted separately so that normalisation cannot mask loss.
Alongside the properties: six shared-collection arrangements each checked for
exact round-trip, idempotence, label-independence and absence of dangling
references; a cell-count check that catches loss and duplication in one
assertion; a ten-pass drift check; a graph shaped like the real-world failure,
with a dozen lists sharing tails; and a check that the collection-free fallback
is faithful on its own, since it is what guarantees the round trip.
Against the unfixed library the suite fails 20 of 188, spread across P1, P2 and
the targeted cases. With the fix, 188 pass.
Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
Contains the collection round-trip fix, so a consumer can pin a released version rather than depending on unreleased source. Consumers that canonicalize RDF containing rdf:List structures should upgrade: 0.0.1 could silently drop or duplicate collections. Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
jdsika
force-pushed
the
fix/collection-round-trip
branch
from
August 5, 2026 05:47
e12fef9 to
6efdc7a
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.
Fixes #1.
The defect
deterministic_turtlecould silently rewrite a graph.rdflib's Turtle serializer decides where to use inline
( … )collection syntax withisValidList, which checks that every cell of anrdf:Listcarries exactly two predicates — but not how many statements point into the chain. Two consequences, both silent.A shared interior cell loses a list. The cell is consumed by the inline form and the other reference is left undefined:
serialized as
A shared head duplicates a list. It is written inline at every reference, so re-parsing yields one private copy per reference and the triple count grows.
On the graph from #1 — a SHACL shapes file of 4963 triples with 81
sh:inconstraints over 51 collections, 16 of them multiply referenced — every pass added 76rdf:firstand 76rdf:resttriples, and canonicalization never reached a fixed point.Not the canonicalizer
Worth stating, since #1 pointed at the WL hashing: RDFC-1.0 and the Weisfeiler-Lehman relabelling are both faithful on that graph — 4963 triples preserved, 276 blank nodes mapped to 276 distinct labels, 0 triples collapsed. The defect was entirely in the final serialization step.
The fix
Serialization is verified rather than trusted. The output is re-parsed and compared with the graph it was produced from. A graph whose collections the inline form cannot represent falls back to explicit
rdf:first/rdf:reststatements, which are always faithful:_SharingAwareTurtleSerializerdeclines inline syntax for a chain any other statement points into_NoCollectionTurtleSerializerdeclines it entirelyIf neither round-trips, the call raises rather than returning a file that does not say what it was given.
I kept the sharing-aware serializer as the first attempt so the readable
( … )form survives for the overwhelmingly common private list; the verify-and-fall-back structure is what makes correctness unconditional, since rdflib chooses where to inline by a traversal that depends on blank node ordering and is not fully predictable from the graph alone.Effect on the graph from #1
sh:inconstraints resolving to their literalsTest harness
tests/test_canonicalization_properties.pyasserts the four promises the library makes, over 40 seeded graphs containing blank node cycles, nested collections, shared heads, shared interior cells and the full range of literal forms:P3 is what makes the form canonical and had no coverage before this.
Comparison for P1 is modulo RDF 1.1 literal identity —
"a"^^xsd:stringand"a"are the same literal (Concepts, Sec. 3.3), and numeric lexical forms may legitimately differ — with triple counts asserted separately so that normalisation cannot mask loss.Alongside the properties:
Against the unfixed library the suite fails 20 of 188, spread across P1, P2 and the targeted cases. With the fix, 188 pass.
Note
The commit is unsigned — the GPG agent could not prompt for a passphrase in the environment this was produced in. Please re-sign before merging if that matters.