Canonicalization & Diff
Byte-deterministic serialization (Codecs & Determinism) means the same dataset always emits the same bytes. Canonicalization is the stronger property: two different in-memory datasets that are isomorphic — the same graph up to blank-node relabeling — canonicalize to the same bytes.
RDFC-1.0
PurRDF implements W3C
RDF Dataset Canonicalization (RDFC-1.0)
natively in the kernel, tested against the W3C rdf-canon fixture suite
(65 vectors — 64 eval plus 1 negative — all green; see
docs/CONFORMANCE.md).
The entry point is canonicalize (with a canonicalize_with variant for
choosing the hash), producing canonical blank-node labels and, one layer up in
purrdf-rdf, canonical flat N-Quads over the frozen IR:
#![allow(unused)]
fn main() {
use purrdf::{canonicalize, parse_dataset};
let ds = parse_dataset(b"_:source <https://example.org/name> \"Alice\" .", "application/n-triples", None)?;
let renamed = parse_dataset(b"_:renamed <https://example.org/name> \"Alice\" .", "application/n-triples", None)?;
let canon = canonicalize(&ds);
// Canonical labels are stable across runs, hosts, and language bindings.
assert_eq!(canon.nquads, canonicalize(&renamed).nquads);
Ok::<(), Box<dyn std::error::Error>>(())
}
Use canonicalization when you need a content identity for a graph: hashing, signing, deduplication, or comparing datasets produced by different writers.
Native relabeling with source attribution
Use canonical_relabel when the next stage consumes a native dataset. It keeps
RDF 1.2 terms, reifiers, annotations, graph declarations and source locations
without rendering and reparsing a canonical document.
When a compiler or analyzer must associate source nodes with that result, use
canonical_relabel_with_mapping. It records the actual term mapping during
the same rebuild, after one canonical-label search:
#![allow(unused)]
fn main() {
use purrdf::{canonical_relabel_with_mapping, datasets_isomorphic, parse_dataset, TermRef};
let source = parse_dataset(b"<https://example.org/alice> <https://example.org/knows> _:friend .", "application/n-triples", None)?;
let source_node = source.term_id_by_iri("https://example.org/alice").ok_or("source IRI not interned")?;
let result = canonical_relabel_with_mapping(&source)?;
let output_node = result.map_term(source_node).ok_or("source node not retained")?;
let canonical_value = result.dataset().resolve(output_node);
assert!(matches!(canonical_value, TermRef::Iri("https://example.org/alice")));
// Associate source receipts and owner metadata with canonical_value here.
let canonical_dataset = result.into_dataset();
assert!(datasets_isomorphic(source.as_ref(), &canonical_dataset));
Ok::<(), Box<dyn std::error::Error>>(())
}
source_node must belong to the exact input dataset; output_node belongs to
this result. A same-index ID from another dataset cannot be detected. The map
includes nested triple components, literal datatypes, composite embedded blanks
and already-interned IRIs, reifier and annotation terms, and empty named graphs.
Unused dictionary entries omitted by the rewrite return None.
The map uses four bytes per source term, with constant-time lookup. The existing
canonical_relabel API does not allocate it. The map is not a durable identity
or proof certificate: persist resolved RDF identities with authenticated input
and output receipts, never raw TermId values. Source roles and standpoint
ownership remain the caller’s metadata, not consequences of canonical labels.
Over RDF 1.2 constructs: two forms, named apart
RDFC-1.0 has no notion of reifiers, annotations or triple terms. A dataset that is plain RDF 1.1 canonicalizes identically everywhere in PurRDF; one that carries those constructs canonicalizes in one of two forms, and which one you are holding is part of the identity:
- The flat form —
canonical_flat_nquadsinpurrdf-rdfrewrites the statement layer to plainrdf:reifies/ annotation triples first and canonicalizes that triple set under conformant RDFC-1.0. This is what the CLI’sconvert --canonical, the wasmDataset.canonicalize()and the W3C conformance gate run. - The
purrdf-rdfc12v2 profile —canonicalizeinpurrdf-corekeeps the statement layer and lowers it into a reservedurn:purrdf:rdfc:namespace instead (any input already carrying that namespace is refused, unless it is written in one of the two shapes the profile itself emits — those fold back into the statement layer, which is what makes canonicalization idempotent over its own output). It agrees with RDFC-1.0 byte for byte only on the RDF 1.1 subset, and a digest taken over its output must not be labelled RDFC-1.0;CANON_PROFILE_ID/CANON_PROFILE_VERSIONname the profile at runtime. The normative text isdocs/RDF12-CANON-PROFILE.md.
Isomorphism
datasets_isomorphic(a, b) decides whether two frozen datasets are
RDF-structurally isomorphic: the same quads, reifier bindings, and annotations
under a single blank-node bijection.
Canonicalization gives the equivalent verdict — two datasets are isomorphic
iff their canonicalizations are equal — but the direct check is the
convenient form for tests and harnesses. PurRDF’s own conformance harnesses
use RDFC-1.0 isomorphism to compare, for example, SHACL Rules output graphs
against expected inferred graphs.
Diff
dataset_diff(a, b) produces a structural diff between two frozen datasets,
including an isomorphic verdict. For a human-facing review flow,
purrdf-rdf additionally provides per-subject Symmetric-CBD extraction
(“describe”) and a review-friendly Turtle normalizer, so a graph change reads
like a code change.
Choosing the right tool
| Need | Use |
|---|---|
| Same dataset → same bytes | any native serializer (always true) |
| Same graph (up to blank nodes) → same bytes, RDF 1.1 subset | RDFC-1.0 (canonicalize or canonical_flat_nquads, identical here) |
| Same graph with reifiers/annotations → same bytes | canonical_flat_nquads (RDFC-1.0 over the flattened layer) or canonicalize (purrdf-rdfc12 profile) — not interchangeable |
| “Are these two datasets the same graph?” | datasets_isomorphic |
| “What changed between these datasets?” | dataset_diff + describe/normalize |
| Content-addressed transport of a graph | GTS (BLAKE3 content ids) |
API details are on
docs.rs/purrdf-core (the canonicalize,
datasets_isomorphic, and dataset_diff items) and
docs.rs/purrdf-rdf (describe and normalization).