Skip to content

Carry graph.base on the fallback path, and add diff_stable to canonicalize_rdf_graph - #60

Merged
jdsika merged 1 commit into
mainfrom
feat/fallback-base-and-diff-stable
Sep 11, 2026
Merged

Carry graph.base on the fallback path, and add diff_stable to canonicalize_rdf_graph#60
jdsika merged 1 commit into
mainfrom
feat/fallback-base-and-diff-stable

Conversation

@jdsika

@jdsika jdsika commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

What

Two changes for 0.4.0:

  1. The rdflib fallback path stops dropping graph.base unconditionally, and instead keeps it when keeping it provably preserves the graph.
  2. canonicalize_rdf_graph gains diff_stable: bool = False.

Why the base was being dropped

The fallback's docstring justified the drop, and the reason was real:

The source graph's base is deliberately not carried across either -- rdflib relativizes against it by naive string prefixing, which corrupts terms under a hash base.

That is accurate. rdflib.serializer.Serializer.relativize is:

def relativize(self, uri):
    base = self.base
    if base is not None and uri.startswith(base):
        uri = URIRef(uri.replace(base, "", 1))

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:

  • RFC 3986 §5.1.3: absent an embedded base, the retrieval URI becomes the base.
  • RFC 3986 §5.1.4: "A sender of a representation containing relative references is responsible for ensuring that a base URI for those references can be established."

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> as file:///C:/repository/linkml/testing. Read the same bytes from two directories, get two different graphs.

docs/api.md already 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:

base terms safe? shape check
http://example.org/d/ absolute carry
http://ex.org/d# absolute drop — loses http://ex.org/d#a
http://example.org/d/ + relative carry
http://ex.org/a/b absolute drop — loses http://ex.org/a/bc passes, corrupts
http://ex.org/ absolute carry
http://ex.org/d?q=1 absolute drop — loses http://ex.org/d?q=1x passes, corrupts
http://ex.org/d/ term == base carry (<>)
http://ex.org/d# + relative drop

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.5 IRIREF admits 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_iri already does for prefix declarations.

diff_stable

The same Weisfeiler-Leman relabelling wl_relabel_quads already exposes, available directly on canonicalize_rdf_graph so 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 (was 1168 passed, 2 skipped; +57 new tests, zero pre-existing tests changed)
  • ruff check src tests scripts — clean
  • mypy src/diffable_rdf — clean
  • coverage 96.13%, gate 95%

New 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_stable determinism/isomorphism/locality, and the fallback warning.

Two tests assert the machine-independence property directly by parsing one document under two different publicID values: 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.

@jdsika

jdsika commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Downstream validation against linkml

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

_impls(diffable_rdf="the library's rdflib fallback serializes without passing graph.base through")

Running that suite against this branch:

  • with 0.3.0: 10 passed, 10 xfailed
  • with this branch: 1 failed, 10 passed, 9 xfailed[XPASS(strict)] the library's rdflib fallback serializes without passing graph.base through

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 rdf_canonicalize.py to this library. test_loaders_no_namespace asserts @base <http://example.org/default/> . survives rdflib_dumper.dumps(..., prefix_map={"@base": ...}), on a graph that does take the fallback path — the metamodel produces the relative <testing> from a bare status: testing on a uriorcurie slot, which pyoxigraph rejects. Checked directly against this branch:

@base kept http://example.org/default/org%201 preserved
linkml's local copy yes yes
this branch yes yes
0.3.0 no yes

Note for whoever releases this: linkml currently floors diffable-rdf>=0.3.0, so publishing 0.4.0 will make that strict xfail start failing there. Raising the floor to >=0.4.0 and dropping the mark belong in the same commit.

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

1 participant