Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,51 @@ about it.

## [Unreleased]

## [0.4.0] - 2026-09-11

**Output bytes change** for graphs that take the rdflib fallback path and carry
a base IRI. If you serialize only standard RDF, nothing here changes your
output. Regenerate the affected artifact once and subsequent runs are stable.

### Added

- `canonicalize_rdf_graph` accepts `diff_stable=True`, applying the same
Weisfeiler-Leman blank-node labelling as `wl_relabel_quads` so that editing
one part of a graph no longer renumbers blank nodes elsewhere. Opt-in;
output is deterministic and isomorphic to the input either way. The rdflib
fallback path cannot relabel — Weisfeiler-Leman consumes pyoxigraph quads
that path never produces — so it logs a warning rather than passing
silently.

### Fixed

- The rdflib fallback no longer drops `graph.base` unconditionally. A document
holding relative references and declaring no base is not self-describing:
RFC 3986 §5.1.3 hands resolution to the retrieval URI, so the same bytes read
from two directories produced two different graphs, and §5.1.4 places that
responsibility on the sender. The base was dropped because rdflib's
`Serializer.relativize` shortens IRIs by string prefix rather than by the
component algorithm RFC 3986 §5.2.2 defines and Turtle §6.3 requires, which
corrupts terms under a base ending in `#`, in `?`, or mid-path-segment.

The blanket drop over-corrected: it also discarded safe path-segment and
authority-only bases, which are the ones ordinary tooling actually emits.
RFC 3986 specifies resolution and never its inverse, so no static test can
decide this; 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 §3.2) and always
resolves to something on re-reading. Each drop logs a warning naming the base
and an IRI that forced it.

This was already the documented contract for this path in `docs/api.md`
("every rendering must verify before it is returned"); only the fallback
did not honour it.

- A base that is not itself a valid absolute IRI is never declared. rdflib
stores whatever base string it is handed, and Turtle §6.5 `IRIREF` admits no
space, brace or quote, so such a directive yields a document a strict parser
rejects outright.

## [0.3.0] - 2026-09-11

Two kinds of change here, and the difference matters when you upgrade.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ in another order, or with the blank nodes renamed — produces the same bytes.
| Function | Use it for |
|---|---|
| `deterministic_turtle(graph)` | Diff-stable, idiomatic Turtle. The default choice for files kept in version control. |
| `canonicalize_rdf_graph(graph, output_format="turtle")` | Deterministic serialization using RDFC-1.0 blank-node labels: N-Triples, N-Quads, RDF/XML, TriG, N3, JSON-LD. Its Turtle is laid out differently from `deterministic_turtle`'s — same terms, different presentation. |
| `canonicalize_rdf_graph(graph, output_format="turtle")` | Deterministic serialization using RDFC-1.0 blank-node labels: N-Triples, N-Quads, RDF/XML, TriG, N3, JSON-LD. Its Turtle is laid out differently from `deterministic_turtle`'s — same terms, different presentation. Pass `diff_stable=True` for blank-node labels that keep an edit local. |
| `deterministic_json(obj)` | Ordering an existing JSON or JSON-LD document, without touching RDF. |
| `well_known_prefix_map()` | Normalizing prefix aliases (`sdo` → `schema`) to rdflib's curated names. |
| `wl_blank_node_labels(quads)` | Diff-stable labels for blank nodes in quads you have already canonicalized. |
Expand Down
52 changes: 50 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ print(turtle)
<!-- signature -->

```python
def canonicalize_rdf_graph(graph: rdflib.Graph, output_format: str = "turtle") -> str
def canonicalize_rdf_graph(
graph: rdflib.Graph,
output_format: str = "turtle",
diff_stable: bool = False,
) -> str
```

