Carry graph.base on the fallback path, and add diff_stable to canonicalize_rdf_graph - #60
Conversation
Downstream validation against linkmlThe linkml conformance suite in ASCS-eV/linkml#25 / linkml/linkml#3986 asserts both implementations against the same properties, with strict xfails in both directions so that a fix on either side makes the suite fail until the mark is removed. Exactly one case was marked against this library:
Running that suite against this branch:
One mark flips, nothing else moves. That is the ratchet doing its job: it confirms the fix closes the one gap the library had, and that no other conformance property regressed. This also clears the blocker that previously stopped linkml delegating its local
Note for whoever releases this: linkml currently floors |
The rdflib fallback dropped graph.base unconditionally. A document that
holds relative references and declares no base is not self-describing:
RFC 3986 section 5.1.3 hands resolution to the retrieval URI, so the same
bytes read from two directories produced two different graphs, and
section 5.1.4 places that responsibility on the sender.
The drop had a real cause. rdflib's Serializer.relativize shortens an IRI
by string prefix rather than by the component algorithm RFC 3986 section
5.2.2 defines and Turtle section 6.3 requires, so under a base ending in
'#', in '?', or mid-path-segment it emits a reference that resolves back
to a different IRI. But the defence was too broad: it also discarded
path-segment and authority-only bases, which are the ones ordinary
tooling emits.
RFC 3986 specifies resolution and never its inverse, so a relativization
has no conformance criterion of its own and no static test can decide it.
The rendering is now re-read and the base kept only if every absolute IRI
of the source survives. Only loss counts: a relative source term is
outside the RDF abstract syntax (RDF 1.1 Concepts section 3.2) and always
resolves to something on re-reading, so a newly appearing IRI proves
nothing. A base that is not itself a valid absolute IRI is never declared,
since Turtle section 6.5 IRIREF admits no space, brace or quote.
docs/api.md already promised this for the path ("every rendering must
verify before it is returned"); only the fallback did not honour it.
Also adds diff_stable to canonicalize_rdf_graph, applying the same
Weisfeiler-Leman labelling as wl_relabel_quads so an edit stops
renumbering blank nodes elsewhere in a file. The fallback cannot relabel,
because Weisfeiler-Leman consumes pyoxigraph quads that path never
produces, so it warns rather than passing silently.
49e09fd to
0e92639
Compare
What
Two changes for 0.4.0:
graph.baseunconditionally, and instead keeps it when keeping it provably preserves the graph.canonicalize_rdf_graphgainsdiff_stable: bool = False.Why the base was being dropped
The fallback's docstring justified the drop, and the reason was real:
That is accurate.
rdflib.serializer.Serializer.relativizeis:Pure string prefixing, no component parsing — while Turtle §6.3 requires references to be "resolved with base IRIs as per [RFC3986] using only the basic algorithm in section 5", and RFC 3986 §5.2.1–5.2.2 parse base and reference into five components. So under a base ending in
#, in?, or mid-path-segment, rdflib emits a reference that resolves back to a different IRI.Why dropping it was nonetheless wrong
The defence was too broad. It discarded every base, including the path-segment and authority-only shapes that ordinary tooling actually emits — and dropping a base is not neutral:
This path emits relative references whenever the source graph holds them. Dropping the directive therefore moved the reader's own working directory into the graph's meaning — measurably: a dropped-base document re-parsed
<testing>asfile:///C:/repository/linkml/testing. Read the same bytes from two directories, get two different graphs.docs/api.mdalready promised the opposite for this function — "Every rendering must verify before it is returned" — with no fallback exemption. This is the implementation contradicting its own documented contract, not a feature request.Why verification rather than a shape check
RFC 3986 specifies resolution and never its inverse. A relativizer therefore has no conformance criterion of its own, and no static test can decide the question. Correctness can only be established by resolving the output back.
A shape heuristic ("reject bases ending in
#") is provably insufficient. Of the eight base shapes tested, it gets two wrong:http://example.org/d/http://ex.org/d#http://ex.org/d#ahttp://example.org/d/http://ex.org/a/bhttp://ex.org/a/bchttp://ex.org/http://ex.org/d?q=1http://ex.org/d?q=1xhttp://ex.org/d/<>)http://ex.org/d#The rule
Render with the base, re-read, and keep it only if every absolute IRI of the source is still present.
Only loss counts. A relative source term is outside the RDF abstract syntax — RDF 1.1 Concepts: "IRIs in the RDF abstract syntax MUST be absolute" — it reaches this path only because pyoxigraph refused the graph, and on re-reading it always resolves to something (§5.1.3 again). So a newly appearing IRI is unavoidable and proves nothing, whereas a missing absolute IRI is exactly the damage bad relativization does.
Excluded: N-Triples and N-Quads, which have no base directive to declare (N-Triples 1.1 §2.2) and which rdflib warns about and ignores.
A second defect found while testing
rdflib stores whatever base string it is handed, including text that is not an IRI at all. Writing that into a directive produces
@base <http://ex.org/a b/> .— a document a strict parser rejects outright, since Turtle §6.5IRIREFadmits no space, brace or quote. That is strictly worse than the relativization the directive was meant to support, so an invalid base is now never declared. Validation asks pyoxigraph, exactly as_is_safe_prefix_irialready does for prefix declarations.diff_stableThe same Weisfeiler-Leman relabelling
wl_relabel_quadsalready exposes, available directly oncanonicalize_rdf_graphso a caller does not have to build a pipeline around it. RDFC-1.0 labels are a function of the whole graph, so a one-triple edit can renumber every blank node in a document; WL labels depend only on a node's neighbourhood.Measured on a six-branch graph, adding one branch: 6 changed lines with RDFC-1.0, 2 with WL — the added lines and nothing else.
Opt-in, and it changes nothing else: output is deterministic either way and both renderings are isomorphic to the input, since RDF 1.1 Concepts §3.4 gives blank-node identifiers no meaning beyond a single document.
The fallback path cannot apply it — WL consumes pyoxigraph quads, and that path exists precisely because pyoxigraph refused the graph. It therefore warns rather than passing silently, so a caller is never told a stability guarantee applies to bytes that never received it.
Cost
The fallback may serialize twice. This is accepted: it affects only graphs that are already degraded, and RFC 3986 offers no static decision procedure that would let it be avoided.
Verification
1225 passed, 2 skipped(was1168 passed, 2 skipped; +57 new tests, zero pre-existing tests changed)ruff check src tests scripts— cleanmypy src/diffable_rdf— cleanNew tests cover the full eight-shape matrix in both directions, the invariant that no shape ever loses an absolute IRI, the warning naming the IRI that forced each drop, the silence when a base is safe, cross-format behaviour, the invalid-base guard,
diff_stabledeterminism/isomorphism/locality, and the fallback warning.Two tests assert the machine-independence property directly by parsing one document under two different
publicIDvalues: with a declared base the readings are identical (RFC 3986 §5.1.1 ranks an embedded base above §5.1.3's retrieval URI); without one they are not. The second is the non-vacuousness proof for the first.