From 5903195b28994e7e1c5810a445852123ee443faf Mon Sep 17 00:00:00 2001 From: jdsika Date: Tue, 11 Aug 2026 14:36:02 +0200 Subject: [PATCH] refactor: let rdflib decide where collection syntax is safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deterministic_turtle carried a TurtleSerializer subclass that re-decided where ``( … )`` collection syntax may be used, rejecting any chain with a cell more than one statement points into. That decision belongs to rdflib, and it now makes it: the same condition is implemented in both of rdflib's Turtle serializers upstream. Two code paths claiming the same thing is one too many, so the subclass goes and the first render uses rdflib's serializer directly. What stays is the part that is this library's own: rendering, checking the text round-trips, and falling back to explicit rdf:first/rdf:rest statements when it does not. That check is what makes the guarantee hold, and it holds whichever way rdflib decides - so this needs no rdflib floor bump, and a future rdflib that inlines more aggressively cannot silently break the canonical form. Verified inert, not assumed: - the deleted subclass and rdflib's serializer emit BYTE-IDENTICAL text for all four ASAM OpenX artifacts it is used on (opendrive/openscenario, OWL and SHACL; 8,089 / 5,476 / 15,203 / 11,040 triples). Same text means the same round-trip verdict, the same fallback branch and the same output, so no committed artifact changes and nothing needs regenerating. - 188 tests pass, including the six sharing arrangements in SHARING_CASES, which exercise the fallback rather than the subclass now. - a private list still gets ``( … )``; a shared list still falls back; a graph mixing both keeps ``( … )``. Readability is unchanged. Removes the now-unused RDF import. Signed-off-by: jdsika --- src/diffable_rdf/turtle.py | 64 ++++++++++---------------------------- 1 file changed, 16 insertions(+), 48 deletions(-) diff --git a/src/diffable_rdf/turtle.py b/src/diffable_rdf/turtle.py index c7e1cff..f99abab 100644 --- a/src/diffable_rdf/turtle.py +++ b/src/diffable_rdf/turtle.py @@ -18,7 +18,6 @@ logger = logging.getLogger(__name__) -from rdflib import RDF # noqa: E402 (kept beside the serializer that uses it) from rdflib.plugins.serializers.turtle import TurtleSerializer # noqa: E402 @@ -123,52 +122,19 @@ def _wl_signatures( -class _SharingAwareTurtleSerializer(TurtleSerializer): - """Turtle serializer that only uses ``( … )`` for lists nothing else points into. - - rdflib's :meth:`isValidList` checks that every cell of an ``rdf:List`` carries exactly - two predicates, but not how many statements point *into* the chain. When a cell is - referenced from more than one place, the inline collection form consumes it and the other - reference is left dangling, with no ``rdf:first``/``rdf:rest`` of its own:: - - _: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 lost - - A shared *head* is corrupted differently: the collection is written inline at every - reference, so re-parsing yields one private copy per reference and the triple count grows. - Either way the output does not round-trip, which defeats the point of a canonical form. - - Requiring that every cell in the chain has exactly one inbound reference keeps the - readable ``( … )`` form for the overwhelmingly common private list, and falls back to - explicit ``rdf:first``/``rdf:rest`` statements exactly where sharing makes it unsafe. - """ - - def isValidList(self, l_: "Node") -> bool: - if not super().isValidList(l_): - return False - node = l_ - while node and node != RDF.nil: - # A cell of a private list is pointed at once: by the statement that introduces - # the list, or by its predecessor's rdf:rest. More than that means sharing. - if sum(1 for _ in self.store.subject_predicates(node)) > 1: - return False - node = self.store.value(node, RDF.rest) - return True - - class _NoCollectionTurtleSerializer(TurtleSerializer): """Turtle serializer that never uses ``( … )`` collection syntax. The fallback for graphs where the inline form cannot represent the collections faithfully. - Explicit ``rdf:first``/``rdf:rest`` statements are always correct, if less readable, so this - guarantees a round-trip at the cost of verbosity - and only for the graphs that need it. + Whether it can depends on how the serializer decides to use ``( … )``, which is rdflib's + decision to make and not this library's to second-guess; what belongs here is checking the + result. An inline collection is written out at one reference only, so a chain that anything + else points into is either detached from those references or copied once per reference, and + in both cases the text says something the graph does not. + + Explicit ``rdf:first``/``rdf:rest`` statements can express any arrangement of cells, shared + or not, so this is always faithful — at the cost of verbosity, and only for the graphs that + need it. """ def isValidList(self, l_: "Node") -> bool: @@ -318,11 +284,13 @@ def _round_trips(text: str) -> bool: return len(reparsed) == len(result_graph) and isomorphic(reparsed, result_graph) # A canonical form that does not round-trip is worse than none: it silently rewrites the - # graph. rdflib decides where to use inline ``( … )`` collection syntax with a heuristic - # that does not account for statements pointing into a collection, so for some graphs the - # inline form detaches cells or duplicates them. Verify, and fall back to explicit - # rdf:first/rdf:rest statements - always faithful - for the graphs where it does. - text = _render(_SharingAwareTurtleSerializer) + # graph. Where inline ``( … )`` collection syntax is safe is rdflib's decision, and it is + # the one place this pipeline cannot verify by construction, because it depends on how many + # statements point into a chain rather than on anything the graph says locally. So take + # rdflib's output and check it, rather than predicting it: on the graphs where the inline + # form detaches or duplicates cells, fall back to explicit rdf:first/rdf:rest statements, + # which can express any arrangement of cells. + text = _render(TurtleSerializer) if not _round_trips(text): text = _render(_NoCollectionTurtleSerializer) if not _round_trips(text):