Skip to content

Guarantee the canonical form round-trips when RDF collections are shared - #2

Merged
jdsika merged 2 commits into
mainfrom
fix/collection-round-trip
Aug 5, 2026
Merged

Guarantee the canonical form round-trips when RDF collections are shared#2
jdsika merged 2 commits into
mainfrom
fix/collection-round-trip

Conversation

@jdsika

@jdsika jdsika commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #1.

The defect

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 shared interior cell loses a list. The cell is consumed by the inline form and the other reference is left undefined:

_: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 .

serialized as

ex:s1 sh:in ( "a" "b" ) .
ex:s2 sh:in _:tail .          # _:tail is never defined — the list is gone

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: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.

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:rest statements, which are always faithful:

  • _SharingAwareTurtleSerializer declines inline syntax for a chain any other statement points into
  • _NoCollectionTurtleSerializer declines it entirely

If 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

before after
triples out (from 4963 in) 5115, then 5267, 5419, … 4963
orphaned list cells 76, then 152, 228, … 0
fixed point never pass 1
isomorphic to input no yes
sh:in constraints resolving to their literals 81 → 452 literals

Test harness

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:

property
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 this.

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 legitimately 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, a dozen lists sharing tails — this is what showed the sharing-aware heuristic alone was insufficient
  • 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.

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.

jdsika added 2 commits August 5, 2026 07:46
…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
jdsika force-pushed the fix/collection-round-trip branch from e12fef9 to 6efdc7a Compare August 5, 2026 05:47
@jdsika
jdsika merged commit 31bfb8d into main Aug 5, 2026
4 checks passed
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.

deterministic_turtle is not idempotent on graphs containing shared RDF collections

1 participant