Serializes one graph deterministically in the requested format. Use this when
Expand All @@ -150,7 +154,22 @@ not expose a standalone RDFC processor or a selectable hash algorithm.
This is the lower-level entry point: blank nodes keep their RDFC-1.0 `c14nN`
labels, which are deterministic but sequential, so inserting a triple can
renumber the rest. For output kept in version control, prefer
`deterministic_turtle`, or apply `wl_relabel_quads` in your own pipeline.
`deterministic_turtle`, or pass `diff_stable=True`.

**`diff_stable=True`** labels blank nodes by Weisfeiler-Leman refinement
instead, so a label depends on a node's own neighbourhood rather than on the
whole graph. Editing one part of a graph then leaves the rest of the file
untouched, which is what makes a version-controlled diff readable. The option
is opt-in and 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. It is the same
relabelling `wl_relabel_quads` applies, without having to build a pipeline
around it.

A graph that reaches the rdflib fallback cannot be relabelled — Weisfeiler-Leman
consumes the pyoxigraph quads that graph could not produce — so the call logs a
warning and returns rdflib-canonicalized labels, which are deterministic but
not diff-stable. It never passes silently.

**The two entry points lay Turtle out differently.** Both are correct and both
preserve every term exactly; only the presentation differs, so the same graph
Expand Down Expand Up @@ -266,6 +285,35 @@ instead would invent a graph name the input never had. N-Quads goes into a
Dataset's default graph for the same reason, and says exactly what N-Triples
says.

**`graph.base` is carried on the fallback path too, when it survives.** The
rule is the same as above — every rendering must verify before it is returned —
but the reason it has to be checked is different. The fallback writes through
rdflib, whose `Serializer.relativize` shortens an IRI by string prefix rather
than by the component algorithm RFC 3986 §5.2.2 defines and Turtle §6.3
requires. Under a base ending in `#`, in `?`, or mid-path-segment it therefore
emits a reference that resolves back to a *different* IRI.

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 instead re-read, and the base is kept only if every **absolute**
IRI of the source graph is still there. Only loss counts: a *relative* source
term is outside the RDF abstract syntax (RDF 1.1 Concepts §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 at all, since
Turtle §6.5 `IRIREF` admits no space, brace or quote and an invalid directive
costs more than it saves. Each drop logs a warning naming the base and an IRI
that forced it.

Keeping it matters because dropping it is not neutral. A document holding
relative references and declaring no base is not self-describing: RFC 3986
§5.1.3 hands resolution to the retrieval URI, so the reader's own location
becomes part of the graph — the same bytes read from two directories yield two
different graphs. §5.1.4 puts that responsibility on the sender. Where a base
cannot be kept, absolute terms are preserved in preference to relative ones.

The line-oriented formats are excluded: N-Triples and N-Quads have no base
directive to declare, and rdflib warns and ignores one.

**The line-oriented formats refuse a graph they cannot represent.** N-Triples
and N-Quads accept only absolute IRIs — "IRIs may be written only as absolute
IRIs", N-Triples 1.1 §2.2 — and a graph reaches the fallback precisely because
Expand Down
7 changes: 4 additions & 3 deletions src/diffable_rdf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
Public API:
deterministic_turtle(graph)
Diff-stable, idiomatic Turtle. The usual entry point.
canonicalize_rdf_graph(graph, output_format="turtle")
canonicalize_rdf_graph(graph, output_format="turtle", diff_stable=False)
Deterministic serialization using RDFC-1.0 labels in Turtle, N-Triples, N-Quads,
RDF/XML, TriG, N3 or JSON-LD. Any other format name is delegated to
rdflib with no determinism guarantee.
rdflib with no determinism guarantee. Pass diff_stable=True for
Weisfeiler-Leman blank-node labels that keep an edit local.
deterministic_json(obj, indent=3, preserve_list_order_keys=None)
Deterministically ordered JSON, keeping arrays whose order carries
JSON-LD meaning.
Expand Down Expand Up @@ -43,4 +44,4 @@
"__version__",
]

__version__ = "0.3.0"
__version__ = "0.4.0"
Loading
Loading