Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

PurRDF is an RDF 1.2 toolkit: primitives, codecs, SPARQL, SHACL, ShEx, entailment, and graph transport, implemented once in Rust and carried verbatim into Python, WebAssembly/JavaScript, and C, with one exception stated up front: the GTS container reaches Rust, the CLI (as an input format), Python and C, and is not exposed by the wasm/JavaScript package. Every published crate builds for wasm32-unknown-unknown, so the engine that answers a query on a server answers it, byte for byte, in a browser tab. It is developed by Blackcat Informatics® Inc. and published under MIT OR Apache-2.0.

One RDF engine. One behavior. Every language.

What it removes from your architecture. The three jobs that usually keep a PostgreSQL instance running beside a triple store — ranked full-text search, spatial predicates, and vector similarity — are answered inside PurRDF: in-process, over the same dataset, from the same SPARQL query, with no second database and no sync job. Each answer is exact and deterministic, natively and on wasm32. This is not a projection: these query surfaces have already removed a whole PostgreSQL requirement from one RDF project. See One engine instead of three databases below.

Why does PurRDF exist?

RDF tooling fragments along two axes.

Across languages: every ecosystem has its own parser, with its own bugs, its own corner-case interpretations, and its own subset of the spec. Move a graph from a Rust service to a Python pipeline to a browser and you have silently changed what the data means three times.

Across time: RDF 1.2 — triple terms, reifiers, base-direction literals — is the current revision of the standard, and almost no incumbent library carries it.

PurRDF exists so that a graph is the same graph everywhere. It is a from-scratch, dependency-light Rust core — parser to SPARQL engine to SHACL validator to binary transport — exposed through native bindings rather than reimplemented per language.

What’s inside

  • RDF 1.2 primitives — an immutable, value-interned dataset IR (TermId space, string arena, copy-on-write mutation), with triple terms in object position, reifier/annotation side-tables, and base-direction literals. See The Interned Dataset IR.
  • Native codecs — first-party parsers/serializers for Turtle, TriG, N-Triples, N-Quads, RDF/XML, TriX, HexTuples, JSON-LD (star), and YAML-LD, with byte-deterministic output. See Codecs & Determinism. Every syntax resolves relative IRI references through one RFC 3986 layer, and a relative reference with no base in scope is a hard error. See Base IRIs & Relative References.
  • Canonicalization — W3C RDFC-1.0 on the RDF 1.1 subset; over RDF 1.2 constructs, either the flat form (RDFC-1.0 over rdf:reifies triples) or the first-party purrdf-rdfc12 profile, named apart — plus dataset diff and isomorphism. See Canonicalization & Diff.
  • Projections & carriers — deterministic LPG, CSVW, OBO Graphs, dataset-description, and research-object projections, each with a located loss ledger. See Graph, Tabular & Research-Object Projections.
  • SPARQL 1.1/1.2 — native parser → algebra → multiset evaluator, with full Update, SEP-0002 temporal arithmetic, LATERAL (SEP-0006), the SEP-0008 SHA-3 hash builtins (SHA3-224/256/384/512), quad templates that CONSTRUCT into named graphs (a first-party extension, not a SPARQL 1.2 feature), the SEP-0009 composite datatypes (cdt:List/cdt:Map, FOLD, UNFOLD — with one stated divergence: PurRDF admits RDF 1.2 triple terms and directional language-tagged literals as composite elements, a lexical superset a conformant SEP-0009 reader will call ill-formed), caller-registered aggregates and property functions (including path witnesses that bind a traversal hop by hop), governed execution with per-node explain receipts, and a SERVICE seam — a host-injected resolver carrying per-service context; no HTTP client and no resolver ship, so SERVICE and LOAD fail by name on every shipped surface unless written SILENT — gated by the W3C conformance suites. See SPARQL.
  • SPARQL extensions outside purrdf-core — deterministic full-text search with exact fixed-point BM25 (Full-Text Search), exact and float-free GeoSPARQL 1.1 (GeoSPARQL), and nearest-neighbour search over a PURREMB embedding space (Embedding Nearest Neighbours) — each a consumer of the extension seams, registered under IRIs the caller supplies.
  • SHACL and ShEx — native validators for both shape languages; the SHACL engine covers Core, SHACL-SPARQL and SHACL-AF, aligned with the SHACL 1.2 node-expression and rule-layering drafts. See Validation.
  • Entailment — Simple/RDF/RDFS/OWL-RL/D materialization (all 78 OWL 2 RL rules implemented — rule-table coverage, distinct from entailment conformance, where the OWL 2 RL entailment tests score 27 of 27 positive and 23 of 23 negative on this vendored W3C corpus), an OWL-Direct tableau, and RIF-Core rules, with a reasoning report on every closure. See Entailment, evaluated on the Datalog fixpoint engine.
  • GTS graph transport — a single-file, content-addressed, append-only container for RDF 1.2 graphs and binary payloads. See GTS Graph Transport.
  • Slices, mappings, and provenance — a slice catalog, an explicit RDF↔GTS loss ledger, SSSOM, and FnO. See Slices, Mappings & Provenance.

One engine instead of three databases

An RDF project that needs ranked text search, spatial predicates, or nearest-neighbour search has usually run PostgreSQL beside its triple store for exactly those three jobs. PurRDF answers all three from SPARQL, over the dataset already in memory, through the evaluator’s caller-keyed extension seams — and each page below opens with what its surface replaces and where it stops.

You neededUsually fromNow inside PurRDFWhere it stops
Ranked full-text searchPostgreSQL tsvector/tsqueryFull-Text Search: purrdf-text, an inverted index over RDF 1.2 literals with BM25 ranking in exact i128 fixed point and no floating point in the crate.BM25 ranking, not a Lucene: no stemming, no stop-word lists, no query dialect; an in-memory index built once over a frozen dataset.
Spatial predicatesPostGISGeoSPARQL: purrdf-geo, GeoSPARQL 1.1 with WKT and GeoJSON as exact rationals and every Simple Features, Egenhofer and RCC8 relation over an exact DE-9IM; no GEOS, no PROJ.Topological predicates, accessors and exactly computable measures over vector geometry, not a PostGIS: no CRS transform, no ellipsoidal geodesic, no buffers, no concave hull, no overlay set operations, no raster — each unimplemented function hard-errors by name (geof:convexHull is implemented).
Vector similaritypgvectorEmbedding Nearest Neighbours: exact top-k over a PURREMB embedding space, binary64 in a pinned accumulation order.Exact scan bounded by a caller-supplied KnnGuard, three metrics, no approximate index; PurRDF computes no embeddings — the vectors come from a PURREMB artifact the caller fills, which PurRDF itself writes (EmbeddingBuilder, EmbeddingStreamWriter; Rust only) and opens fail-closed.

All three are pure functions of their input on every target — fixed point, exact rationals, or a pinned binary64 order, with canonical tie-breaks — and the claim is executed rather than argued: the text and kNN determinism tests run the same body natively and on wasm32-unknown-unknown, and make geo-determinism compares the two targets byte for byte. They are Rust-host seams: a host registers an index or space under its own IRIs, and that host may itself be compiled to wasm32. The shipped npm package and Python wheel do not yet expose these three relations.

Two design rules worth knowing on day one

No feature flags — ever. There are deliberately no Cargo feature flags anywhere in the workspace, and CI enforces this. A data carrier must not have optional behavior: optionality changes semantics per consumer, so every consumer gets the same byte-identical semantics instead.

PurRDF is a toolkit, not an ontology — it mints no vocabulary IRIs. Every vocabulary the library reads or writes is caller-supplied configuration with no fabricated default. A feature exercised without its vocabulary hard-errors or stays inactive; it never invents an IRI for you. (Test fixtures use example.org.)

The full invariant list is in Design Rules & Invariants.

What the version number commits to

From 1.0.0 the suite follows semantic versioning in full: a breaking change bumps the major version, a minor bump is additive, a patch bump is bugfix-only, and the crates.io, PyPI and npm packages ship one workspace version in lockstep. The one exception is the C ABI (purrdf.h), which carries its own 0.x ABI version, bumped on every exported-signature change, and is not frozen. See Versioning & Releases.

Why RDF 1.2?

RDF 1.2 (and SPARQL 1.2) add first-class statement-level metadata to the data model: triple terms that can appear in object position, reifiers that name occurrences of a triple, and base-direction literals (rdf:dirLangString) for bidirectional text. PurRDF treats these as core data model, not an extension: they flow through the IR, the codecs, SPARQL, SHACL (a scoped SHACL 1.2 feature), the RDF/JS surface, and the GTS transport. See RDF 1.2 Features.

Where PurRDF sits

PurRDF is the library layer of a small family of linked-data projects: it is the data backbone of the GMEOW stack and the reference home of the Rust GTS engine — but it assumes nothing about your ontology or application.

How to read this book

API reference documentation lives on docs.rs/purrdf; the repository is github.com/Blackcat-Informatics/purrdf.

Getting Started: Rust

The single dependency a Rust downstream needs is the umbrella purrdf crate. It re-exports the RDF 1.2 implementation surface at its root and carries every other published crate under a stable module (purrdf::sparql, purrdf::shapes, purrdf::shex, purrdf::gts, purrdf::entail, purrdf::validate, purrdf::slice, purrdf::iri, purrdf::xsd, purrdf::events) — anything a consumer legitimately imports is reachable from purrdf alone, never by reaching into a sub-crate.

cargo add purrdf

The MSRV is Rust 1.96 (stable toolchain only; the workspace is nightly-free by policy).

Build, freeze, serialize, parse

use purrdf::{parse_dataset, serialize_dataset, RdfDatasetBuilder, RdfLiteral, SerializeGraph};

// Build a dataset in interned TermId space.
let mut b = RdfDatasetBuilder::new();
let alice = b.intern_iri("https://example.org/alice");
let knows = b.intern_iri("http://xmlns.com/foaf/0.1/knows");
let bob = b.intern_iri("https://example.org/bob");
let name = b.intern_iri("http://xmlns.com/foaf/0.1/name");
let hi = b.intern_literal(RdfLiteral::simple("Alice"));
b.push_quad(alice, knows, bob, None);
b.push_quad(alice, name, hi, None);
let ds = b.freeze().expect("freeze");

// Serialize to any native codec and parse back, losslessly.
let ttl = serialize_dataset(&ds, "text/turtle", SerializeGraph::Dataset).unwrap();
let back = parse_dataset(&ttl, "text/turtle", None).unwrap();
assert_eq!(back.quad_count(), 2);

The builder→freeze split is the heart of the API: you intern terms and push quads on a mutable RdfDatasetBuilder, then freeze() into an immutable, indexed RdfDataset that every engine (SPARQL, SHACL, ShEx, entailment) evaluates over. See The Interned Dataset IR.

Parsing text directly

let turtle = r#"
    @prefix ex: <https://example.org/> .
    ex:cat ex:says "meow" .
"#;
let dataset = purrdf::parse_dataset(turtle.as_bytes(), "text/turtle", None)
    .expect("valid Turtle");
assert_eq!(dataset.quad_count(), 1);

Malformed input is a typed RdfDiagnostic with a source location where the codec can provide one — never a silent partial parse.

Reaching the other engines

Every engine hangs off the same facade. For example, the zero-dependency IRI leaf and the ShEx schema layer:

let iri = purrdf::iri::parse("https://example.org/cat").expect("valid IRI");
assert_eq!(iri.as_str(), "https://example.org/cat");

let schema = purrdf::shex::parse_shexc(
    "PREFIX ex: <https://example.org/>\nex:Cat { ex:says . }",
    None,
).expect("valid ShExC");

When to depend on a sub-crate instead

Most applications should stop at purrdf. The sub-crates (purrdf-core, purrdf-rdf, purrdf-columnar, purrdf-sparql-algebra, purrdf-sparql-eval, purrdf-sparql-results, purrdf-cdt, purrdf-shapes, purrdf-shex, purrdf-gts, purrdf-datalog, purrdf-entail, purrdf-geo, purrdf-text, purrdf-validate, purrdf-slice, purrdf-iri, purrdf-xsd, purrdf-events, purrdf-wasm) exist for consumers that want exactly one engine — for example, a tool that only needs IRI parsing can depend on the zero-dependency purrdf-iri alone. The crate map is in the repository README.

Every release crate builds cleanly for wasm32-unknown-unknown, so the same Rust code paths work in native and wasm hosts.

Next steps

Getting Started: Python

The Python package wraps the same native Rust engine — not a reimplementation — so parsing, serialization, SPARQL, and validation behave identically to the Rust, JavaScript, and C surfaces.

pip install purrdf

Parsing

import purrdf

quads = purrdf.parse(
    '<https://example.org/alice> <http://xmlns.com/foaf/0.1/name> "Alice" .',
    purrdf.RdfFormat.TURTLE,
)

Validation: SHACL and ShEx

The native validation engines are exposed as top-level submodules mirroring the Rust purrdf umbrella crate — never through the internal purrdf_native extension module directly:

from purrdf import shapes, shex

report = shapes.validate(shapes_ttl=my_shapes, data_nt=my_data)
print(report["conforms"])

results = shex.validate(my_schema_shexc, my_data_ttl,
                        [("https://example.org/alice", "https://example.org/PersonShape")])
print(results[0]["conformant"])

SHACL result dicts keep the stable keys focus, path, value, severity, component, source_shape, and message. See SHACL and ShEx for what the engines cover.

Entailment

purrdf.entail closes a dataset under a SPARQL entailment regime. It is not purrdf.shapes.entail, which applies the SHACL-AF sh:rules a shapes graph declares; this one takes no shapes and uses the regime’s own specification rule table.

import purrdf
from purrdf import entail

dataset = purrdf.RdfDataset(my_turtle, purrdf.RdfFormat.TURTLE)
closure, report = entail.materialize(dataset, "rdfs", "")
print(closure.to_nquads())
print(report)          # what fired, what did not, boundaries, budget, contract hash

The report is the second return value and is never optional — the same discipline the Rust, WebAssembly, and C surfaces enforce. entail.materialize_nt(text, regime) is the text-in/text-out twin for callers holding an N-Triples/N-Quads document.

Coverage is measurable rather than asserted: entail.rules(regime) is the rule table the specification defines the regime by, and entail.implemented_rules(regime) is the subset that fires. "owl-direct" and "rif" return [] here — neither has a specification rule table of its own, since one decides through the tableau and the other entails under the caller’s own rules — not a raised error. See Entailment for the full picture and the rule inventory for the per-rule table.

rdflib compatibility

The package ships an rdflib compatibility layer:

from purrdf.compat.rdflib import Graph

For a literal, zero-change import rdflib, there is an opt-in extra:

pip install purrdf[rdflib]

This pulls in the separate purrdf-rdflib distribution, whose top-level rdflib package re-exports the compat surface, so existing third-party code doing import rdflib / from rdflib.namespace import RDF transparently runs on purrdf. Caveat: that shadow claims the rdflib import name and must never be installed alongside the genuine rdflib — the two cannot co-inhabit one environment. It is a separate distribution (never bundled into the main purrdf wheel) precisely so environments that need the real rdflib simply omit it.

The compat layer is gated in CI against rdflib 7.6’s own vendored test suite plus a first-party differential parity suite — see rdflib Compatibility for details and the known, ledgered divergences.

GTS relational rows

The Python package reads a GTS container back as in-memory relational rows:

from purrdf import gts_relational_rows_from_bytes

rows = gts_relational_rows_from_bytes(gts_bytes)  # terms, quads, reifiers, annotations, blobs

gts_to_sqlite(data, path), gts_to_duckdb(data, path) and gts_to_parquet(data, out_dir) write those same five tables out, in the projection’s own row order — so exporting a container twice produces the same content. SQLite needs nothing beyond the standard library; the other two raise ModuleNotFoundError naming the extra to install (purrdf[duckdb], purrdf[parquet]).

Graph, tabular, and research-object archives

purrdf.project(data, format=..., profile=..., config=...) returns canonical USTAR bytes and structured loss records. purrdf.lift(archive, profile=..., config=...) reconstructs RDF for the ten bidirectional profiles. The same strict configuration and deterministic Rust code paths are used in every host; see Graph, Tabular & Research-Object Projections for profiles and a complete example.

Next steps

Getting Started: JavaScript / WebAssembly

The npm package @blackcatinformatics/purrdf is the same Rust engine compiled to wasm32 and surfaced through an RDF/JS-shaped API (DataFactory, DatasetCore, Stream/Sink). It runs in the browser and in Node, entirely in memory.

npm install @blackcatinformatics/purrdf

Prefer to try it before installing anything? The RDF-1.2 playground runs this exact wasm build in your browser — parse, SPARQL, SHACL, serialize, and canonicalize/compare RDF-1.2 graphs client-side, with no toolchain and no server.

First dataset

Await ready() once before anything else — it performs the one-time async wasm instantiation:

import { ready, DataFactory, Dataset, QueryEngine } from "@blackcatinformatics/purrdf";

await ready(); // one-time async wasm instantiation

const f = new DataFactory();
const rtl = f.directionalLiteral("مرحبا", "ar", "rtl");

const ds = new Dataset();
ds.add(f.quad(f.namedNode("https://ex/s"), f.namedNode("https://ex/says"), rtl));

const nq = ds.serialize("nquads");           // directions survive the round-trip
const reparsed = Dataset.parse(nq, "nquads");

const engine = new QueryEngine();
const ask = engine.ask(reparsed, "ASK { <https://ex/s> <https://ex/says> ?msg }");

The RDF 1.2 wedge

No incumbent RDF/JS library carries RDF 1.2 quoted-triple terms or directional literals. PurRDF’s DataFactory exposes both:

// A quoted triple, usable as a subject/object (RDF-star / RDF 1.2).
const quoted = f.quotedTriple(
  f.namedNode("https://ex/alice"),
  f.namedNode("https://ex/knows"),
  f.namedNode("https://ex/bob"),
);

// A base-direction literal (rdf:dirLangString).
const hello = f.directionalLiteral("مرحبا", "ar", "rtl");

API surface

  • ready(bytesOrUrl?) — await once before anything else.
  • DataFactorynamedNode, blankNode, literal(value, languageOrDatatype?), typedLiteral, directionalLiteral, variable, defaultGraph, quad, quotedTriple, fromTerm, fromQuad.
  • Dataset (RDF/JS DatasetCore) — Dataset.parse(input, format, base?), serialize(format), add/delete/has/match/quads/size, and iteration (for (const quad of dataset)). Formats: turtle, ntriples, nquads, trig, rdfxml (or their media types); serialize additionally accepts jsonld.
  • Graph identityDataset.canonicalize() returns the RDFC-1.0 canonical, flat N-Quads for the graph; Dataset.isomorphic(other) decides RDF graph equality under blank-node relabeling (an exact oracle backed by full RDFC-1.0 canonicalization).
  • Graph/tabular/research-object carriersDataset.project(profile, configJson) returns canonical USTAR bytes and loss-ledger JSON; Dataset.projectWithAssets("ro-crate-1.3", configJson, payloadArchive) adds bounded attached RO-Crate payloads; liftProjection(...) reconstructs RDF for the bidirectional profiles. See Graph, Tabular & Research-Object Projections.
  • SPARQLQueryEngine keeps the native plan cache alive across calls and exposes typed select / ask / construct / describe, atomic update, and queryRaw serialization. Dataset.query(...) remains the compatibility raw-string helper.
  • SHACLshaclValidateToSarif(shapesTtl, dataNt) validates an N-Triples data graph against a Turtle shapes graph and returns a SARIF 2.1.0 report; shaclEntail(shapesTtl, dataNt) materializes the SHACL-AF sh:rule inferences as N-Triples.
  • Sink — a streaming consumer (push(quad) / finish() → Dataset); datasetToStream / streamToDataset are the async RDF/JS Stream/Sink helpers.

More on the RDF/JS mapping in RDF/JS in JavaScript.

Scope and current limitations

  • In-memory only. SPARQL queries run over the in-memory dataset; this package provides no network resolver, so remote SERVICE and LOAD fail explicitly.
  • Triple terms per format. serialize is the writer-native lane: an object-position quoted-triple term and the RDF 1.2 statement layer survive Turtle, N-Triples, N-Quads and TriG (as <<( … )>>), RDF/XML (as rdf:parseType="Triple"), and JSON-LD / YAML-LD (as @triple). TriX and HexTuples have no triple-term surface, so serializing a dataset that carries one to either of them throws rather than dropping the layer silently. Single-graph targets (Turtle, N-Triples, RDF/XML) emit the default graph alone.

Building from source

The Rust cdylib lives in crates/rdf-wasm; the published ESM package is generated from it:

make wasm-pkg        # release wasm + wasm-bindgen ESM bindings → js/pkg/
make wasm-pkg-test   # the above + TypeScript, Node, and packed-tarball gates

This requires the wasm32-unknown-unknown Rust target and a wasm-bindgen-cli pinned to the crate’s wasm-bindgen version.

Getting Started: C

libpurrdf is a stable, SemVer-disciplined extern "C" surface over the native PurRDF stack: parse, serialize, pattern iteration, copy-on-write mutation, SPARQL, SHACL validation/entailment, and GTS container round-trips. The committed, reproducible header include/purrdf.h is the ABI contract — CI fails if it drifts from the crate.

It is one shared library: libpurrdf statically reuses the purrdf-gts Rust crate, so a language shim links libpurrdf alone and still reads/writes .gts containers.

Building

The library, header, and pkg-config file are produced by cargo-c:

make capi-build                 # cargo capi build: libpurrdf.{so,a} + purrdf.h + purrdf.pc
make capi-install PREFIX=/usr   # cargo capi install into a prefix
make capi-check                 # verify the committed header is current + run the C smoke

A first program

Adapted from the repository’s C smoke test (crates/rdf-capi/tests/smoke.c):

#include "purrdf.h"
#include <stdio.h>
#include <string.h>

int main(void) {
    const char *doc = "<http://a> <http://b> <http://c> .";
    PurrdfDataset *dataset = NULL;
    PurrdfError *error = NULL;

    int rc = purrdf_parse((const uint8_t *)doc, strlen(doc), "text/turtle",
                          NULL, NULL, &dataset, &error);
    if (rc != PURRDF_STATUS_OK) return 1;

    size_t quad_count = 0;
    purrdf_dataset_quad_count(dataset, &quad_count);
    printf("%zu quad(s)\n", quad_count);

    /* Iterate every quad through a pattern cursor. */
    PurrdfGraphMatch any;
    memset(&any, 0, sizeof(any));
    any.kind = PURRDF_GRAPH_MATCH_KIND_ANY;
    PurrdfCursor *cursor = NULL;
    purrdf_quads_for_pattern(dataset, NULL, NULL, NULL, &any, &cursor, &error);

    PurrdfTermView s, p, o, g;
    uint8_t has_graph = 0;
    while (purrdf_cursor_next(cursor, &s, &p, &o, &g, &has_graph) == PURRDF_STATUS_OK) {
        printf("subject=%.*s\n", (int)s.lexical.len, (const char *)s.lexical.ptr);
    }
    purrdf_cursor_free(cursor);
    purrdf_dataset_free(dataset);
    return 0;
}

purrdf_project and purrdf_lift add deterministic graph/tabular/research-object carrier archives to this same handle model. Project returns independent archive and loss-ledger buffers; lift returns a dataset plus a ledger buffer. The compiled purrdf_project_with_assets entry point accepts a bounded payload-only USTAR and emits an attached RO-Crate with deterministic metadata and preview. The compiled crates/rdf-capi/examples/projection_roundtrip.c example demonstrates the full ownership/free order. Profiles and configuration are described in Graph, Tabular & Research-Object Projections.

The ABI contract

  • No unwinding across the boundary. Every function runs inside catch_unwind; a caught panic becomes PURRDF_STATUS_PANIC, never a process abort across FFI.
  • int32_t status + out-params. Fallible functions return a PurrdfStatus value and write results through out-pointers. PURRDF_STATUS_CURSOR_EXHAUSTED is the (non-error) end-of-rows signal.
  • SemVer-frozen ABI. The status enum is append-only; new fields and functions are additive. purrdf_abi_version reports the current ABI version at runtime; the PURRDF_ABI_MAJOR/_MINOR/_PATCH macros give the same triple for the header you compiled against, and comparing the two is how you check a library you did not build. The minor number tracks the exported signatures: any change to a parameter list, a return contract, or the documented behaviour of an exported symbol bumps it, additive parameters included, because an additive parameter is still a recompile for every C consumer.

Ownership and lifetimes

  • Every handle/buffer/error/cursor has exactly one matching *_free (purrdf_dataset_free, purrdf_graph_free, purrdf_cursor_free, purrdf_rowcursor_free, purrdf_buffer_free, purrdf_error_free). Freeing NULL is a no-op.
  • The C side never free()s a PurrdfStr.ptr — it borrows library-owned memory; copy the bytes out if they must outlive the borrow. Term views from purrdf_cursor_next are valid until the next purrdf_cursor_next on that cursor or purrdf_cursor_free.
  • Pattern cursors pin the dataset and pull rows lazily from the selected core index; opening a cursor does not allocate a matching-row snapshot.
  • PurrdfDataset is frozen and Send + Sync — readable concurrently from many threads. PurrdfGraph (the copy-on-write mutable delta) and cursors are single-threaded.

GTS star-layer round-trip

The GTS star layer round-trip (purrdf_to_gtspurrdf_from_gts of a dataset containing quoted triples / reifier bindings) succeeds with PURRDF_STATUS_OK, the same as a star-free round-trip. The C ABI calls the canonical kernel path (to_gtsread_graphimport_gts_graph); a characterization test, gts_star_roundtrip_preserves_the_statement_layer in crates/rdf-capi/tests/abi.rs, pins the restored dataset’s quoted triple and reifier binding so a regression in either layer fails there.

Full contract details — status codes, term crossing representations, thread-safety per handle — are in the purrdf-capi README.

The Interned Dataset IR

Everything in PurRDF evaluates over one intermediate representation: an immutable, value-interned RDF 1.2 dataset owned by the ring-fenced purrdf-core kernel.

Terms are interned once

Every term — IRI, blank node, literal, triple term — is stored once in a string arena and addressed by a copyable TermId (a niche-optimized NonZeroU32). Quads are rows of four TermIds. That makes term equality a single integer compare, keeps quads at a fixed small size, and means a term that appears in a million quads costs its bytes exactly once.

Hot maps use fixed-key ahash — deterministic hashing is part of the byte-determinism discipline, not just a speed choice.

Builder → freeze

The IR has a strict two-phase life cycle:

use purrdf_core::{RdfDatasetBuilder, RdfLiteral};

// Intern terms once; quads are rows of copyable TermIds.
let mut b = RdfDatasetBuilder::new();
let cat = b.intern_iri("https://example.org/cat");
let says = b.intern_iri("https://example.org/says");
let meow = b.intern_literal(RdfLiteral::simple("meow"));
b.push_quad(cat, says, meow, None);

// Freeze into the immutable, indexed dataset the engines evaluate over.
let ds = b.freeze().expect("well-formed dataset");
assert_eq!(ds.quad_count(), 1);

RdfDatasetBuilder is the mutable ingestion phase: intern terms, push quads, attach reifiers and annotations. freeze() validates the structure and produces an immutable RdfDataset: quad rows in Box<[QuadRow]> tables with lazy ordinal permutation indexes (roughly 4 bytes per quad per axis). The frozen dataset is what SPARQL, SHACL, ShEx, and entailment all read, through the allocation-free DatasetView trait.

Freezing is also what makes concurrency simple: a frozen dataset is immutable, so it can be shared and read from many threads (the C ABI exposes exactly this as a Send + Sync handle).

Copy-on-write mutation

“Immutable” does not mean “static”. Mutation happens through a copy-on-write delta over a frozen base: edits accumulate in a lightweight overlay, and the result freezes into a new dataset without copying the untouched base rows or re-interning shared terms. SPARQL UPDATE and the C ABI’s mutable PurrdfGraph handle both ride this path.

What else lives in the kernel

Beyond the IR itself, purrdf-core owns:

  • DatasetView — the static read trait every engine evaluates over.
  • Structured diagnostics — typed RdfDiagnostics with source locations (deliberately SARIF-free; the SARIF boundary is purrdf-validate).
  • Canonicalization (RDFC-1.0 on the RDF 1.1 subset; the purrdf-rdfc12 profile over reifiers and annotations), dataset diff, and isomorphism — see Canonicalization & Diff.
  • Store and engine seams — the narrow parser-ingress, serializer-egress, and SparqlEngine traits that adapters implement in sibling crates.
  • Provenance and the loss ledger — a generic provenance sidecar and the machine-readable RDF↔GTS loss matrix, plus native FnO and SSSOM codecs (see Slices, Mappings & Provenance).

Text codecs are not in the kernel — parsing and serialization live one layer up in purrdf-rdf. The split keeps the kernel small and its invariants enforceable at the crate boundary: no oxigraph, no PyO3 (a hygiene gate asserts the dependency tree), wasm32-clean, and a file-IO-free IR layer.

Why this design

The layout is chosen by measurement, not assertion: the criterion bench crates/rdf-core/benches/ir_layout.rs compares array-of-structs, struct-of-arrays, and predicate-adjacency layouts on allocation counts, high-water memory, and end-to-end latency — the shipped layout is whichever wins. See Performance.

RDF 1.2 Features

PurRDF is RDF 1.2-first: the features RDF 1.2 Concepts adds over RDF 1.1 are part of the core data model, carried through the IR, the codecs, SPARQL, validation, the language bindings, and the GTS transport.

Triple terms

RDF 1.2 lets a triple itself be a term in object position — the “quoted triple” of RDF-star, written <<( s p o )>> in SPARQL 1.2 syntax. In the IR a triple term is interned like any other term and gets a TermId, so it composes with everything else (patterns, results, serialization).

  • The star-capable codecs (Turtle, TriG, N-Triples, N-Quads, JSON-LD star) round-trip triple terms; see Codecs & Determinism for what happens on a star-incapable projection.
  • SPARQL 1.2 quoted-triple syntax is parsed by purrdf-sparql-algebra and evaluated natively — the W3C SPARQL 1.2 triple-term surface passes in the conformance harness (Conformance & Testing).
  • In JavaScript, DataFactory.quotedTriple(...) produces the same term (RDF/JS in JavaScript).

Reifiers and annotations

RDF 1.2 replaces old-style reification with reifiers: terms that name an occurrence of a triple (rdf:reifies), so you can attach metadata to a statement without asserting anything odd. In PurRDF, reifier bindings and annotations live in dedicated side-tables on the dataset rather than being smeared into the quad table.

Reifier bindings and annotations survive every star-capable codec round-trip; projections into star-incapable formats drop them loudly, with the realized count handed to the loss ledger (see Slices, Mappings & Provenance). SHACL support for validating reified statements — the draft sh:reifierShape / sh:reificationRequired surface — is covered in SHACL.

Base-direction literals

RDF 1.2 adds rdf:dirLangString: a language-tagged literal that also carries a base direction (ltr or rtl) for correct bidirectional-text handling. These are first-class in the IR and in every binding:

const rtl = f.directionalLiteral("مرحبا", "ar", "rtl");

Directions survive serialization round-trips through the star-capable codecs — the JavaScript quickstart demonstrates the N-Quads round-trip.

RDF 1.2 is a complete target, not a draft excuse

PurRDF treats the RDF 1.2 / SPARQL 1.2 specifications as a complete, implementable target. Where a feature is scoped (for example, the SHACL 1.2 reifier-shape support is a scoped Working Draft feature, not full SHACL 1.2 conformance), the scope is stated explicitly and gated by tests — never left as a silent partial implementation. The live per-feature status is the conformance matrix in docs/CONFORMANCE.md.

Where each feature shows up

FeatureIRCodecsSPARQLSHACLRDF/JSGTS
Triple terms (object position)interned termstar-capable formats<<( s p o )>>via paths/valuesquotedTriplemapped per spec
Reifiers / annotationsside-tablesstar-capable formatsreifier surfacesh:reifierShape (draft)rdf:reifies mapping
Base-direction literalsliteral kindround-tripsmatched/producedvalue nodesdirectionalLiteralcarried

The GTS mapping of triple terms and rdf:reifies is formalized in the GTS specification, which pins its RDF 1.2 substrate to the 07 April 2026 W3C Candidate Recommendation Snapshot.

RDF 1.2 Visualization

PurRDF projects RDF into a renderer-neutral statement model before producing a layout or SVG. The projection keeps structural triple terms, assertion occurrences, reifier identity, annotations, graph context, nesting, and RDF dialect diagnostics distinct. The SVG carries that model as embedded JSON metadata, so it is both a visual document and a lossless machine-readable export.

The examples below are generated directly by the Rust visualization surface. Line colour varies subtly within each semantic relation class to make dense routes easier to follow; line width, dash pattern, labels, and arrow grammar continue to carry the RDF meaning without relying on colour.

Ordinary shared resources

The compact view preserves the familiar RDF resource graph while separating routes that share nodes.

Open compact SVG

Ordinary RDF graph with shared resources

Exact statement incidence view

Open exact SVG

Ordinary RDF exact statement incidence graph

Statement table

Open table SVG

Ordinary RDF statement table

Asserted, reified, and annotated statements

Solid arrows remain assertions. Addressable statement anchors connect those assertions to reifier resources, whose ordinary RDF properties carry provenance, confidence, timestamps, and directional language literals.

Open compact SVG

Asserted RDF statements with reifiers and annotations

Exact statement incidence view

Open exact SVG

Exact incidence graph for asserted and reified statements

Statement table

Open table SVG

Statement table for asserted and reified RDF

Quoted-only and nested triple terms

A quoted-only triple is a bounded statement glyph, never a solid assertion arrow. Reifier resources stay distinct from the structural statement they reify.

Open quoted-only compact SVG

Quoted-only RDF triple terms and their reifiers

Quoted-only exact view and table

Open exact SVG · Open table SVG

Quoted-only exact incidence graph

Quoted-only statement table

Nested triple terms retain recursive statement identity. Dialect badges make symmetric or generalized RDF positions explicit rather than silently rendering them as ordinary RDF 1.2.

Open nested compact SVG

Nested triple terms with RDF dialect diagnostics

Nested exact view and table

Open exact SVG · Open table SVG

Nested exact statement incidence graph

Nested statement table

Dense connected data

The exact view uses separated vertical channels and rounded orthogonal turns so individual subject, predicate, object, reification, and annotation routes remain traceable through a dense connected dataset.

Open dense exact SVG

Dense RDF exact statement incidence graph

Dense compact view and table

Open compact SVG · Open table SVG

Dense compact RDF resource graph

Dense RDF statement table

Regenerating the samples

The committed SVGs are projections of the Rust fixtures, not hand-edited book artwork:

make book-samples
make book

make check regenerates the same artifacts in a temporary directory and rejects any drift between the renderer and the book.

Graph, Tabular, Dataset-Description & Research-Object Projections

PurRDF projects an RDF 1.2 dataset into graph, tabular, dataset-description, and research-object formats without making any of those formats the semantic authority. One Rust engine implements the mapping, packages its artifacts as canonical USTAR, and is exposed unchanged through Rust, the CLI, Python, WebAssembly, and C.

Every operation has four non-negotiable properties:

  • configuration supplies every vocabulary role, identity IRI, processing policy, and resource limit; the library fabricates none;
  • output bytes are deterministic for the same dataset and configuration;
  • loss is always computed as a closed, located ledger, even when a host chooses not to display it;
  • malformed, ambiguous, non-canonical, or out-of-bounds input is a hard error.

Profiles

ProfileNative artifactsDirectionRDF fidelity
lpg-csvgeneric nodes/edges CSVRDF ↔ carrierexact RDF sideband; property-graph lowering ledgered
neo4j-csvNeo4j Admin Import CSVRDF ↔ carriersame canonical LPG authority and ledger
open-cypherdeterministic CREATE programRDF ↔ carrierstrict reader accepts exactly the emitted grammar
graphmlGraphML 1.0 XMLRDF ↔ carrierexact RDF sideband; strict namespace/key validation
csvw-exactCSVW metadata plus RDF 1.2 tablesRDF ↔ carrierlossless
csvw-termsCSVW metadata plus caller-declared entity tablesRDF → viewlocated, closed loss ledger
okf-termsOKF v0.1 concept documents and indexesRDF → viewlocated, closed loss ledger
obo-graphsOBO Graphs 0.3.2 JSONRDF → viewlocated, closed loss ledger
skosSKOS TurtleRDF → viewlocated, closed loss ledger
croissant-1.1croissant.jsonRDF ↔ carriershared model; profile loss is located
ro-crate-1.3ro-crate-metadata.jsonRDF ↔ carriershared model; profile loss is located
datacite-4.6datacite.xmlRDF ↔ carriershared model; profile loss is located
dcat-3dcat.jsonldRDF ↔ carriershared model; profile loss is located
dcat-rdfdcat.<native extension>RDF → viewmapped or caller-CONSTRUCTed; blank-free RDF
voidvoid.<native extension>RDF → viewselected-graph statistics, partitions, and linksets
frictionless-data-package-1datapackage.jsonRDF ↔ carriershared model; profile loss is located

The type distinction between ProjectionProfile and LiftProfile matters: curated CSVW/OKF terms, OBO Graphs, SKOS, native DCAT RDF, and VoID cannot even be named as lift profiles. They are useful views, not pretend interchange formats.

One canonical LPG model

The four labeled-property-graph syntaxes are adapters over one typed LPG model, not independent RDF mappings. Nodes, edges, labels, typed property atoms, graph context, reifiers, annotations, and exact RDF statements are ordered and validated once. This gives all four carriers the same identity and reverse mapping.

Property graphs do not define RDF semantics. PurRDF therefore records each semantic lowering in the RDF-to-LPG ledger even though the canonical package also retains exact RDF sideband for reconstruction. A carrier consumer may use the native LPG view; the sideband remains the authority for an RDF lift.

Readers accept the complete form PurRDF emits and reject drift: wrong headers, duplicate rows or keys, dangling endpoints, token-map inconsistencies, unknown Cypher statements, unsafe XML, unexpected package members, and non-canonical encodings all fail.

LPG scope, limits, progress, and memory

Every LPG configuration contains a mandatory scope. {"mode":"all"} is the only way to request every graph and predicate; omission never means “all.” A selective scope independently controls the default graph, exact named-graph terms, predicate allow/deny sets, node RDF types, and native edge predicates. Named blank graphs retain their scope ordinal, and every selector IRI must be absolute.

Selection is one closed RDF 1.2 operation. Node types are indexed from graph-selected rdf_type statements even when that predicate is omitted from the output. A retained edge retains its endpoints. Reifiers and annotations are retained only when their source statement survives, and annotation predicates obey the same predicate selector. These rules prevent a selected property graph from containing dangling sideband.

LpgExecutionLimits independently bounds input records scanned, model records, nodes, and edges. ProjectionLimits bounds artifact count, one-artifact bytes, total artifact-body bytes, canonical USTAR bytes, and recursive RDF-term depth. Each bound is consumed before the corresponding model mutation or sink write; the first excess is a typed hard error. The engine does not paginate. Splitting one canonically ordered, exactly reversible carrier would make page identity and cross-page endpoints order-sensitive; callers instead choose a narrower scope or a larger explicit bound.

The direct sink path retains the selected canonical LPG model because stable backend-independent ordering, type selection, endpoint closure, and exact RDF sideband require that bounded sort/index. It then emits each artifact in lexical path order in chunks no larger than 16 KiB, retaining neither complete artifact bodies nor a USTAR buffer. The archive convenience path additionally retains materialized artifacts and the final archive. Thus the sink path is bounded by the selected model plus encoder scratch and one chunk; it is not a constant-memory RDF-to-LPG mapper.

Progress observers receive monotonic scanning, building, writing, complete, or aborted snapshots with input/model/node/edge, finished-artifact, body-byte, and active-path counters. A sink or observer failure aborts the active transaction. A sink must stage partial state, publish only on commit_package, and discard it on abort_package.

CSVW

The lower-level CSVW API models annotated table groups, schemas, dialects, columns, rows, inherited properties, datatypes and formats, language and text direction, null/default/separator handling, virtual or suppressed columns, primary and foreign keys, row titles, annotations, and URI templates. It supports the standard CSVW CSV-to-RDF processing modes over a complete in-memory package; filesystem and network discovery remain caller responsibilities.

The csvw-exact archive profile uses that machinery to carry RDF 1.2 without loss. Its canonical tables preserve terms, quads, named graph placement, recursive triple terms, reifier bindings, annotations, datatypes, language, direction, and blank-node scope. A valid exact round trip has an empty ledger.

The separate csvw-terms profile is a deliberately curated wide-table view. It is for compact catalogs such as classes, properties, individuals, business entities, or release inventories. Those names have no built-in meaning: every table, selector, predicate, datatype, and identity rule is supplied by the caller. The profile never imports an ontology model or assumes RDF, RDFS, OWL, or any application vocabulary.

A CsvwTermsConfig contains only mandatory policy:

  • csvw is the complete normative CSVW context, vocabulary, processing mode, record bound, and package limits;
  • metadata_path is the safe package-relative metadata member;
  • graph_selection is either explicit all or an exact default-graph flag and set of named-graph IRIs;
  • each ordered table declaration supplies a stable name, absolute table URL, artifact path, row selector, visible subject-IRI column, and one or more ordered predicate columns;
  • a selector may constrain any/all/none RDF types through an explicitly named type predicate and may constrain subject IRI prefixes; the type predicate is present exactly when a type set is non-empty, while an empty prefix set means no subject-namespace constraint;
  • a predicate column accepts either exact IRI objects or literals with exact datatype, language, and RDF 1.2 direction facets, plus requiredness and an explicit one-or-many cardinality;
  • execution_limits bounds total output rows, represented values, and values in one cell independently of the package and input-record bounds.

Table overlap is intentional. A subject may appear in several views when it matches several selectors. Duplicate table identities, paths, column names, or mapped predicates are rejected. One fails on a second matching value. Many sorts RDF terms canonically and joins them with the caller’s separator; an actual value containing that separator fails instead of creating an ambiguous cell. RDF direct-value statements do not carry a source sequence, so the profile does not fabricate CSVW ordered-list semantics.

Rows are ordered by subject IRI, values by canonical RDF term order, columns and tables by their declaration order, and archive members by lexical path. The same dataset and configuration therefore produce the same CSV, metadata, and USTAR bytes regardless of source interning or statement insertion order.

Every source row is accounted for. Unselected graphs or subjects, blank or triple-term subjects, unmapped predicates, facet-mismatched objects, selected named-graph placement, empty named graphs, reifier bindings, and annotations produce stable source-located ledger entries. The generated tables themselves can be read with the normative read_csvw API, but they cannot reconstruct omitted source RDF. RDF 1.2 direction remains exact in the annotated CSVW value and textDirection metadata; the W3C CSVW-to-RDF algorithm itself targets RDF 1.1 and therefore returns the language literal without direction. csvw-terms consequently has no LiftProfile variant and contains no hidden exact sideband; use csvw-exact whenever archival fidelity or an RDF round trip is required.

Research-object carriers

Croissant 1.1, RO-Crate 1.3, DataCite Metadata Schema 4.6, DCAT 3, and Frictionless Data Package v1 are adapters over one typed ResearchObjectModel. The model covers dataset identity and description, identifiers and dates, agents, licenses, resources and checksums, activities, record sets, and fields. It is the N-to-N semantic pivot: a document can be lifted to caller-vocabulary RDF and projected into any other profile without a format-pair implementation.

The three JSON-LD profiles are completely offline. Croissant, RO-Crate, and DCAT configuration supplies both the exact accepted/emitted @context JSON and the complete term-to-absolute-IRI definition map. PurRDF never dereferences a context URL, follows @import, or supplies a vocabulary. Expanded graphs are validated through the same native RDF 1.2 JSON-LD engine used elsewhere.

RO-Crate also requires an explicit packaging value. metadata-only is the single-descriptor form. attached accepts a bounded RoCrateAssets payload carrier and produces ro-crate-metadata.json, a self-contained deterministic ro-crate-preview.html, and the referenced payload members. The engine owns no filesystem access: Rust receives RoCrateAssets, the CLI receives a canonical payload-only USTAR via --assets, and the language bindings receive the same archive bytes. Local File identities and payload paths must match one-to-one; missing/extra assets, reserved paths, duplicate ownership, declared-size drift, or a nonstandard attached crate root are integrity errors. The reader validates the same contract and exact preview rendering. Preview files are not added to hasPart; only payload File entities are root data entities.

DataCite configuration supplies the namespace, schema location, XML-Schema-instance IRI, controlled values, and common RDF roles. Its reader is namespace-aware and rejects DTD/entity input. A separate identifier is used when present; otherwise the mandatory caller/document dataset identity is the primary identifier, so no DOI is synthesized. Frictionless configuration supplies the exact package profile and package name. A resource without a separate locator uses its caller-bounded relative entity identity as the safe Data Package path; no new IRI is created.

Each native reader accepts the complete profile form emitted by PurRDF and rejects duplicate members, dangling references, unsafe relative paths, incorrect context/profile identity, ambiguous cardinality, and resource-limit excesses. Format-specific constructs outside the shared model are represented by stable, location-bearing ledger entries. The committed adversarial fixtures exercise a non-empty reverse ledger for every profile; a 5×5 metamorphic matrix proves the shared semantic intersection stabilizes through every source/target pair.

Native RDF dataset descriptions

dcat-3 remains the bidirectional JSON-LD research-object carrier described above. The separate dcat-rdf profile emits a blank-free default graph in any registered native RDF syntax. Its tagged source is mandatory and has two explicit modes:

  • mapped reuses the caller-configured research-object interpretation, then emits direct RDF IR with the caller’s complete DCAT context/role map plus explicit rdf:type and XSD-string IRIs. The semantic lowering ledger from the shared model is preserved.
  • construct treats one caller-supplied SPARQL CONSTRUCT as the complete view. Query bytes, input records, output records, term depth, artifacts, bodies, and archive bytes are all bounded. The query is parsed at configuration time, runs over the complete dataset including RDF 1.2 statement layers, and must produce a graph.

Both modes use the same deterministic native serializer. The selected syntax controls the single dcat.<extension> member; configuration never infers a vocabulary, query, syntax, or base IRI. Because the result is a description view rather than an exact carrier, dcat-rdf has no lift profile.

The void profile generates a deterministic, blank-free dataset description directly in caller-vocabulary RDF. Its configuration names the described dataset, the base for stable generated resource IRIs, the source header subject and predicates, three distinct header/alignment/metadata graph selectors, a non-empty exact set of data graphs, all 22 target roles, local and external dataset-prefix registries, optional metadata-link mappings and static dataset statements, and every package/execution limit.

Statistics are computed only from the selected data graphs. triples counts selected statements, entities and distinctSubjects count distinct subjects, distinctObjects counts distinct objects, classes counts IRI objects of the configured type predicate, and properties counts predicates. A class partition includes every selected statement whose subject has that class; a property partition includes statements with exactly that predicate. Partition and linkset IRIs use full deterministic identifiers beneath the caller’s base.

Alignment rows must have IRI endpoints. Longest-prefix matching classifies each endpoint, equal-length ambiguity or no match is an error, and every alignment must have at least one local endpoint. Linksets retain source/object orientation and are grouped by subject dataset, object dataset, and predicate. The generator never guesses ownership, swaps direction, fetches metadata, or supplies a VoID/RDF/XSD namespace.

Complete portable example.org configurations and an executable TriG source are under crates/rdf/tests/fixtures/dataset-description/. They run unchanged through every host, for example:

purrdf project --profile dcat-rdf \
  --config crates/rdf/tests/fixtures/dataset-description/dcat-rdf.json \
  --from trig crates/rdf/tests/fixtures/dataset-description/void-source.trig dcat.tar

purrdf project --profile void \
  --config crates/rdf/tests/fixtures/dataset-description/void.json \
  --from trig crates/rdf/tests/fixtures/dataset-description/void-source.trig void.tar

OBO Graphs and SKOS views

The OBO Graphs writer emits version 0.3.2 nodes, edges and metadata plus directly representable equivalent-node sets, logical definitions, restrictions, domain/range axioms, and property chains. The caller supplies the graph identity and every RDF/RDFS/OWL/OBO role. Output is checked against the pinned official 0.3.2 JSON Schema.

The SKOS writer maps a caller-selected RDF graph into a caller-identified concept scheme. It supports concepts, labels, notation, documentation, hierarchy, mapping relations, membership, and top concepts, while enforcing the relevant SKOS integrity conditions. Target SKOS role IRIs are supplied just like source roles; PurRDF is a carrier and does not mint even standard vocabulary defaults.

Both views record every omitted or widened source construct, named-graph placement, and RDF 1.2 statement-layer row with a stable source location.

Configuration

The production archive API accepts strict tagged JSON of the form {"profile":"…","config":{…}}. Unknown fields and a profile/config mismatch fail. There is no default configuration. A minimal generic LPG example is:

{
  "profile": "lpg-csv",
  "config": {
    "rdf_type": "https://example.org/type",
    "scope": {"mode": "all"},
    "limits": {
      "max_artifacts": 16,
      "max_artifact_bytes": 1000000,
      "max_total_bytes": 4000000,
      "max_archive_bytes": 5000000,
      "max_term_depth": 16
    },
    "execution_limits": {
      "max_input_records": 1000,
      "max_model_records": 1000,
      "max_nodes": 1000,
      "max_edges": 1000
    }
  }
}

To retain one named graph while admitting every predicate and type, replace the scope object with:

{
  "mode": "select",
  "include_default_graph": false,
  "named_graphs": {
    "mode": "only",
    "include": [{"kind": "iri", "value": "https://example.org/business"}],
    "exclude": []
  },
  "predicates": {"mode": "all", "deny": []},
  "node_types": {"mode": "all", "deny": []},
  "edge_types": {"mode": "all", "deny": []}
}

The package and canonical-model bounds apply on write and read as relevant; the input-record bound governs RDF projection scans. Together they cover member count, one-member bytes, total body bytes, encoded archive bytes, input/model records, nodes, edges, and recursive RDF term depth. They are trust-boundary policy and must be chosen by the application.

Research-object configurations add a mandatory common object containing the complete roles, identity, and bounded policy maps. Profile-specific vocabulary roles, context data, schema identity, controlled values, and native profile identity are also mandatory. Complete runnable example.org configurations for all five profiles are under crates/rdf/tests/fixtures/research-objects/carrier/; they are examples, never library defaults.

The complete strict tagged-JSON shape for curated CSVW is exercised by crates/rdf/tests/fixtures/csvw-terms.json. Its graph selector is explicit:

{
  "kind": "include",
  "default_graph": true,
  "named_graphs": ["https://example.org/business"]
}

Each table selector then names its own caller vocabulary and row population:

{
  "type_predicate": "https://example.org/type",
  "any_types": ["https://example.org/Class"],
  "all_types": [],
  "none_types": ["https://example.org/Retired"],
  "iri_prefixes": ["https://example.org/vocab/"]
}

The runnable csvw_terms Rust example constructs the complete configuration with three ordinary table declarations—classes, properties, and individuals— and writes the canonical archive:

cargo run -p purrdf-rdf --example csvw_terms -- /tmp/terms.tar

The separate okf-terms profile projects caller-classified RDF resources into OKF v0.1 concept documents and navigation indexes. Configuration is the entire projection algebra: graph scope, category selectors, safe path identity, fixed standard-field roles, producer extensions, body/link sections, index prose, the in-band fidelity declaration, and resource ceilings. No vocabulary or category has library-owned meaning. Concept frontmatter uses the OKF standard key order followed by lexical extension keys; reserved index.md files contain navigation Markdown without concept frontmatter.

The complete strict portable configuration is crates/rdf/tests/fixtures/okf-terms.json. It is accepted unchanged by Rust, the CLI, Python, WebAssembly/TypeScript, and C:

purrdf project --profile okf-terms --config okf-terms.json \
  --from trig ontology.trig knowledge.tar

The profile is deliberately absent from LiftProfile. Bundle-to-RDF import remains the responsibility of the existing caller-profiled OKF codec; the curated generator records every source row it does not carry instead of claiming an inverse.

Rust archive API

use purrdf::{
    LiftProfile, LpgConfig, LpgExecutionLimits, LpgScope, ProjectionConfig,
    ProjectionLimits, ProjectionProfile, lift_archive, parse_dataset,
    project_archive,
};

let dataset = parse_dataset(
    b"<https://example.org/alice> <https://example.org/knows> <https://example.org/bob> .",
    "text/turtle",
    None,
)?;
let limits = ProjectionLimits::new(16, 1_000_000, 4_000_000, 5_000_000, 16)?;
let config = ProjectionConfig::LpgCsv(LpgConfig::new(
    "https://example.org/type",
    LpgScope::all(),
    limits,
    LpgExecutionLimits::new(1_000, 1_000, 1_000, 1_000)?,
)?);

let package = project_archive(dataset.as_ref(), ProjectionProfile::LpgCsv, &config)?;
let lifted = lift_archive(&package.archive, LiftProfile::LpgCsv, &config)?;
assert_eq!(lifted.dataset.quad_count(), 1);

ProjectionArchive contains the profile, USTAR bytes, and loss ledger. ProjectionLift contains the reconstructed immutable dataset and its lift ledger. project_lpg_artifacts_to_sink dispatches the four LPG profiles into a caller-owned ProjectionArtifactSink through the same configuration and mapping engine; LpgProgressObserver supplies structured progress. Lower-level APIs also expose the typed LPG, CSVW, OBO, SKOS, DCAT RDF, VoID, and in-memory research-object/artifact models.

Other production surfaces

The surface names follow the same profile/config/archive contract:

HostMaterialized projectAttached RO-CrateDirect LPG artifactsLift
Rustproject_archiveproject_archive_with_assetsproject_lpg_artifacts_to_sinklift_archive
CLIpurrdf projectpurrdf project --assets PAYLOADS.tarpurrdf lift
Pythonpurrdf.project(...)purrdf.project(..., assets=archive)purrdf.project_artifacts(...)purrdf.lift(...)
JavaScriptdataset.project(...)dataset.projectWithAssets(...)liftProjection(...)
Cpurrdf_project(...)purrdf_project_with_assets(...)purrdf_lift(...)

Runnable examples live at:

  • crates/rdf/examples/projection_archive.rs
  • crates/rdf/examples/csvw_terms.rs
  • crates/rdf/examples/okf_terms.rs
  • crates/rdf/examples/research_object_roundtrip.rs
  • crates/rdf/examples/attached_ro_crate.rs
  • crates/cli/examples/projection-roundtrip.sh
  • crates/cli/examples/dataset-descriptions.sh
  • bindings/python/examples/projection_roundtrip.py
  • bindings/python/examples/projection_stream.py
  • crates/rdf-wasm/js/examples/projection-roundtrip.mjs
  • crates/rdf-capi/examples/projection_roundtrip.c

Determinism and verification

Archive members use safe POSIX-relative paths in lexical order. USTAR headers, metadata, checksums, padding, and trailer are fixed. A reader validates the archive and requires its canonical re-encoding to match the input bytes, so it does not silently normalize attacker-controlled alternatives.

The pinned W3C CSVW manifests exercise 270 RDF cases and 282 validation cases. A locked independent csvw implementation validates production output and rejects deliberate metadata/data corruption. The OBO writer is independently validated against the pinned official schema with corruption probes. Run the whole projection verification slice with:

make projection-oracles
cargo bench -p purrdf-rdf --bench projections -- --quick

The benchmark is report-only. It measures RDF-to-LPG mapping, scoped versus explicit-all mapping over a 20-graph carrier, materialized package versus direct sink output for every LPG syntax, every LPG read path, exact CSVW write/read, exact-versus-curated CSVW over the same 12,000-quad carrier, one-graph versus all-graph curated scope, OBO Graphs and SKOS projection, mapped and CONSTRUCT DCAT RDF, VoID generation, the shared research-object model, and all five research-object write/read paths. It also reports allocation and artifact-body-size observations over deterministic fixtures.

Codecs & Determinism

PurRDF ships first-party parsers and serializers — no wrapped third-party codec — for nine formats:

FormatMedia typeStar-capable
Turtletext/turtleyes
TriGapplication/trigyes
N-Triplesapplication/n-triplesyes
N-Quadsapplication/n-quadsyes
RDF/XMLapplication/rdf+xmlno
TriXapplication/trixno
HexTuplesapplication/x-hextuplesno
JSON-LD (star)application/ld+jsonyes
YAML-LDapplication/ld+yamlyes

They live in purrdf-rdf, one layer above the kernel, and are reachable through the umbrella crate:

use purrdf::{parse_dataset, serialize_dataset, SerializeGraph};

let turtle = br#"
    @prefix ex: <https://example.org/> .
    ex:cat ex:says "meow" .
"#;

// Parse into the frozen, value-interned RDF 1.2 dataset IR.
let ds = parse_dataset(turtle, "text/turtle", None).expect("valid Turtle");
assert_eq!(ds.quad_count(), 1);

// Serialize back out through any native codec — byte-deterministic output.
let nq = serialize_dataset(&ds, "application/n-quads", SerializeGraph::Dataset)
    .expect("serializes");

Open Knowledge Format bundles

The native OKF codec maps caller-profiled RDF 1.2 datasets to agent-facing Markdown files with YAML frontmatter and lifts them back through the RDF event seam. OKF is an in-memory bundle API rather than another media type: callers choose how to store the files, so the same code remains deterministic and wasm-clean.

OkfConfig::new requires the vocabulary namespace, document base IRI, and recognized frontmatter keys. There is no built-in ontology or namespace. Use lift_okf_bundle to drive an RdfEventSink, or write_okf_bundle (backed by OkfWriter, an RdfDatasetVisitor) to project a frozen dataset. Both directions always return a loss ledger. A lossless profile yields an empty ledger; named graphs, non-profile/OWL rows, and unrelated reifier or annotation rows are pinpointed explicitly when writing.

Byte determinism

Every serializer is byte-deterministic: the same dataset always produces the same bytes, on every platform and in every language binding. This is a hard workspace invariant, not a best effort — no iteration-order, time, or RNG dependence is allowed in any output path (hashers are fixed-key ahash for exactly this reason), and golden-file tests pin the emitted bytes.

Determinism is what makes the rest of the toolkit composable: content addressing in GTS and the slice catalog, diffable serializations in review, and cross-language conformance vectors that can be compared byte-for-byte.

Diagnostics, not partial parses

Malformed input is a typed RdfDiagnostic with a source location where the codec can provide one — never a silent partial parse. Parsing can optionally record a source-position span table for richer diagnostics. Diagnostics stay structured (SARIF-free) in the core; render them as byte-deterministic SARIF 2.1.0 for editors and CI with purrdf-validate (see SHACL).

Lossy projections are loud

RDF 1.2 statement-level data (triple terms, reifier bindings, annotations) survives every star-capable round-trip. Serializing into a star-incapable projection drops that layer loudly: the realized drop count is handed to the machine-readable loss ledger (generated/transcode-loss-matrix.json, code rdf12-star-unrepresentable) rather than disappearing. The same discipline applies at the SPARQL results boundary (Result Formats) and the RDF↔GTS boundary.

The succinct pack codec

Alongside the text codecs above, purrdf-core ships a binary codec for a different job: a read-only, query-the-compressed-form encoding of a whole dataset for large-scale reference bundles, not an interchange format with a media type. PackBuilder::build_bytes(&dataset) writes a self-contained, byte-deterministic pack — a value dictionary, graph-partitioned succinct bitmap-triples, and RDF 1.2 side-tables (reifier bindings, statement annotations) — into one Vec<u8>. PackView::from_bytes(&[u8]) opens it zero-copy over a borrowed slice and answers pattern queries directly against the packed bytes, with no decompression or materialization step first.

Reach for a pack when a dataset is done changing and needs to be distributed, archived, or served at a scale where re-parsing text on every load is too slow: RDF 1.2 (named graphs, quoted triples, reifiers, annotations) is fully supported, and verify_pack independently recomputes the dataset’s RDFC-1.0 digest from the pack’s own decoded contents — a certified read-only projection, not merely a compressed file. The library never memory-maps a pack itself (every published crate stays wasm32-unknown-unknown-clean); a native consumer that wants a durable, larger-than-heap tier mmaps the file and hands PackView::from_bytes the resulting borrowed slice. See the “Pack backend” section of the backend contract for the full contract.

Deterministic embedding companions

.purremb is the mmap-native companion for embedding projections over one exact .purrpck. It does not modify the pack or RDF canonical identity. Its sorted section directory binds finite dense f32 or f64 matrices to the source pack’s exact SHA-256, an independently verified RDFC digest, complete model and processing contracts, stable target sets, and per-section plus whole-artifact integrity evidence. EmbeddingBuilder accepts unordered rows; EmbeddingStreamWriter accepts canonical rows with bounded matrix working memory; both produce the same canonical bytes.

Two subject families are first class. Large text collections use a corpus–document–chunk hierarchy: UTF-8 text remains external while target records retain content digests, logical identities, byte and Unicode-scalar coordinates, chunking contracts, and family-scoped token spans. RDF data uses one RDF 1.2 model for datasets, default and named graphs, statements, reifier bindings, annotations, directional literals, blank nodes, and recursive triple terms. Source-local pack ordinals are verified lookup hints, never identity.

Matryoshka families store only their widest dense matrix. Each declared leading prefix is a distinct VectorSpaceId and ProjectionId, so a coarse prefix cannot be silently compared with or substituted for the full space. Raw prefix rows are zero-copy strided views; deterministic L2 prefixes are calculated on demand. Approximate indexes remain opaque, rebuildable derived artifacts bound to one exact prefix projection. They never replace the authoritative matrix.

Construction follows one evidence path. First obtain a CertifiedPurrpckSource by building or independently verifying the exact source pack; arbitrary digest claims cannot construct this type. For a corpus, derive CorpusTarget, DocumentTarget::from_content, and TextChunkTarget::from_document records, add the required hierarchy relations, and add a TokenSpan for every document or chunk placed in a family matrix. For RDF, derive dataset, graph, statement, reifier, annotation, and term targets from that verified RDF 1.2 dataset. RDF-star triple terms use RdfTermTarget::Triple; they do not enter a separate identity system.

An EmbeddingFamilyContract defines the complete generation pipeline. A Matryoshka contract lists its allowed leading dimensions, while its MatrixInput carries rows only at the widest dimension and one ProjectionSpec per declared space. Consumers resolve an exact (TargetSetId, VectorSpaceId) through effective_matrix and must call require_compatible_vector_spaces before comparing rows from independent inputs.

Large collections shard at artifact boundaries: each .purremb names its own exact source pack and local target set, while equal family contracts retain the same FamilyId and VectorSpaceId. Corpus manifests and ExternalBinding::from_bytes bind external text or other exact artifacts; ExternalBinding::from_purrpck adds independently certified RDF evidence. Bindings carry caller-supplied roles and media types. PurRDF does not invent a policy or ontology vocabulary for them.

EmbeddingView::from_bytes borrows any stable byte slice, whether heap-owned, memory-mapped by the caller, or WebAssembly linear memory. Structural opening, full artifact verification, exact source verification, and certified source verification are explicit states of evidence rather than access gates. Callers that mmap files must keep the backing bytes immutable while a view or resident verification certificate exists.

Embeddings and ANN structures are sensitive derived content: model inversion, membership inference, similarity probing, digest dictionary attacks, and index structure can disclose source properties. Container hashes detect corruption and stale attachment; they do not authenticate an author, encrypt content, or grant access. See the byte-exact PURREMB v1 specification.

The columnar Parquet codec

purrdf::columnar exposes the bidirectional SQL/DataFrame interchange path. It maps any DatasetView plus a content-addressed blob store to five standard Parquet files (terms, quads, reifiers, annotations, and blobs) and reads that exact profile back without Arrow or a general Parquet runtime. The mapping retains RDF 1.2 triple terms, reifiers, annotations, graph scope, directional literals, blank-node scope, and explicitly empty named graphs.

The files are byte-deterministic and readable by engines such as DuckDB. See the normative columnar schema for every field and the deliberately narrow Parquet profile.

Conformance

The codecs are gated by the W3C rdf-tests syntax corpus, vendored and frozen in-repo — 264/264 round-trip cases across N-Quads, N-Triples, RDF/XML, TriG, and Turtle. The live scoreboard is docs/CONFORMANCE.md.

  • Canonicalization & Diff — when you need a canonical serialization rather than just a deterministic one.
  • The Interned Dataset IR — what the text codecs parse into, and the DatasetView read seam the pack codec implements alongside RdfDataset.

Base IRIs & Relative References

An RDF document may spell an IRI relatively<other.ttl>, <#me>, <> — and the reader must turn it into an absolute IRI before it can be a term. That turning needs a base IRI. PurRDF resolves every relative reference, in every syntax and on every surface, through one layer: purrdf-iri.

This is a hard failure, and it used to be silent. A relative IRI with no base in scope is now an error. Documents that previously “worked” this way did not: they interned a relative string as if it were an IRI, and emitted N-Triples that no conformant parser accepts. If a document of yours starts failing with iri-relative-no-base, it was already producing invalid RDF — give it a base (below) rather than working around the error.

Where the base comes from

The precedence chain is RFC 3986 §5.1’s, in its order. The first step that yields a base wins:

SourceRFC 3986Example
1An in-document base directive§5.1.1Turtle/TriG @base or BASE, SPARQL BASE, RDF/XML xml:base, JSON-LD/YAML-LD @context.@base
2A base the caller supplied§5.1.2the base argument to parse_dataset, the CLI’s --base
3The document’s retrieval IRI§5.1.3the file:// IRI of the file the CLI read
4(none) — hard failure§5.1.4iri-relative-no-base

Step 1 nests: a @base inside a document rebinds relative to the base already in force, so @base <sub/> under http://example.org/dir/ yields http://example.org/dir/sub/.

Nothing is ever invented. There is no default base, no fabricated urn: placeholder, and no fall back to the current working directory. Step 4 is a real, specified outcome, not a gap.

Which surfaces have a retrieval IRI

Step 3 needs a retrieval IRI, and only a surface that opened the file itself has one. Three do. The derivation lives in exactly one of them, and the other two consume it rather than re-deriving one.

SurfaceRetrieval IRI?Consequence
purrdf-slice (slice tree, catalog, dependency fixes)yes — derives it; the workspace’s one implementation of §5.1.3a relative IRI in an on-disk slice document resolves with no flags
purrdf-shapes shape-union loaderyes — consumes purrdf-slice’seach shape file parses under its own file:// IRI
purrdf CLI, per input fileyes — consumes purrdf-slice’sa relative IRI resolves with no flags
purrdf CLI, stdin (-)noa relative IRI is iri-relative-no-base; pass --base
Rust byte APIs (parse_dataset, purrdf-rdf, purrdf-iri)nopass a base, or the document must carry one
WebAssemblynoas above
C ABInoas above
Pythonnoas above

Every surface that is handed bytes rather than a path is in the second group. Bytes have no retrieval IRI, so §5.1.3 is vacuous there and §5.1.4 — the hard failure — is the specified answer. This is deliberate rather than an omission: a base invented from the local filesystem would differ per machine and leak local paths into published RDF, which would break the byte determinism the whole toolkit rests on. It is also why purrdf-iri and purrdf-rdf never touch the filesystem at all — that is what keeps them wasm32-clean.

purrdf-slice derives the retrieval IRI from the canonicalized path, translating Windows paths (including UNC hosts and the extended-length \\?\ prefix) into RFC 8089 form and percent-encoding each component. Its consumers apply it only when nothing of higher precedence was given, and a path with no usable file:// IRI is a hard error naming --base, never a silent fall back to “no base”.

Two grammar families

Whether a base can help at all depends on the syntax, not on the base:

FamilySyntaxesRelative reference
Admits relative referencesTurtle, TriG, RDF/XML, JSON-LD, YAML-LD, SPARQLresolved against the base in force
Absolute-only by grammarN-Triples, N-Quads, TriX, HexTuplesrejected — no base is ever applied

The second family’s grammars have no base directive and no relative-IRI production. A relative reference there makes the document invalid for its own syntax, so PurRDF reports a different code and supplying a base will not rescue it: convert the source to Turtle or N-Triples-with-absolute-IRIs instead.

Absolute references are never touched. In both families an IRI a document spelled absolutely is taken lexically verbatim — <http://a/bb/ccc/../d;p?q> survives intact, with or without a base in scope. Putting it through resolution anyway would apply RFC 3986 §5.2.4 dot-segment removal, which is §6.2.2.3 syntax-based normalization, forbidden by RDF Concepts §3.2. Identical document bytes must denote one graph with one canonical digest.

Beyond documents: the IR boundary

Everything above is about reading a document, but a document is not the only way a term reaches the store. You can also build a dataset directly — from Python quad objects, from a GTS container, by reopening a .pack file, with SPARQL INSERT DATA, or through a projection — and none of those pass a parser that could resolve a base for you.

The absoluteness rule therefore does not live in the codecs. It lives at the interned term table every one of those paths necessarily arrives at, so a relative IRI is not merely rejected on the way in: it is unrepresentable. A frozen dataset carrying one cannot be constructed, and the refusal reports the same iri-relative-no-base code a parser would.

There is no base in scope at that boundary and there cannot be: a frozen dataset is a set of resolved identities, with no document and no @base alongside it to resolve against later. So a relative reference there is not a term awaiting resolution — it is a term whose identity is unknowable. Resolve it against whatever base your application means before it becomes a term.

When you see the error

At the mutation that introduced it. Store.add, MutableDataset.add, the RDF/JS dataset.add, and purrdf_graph_insert all refuse a relative IRI at the call, naming the offending term:

>>> store.add(Quad(NamedNode("rel"), p, o))
ValueError: iri-relative-no-base: relative IRI reference "rel" cannot be resolved:
no base IRI is in scope; add a base to the document (`@base`/`BASE` in
Turtle-family syntaxes, `xml:base` in RDF/XML) or pass a base IRI to the API

The refused quad does not land — the store is unchanged and still usable.

The check is repeated at every point where a working set becomes a dataset: freezing a builder or a store’s pending edits, serializing, canonicalizing or digesting the result, and reopening a .pack file (whose bytes may have been written by another engine, an older version, or corrupted on disk). That repetition is deliberate. The freeze-time check is the invariant — it is what makes a relative IRI unrepresentable in the IR from any ingress, including ones that do not go through a mutation call at all. The insert-time check is the diagnosis: it exists so the error can name the line that caused it. Neither subsumes the other.

One exception you may rely on

Blank node labels and literal lexical forms are arbitrary strings and are not touched by any of this. Only IRIs are IRIs.

The diagnostic codes

These codes are stable and machine-readable. Every codec, the CLI, the C ABI, Python and wasm report the same code for the same condition.

CodeConditionWhat to do
iri-relative-no-basea relative reference in a syntax that admits one, with no base in scopeadd @base/BASE/xml:base/@context.@base to the document, or pass a base to the API (--base on the CLI)
iri-not-absolute-by-grammara relative reference — including the empty reference <> — in N-Triples, N-Quads, TriX or HexTuples; or an RDF/XML element or attribute QName whose xmlns: namespace is itself relativewrite the IRI in absolute form; a base cannot help — this position admits no relative reference
iri-non-absolute-basethe base itself has no scheme (RFC 3986 §5.1 requires an absolute base)supply a base with a scheme, e.g. http://example.org/dir/. A filesystem path is a relative reference, not a base IRI — the CLI rejects one at the argument boundary and suggests the file:// IRI you meant

RDF/XML appears in both grammar families for a reason, and the row above is the narrow half. Its rdf:about / rdf:resource / rdf:ID values are references and do resolve against xml:base, which is why the table further up lists it as admitting relative references. But an element or attribute name is composed from an xmlns: declaration plus a local name; it is not a reference, so nothing resolves it, and a relative xmlns:ex="rel/" composes to a relative IRI no base may rescue:

$ purrdf convert data.rdf --from rdfxml --to ntriples
purrdf: error iri-not-absolute-by-grammar: invalid IRI from an XML qualified name:
relative IRI reference "rel/p" is not permitted by this syntax (the caller-supplied
base, <file:///tmp/data.rdf>, is in scope but is never applied here); write the IRI
in absolute form; this syntax admits no relative IRI reference, so supplying a base
will not help

Note that the message names the base that is in scope and says it is deliberately not applied there, so a caller who passed one is not sent hunting for a dropped parameter.

The message rendered for each already carries its remedy, so a consumer that prints the error alone still tells its user what to do.

Writing a base out

Resolution has a mirror on the serialize leg. A syntax that can express a document base emits one when a base is supplied, and relativizes its IRIs against it:

SyntaxReads a baseWrites a base
Turtle, TriG@base / BASE@base
RDF/XMLxml:basexml:base
JSON-LD, YAML-LD@context.@base@context.@base
N-Triples, N-Quads, TriX, HexTuplesnono — absolute IRIs only

A format in the last row reaches its writer with no base and emits absolute IRIs. That is not a silent drop: the format simply has no base surface, so there is nothing to write, and the output stays valid for its grammar. On the CLI there is one more step — if a --base was given and neither leg of the run can spend it, the command is refused outright rather than accepting an inert flag. See below.

--base on the command line

purrdf convert, query, update and the other RDF-producing subcommands take --base <IRI>, and it acts on both legs:

  • Parsing — it is the caller-supplied base (§5.1.2), so it outranks the input’s retrieval IRI but not an in-document directive. A parse leg can spend the base only if the source syntax admits a relative reference.

  • Serializing — if the target syntax can write a base, it is emitted as the output document’s base and the IRIs are relativized against it. A serialize leg can spend the base only if the target syntax emits a base directive.

  • Neither leg can spend it — a usage error, exit 2. A base spent by any one leg is honoured, so --from turtle --to ntriples --base … is fine (the parse leg spends it) and so is --from ntriples --to turtle --base … (the serialize leg does). But --from ntriples --to ntriples --base … has nowhere to put it. Rather than exit 0 having silently ignored the flag, the CLI names both legs and refuses:

    $ purrdf convert data.nt --from ntriples --to ntriples --base http://example.org/dir/
    purrdf: --base has no effect on this run: on the source `data.nt`, ntriples's
    grammar admits no relative IRI reference, so nothing in the document resolves
    against a base; and on the --to target, ntriples can express no base directive,
    so nothing is written under one or relativized against it. Drop --base, or name
    a syntax that carries one (turtle, trig, rdfxml, jsonld, yamlld)
    $ echo $?
    2
    

    The verdict is read off the format registry’s admits_relative_iri and emits_base columns, so a newly registered syntax is classified by its own row rather than by a hand list. It applies to convert, validate, reason, entails, consistency and project. query, update, shex’s shape map and describe --iri are deliberately exempt: each has a command-line-text IRI surface with no document of its own, so --base is never inert there whatever the format rows say.

# A file input needs no flag: its own file:// retrieval IRI is the base.
purrdf convert data.ttl --to ntriples

# stdin has no retrieval IRI, so a relative IRI in it needs an explicit base.
cat data.ttl | purrdf convert - --from turtle --to ntriples \
  --base http://example.org/dir/

# Re-root a document: parse under one base, write another and relativize.
purrdf convert data.ttl --to turtle --base http://example.org/v2/

--base is validated where it is typed. A value that is not an absolute IRI is a usage error, and a path-shaped value gets a derived suggestion — ./vocab/ is answered with the file:// IRI it actually denotes, resolved rather than spliced.

A shape map given to purrdf shex is command-line text with no document of its own, so --base is the only base it can ever have. A --schema file, by contrast, is an independent document and resolves against its own retrieval IRI or its own BASE directive.

Conformance

The RFC 3986 §5.4 normative resolution table is asserted directly against the resolver in crates/iri/tests/. End-to-end, the W3C rdf-tests IRI-resolution-01/02/07/08 cases — the same table driven through @base in a real document, plus bases with trailing slashes, file paths, empty segments and colon-bearing segments — are vendored under crates/rdf/tests/corpus/w3c/{turtle,trig}/iri/ and graded on every run.

JSON-LD Contexts & Compaction

PurRDF has three explicit JSON-LD/YAML-LD serialization modes:

  • expanded preserves the byte-frozen empty-@context representation;
  • context compiles a caller prefix map or local JSON-LD 1.1 context once and applies normative compaction;
  • derived deterministically assigns neutral ns0, ns1, … aliases solely from absolute IRIs in the dataset. It never invents a vocabulary or infers @vocab.

An RDF dataset does not retain prefix declarations from Turtle, JSON-LD, or another source syntax. Supply them explicitly when those declarations are application policy. All configured surfaces consume the same closed version-1 JSON document and reject duplicate members, unknown fields, invalid contexts, network-only context references, cycles, and resource-limit excesses before emitting output.

{
  "version": 1,
  "mode": "context",
  "prefixes": {
    "ex": "https://example.org/",
    "schema": "https://schema.org/"
  },
  "yaml_schema_url": "https://example.org/purrdf.schema.json"
}

Rust

use purrdf::{
    JsonLdSerializeOptions, parse_dataset,
    serialize_dataset_to_jsonld_with_options,
};

let dataset = parse_dataset(
    b"<https://example.org/alice> <https://schema.org/name> \"Alice\" .",
    "application/n-triples",
    None,
)?;
let options = JsonLdSerializeOptions::prefixes([
    ("ex", "https://example.org/"),
    ("schema", "https://schema.org/"),
])?;
let jsonld = serialize_dataset_to_jsonld_with_options(&dataset, &options)?;
Ok::<(), purrdf::RdfDiagnostic>(())

Keep a CompiledJsonLdContext (or JsonLdSerializeOptions::compiled) when the same application context is used for many datasets. JsonLdContextRegistry resolves context IRIs and @import only from caller-supplied immutable local documents; PurRDF never performs network context loading.

CLI

Write the versioned document to a file, then pass it to any RDF-producing CLI path:

purrdf --jsonld-options context.json convert --from turtle --to jsonld input.ttl output.jsonld

The option is rejected for non-JSON-LD/YAML-LD output, canonical output, non-graph SPARQL results, and carrier projections rather than being ignored.

Python

import json
import purrdf

options = json.dumps({
    "version": 1,
    "mode": "context",
    "prefixes": {"ex": "https://example.org/"},
})
context = purrdf.CompiledJsonLdContext(options)
text = purrdf.serialize_jsonld(
    nquads,
    format=purrdf.RdfFormat.N_QUADS,
    output_format="jsonld",
    context=context,
)

Store.dump, MutableDataset.dump, immutable RdfDataset, and the RDFLib compatibility Graph.serialize surface accept the same configuration. Prefixes explicitly bound on a compatibility graph become its caller context.

JavaScript and WebAssembly

const options = JSON.stringify({
  version: 1,
  mode: "context",
  prefixes: { ex: "https://example.org/" },
});
const context = new CompiledJsonLdContext(options);
const text = dataset.serializeWithContext("jsonld", context);

Use serializeConfigured for one-shot requests. QueryEngine exposes matching configured methods for CONSTRUCT and DESCRIBE graph results, and the playground worker accepts the same options document on its serialization path.

C

Compile options with purrdf_jsonld_context_compile, reuse the returned PurrdfJsonLdContext with purrdf_serialize_jsonld_configured, then release it with purrdf_jsonld_context_free. The serializer accepts exactly one options byte slice or compiled handle. Buffers and errors retain the normal libpurrdf ownership rules.

YAML-LD uses the same context lens. Its optional schema URL changes only the deterministic YAML language-server header; it does not change RDF semantics.

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:

use purrdf::canonicalize;

let canon = canonicalize(&ds);
// Canonical labels are stable across runs, hosts, and language bindings.

Use canonicalization when you need a content identity for a graph: hashing, signing, deduplication, or comparing datasets produced by different writers.

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 formcanonical_flat_nquads in purrdf-rdf rewrites the statement layer to plain rdf:reifies / annotation triples first and canonicalizes that triple set under conformant RDFC-1.0. This is what the CLI’s convert --canonical, the wasm Dataset.canonicalize() and the W3C conformance gate run.
  • The purrdf-rdfc12 v1 profilecanonicalize in purrdf-core keeps the statement layer and lowers it into a reserved urn:purrdf:rdfc: namespace instead (any input already carrying that namespace is refused). 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_VERSION name the profile at runtime. The normative text is docs/RDF12-CANON-PROFILE.md.

Isomorphism

datasets_isomorphic(a, b) decides whether two frozen datasets are RDF-structurally isomorphic: the same quads under a 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

NeedUse
Same dataset → same bytesany native serializer (always true)
Same graph (up to blank nodes) → same bytes, RDF 1.1 subsetRDFC-1.0 (canonicalize or canonical_flat_nquads, identical here)
Same graph with reifiers/annotations → same bytescanonical_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 graphGTS (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).

SPARQL: Querying

PurRDF’s SPARQL stack is native and three-layered, gated by the W3C SPARQL 1.1 and 1.2 conformance suites:

  1. purrdf-sparql-algebra — parses query and update text into a PurRDF-owned, RDF 1.2-native query algebra (Query/GraphPattern, Update/GraphUpdateOperation). Parse and algebra only.
  2. purrdf-sparql-eval — the multiset evaluator over the frozen IR’s DatasetView, entirely in interned TermId space.
  3. purrdf-sparql-results — the results boundary (next chapter).

All three are re-exported under purrdf::sparql.

A first query

use purrdf::{RdfDatasetBuilder, RdfLiteral, SparqlEngine, SparqlRequest, SparqlResult};
use purrdf::sparql::NativeSparqlEngine;

// A tiny dataset in interned TermId space.
let mut b = RdfDatasetBuilder::new();
let cat = b.intern_iri("https://example.org/cat");
let says = b.intern_iri("https://example.org/says");
let meow = b.intern_literal(RdfLiteral::simple("meow"));
b.push_quad(cat, says, meow, None);
let ds = b.freeze().expect("freeze");

// Evaluate through the SparqlEngine seam; parsed plans are memoized.
let engine = NativeSparqlEngine::new();
let result = engine.query(&ds, SparqlRequest {
    query: "SELECT ?what WHERE { <https://example.org/cat> <https://example.org/says> ?what }",
    base_iri: None,
    substitutions: &[],
}).expect("evaluates");

if let SparqlResult::Solutions { rows, .. } = result {
    assert_eq!(rows.len(), 1);
}

The SparqlEngine trait itself lives in purrdf-core, so hosts can swap engines behind one seam; NativeSparqlEngine is the shipped implementation.

What the front-end covers

  • Query — all four query forms (SELECT/ASK/CONSTRUCT/DESCRIBE), basic graph patterns, OPTIONAL, UNION, MINUS, GRAPH, FILTER/BIND/VALUES, property paths, GROUP BY/aggregates, EXISTS/NOT EXISTS, solution modifiers, RDF 1.2 quoted triple terms (<<( s p o )>>), LATERAL (a SEP-0006 extension — see below), and the SEP-0009 composite datatypes with their FOLD aggregate and UNFOLD graph pattern (see below).
  • UpdateINSERT DATA/DELETE DATA, the DELETE/INSERT … WHERE family (WITH/USING, DELETE WHERE), LOAD, and CLEAR/DROP/CREATE/ADD/MOVE/COPY.
  • Beyond the grammar — everything else arrives through the caller-keyed extension seams described further down this page, never as new syntax: path witnesses, full-text search, GeoSPARQL and embedding nearest neighbours are each a property function (or, for the geof: family, a scalar function) registered under an IRI the caller supplies.

Anything outside this surface — and every malformed query — is a typed ParseError, never a silently degraded parse.

How the evaluator works

  • Multiset (bag) semantics — solutions are a bag, preserved until DISTINCT/REDUCED, per the SPARQL algebra.
  • Interned evaluation — constants resolve to a dataset TermId once; solution comparison is an integer compare; computed FILTER/BIND values that already exist in the dataset are promoted to the interned id at mint time.
  • Property paths in-engine — the full path algebra (* + ? / | ^ !()) evaluated over the same indexed surface, wasm-safe.
  • Cost-based BGP planning — join order is chosen by a cost model; NativeSparqlEngine::explain_query exposes the chosen order as an ordered list of triple-pattern strings so you can audit planner decisions without running the query.
  • EXISTS/NOT EXISTS, defensibly — one substitution-based definition (SEP-0007), answered either by a memoized existence probe or by the per-row definition itself, chosen per site by a prepare-time admissibility proof — see below.
  • The SERVICE seamSERVICE federation is evaluated through a host-injectable ServiceResolver: the engine itself performs no I/O, so federation stays wasm-portable and the host decides how (and whether) remote endpoints are reached — down to per-service headers, credentials and capabilities (see below). Where it stops: PurRDF ships no HTTP client (the exchange is an HttpTransport trait the Rust host implements) and no shipped surface — CLI, Python, wasm or C — installs a resolver, so a non-SILENT SERVICE or LOAD there fails by name; federation is a Rust-host composition. All seven W3C service federation cases pass through this seam. The forwarded body is re-emitted through the deterministic serializer — the federation wire format — whose parse → serialize → re-parse fidelity is swept over the 823-item corpus (every vendored W3C and first-party query and update text, plus this book’s own examples) with an empty exception ledger.
  • Governed twins and explain receipts — every query/update entry point has a governed counterpart running under caller-set ceilings (fuel, answer rows, intermediate cells, scratch bytes, remote requests, deadline) that trips with certified rows rather than a wrong answer, and explain_query returns a QueryExplanation whose ledger decomposes the fuel spent per algebra node and per charge point, beside the cost planner’s estimate for each basic graph pattern. The normative charge schedule and the frozen 50-case governor corpus are documented in docs/SPARQL-GOVERNOR-PROFILE.md.
  • Hard-fail — an out-of-scope algebra node or unimplemented builtin is a typed EvalError::Unsupported, never a partial or wrong answer.

SPARQL 1.2 temporal arithmetic and adjustment

+, -, *, /, and unary - extend past the numeric tower to xsd:dateTime/xsd:date/xsd:time (instants), xsd:duration and its two subtypes xsd:dayTimeDuration/xsd:yearMonthDuration, and the five Gregorian partial-date types (xsd:gYearMonth, xsd:gYear, xsd:gMonth, xsd:gMonthDay, xsd:gDay). The SPARQL 1.2 Query specification’s own text defines no arithmetic beyond the numeric tower; the one documented table is SEP-0002’s, which this section follows for coverage. ADJUST (below) is SEP-0002’s remaining, non-arithmetic addition.

The operator table

SEP-0002’s table has 24 rows: 11 are comparisons (</> between two yearMonthDurations or two dayTimeDurations, = between two durations, and =/</> between two dates or two times), already covered by ordinary value comparison. The remaining 13 are arithmetic:

OperandsResult
date - datedayTimeDuration
date + yearMonthDurationdate
date - yearMonthDurationdate
date + dayTimeDurationdate
date - dayTimeDurationdate
time - timedayTimeDuration
time + dayTimeDurationtime
time - dayTimeDurationtime
dateTime - dateTimedayTimeDuration
dateTime + yearMonthDurationdateTime
dateTime - yearMonthDurationdateTime
dateTime + dayTimeDurationdateTime
dateTime - dayTimeDurationdateTime
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?diff WHERE {
  BIND("2002-01-02T10:00:00"^^xsd:dateTime - "2001-01-01T10:00:00"^^xsd:dateTime AS ?diff)
}

Beyond this table, purrdf also accepts the general xsd:duration on every row above (SEP-0002 lists xsd:duration among the operand types the operator table covers, and the general type’s own value space subsumes both subtypes), duration ± duration, duration ×/÷ an exact number, and Gregorian ± duration for all five Gregorian types where the result does not require fabricating an absent field (see Divergence below):

PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?next WHERE {
  BIND("2012-10"^^xsd:gYearMonth + "P1Y1M"^^xsd:yearMonthDuration AS ?next)
}

Result datatype

A +/- between two durations, or between a duration and an instant/Gregorian value, resolves its result’s datatype from the operands’ declared tags, never from the computed component values: the result is dayTimeDuration iff every duration operand declares dayTimeDuration, yearMonthDuration iff every duration operand declares yearMonthDuration, and the general xsd:duration otherwise. Two cases make this rule concrete, because either one alone is satisfied just as well by a components-based rule that happens to agree at that one point:

A zero-valued result keeps its operands’ declared subtype rather than collapsing to a generic zero:

PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?zero WHERE {
  BIND("P1Y"^^xsd:yearMonthDuration - "P1Y"^^xsd:yearMonthDuration AS ?zero)
}

?zero is "P0M"^^xsd:yearMonthDuration — a components-only rule that inspects the (zero) result rather than the (matching) declared tags would reach the same answer here, which is exactly why the second case is needed. Conversely, a sum whose components look exactly like a pure yearMonthDuration still widens to the general type the moment either operand’s declared tag is the general one:

PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?mixed WHERE {
  BIND("P1M"^^xsd:yearMonthDuration + "PT0S"^^xsd:dayTimeDuration AS ?mixed)
}

?mixed is "P1M"^^xsd:duration, not "P1M"^^xsd:yearMonthDuration — the zero dayTimeDuration operand contributes nothing to the components but still widens the result’s declared type, because the rule reads tags, not values.

Divergence from other implementations

Gregorian ± duration can ask for a field the operand’s type does not carry — adding a duration with a months component to an xsd:gDay needs a month to apply it to, and xsd:gDay has none. xsd:gMonthDay clamps a day that does not exist in the shifted month down to that month’s actual length, the same rule date/dateTime already follow (XML Schema Appendix E) — but xsd:gMonthDay carries no year for that clamp to run against, so purrdf decides whether one would need to be fabricated by anchoring the complete months-then-days computation at every year in one full 400-year Gregorian period (the calendar’s leap rule is exactly periodic at that length, so one period is every anchor that could ever matter) and checking whether every anchor agrees on the answer. purrdf answers exactly when they do, and returns a typed error exactly when they don’t — for a duration of any magnitude, not only a bounded/ordinary one: the months and days carries are reduced by the calendar’s exact periodicity (400 years, 146,097 days) before any anchor’s arithmetic runs, so an astronomically large yearMonthDuration or dayTimeDuration component decides the same way, in the same bounded work, as a small one. The computation is judged as a whole, not component by component: a duration’s months half can land on an intermediate day whose clamp is itself year-dependent even though the finished answer, after the days half also runs, is not — `“–01-31”^^xsd:gMonthDay

  • “P1M1D”^^xsd:durationis“–03-01”from every anchor (the day after either Feb 28 or Feb 29 is always Mar 1), even though“–01-31”^^xsd:gMonthDay
  • “P1M”^^xsd:yearMonthDurationalone is genuinely ambiguous. The one recurring example of a refused class is February: every other month has the same length in every year, so a shift landing there is always safe, while a shift landing on February with the day being clamped the 29th or later is the case whose answer can turn on a yearxsd:gMonthDay` does not carry — that is an example of the refused class, not the rule itself. RDF4J answers these by fabricating the missing field (year 0, January, or day
  1. through its underlying JAXP calendar and returning a value built on that fabrication — for example "---31"^^xsd:gDay + "P1M"^^xsd:yearMonthDuration answers "---29", clamped against a fabricated leap year. purrdf matches RDF4J on every case whose answer does not depend on the fabricated field — including "2012-10"^^xsd:gYearMonth + P1Y1M = "2013-11", the one Gregorian case RDF4J’s own test suite pins — and returns a typed error exactly where the answer would depend on which fabricated value RDF4J’s calendar happened to pick:
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?bound WHERE {
  BIND("---31"^^xsd:gDay + "P1M"^^xsd:yearMonthDuration AS ?maybe)
  BIND(BOUND(?maybe) AS ?bound)
}

?bound is false: the typed error ?maybe’s BIND raises poisons to unbound, the engine’s ordinary type-error discipline. That poisoning is also this divergence’s limit: at the SPARQL surface, a typed error and a reference implementation that instead discarded its own fabricated answer (rather than returning it) would both leave the same variable unbound — identically. The visible difference between “refuses to fabricate” and “fabricates and returns a value” is real, but it lives at the value-space API boundary (an Err versus an Ok carrying a specific answer), not in a SPARQL query’s own results, where both a refusal and a hypothetical discard render the same way.

Extensions beyond SEP-0002 and F&O

purrdf adds three operators SEP-0002 and XPath and XQuery Functions and Operators (F&O) do not define, each grounded in an existing rule extended to a type F&O left out:

  • SUM/AVG over durations. SPARQL 1.1 §18.5.1.3 defines SUM by repeated op:numeric-add, whose domain is the numeric tower only; purrdf extends the same fold to the duration group, which is exact and associative under componentwise addition.

    PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
    SELECT (SUM(?d) AS ?total) WHERE {
      VALUES ?d { "P1M"^^xsd:yearMonthDuration "P2M"^^xsd:yearMonthDuration }
    }
    

    ?total is "P3M"^^xsd:yearMonthDuration. A group mixing numeric and duration values stays unbound — the extension does not widen SUM’s existing numeric-only acceptance, it only adds a second, disjoint one.

  • Unary minus on durations. F&O’s unary minus (§4.2.8) is numeric-only and defines no duration form. purrdf negates a duration’s two components together, so -(?duration) never produces the mixed-sign value the type cannot represent. Unary plus deliberately stays numeric-only, so +(?duration) is a type error while -(?duration) is not:

    PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
    SELECT ?neg WHERE {
      BIND(-("P1Y2M"^^xsd:yearMonthDuration) AS ?neg)
    }
    

    ?neg is "-P1Y2M"^^xsd:yearMonthDuration.

  • duration ÷ duration by value commensurability. F&O defines only the two same-subtype forms (op:divide-yearMonthDuration-by-yearMonthDuration, op:divide-dayTimeDuration-by-dayTimeDuration). purrdf also accepts the general xsd:duration, dispatching on whether the two operands’ values are commensurable (both purely months, or both purely seconds) rather than on their declared tags, so a dayTimeDuration and a general xsd:duration that happens to be purely day-shaped still divide:

    PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
    SELECT ?ratio WHERE {
      BIND("P30D"^^xsd:duration / "P1D"^^xsd:dayTimeDuration AS ?ratio)
    }
    

    ?ratio is 30, typed xsd:decimal. Two operands whose values are not commensurable (a purely-months value against a purely-seconds one, even under matching declared tags) are a typed error, not an arbitrary answer.

ADJUST

ADJUST(value, timezone) shifts an xsd:dateTime/xsd:date/xsd:time value to a given timezone offset, or attaches one to an untimezoned value. The SPARQL 1.2 Query specification’s own text carries no ADJUST section; the function’s one documented definition is SEP-0002’s two-argument signature, which maps onto XPath and XQuery Functions and Operators §9.6’s fn:adjust-*-to-timezone family (the same table purrdf-xsd implements for every other SPARQL 1.2 temporal builtin).

PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?adjusted WHERE {
  BIND(ADJUST("2011-01-10T14:45:13Z"^^xsd:dateTime,
              "-PT05H"^^xsd:dayTimeDuration) AS ?adjusted)
}

timezone is an xsd:dayTimeDuration in [-PT14H, PT14H] (whole minutes only), or the empty simple literal "" — SPARQL’s stand-in for XPath’s empty-sequence “remove the timezone” argument, since SPARQL itself has no empty sequence; this is the same resolution every other known SEP-0002 implementation (e.g. Apache Jena’s E_AdjustToTimezone) reaches. Arity is fixed at two and enforced at parse time — ADJUST(?x) and ADJUST(?x, ?tz, ?extra) are both refused before evaluation. Every domain or type violation (a non-whole-minute offset, an out-of-range offset, a non-temporal first argument) is a SPARQL type error, which — inside BIND, FILTER, or an aggregate argument — poisons to unbound rather than aborting the query, per the engine’s ordinary type-error discipline.

LATERAL (SEP-0006)

The SPARQL 1.2 Query specification’s own text carries no LATERAL production; the one documented definition is SEP-0006’s, implemented in Apache Jena 4.7.0, which this section follows. LATERAL adds one production to GroupGraphPatternSub, positioned alongside OPTIONAL/MINUS/ GRAPH and left-associative the same way:

GroupGraphPatternSub ::= ... | 'LATERAL' GroupGraphPattern

Unlike an ordinary join, LATERAL’s right-hand side is evaluated ONCE PER SOLUTION of its left-hand side, with that solution’s bindings visible inside — the same relationship a SQL LATERAL/CROSS APPLY subquery has to its outer query. This makes a per-group “top N” query expressible in plain SPARQL: each subject on the left picks its own smallest label on the right, rather than one globally-smallest label being computed once and joined against every row.

PREFIX : <https://example.org/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT * WHERE {
  ?s :p ?o
  LATERAL {
    SELECT * WHERE { ?s rdfs:label ?label }
    ORDER BY ?label LIMIT 1
  }
}

Correlation carries a left-hand binding of ANY RDF 1.2 term kind — including a blank node or a quoted triple — into the right-hand side, even when the only occurrence of that variable there is inside an expression (a FILTER, a BIND, a BOUND(?s) call) rather than as a triple-pattern leaf: BOUND(?s) answers correctly for a left-bound blank node or quoted triple the same as it does for an IRI or a literal, and such a row is not silently dropped for lacking a leaf occurrence to carry it.

The scope restriction

LATERAL’s right-hand side may freely REUSE a variable already bound on the left (that is the whole point — it is how correlation happens), but it may not INTRODUCE a fresh binding for one: no variable target of a BIND, of a sub-SELECT’s (expr AS ?v) projection, of a GROUP BY aggregate’s output, of an expression-valued GROUP BY (expr AS ?v) grouping condition, or a VALUES column, at the right-hand side’s own scope level, may collide with a variable already visible on the left. A bare GROUP BY ?v grouping key that just names an already-bound variable is a USE, not an introduction, and never collides. The one construct that opens a fresh scope level is a sub-SELECT’s own projection — OPTIONAL/UNION/GRAPH/a nested group/a nested LATERAL are all transparent to it. The SEP’s own legal and illegal pair:

PREFIX : <https://example.org/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
# Legal: the sub-SELECT projects only ?label, so its own (unprojected) reuse
# of ?s is correlation, not an introduction, and cannot collide.
SELECT * WHERE {
  ?s a :T
  LATERAL { SELECT ?label WHERE { ?s rdfs:label ?label } LIMIT 1 }
}
# Illegal — refused with a typed ParseError naming ?o: ?o is already bound by
# the left-hand triple pattern, and BIND tries to give it a NEW value at the
# LATERAL right-hand side's own scope level.
SELECT * WHERE {
  ?s ?p ?o
  LATERAL { BIND(123 AS ?o) }
}

Points of disagreement with Jena

Two corners of the restriction disagree with Jena’s own SyntaxVarScope check, each pinned by a named test rather than left to drift:

  • Laxer. A BIND/VALUES/aggregate introduction confined to a MINUS right operand is ACCEPTED here at any depth — directly under the LATERAL keyword or nested under a SELECT * sub-select — while Jena rejects it. SPARQL §18.2.1 puts MINUS-right variables out of scope, and §18.5’s evaluation explains why: the right operand is used only to build the compatibility test against the left operand’s rows, then its bindings are discarded, so nothing built on top of a MINUS — a SELECT * projection or anything else — can ever observe a value that operand introduced. There is nothing observable for the rule to reject. (SELECT * over such a right-hand side is one route to this same shape, not a separate one.)
  • Stricter. A SERVICE ?g { ... } endpoint variable counts as left-hand scope here; Jena omits it. SERVICE ?g { ... } with a variable endpoint requires ?g to already be bound in the incoming solution — the endpoint IRI is resolved from that binding before the remote call is made — so by the time a LATERAL right-hand side runs, ?g already holds an observable, per-row value from the left, the same as any other left-bound variable. A BIND/VALUES on the right giving it a NEW value is therefore exactly the kind of observable rebinding this rule exists to reject.

In UPDATE

INSERT/DELETE ... WHERE and WITH ... WHERE route through the same group-graph-pattern grammar a SELECT’s WHERE clause uses, so LATERAL — scope-checked exactly the same way — is legal there too. A DELETE WHERE quad template is a different grammar production (TriplesTemplate, not a GroupGraphPattern) with no group-pattern operators of any kind, so LATERAL there is refused by name rather than misparsed as a subject term.

SERVICE forwarding

A pattern containing a written LATERAL clause — anywhere in the forwarded body, including nested inside another SERVICE — is refused only under SERVICE SILENT: there, a remote’s rejection of the LATERAL extension would otherwise be swallowed into the identity table, a silent wrong answer rather than a typed refusal. A plain, non-silent SERVICE with a fixed IRI forwards the body with its LATERAL { … } text intact, so the endpoint’s actual verdict — an answer from a LATERAL-capable endpoint (LATERAL is Jena’s own extension, so a Jena-backed endpoint answers it), or an honest failure from one that does not implement it — surfaces the same way any other unsupported forwarded construct’s rejection would. A variable-endpoint SERVICE ?g is refused only when nothing supplies ?g’s binding — nothing in the incoming solution names an IRI for the remote evaluator to resolve. When an enclosing pattern binds ?g — a preceding triple pattern in the same group, or a LATERAL left-hand side’s per-row correlation — the endpoint resolves to that IRI before the remote call is made (the same per-row substitution described under “Points of disagreement with Jena” above) and the query dispatches normally, the same as a SERVICE with a fixed IRI.

Per-service context: the ServiceResolver seam

SERVICE is answered through one trait, ServiceResolver, which receives the whole request as a ServiceRequest — endpoint, forwarded query text, the SILENT flag, the executing query’s stop signal, and the intermediate-cell ceiling. Two implementations ship: HttpRemoteQuerySource, which builds and decodes SPARQL Protocol requests over a host-injected HttpTransport, and InProcessServiceResolver, which answers from datasets already in memory.

Everything a resolver needs to know about an individual service — extra headers, a credential, a timeout, and what it is permitted to do — lives on the resolver in a ServiceCatalog, keyed by the service IRI. Deliberately not in the IRI itself: a service IRI is a name, not a credential store, and encoding context there would put it into the query text, where it is visible to whoever wrote the query, is serialized into plans and receipts, and travels along with a nested SERVICE body.

A ServiceCatalog maps each service IRI to a ServiceProfile, and each profile grants an explicit set of capabilities:

CapabilityGrants
Queryresolving this service at all
Networkperforming network I/O to resolve it
Credentialsattaching the profile’s credential to the request

A catalog denies by default: a service with no entry and no explicitly configured fallback profile is refused. Gating is opt-in — a resolver with no catalog behaves exactly as it did before catalogs existed, contacting whatever endpoint it is handed and adding no headers — and configuring a catalog changes no byte of a request whose profile adds nothing.

Withholding Network is what makes an in-process façade provable rather than promised: InProcessServiceResolver owns a dataset map and nothing else — no transport, no socket, no injected callback — so there is no code path through it that performs I/O. A host can therefore offer SERVICE-shaped composition without SERVICE-shaped risk. ServiceRouter composes the two, sending some services in process and the rest to the network, with the routing table rather than the query text deciding which is which. The catalog is consulted on every resolution, nested ones included, so a SERVICE buried inside a forwarded body cannot reach an endpoint the catalog refuses at the top level.

The policy is applied before the transport is called, which is the whole difference between a gate and an audit log: a denied service never has its socket opened and then discarded.

A service IRI is an IRI like any other, so it is resolved by the workspace’s single RFC 3986 base layer (see Base IRIs) and not by a rule this seam invented. SERVICE <sparql> with no BASE in the prologue and no base passed to the API is the shared iri-relative-no-base hard error, raised while the query is parsed — before any resolver is consulted, and not softened by SILENT, which is a promise about an endpoint that does not answer rather than about an endpoint IRI that cannot be formed. With a base in scope the resolver is handed the absolute IRI, which is the form a ServiceCatalog is keyed on.

The SILENT contract

SPARQL 1.1 §10 says a SERVICE SILENT clause whose endpoint cannot be reached “will be considered to have matched with a single, empty, solution” — the join identity, so the surrounding query proceeds unchanged. PurRDF keeps that promise exactly, and confines it to what it is a promise about:

OutcomeSERVICESERVICE SILENT
The endpoint is unreachable, or its response undecodablequery errorjoin identity
A capability was deniedquery errorquery error
This engine’s own governor trippedtruncationtruncation

The first and last rows are long-standing behaviour: SILENT is a statement about an endpoint the caller does not control, never about the caller’s own budget, so a governor trip reached through a SERVICE clause propagates as a truncation whether or not SILENT is written.

The middle row follows from that same principle. A capability denial is a decision taken on this side of the seam — by the host running the engine, deterministically, before any endpoint was consulted — so it is exactly like a governor trip and nothing like an unreachable endpoint. Swallowing one would put the join identity into the surrounding join, making it a no-op, and hand back an answer that looks complete and is wrong; and because a denial is permanent rather than transient, it would be wrong identically on every run, so nothing would ever surface a symptom.

That row holds at every nesting depth. InProcessServiceResolver evaluates a forwarded body itself, so a denial raised by a SERVICE nested inside one travels back out through that inner evaluation — as a structured denial, never flattened into a message. Flattening it would make a nested denial silenceable by an enclosing SERVICE SILENT while the identical denial one level up is not, which is the same look-complete-and-be-wrong outcome the row exists to prevent.

There is deliberately no knob that softens this. A host that genuinely wants a blocked service to behave like an unreachable one already has an exact way to say so: return a transport error from its own resolver. That is an honest claim that the endpoint did not answer, and SILENT swallows it under the first row. Adding a visibility flag would have bought no expressive power, only a second spelling of an existing one — and with it the possibility of two callers running the same query over the same data through the same resolver and getting different answers.

EXISTS under SEP-0007

SPARQL 1.1/1.2 §18.6 defines EXISTS/NOT EXISTS through two functions: substitute, which rewrites a pattern’s variables against the row being filtered, and evalExists, which asks whether the substituted pattern’s evaluation is non-empty. Read as literal term-rewriting, substitute has defects at several precise points:

  • Variable-only positions. substitute is stated over variable POSITIONS in a pattern, with no defined action on a Bgp/Path leaf term or a GRAPH ?g name treated as anything other than an expression — so a correlated variable occurring only in a triple or graph-name position has no substitution to apply.
  • The MINUS domain flip. Rewriting a MINUS’s shared variables away as constants erases the very columns its domain-join compares the two operands on, turning a correlated MINUS into an unconditionally disjoint one — the opposite of what substituting the row in was supposed to preserve.
  • Blank nodes as variables. No SPARQL surface syntax can spell a blank node or a quoted triple as a rewritten constant, so a correlated blank-node or quoted-triple binding has no legal substituted form under literal term-rewriting.
  • Disconnected variables. A correlated variable reachable ONLY through an expression position inside a nested EXISTS — never through that inner’s own triple-pattern leaves — has nothing in substitute’s pattern-rewriting reading to carry it there.
  • The assignment restriction. §18.6’s own text states no rule at all about an EXISTS body rebinding a variable already in scope on the row it filters.

SEP-0007 — a SPARQL-dev proposal, not yet folded into the SPARQL 1.1/1.2 REC text — repairs the first four by restating substitute as Replace, a JOIN rather than a rewrite, and adds the fifth as Part 3, a new restriction. purrdf implements SEP-0007’s repairs in full, including Part 3.

The one definition

exists(X, μ) ⟺ eval(D(G), Replace(PrjMap(X), μ)) is non-empty

Replace is Values Insertion: the current row μ joins into X as a one-row VALUES table at each Bgp/Path/Graph(Var, ·) site, rather than being spliced in as syntactic constants — total over every RDF 1.2 term kind, including blank nodes and quoted triples, because it reaches the dataset through the same evaluation path real VALUES data uses, and it needs no special case for a variable reachable only through an expression, because the join reaches every leaf regardless of which position introduced the correlation. PrjMap is Project-boundary narrowing: μ is restricted to a Project node’s own variable list before it is joined in below that node — the one scope boundary the surface language has, and the reason a sub-SELECT’s own projection can legitimately shadow an outer variable.

SEP-0007 states Replace in terms of toMultiSet(μ) — converting the row to the multiset unit the join needs. purrdf’s algebra has no pattern/query type split for that conversion to bridge: a solution row already IS that multiset unit, so Replace lands directly below whatever solution modifiers wrap the leaf it targets, structurally, with nothing extra to construct or adjudicate.

The HAVING position is outside SEP-0007’s stated coverage — SEP-0007 only specifies Replace for Bgp/Path/Graph Values-Insertion sites and ordinary expression positions, not for a sub-SELECT’s HAVING clause. purrdf follows the literal PrjMap reading: a sub-SELECT’s HAVING filters that sub-SELECT’s own scope only, exactly like every other expression inside it — no special correlation channel is invented for it.

Existential Normal Form

Exactly one bit per row is observed under EXISTS: whether the inner pattern’s evaluation is empty. Four rewrite laws replace a node with something provably emptiness-equivalent but cheaper, applied wherever they appear at the top of the inner (never inside a Join/Filter/Extend/ Graph/Minus operand, which read more than emptiness from their child):

  • LeftJoin(A, B, c) → A. OPTIONAL emits at least one row per left row unconditionally — a padded left row, or one row per compatible match — and never removes one, so LeftJoin(A, B, c) is empty iff A is, regardless of B or its join condition.
  • OrderBy(P) → P. Sorting is a permutation, never a filter: the sort keys are never even evaluated once emptiness is the only question asked.
  • Distinct(P) → P, Reduced(P) → P. De-duplication only ever removes rows, and only when an earlier row already carried the same value, so P non-empty always leaves the first row’s value present.
  • Slice(0, len ≥ 1)(P) → P. An offset-zero LIMIT with room for at least one row drops rows only from the end of P’s bag, so P non-empty always leaves row zero inside the window.
  • Slice(_, Some(0))(P) → false. A zero-length LIMIT is empty for every P and every row — the whole EXISTS folds to constant false (NOT EXISTS to true) without touching the dataset at all.

Every law above is proved emptiness-equivalent over row SETS, never over side effects, so each fires only when the portion it erases (B and the join condition for LeftJoin, the sort keys for OrderBy, the whole inner for the zero-length Slice fold) is effect-free — no SERVICE call, property function, or custom/heldIn/rdf:List function reachable within it — so a hard error or a remote effect that would have propagated outside the EXISTS still does, rather than vanishing merely for having been written inside one.

Two strategies, one answer

purrdf carries exactly two implementations of the one definition above:

  1. The definition itself — per-row substitution via Replace, wrapped in a Slice{0, Some(1)} first-witness stop (sound because “does this bag have a first row” is exactly what EXISTS asks) and backed by a restriction-keyed memo: the memo key is the outer row restricted to the inner’s own correlated-variable columns, so k distinct restrictions across N outer rows evaluate the inner exactly k times, never N. Always correct, for any inner pattern.
  2. The memoized probe — evaluate the inner exactly once, unconstrained, index it on the columns it shares with the outer schema, and existence-probe each row’s μ against that index. Correct only where a prepare-time admissibility proof shows it equivalent to the definition for every row the site can see.

A prepare-time analysis decides, once per site per evaluation, which strategy answers each EXISTS/NOT EXISTS. --explain’s per-algebra-node charge ledger reports the decision through three evidence counters: exists-probe-answered (one memoized-probe evaluation), exists-definition-answered (one per-row-definition evaluation), and exists-inner-solutions-consumed (one row the definition path’s inner actually materialized — bounded at 1 per evaluation by the first-witness stop). An EXISTS/NOT EXISTS inner’s own plan nodes — a MINUS, a Bgp, a nested EXISTS (however deeply nested), whatever the body contains — carry their own ledger lines too, decomposed exactly like an ordinary node’s rather than folded into the enclosing FILTER/BIND, under EITHER strategy: the memoized probe’s one once-per-site evaluation attributes exactly like the per-row definition’s, just charged once rather than once per distinct restriction. A node that legitimately reads fuel=0 rows=0 is one Existential Normal Form erased from the evaluated tree entirely (an OPTIONAL/ORDER BY/DISTINCT/LIMIT wrapper the emptiness-preserving laws proved transparent — see “Existential Normal Form” above) and therefore never ran, not a charge that went missing.

The one named exception: an EXISTS/NOT EXISTS inner whose OWN ENF-normalized top-level shape is a Project (a sub-SELECT) or a Union (a top-level { A } UNION { B }, not one nested further down) does NOT attribute — every node inside it, however deep, folds into the enclosing FILTER/BIND as fuel=0 rows=0, under either strategy, regardless of what the body itself contains. Project/Union synthesize their output from more than one child rather than being a 1:1 structural clone of anything in the original tree, and reconstructing the same original-address correspondence for them would need the same wrapper/wrapped counts_rows arbitration the LATERAL Values-Insertion machinery already does — deliberately not attempted, so the correspondence is simply absent for that one site rather than guessed at. This is not a regression: it is exactly the behavior every EXISTS/NOT EXISTS inner had before per-node attribution existed at all, now confined to this one shape instead of the whole feature.

The performance characteristic, stated plainly: an uncorrelated inner, or a correlated one built only from Bgp/Path/Values/Graph/Join/ Union/OrderBy/Project/Distinct/Reduced and Filter/Extend expressions that read only certainly-bound columns of the inner, is served by the probe — one evaluation total, however many outer rows filter through it. A shape the probe cannot serve — MINUS, a restricting Slice (any offset or limit, not only one past the first row), GROUP BY, LATERAL, a property-function call, a SERVICE call, or a FILTER/BIND expression that reads a correlated variable the inner does not certainly bind (for example, one visible only down an OPTIONAL branch) — evaluates per row through the definition, with the restriction-keyed memo and first-witness stop above bounding the cost.

The Part 3 assignment restriction

Neither a BIND/a sub-SELECT’s (expr AS ?v) projection target/a GROUP BY (expr AS ?v) grouping target, nor a VALUES column, inside an EXISTS/NOT EXISTS body may rebind a variable already in scope on the row being filtered. NOT EXISTS shares the exact same grammar production as EXISTS — there is no separate “NOT EXISTS” wording — so the restriction applies identically to both, and to a nested EXISTS’s own body against whatever its immediately enclosing EXISTS already has in scope. A rebinding confined to a MINUS right operand inside the body is exempt at any depth, the same reasoning LATERAL’s own scope restriction applies (§18.2.1: a MINUS-right introduction never escapes it, so it can never observably rebind the row).

PREFIX : <https://example.org/>
# Legal: ?fresh is not in scope on the row FILTER EXISTS is testing, so BIND
# giving it a value is an ordinary fresh binding, not a rebinding.
SELECT ?s WHERE {
  ?s :p ?o .
  FILTER EXISTS { BIND(1 AS ?fresh) }
}
# Illegal — refused with a typed ParseError naming ?o: ?o is already bound by
# the row FILTER EXISTS is testing, and BIND tries to give it a NEW value at
# the EXISTS body's own scope level.
SELECT ?s WHERE {
  ?s :p ?o .
  FILTER EXISTS { BIND(1 AS ?o) }
}

The restriction is SEP-0007’s own addition, adopted here because it is what makes EXISTS’s substitution semantics defensible at every site — SPARQL 1.1/1.2’s own §18.6 text requires no such rule.

SHA-3 hashing (SEP-0008)

SPARQL 1.1 §17.4.4 ships five hash built-ins — MD5, SHA1, SHA256, SHA384, SHA512. purrdf adds the four SHA-3 (FIPS 202 Keccak) functions SEP-0008 proposes, on exactly the same call convention. SEP-0008 is a proposal, not part of the SPARQL 1.1 or 1.2 recommendation — these four are a first-party extension, and they do not travel:

CallDigestHex characters
SHA3-224(string)SHA3-22456
SHA3-256(string)SHA3-25664
SHA3-384(string)SHA3-38496
SHA3-512(string)SHA3-512128
SELECT ?s (SHA3-256(?label) AS ?fingerprint)
WHERE { ?s <http://example.org/label> ?label }

The argument contract

Each function takes one argument and hashes the UTF-8 bytes of its lexical form, returning the digest as a lowercase hex xsd:string — the same contract SHA256 has, so a query can swap one for the other without changing anything else about the row.

The accepted arguments are a simple literal, an explicitly xsd:string-typed literal, an rdf:langString, and an RDF 1.2 rdf:dirLangString. A tagged literal is hashed on its text only: SHA3-256("abc"@en) and SHA3-256("abc") are the same digest, because the tag is not part of the lexical form.

Anything else is an expression error, which is SPARQL’s ordinary “this row produces no value” outcome rather than a query failure:

  • an unbound variable (SHA3-256(?missing)),
  • an IRI or a blank node,
  • a non-string literal (SHA3-256(7)).

In a SELECT projection an errored call leaves the projected variable unbound on that row; under FILTER it makes the constraint false; a BIND of it binds nothing. Wrap the argument in STR(…) when you mean “hash whatever this term looks like” — SHA3-256(STR(?anything)) — because STR is the function that turns a term into a string, and these do not do it implicitly.

The hyphen is part of the name

These are the only built-in names in the language containing a -, so the spacing rule is worth stating outright:

TextReading
SHA3-256(?o)the built-in call — one token, hyphen included
SHA3 - 256a parse error: SHA3 alone is no function or keyword
STRLEN(SHA3-256(?o)) - 4subtraction — the - follows ), not a word character

The lexer’s PN_PREFIX scan admits - as a name character, so SHA3-256 arrives at the parser as a single word. Whitespace around the hyphen makes it the subtraction operator again, and because SHA3 is not itself a function, the spaced form fails loudly rather than meaning something else. Names are case-insensitive like every other built-in (sha3-256 is the same call).

SEP-0008’s own text spells the four functions with an underscore (sha3_256), so SHA3_256(?o) is accepted as an alias for SHA3-256(?o): a query copied out of the proposal parses. The alias is an input spelling only — the algebra has one function per digest size, so a serialized query always carries the canonical hyphenated name and stays byte-deterministic whichever spelling was typed.

Taking a SHA-3 query to another engine

Expect a parse error. SHA3-256 is a built-in name here, but the SPARQL 1.1/1.2 grammar offers exactly two ways to name a function — a keyword from its own built-in list, or a FunctionCall ::= iri ArgList whose iri is an IRIREF or a prefixed name. On an engine that has not adopted SEP-0008, SHA3-256 is not in the built-in list, and a bare word with no colon is not an iri either, so SHA3-256(?o) has no parse at all. The underscored SHA3_256(?o) spelling fails for the same reason. The failure is therefore loud and immediate rather than a quietly unbound column.

The portable substitute is one of the SPARQL 1.1 §17.4.4 hashes. SHA256 takes the same single argument and returns the same lowercase-hex xsd:string, so swapping the name is the whole edit — it changes the digest, and nothing else about the query. Reach for the SHA-3 names when you control the engine and want the Keccak construction specifically; reach for SHA256 when the query text has to run anywhere.

Reaching it from other hosts

There is nothing to configure: unlike the extension-function and custom-aggregate seams, these are built-ins, so every surface that takes query text has them — purrdf query, Store.query / MutableDataset.query in Python, Dataset.query / QueryEngine.select in WebAssembly, and purrdf_query / purrdf_query_json over the C ABI.

Composite datatypes (SEP-0009)

SPARQL 1.1 and 1.2 have no term that holds several other terms. purrdf implements SEP-0009’s two composite datatypes, cdt:List and cdt:Map, together with the fifteen functions the SEP defines, the FOLD aggregate that builds one from a group, and the UNFOLD graph pattern that takes one apart. SEP-0009 is a proposal, not part of the SPARQL 1.1 or 1.2 recommendation — everything in this section is an extension, and a query using it does not travel unchanged.

The namespace is the SEP’s own, recognized and never invented — every example below is written under this prologue:

PREFIX cdt: <http://w3id.org/awslabs/neptune/SPARQL-CDTs/>

A composite is an ordinary RDF literal whose datatype is cdt:List or cdt:Map and whose lexical form spells the contents out. These are terms, not queries:

"[1,2,3]"^^cdt:List
"[1,null,<http://example.org/s>]"^^cdt:List
"[[1,2],[3]]"^^cdt:List
"{1:'a', 2:'b'}"^^cdt:Map
"[]"^^cdt:List        "{}"^^cdt:Map

List elements may be IRIs, blank nodes, literals, null, or nested composites. Map keys may be IRIs or literals — never blank nodes, never null, never a nested composite — and must be pairwise distinct by term, so {1: "a", "1"^^xsd:integer: "b"} is one key written twice and is refused. Map values carry no such restriction. Every IRI inside a composite lexical form must be absolute: a composite’s contents are its own, and are not resolved against whatever base the surrounding document happens to declare.

The function library

The set is closed. There is no registry to configure and no way for a caller to shadow one of these names, so the same query means the same thing on every host, and a configured extension namespace cannot capture a CDT name. Arity is enforced at parse time, before any evaluation.

CallArityResult
cdt:List(…)anya cdt:List of the argument terms
cdt:Map(…)evena cdt:Map from alternating key/value arguments
cdt:concat(…)anythe argument lists joined end to end
cdt:contains(list, term)2xsd:boolean — does the list hold an equal term
cdt:get(coll, n_or_key)2one element by 1-based position, or one map value by key
cdt:head(list)1the first element
cdt:tail(list)1everything but the first element
cdt:reverse(list)1the elements in opposite order
cdt:size(coll)1xsd:integer element or entry count — the one function that takes either datatype
cdt:subseq(list, start[, len])2–3a contiguous run, start 1-based
cdt:containsKey(map, key)2xsd:boolean — is this a key of the map
cdt:keys(map)1the map’s keys, as a cdt:List
cdt:merge(…)≥ 2the union of the argument maps; a key held by more than one is resolved by the first map that carries it
cdt:put(map, key[, value])2–3the map with one entry set; an omitted or erroring value stores the null entry
cdt:remove(map, key)2the map without that entry; an absent key leaves it unchanged

Handing a list function a map (or the reverse) raises — it is a type error, not an unbound column. The indices are 1-based throughout, per the SEP.

PREFIX cdt: <http://w3id.org/awslabs/neptune/SPARQL-CDTs/>
SELECT ?s ?first
WHERE {
  ?s <http://example.org/tags> ?tags
  FILTER(cdt:size(?tags) > 1)
  BIND(cdt:head(?tags) AS ?first)
}

FOLD: a group becomes one term

FOLD is an aggregate, so it sits wherever SUM or GROUP_CONCAT sits — in the SELECT list, in HAVING, beside other aggregates, under GROUP BY.

Aggregate ::= … | 'FOLD' '(' 'DISTINCT'? Expression ( ',' Expression )?
                    ( 'ORDER' 'BY' OrderCondition+ )? ')'

One expression builds a cdt:List; two build a cdt:Map, the first being the key. The optional ORDER BY belongs to the aggregate itself and follows the last expression with no comma — this is the seam that makes FOLD the first order-dependent aggregate in the engine, and it is why FOLD carries an ORDER BY where every other aggregate rejects one.

Accepted spellings, as a catalogue:

FOLD(?v)                        FOLD(DISTINCT ?v)
FOLD(?v ORDER BY DESC(?v))      FOLD(DISTINCT ?v ORDER BY ASC(?a) ASC(?b))
FOLD(?k, ?v)                    FOLD(?k, ?v ORDER BY ?ord)

and one whole query:

PREFIX cdt: <http://w3id.org/awslabs/neptune/SPARQL-CDTs/>
SELECT ?s (FOLD(?tag ORDER BY ?tag) AS ?tags)
WHERE { ?s <http://example.org/tag> ?tag }
GROUP BY ?s

Four behaviours are worth knowing before you rely on one:

  • An empty group folds to a bound empty composite"[]"^^cdt:List or "{}"^^cdt:Map — never to an unbound column. SUM over nothing is 0 and GROUP_CONCAT over nothing is ""; FOLD follows that shape rather than MIN’s.
  • A row whose element expression is unbound or erroring contributes a null element, which is the opposite of every other aggregate, all of which skip such a row. The SEP wants the arity of the list to match the arity of the group. A map key that is unbound or erroring drops the entry entirely, because there is nothing to file the value under.
  • A repeated map key resolves to the last binding.
  • DISTINCT is RDF-term identity, not value equality, so "1"^^xsd:integer and "01"^^xsd:integer survive as two elements.

There is no ; SEPARATOR= on FOLD — that scalar parameter is GROUP_CONCAT’s alone — and FOLD(*) is not grammar.

UNFOLD: one term becomes rows

UNFOLD is a graph pattern, not a function. It is its own GraphPatternNotTriples alternative — structurally where LATERAL sits — and it stacks above the pattern parsed so far:

Unfold ::= 'UNFOLD' '(' Expression 'AS' Var ( ',' Var )? ')'
Operandfirst variablesecond variable
cdt:Listeach element, in list order, duplicates preservedthe 1-based xsd:integer index
cdt:Mapeach entry’s keythat entry’s value
SELECT ?elem ?i
WHERE {
  ?s <http://example.org/tags> ?tags
  UNFOLD(?tags AS ?elem, ?i)
  FILTER(?i < 4)
}

Both targets obey BIND’s §19.6 scope rule: a variable already in scope in the group graph pattern is a syntax error, not a join, and naming the same variable twice is likewise refused.

The two edges that decide whether an OPTIONAL around an UNFOLD is doing what you think:

  • A well-formed but empty composite ("[]"^^cdt:List, cdt:Map()) yields zero rows. The row is gone.
  • An operand that is not a composite at all — unbound, erroring, an xsd:integer, a plain string, or a cdt:-typed literal whose lexical form does not parse — passes the row through unchanged with both targets unbound. This is SEP-0009 §12.3 verbatim, and it is deliberately not a no-rows outcome, so UNFOLD never silently deletes a row it merely failed to understand.

FOLD and UNFOLD compose, which is how a query re-orders a list without leaving the query language:

PREFIX cdt: <http://w3id.org/awslabs/neptune/SPARQL-CDTs/>
SELECT (FOLD(?e ORDER BY ?e) AS ?sorted)
WHERE { BIND("[3,1,2]"^^cdt:List AS ?l) UNFOLD(?l AS ?e) }

Ordering, MIN and MAX

ORDER BY, MIN, MAX and FOLD’s own ORDER BY all order composite literals by the value they denote, not by their lexical form, through one shared projection of SPARQL §15.1. Composites rank between plain literals and RDF 1.2 triple terms: unbound < blank < IRI < literal < composite < triple term. SEP-0009 does not pin that placement; it is purrdf’s documented choice.

The order purrdf exports for composites is syntactic, and that is not an oversight. SEP-0009’s value relations are partial and raise on incomparable operands, and the obvious repair — order by value, break ties syntactically — is intransitive on SPARQL’s own type lattice ("9"^^xsd:double < "P1D"^^xsd:duration < "8"^^xsd:float < "9"^^xsd:double). Rust’s sorts may panic on a comparator like that, so a total order is a correctness requirement here, not a nicety.

MEDIAN and PERCENTILE are numeric folds and are not composite-aware: a composite is outside their domain and the aggregate comes back unbound.

Blank nodes inside a composite

A _:b written inside a composite lexical form binds through the same ingress rule as a bare _:b token, on every codec — Turtle, TriG, N-Triples, N-Quads, RDF/XML, TriX, HexTuples, JSON-LD, and both GTS import paths. So _:b written as a subject and _:b written inside a cdt:List in the same document are one node, while the same label in two different documents stays two nodes, exactly as RDF 1.1 §4.1 requires. Composite-embedded blank nodes also participate in canonicalization and in skolemization.

Query text gets its own blank scope for the same reason, so a _:b you write inside a composite literal in a query is never the _:b in the data.

Limits, and what a composite refuses

Nesting is bounded, and the bounds are an invariant of the value — the programmatic constructors enforce them exactly as the parser does:

BoundValue
Nesting depth64
Total elements, all levels2²⁰ (1 048 576)
Lexical bytes64 MiB

Crossing one at evaluation is a hard query failure naming the bound crossed, never a quietly unbound answer.

An ill-formed composite literal in a document refuses the whole document (cdt-literal-malformed), where an ill-formed "abc"^^xsd:integer does not. That asymmetry is deliberate and is written down in full: the embedded-blank scanner is lexical, so admitting an unparseable form opaquely would leave half a blank-node scope bound and half of it raw. In query text the same literal parses — ill-typedness is an evaluation-time question there, a function over it is unbound, and a comparison with it raises.

Taking a composite query to another engine

Expect a parse error for FOLD and UNFOLD, which are keywords no unextended grammar admits, and quietly different answers for the function calls, which are ordinary iri ArgList calls that an engine without SEP-0009 will report as unknown functions. There is nothing to configure to reach any of it from a host: like the SHA-3 built-ins, composites are unconditional, so purrdf query, Python’s Store.query / MutableDataset.query, WebAssembly’s Dataset.query / QueryEngine.select, and the C ABI’s purrdf_query / purrdf_query_json all have them.

One divergence runs the other way, and it is stated rather than argued. purrdf’s reader admits two element forms the published SEP-0009 lexical space does not — an RDF 1.2 triple term <<( s p o )>>, and a directional language-tagged literal ("x"@en--ltr) — as list elements and map values, because refusing a term type the RDF 1.2 data model defines, inside a container the data model also defines, is not an admissible outcome for this toolkit. They are emitted only for values the published grammar cannot express at all. A conformant SEP-0009 reader handed one of those literals will call it ill-formed. See docs/CONFORMANCE.md for the divergence and the scan that bounds its blast radius.

Quad templates: CONSTRUCT into named graphs

A SPARQL 1.1 CONSTRUCT template is a set of triples, and the result is one graph. purrdf also accepts a quad template, so a template may name the graph each statement lands in, and a single result may span several named graphs.

Provenance: a purrdf extension, not a SPARQL 1.2 feature

SPARQL 1.2 does not define the quad template. Neither the 1.1 nor the 1.2 grammar admits a GRAPH block inside a CONSTRUCT template (ConstructTemplate ::= '{' ConstructTriples? '}', and ConstructTriples is triples only), and neither defines the CONSTRUCT GRAPH … shorthand. Both spellings documented below are first-party extensions this engine ships. Producing quads from a CONSTRUCT is a long-running request in the SPARQL community’s proposal process, and other engines — Jena and Stardog among them — already ship a form of it, but no standardized spelling exists, so the one described here is purrdf’s.

Declaring VERSION "1.2" does not subtract the extension: a version declaration selects semantics, not a feature whitelist. See The VERSION declaration.

Taking one of these queries to another engine

Expect a parse error, not a different answer. An engine without the extension rejects the GRAPH keyword as soon as it meets it inside a CONSTRUCT template, because its grammar has no production that admits one there — the query fails before evaluation, so there is no risk of silently getting the wrong graphs. An engine that ships its own form of the feature may accept only one of the two spellings, since neither is standardized.

Two portable rewrites:

  • If the result is going into a store, use SPARQL 1.1 Update rather than CONSTRUCT. Update’s template has always been a quad template, so INSERT { GRAPH … { … } } WHERE { … } is standard, universally implemented, and gives the same per-solution graph targeting — including a graph name bound per row:

    PREFIX ex: <http://example.org/>
    INSERT { GRAPH ?g { ?s ex:friend ?o } }
    WHERE  { GRAPH ?g { ?s ex:knows ?o } }
    
  • If the result must come back as a document, issue one ordinary triple-producing CONSTRUCT per target graph and assemble the dataset on the client. This costs a round trip per graph and cannot express a graph name computed per solution row, which is the gap the quad template closes.

Queries that stay inside the triple form are unaffected in either direction: a template with no GRAPH slot is an ordinary SPARQL 1.1 CONSTRUCT here and emits byte-identically, so only the templates that actually name a graph are the ones that will not travel.

GRAPH blocks inside the template

PREFIX ex: <http://example.org/>
CONSTRUCT { GRAPH ex:derived { ?s ex:friend ?o } }
WHERE { ?s ex:knows ?o }

A variable graph name

The graph slot takes a variable as well as an IRI, so the graph a statement lands in can be decided per solution row:

PREFIX ex: <http://example.org/>
CONSTRUCT { GRAPH ?g { ?s ex:friend ?o } }
WHERE { GRAPH ?g { ?s ex:knows ?o } }

Several graphs, and mixed default-graph triples

One template may write into more than one graph, and may mix graph-scoped quads with unscoped triples that land in the default graph:

PREFIX ex: <http://example.org/>
CONSTRUCT {
  ?s ex:seen true .
  GRAPH ex:people  { ?s ex:friend ?o }
  GRAPH ex:reverse { ?o ex:friend ?s }
}
WHERE { ?s ex:knows ?o }

The whole-template shorthand

CONSTRUCT GRAPH <iri> { … } scopes the entire template to one graph without a GRAPH block around it. It also works with the short form (CONSTRUCT GRAPH <iri> WHERE { … }), and it takes a variable (CONSTRUCT GRAPH ?g { … }), a prefixed name, or a BASE-relative IRI:

PREFIX ex: <http://example.org/>
CONSTRUCT GRAPH ex:derived { ?s ex:friend ?o }
WHERE { ?s ex:knows ?o }

The shorthand is a default, not an override: it supplies the graph for every template slot that did not name one itself, so an inner GRAPH block still wins over it. CONSTRUCT GRAPH { … } with no name is a syntax error rather than a silently unscoped template.

Skip semantics

SPARQL §16.2 already skips a template statement whose variables are unbound or whose instantiation is ill-formed. Ill-formed means “not a legal RDF 1.2 statement”, position by position:

  • the subject is an IRI or a blank node — a literal is illegal there, and so is a triple term (a quoted triple is a value; an asserted statement is made about a reifier, not about the quoted triple itself). Both are reachable from ordinary data: over RDF 1.2 input, CONSTRUCT { ?o ?p ?s } WHERE { ?s ?p ?o } binds ?o to a triple term as readily as to a literal;
  • the predicate is an IRI, and nothing else;
  • the object may be any term, but when it is a triple term that triple term’s own components carry the same rules recursively (its subject must not be a literal, its predicate must be an IRI).

The graph slot follows the same rule, and this is worth being explicit about:

An unresolvable graph name skips its statement. It is not an error, and it is not a fallback to the default graph. The graph slot is resolved first, so a statement whose graph name is an unbound variable, or is bound to anything that is not an IRI (a literal, a blank node, a triple term), is not instantiated at all — it mints no blank-node labels either, so the rest of the result is exactly what it would be if that quad were absent from the template.

The skip is per statement, not per row: a sibling template quad whose own graph slot resolves is still emitted for the same solution.

Which output formats can carry the result

A named graph needs a syntax with somewhere to put a graph name. Six of the nine RDF syntaxes have one:

Carries named graphsDoes not
TriG, N-Quads, TriX, HexTuples, JSON-LD, YAML-LDTurtle, N-Triples, RDF/XML

The single-graph serializers drop graph-scoped statements — they do not fold them into the default graph — so writing a graph-carrying result to one of them would produce a well-formed document silently missing exactly what the query asked for. No host lets that pass unsignalled. The three hosts whose egress is a document refuse outright, each naming the graphs it would have dropped, the format it was asked for, and the quad-capable alternatives; the C ABI, whose egress is the dataset itself, reports the loss as a count instead:

  • CLIpurrdf query … --results-format turtle exits 2 (a usage refusal, distinct from an evaluation error) and prints the refusal on stderr, pointing at --results-format trig/nquads/trix/hextuples/jsonld/yamlld.

  • Python — the result of a graph-carrying CONSTRUCT is a QueryQuads (whose members are Quads with a live graph_name) rather than a QueryTriples. QueryQuads.serialize(RdfFormat.TURTLE) raises ValueError, naming RdfFormat.N_QUADS/TRIG/TRIX/HEXTUPLES/JSON_LD/YAML_LD.

  • WebAssembly — an explicit serialize("turtle") / queryRaw(…, {format: "turtle"}) throws, with the same sentence and the same alternatives. When the caller names NO format the default widens from turtle to trig instead of throwing, because there was no request to contradict and an empty document would be the wrong answer; a result with no named graph still gets turtle, byte for byte.

  • C ABI — the shape is different, and deliberately so. purrdf_query hands back a PurrdfDataset handle, which is the frozen IR itself and has somewhere to put a graph name, so the graphs are never lost at the query boundary. Serializing that handle is a separate call, and purrdf_serialize to a single-graph media type succeeds while reporting what it discarded through the out_named_graph_rows_dropped out-parameter — a count rather than an exception, because that is the signal C has. The parameter is independently nullable; a caller that passes null for it has asked not to be told, so read it — or serialize to a quad-capable media type — whenever the result may carry graphs.

    The convenience path, purrdf_query_json, needs neither: it renders a CONSTRUCT/DESCRIBE result as N-Quads inside its {"graph": "..."} envelope, so the graph names, the base quads and the RDF 1.2 statement layer all survive and there is no loss to report. That member is purrdf’s own envelope rather than a caller-selected RDF syntax, which is why it widens the way the WebAssembly no-format default does instead of refusing the way an explicit serialize("turtle") does. A default-graph-only result is byte-identical to the N-Triples the member used to hold — an N-Quads line with no graph term is the N-Triples line.

A result carrying only default-graph statements is untouched everywhere: every SPARQL 1.1 CONSTRUCT and every DESCRIBE serializes to Turtle exactly as before. A mixed result is refused as a whole rather than half-emitted, because emitting the default-graph half would report a partial answer as a complete one.

The VERSION declaration

A query or update prologue may declare VERSION "<string>" (SPARQL 1.2 Query specification §4.4). Parsing is syntax-only — any string is accepted, and when the prologue repeats the declaration the last one wins — but the declared value is no longer discarded: Query::version() / Update::version() expose it as a typed SparqlVersion, alongside the existing dataset()/base_iri() accessors.

Evaluation is the admission boundary. VERSION "1.2" and VERSION "1.2-basic" are recognized; any other declared string — VERSION "1.1", a typo, a future version this build predates — is refused at evaluation admission with a typed error naming the declared string, before any work is spent.

VERSION "1.2" evaluates normally on the full engine. VERSION "1.2-basic" is enforced as a narrower profile: the SPARQL 1.2 Query specification’s §4.3.1 “Version Labels” table defines 1.2-basic as full 1.2 syntax “without triple terms and without triple patterns that have a triple pattern in their subject or object position” — the RDF 1.2 triple-term/reification feature area. A 1.2-basic query or update that uses a quoted triple term (<<( s p o )>>), a reifying triple or annotation (<< s p o >>, {| ... |}), a ground triple term in VALUES, or one of the “Functions on Triple Terms” (TRIPLE, isTRIPLE, SUBJECT, PREDICATE, OBJECT, §17.4.6) is refused at evaluation admission with a typed error naming the offending construct — for an update, with no mutation applied. A 1.2-basic request that uses none of those constructs evaluates exactly as a 1.2 one would.

Aggregate determinism: row order, DISTINCT, and GROUP_CONCAT

SPARQL 1.1/1.2 leave several corners of GROUP BY/aggregate evaluation intentionally underspecified — a conforming engine may pick any answer within the spec’s envelope. purrdf-sparql-eval picks ONE deterministic answer for each and documents it here (mirrored in purrdf_sparql_eval::modifier’s crate docs), so “what does GROUP_CONCAT return” has a single, testable meaning rather than “any order the engine happened to produce.”

Row and group order (§18.6.1 “Aggregate Algebra”). GROUP BY partitions the inner solution sequence into groups; this crate keeps groups in first-seen order (the order each group’s key first appears in the inner solution sequence) and keeps each group’s own rows in inner-operator order (the order the ungrouped input produced them). Every order-sensitive fold — GROUP_CONCAT’s concatenation, SAMPLE’s “first value wins”, a custom aggregate’s OrderDependent fold — folds over rows in exactly that order.

“Inner-operator order” is REPRODUCIBLE (the same query against the same dataset yields the same order every time) but is not, by itself, a documented invariant a query text can rely on for a plain triple-pattern scan: a bare BGP’s solution order follows this store’s internal index layout (currently sorted by interned term id along the index the planner picks), which is an implementation detail, not a promise. The one row order a query CAN rely on is an explicit ORDER BY: when the aggregate’s immediate input is (or is fed by) an ORDER BY-sorted solution sequence — for example a subquery { SELECT ?v WHERE { ... } ORDER BY ?key } feeding an outer aggregate — “inner-operator order” is exactly that ORDER BY’s SPARQL total order (§15.1), which the specification itself fixes.

DISTINCT (inside an aggregate call). Per §18.6.1’s Aggregation definition, DISTINCT folds Dedup(M(Ψ)) rather than M(Ψ) — an order-preserving, duplicate-free view whose relative order of first occurrences is preserved. This crate’s dedup keeps the FIRST occurrence (in the row order above) of an equal-by-value tuple; every later occurrence never reaches the fold’s step.

GROUP_CONCAT ordering

§18.6.1.7 defines GroupConcat as concatenating the sequence’s elements with sep between them, but explicitly leaves the sequence’s own order unspecified (“The order of the strings is not specified”) — exactly the freedom the paragraphs above pin down. This crate concatenates in the row order stated above: groups first-seen, rows in inner-operator order, DISTINCT keeping the first occurrence — producing a plain xsd:string of the lexical forms joined by sep (default " " per §18.6.1.7, absent an explicit SEPARATOR). A term with no lexical form (a blank node or a triple term) poisons the fold to unbound, the same reading SUM/AVG use for a non-numeric running total.

Because a plain BGP’s own scan order is an implementation detail rather than a documented guarantee (see above), a GROUP_CONCAT fixture that wants to demonstrate the determinism reading with an exact-string pin cannot rest the proof on scanning a triple pattern directly — that would pin an incidental property of the current index layout, not the specification-backed ordering this section documents. This project’s own conformance fixture, crates/sparql-conformance/suite/purrdf-extend/group-concat-order.rq, drives its row order from an ORDER BY DESC(?s) subquery feeding the outer GROUP_CONCAT — anchoring the pin to SPARQL’s own ORDER BY total order (§15.1) over distinct IRIs, and using DESC rather than ASC so a regression that silently ignored the subquery’s ORDER BY (and fell back to the store’s incidental scan order) would produce a detectably different, wrong string instead of coincidentally passing.

Extending the evaluator: custom aggregates

Beyond the SPARQL 1.1 built-in aggregates (COUNT/SUM/AVG/MIN/MAX/ SAMPLE/GROUP_CONCAT), a Rust host may register additional GROUP BY reductions and reach them from query text as AGG(<iri>, [DISTINCT] arg, arg, …) — the normative positional spelling (a deliberate divergence from Jena ARQ’s AGG <iri>(args)). Where purrdf_sparql_eval::property_fn injects a relation (a row source in graph-pattern position) and user_fn injects a scalar function (one value per call), agg_fn injects a fold: a group’s rows reduce to one value through a caller-supplied accumulator, exactly as a built-in aggregate does.

A registered aggregate implements two traits — CustomAggregate (the per-IRI factory: declared arity, Volatility, AlgebraicClass, and a declared state bound) and AggregateAccumulator (the per-invocation fold: step one already-evaluated argument tuple at a time, combine two partial folds in source order, finish to the group’s answer) — and is registered into an AggregateRegistry under an IRI of the caller’s choosing:

use std::sync::Arc;
use purrdf_core::{SparqlRequest, TermValue};
use purrdf_sparql_eval::{
    AggregateAccumulator, AggregateRegistry, AlgebraicClass, Arity, CustomAggregate, EvalError,
    NativeSparqlEngine, QueryOptions, Volatility,
};

/// A running total over one numeric argument — `example.org`'s own
/// `AGG(<https://example.org/agg#total>, ?x)`.
struct TotalAccumulator {
    sum: i64,
}

impl AggregateAccumulator for TotalAccumulator {
    fn step(&mut self, args: &[TermValue]) -> Result<(), EvalError> {
        if let Some(TermValue::Literal { lexical_form, .. }) = args.first()
            && let Ok(n) = lexical_form.parse::<i64>()
        {
            self.sum += n;
        }
        Ok(())
    }

    fn combine(&mut self, other: Box<dyn AggregateAccumulator>) -> Result<(), EvalError> {
        // Recover `other`'s real state through `into_any` rather than
        // re-deriving it from `finish()`'s lexical form: `finish()` is a
        // lossy, string-typed answer, and re-parsing it is exactly the
        // pattern this crate's own `agg_fn`/`stat_agg` merges avoid (see
        // their "Real merges via `into_any`" docs). A running total happens
        // to survive that round trip losslessly, but the type-recovered
        // merge below is the pattern to copy for a fold whose finished
        // answer is NOT itself sufficient mergeable state.
        let other = other.into_any().downcast::<Self>().map_err(|_| {
            EvalError::function(
                "combine received a partial accumulator of a different concrete type",
            )
        })?;
        self.sum += other.sum;
        Ok(())
    }

    fn into_any(self: Box<Self>) -> Box<dyn std::any::Any + Send> {
        self // a running total IS its own sufficient merge state
    }

    fn finish(self: Box<Self>) -> Result<Option<TermValue>, EvalError> {
        Ok(Some(TermValue::typed_literal(
            self.sum.to_string(),
            "http://www.w3.org/2001/XMLSchema#integer",
        )))
    }
}

struct TotalAggregate;

impl CustomAggregate for TotalAggregate {
    fn arity(&self) -> Arity {
        Arity::Exact(1)
    }
    fn volatility(&self) -> Volatility {
        Volatility::Stable // eligible for the within-group parallel fold
    }
    fn algebraic_class(&self) -> AlgebraicClass {
        AlgebraicClass::Commutative
    }
    fn state_bound(&self) -> u64 {
        0 // stateless besides the running total
    }
    fn init(&self, _scalarvals: &[(String, TermValue)]) -> Box<dyn AggregateAccumulator> {
        Box::new(TotalAccumulator { sum: 0 })
    }
}

let mut registry = AggregateRegistry::new();
registry.register(
    "https://example.org/agg#total",
    Arc::new(TotalAggregate),
);

let engine = NativeSparqlEngine::new();
let result = engine.query_with_options_view(
    &ds,
    SparqlRequest {
        query: "SELECT (AGG(<https://example.org/agg#total>, ?x) AS ?t) WHERE { ?s <https://example.org/n> ?x }",
        base_iri: None,
        substitutions: &[],
    },
    QueryOptions {
        aggregates: &registry,
        ..QueryOptions::EMPTY
    },
)?;

An AGG(<iri>, …) call to an unregistered IRI, with the wrong argument count, or with an invalid ; NAME=value scalarval clause (an unrecognized name, a duplicate name, a missing required name, or a wrong-typed value — see below), is refused when the query is prepared — before any budget unit is spent — and the prepared plan carries the registry’s fingerprint, so a plan admitted under one registry can never silently run under another (see purrdf-sparql-eval’s agg_fn module docs for the full trust-boundary and determinism contract, including into_any’s role in merging structural state a fold’s finished answer alone cannot reconstruct).

Named scalar-value parameters

Beyond its positional args, AGG(<iri>, …) admits zero or more trailing ; NAME=value clauses — a named, per-aggregation scalar parameter, generalizing GROUP_CONCAT’s own ; SEPARATOR="…" (SPARQL’s existing precedent for a named scalar aggregate parameter) to any custom aggregate: AGG(<{NS}PERCENTILE>, ?x; P=0.95), AGG(<{NS}TOPK>, ?x; K=3). NAME is matched case-insensitively and stored upper-cased; value is any SPARQL literal — the full numeric tower including its signed forms (Q=-1, P=+0.5), the boolean literals (B=true), and strings — so a numeric scalarval keeps its natural datatype. Unlike a positional argument, a scalarval is evaluated once for the whole aggregation, never per row — the correct semantics for a parameter like a percentile rank or a “top k” count, which must be one fixed value across the group, not a per-row expression the query author merely intends to hold constant.

A registered aggregate declares which names it accepts via CustomAggregate::scalarvals, returning a slice of ScalarvalSpec { name, kind } (kind is ScalarvalKind::Numeric or ScalarvalKind::String); every declared name is required. CustomAggregate::init receives the call site’s scalarvals already resolved to TermValue and already validated against that declaration, so an accumulator can read them back by name (scalarvals.iter().find(|(k, _)| k == "P")) without re-checking type or presence.

AGG(<iri>, …) execution is governed like any other fold: profile v6 adds two charge points — aggregate-invocation (once per group per aggregate expression) and aggregate-accumulation (once per value inspected) — shared by built-in and custom aggregates alike, so a registered aggregate over a given group shape spends identical fuel to a built-in one. See docs/SPARQL-GOVERNOR-PROFILE.md.

The statistical aggregate set

purrdf-sparql-eval ships ten exact statistical aggregates — MEDIAN, PERCENTILE, STDDEV, STDDEV_POP, VARIANCE, VAR_POP, MODE, FIRST, LAST, TOPK — as first-party CustomAggregate instances behind the same agg_fn seam, closed (no caller extension point) and reached through one call: AggregateRegistry::register_statistical_aggregates:

let mut registry = AggregateRegistry::new();
registry.register_statistical_aggregates("https://example.org/agg#");
// Now reachable: AGG(<https://example.org/agg#MEDIAN>, ?x), …STDDEV…, …TOPK…

As with every vocabulary PurRDF touches, namespace is caller-supplied configuration with no fabricated default — a host that never calls this method gets none of the ten IRIs registered, exactly as a host that never configures ParserOptions::extension_fn_namespaces gets none of the built-in scalar extension functions.

Per-member semantics:

  • Numeric members (STDDEV*/VARIANCE*, MEDIAN, PERCENTILE) follow the same integer ⊂ decimal ⊂ float ⊂ double promotion tower the built-in SUM/AVG fold uses; a non-numeric input poisons the fold to unbound rather than raising a hard error, matching SUM’s own discipline. Only STDDEV/STDDEV_POP’s final square root leaves the exact decimal tower; VARIANCE/VAR_POP never do.
  • STDDEV/VARIANCE are the sample statistics (n - 1 denominator, unbound under two values); STDDEV_POP/VAR_POP are the population statistics (n denominator, defined at one value) — SQL’s own naming convention.
  • MEDIAN is PERCENTILE at p = 0.5 under linear interpolation between the two closest ranks — for an even-sized group this is exactly the mean of the two middle values.
  • PERCENTILE takes a named scalarval, AGG(<{NS}PERCENTILE>, ?x; P=0.95): P is resolved once for the whole aggregation (never per row — see “Named scalar-value parameters” above); a P outside [0, 1] poisons to unbound, the same “poison, don’t abort” discipline every numeric member uses. A missing or non-numeric P is refused at prepare time.
  • MODE works over any term kind, counted by RDF term identity (not value equality — "5"^^xsd:integer and "05"^^xsd:integer count as two different terms). A tie is broken toward the smallest term under the same total order MIN/ORDER BY use.
  • FIRST/LAST work over any term kind, in input row order.
  • TOPK takes a named scalarval, AGG(<{NS}TOPK>, ?x; K=3): K must be a positive xsd:integer, resolved once for the whole aggregation (a K ≤ 0 poisons to unbound; a missing or non-integer K is refused at prepare time). Since every SPARQL aggregate yields exactly one RDF term, TOPK answers the same way GROUP_CONCAT answers a multi-valued question — the top k values, in descending order, with their lexical forms joined by a single fixed space separator.

All ten declare Volatility::Stable and merge partial folds through real, type-recovered structural state (AggregateAccumulator::into_any) rather than a lossy re-derivation from a finished answer — so a large single group folds in parallel chunks (see crate::modifier::eval_custom_aggregate) with a result byte-identical to the sequential fold.

Path witnesses: binding the derivation, not the endpoints

The core grammar’s property paths answer a reachability question. ?s ex:p+ ?o says that some sequence of ex:p edges leads from ?s to ?o, and the answer is the endpoint pair; which edges is not expressible, so a query that needs to explain, weight, filter, or re-join the route has nowhere to look.

A path-witness property function answers the derivation question instead. A call reads

?start <caller-iri> ( ?end ?pathId ?len ?step ?node ?edge )

and emits one row per hop. For a walk of k hops, row i binds ?len = k, ?step = i, ?node to the node that hop arrived at, and ?edge to the statement it traversed — a first-class RDF 1.2 triple term in asserted orientation, so it joins straight back into the dataset by an ordinary basic graph pattern. ?step and ?len are xsd:integer literals precisely so ORDER BY ?step orders numerically rather than putting "10" before "2".

There is no default relation IRI and no default traversal envelope. PurRDF mints no vocabulary IRIs, so the name a query spells in predicate position is caller-supplied; and a zero-hop path has no witness while an unbounded depth is a stack-overflow abort, so the minimum and maximum hop counts and the two resource guards are stated every time rather than invented by the library.

Two relation TYPES, not one with a mode switch: PathWitnessRelation enumerates every simple-prefix walk (exponential in the worst case) and ShortestPathWitnessRelation yields one shortest witness per reachable pair (polynomial). The planner reads cardinality off the registration, so which of the two answers an IRI is a property of the registration and not of a runtime value the planner cannot see.

A worked example

Over a three-edge chain ex:a → ex:b → ex:c → ex:d:

purrdf query --data chain.ttl --results-format csv \
  --path-relation 'iri=http://example.org/pf#walk;forward=http://example.org/p;min-hops=1;max-hops=4;max-paths-per-seed=1024;max-expansions=100000;mode=walk' \
  'SELECT ?end ?len ?step ?node
   WHERE { <http://example.org/a> <http://example.org/pf#walk>
           ( ?end ?pathId ?len ?step ?node ?edge ) }
   ORDER BY ?len ?step'
end,len,step,node
http://example.org/b,1,1,http://example.org/b
http://example.org/c,2,1,http://example.org/b
http://example.org/c,2,2,http://example.org/c
http://example.org/d,3,1,http://example.org/b
http://example.org/d,3,2,http://example.org/c
http://example.org/d,3,3,http://example.org/d

GROUP BY ?pathId is how the hop rows become walks again. The identifier is constant across one walk’s rows and distinct between walks, so each group IS one walk; ?start, ?end and ?len are constant within a group, so grouping by them alongside is free and lets them be projected:

purrdf query --data chain.ttl --results-format csv \
  --path-relation 'iri=http://example.org/pf#walk;forward=http://example.org/p;min-hops=1;max-hops=4;max-paths-per-seed=1024;max-expansions=100000;mode=walk' \
  'SELECT ?end ?len (GROUP_CONCAT(?node; separator="->") AS ?route)
   WHERE { <http://example.org/a> <http://example.org/pf#walk>
           ( ?end ?pathId ?len ?step ?node ?edge ) }
   GROUP BY ?pathId ?end ?len ORDER BY ?len'
end,len,route
http://example.org/b,1,http://example.org/b
http://example.org/c,2,http://example.org/b->http://example.org/c
http://example.org/d,3,http://example.org/b->http://example.org/c->http://example.org/d

Swap ?node for ?edge and the same grouping reconstructs the STATEMENT sequence, which is where an aggregate over a walk belongs: a SUM of a per-hop weight joined in through ?edge, a MIN of a per-hop confidence, a HAVING that keeps only routes whose every hop is annotated. None of that is reachable from a relation that returned endpoints, and none of it needed a list term.

The same registration from Python:

store.query(
    "SELECT ?end ?len ?step ?node WHERE { <http://example.org/a> "
    "<http://example.org/pf#walk> ( ?end ?pathId ?len ?step ?node ?edge ) } "
    "ORDER BY ?len ?step",
    path_relations={
        "http://example.org/pf#walk": (
            [(purrdf.NamedNode("http://example.org/p"), "forward")],
            1, 4, 1024, 100000, "walk",
        )
    },
)

Under an entailment regime, the walk reads the closure

A path relation is a snapshot of the step’s edges, and the dataset it must be snapshotted from is the one the query is answered over. Under --entailment (or Python’s query_entailment_governed) that is the closure, not the source graph — so a regime that derives a quad under the step’s predicate widens the walk exactly as it widens a p+ written beside it. Over

ex:sub rdfs:subPropertyOf ex:p .
ex:a   ex:p ex:b .
ex:b   ex:sub ex:c .

SELECT ?end WHERE { ex:a ex:p+ ?end } under rdfs answers ex:b, ex:c, and the registered walk seeded at ex:a reaches ex:c too. Without the regime both stop at ex:b, because the derived ex:b ex:p ex:c does not exist to be walked.

One pairing is refused rather than answered: --entailment owl-direct on an ontology whose restricted chase mints existential witnesses. Those witnesses are blank nodes that are not terms of the scoping graph, and the regime’s witness filtration cannot reach a property function’s output — so a walk over that closure could hand one back as a binding. The refusal carries the code reasoning-closure-relation-witness and names the regimes that do accept the pairing. An owl-direct run that mints no witness — a TBox outside the combined approach’s Horn fragment, or one whose chase fires no existential rule — is not refused.

The Rust surface spells the same thing explicitly: query_with_entailment and query_with_entailment_governed take a ClosureRelations argument, which is either ClosureRelations::NONE (every registered relation is dataset-independent, so the caller’s registry is used unchanged) or ClosureRelations::rebuilt_by(&f), where f is handed the materialized closure and returns the registry to answer with.

Reaching extensions from other hosts

The SHACL-AF function registry and the GENERAL custom-aggregate registry are Rust-closure seams: a registered function or aggregate is arbitrary host Rust (init/step/combine/finish closures for an aggregate), so registering one is a Rust-host-only operation. It genuinely cannot cross a Python, WebAssembly, or C boundary as a string or any other FFI-shaped value — there is no callback protocol this project is willing to invent for it, and none of the four host surfaces below expose it.

The property-function registry is a closure seam in the same sense — a PropertyFunction is an arbitrary host implementation — but its DATA-SHAPED members are not, and those do cross. A frozen table of terms, a table read out of the store’s own dataset as an rdf:List of rdf:Lists, and a path-witness traversal are each fully described by owned data, so each is registrable from Python (relations, relations_from_graph, path_relations on every Store / MutableDataset query and update entry) and a path relation is registrable from the CLI (purrdf query --path-relation, purrdf update --path-relation). None of them is a callback: nothing the engine invokes while the GIL is released can re-enter the interpreter, which is exactly why they are the members that cross. The WebAssembly and C-ABI surfaces expose no property-function registration at all.

PurRDF’s first-party statistical set is different, precisely because it is NOT an arbitrary closure: AggregateRegistry::register_statistical_aggregates takes only a namespace string and wires ten pre-built Rust instances internally, so it crosses every host boundary this crate ships exactly the way property_fn_namespaces does — no callback, no per-aggregate marshaling. Every host surface threads it through as a keyword argument / flag / parameter named aggregate_namespace (aggregateNamespace in camelCase-spelled JavaScript), mirroring however that surface already threads property_fn_namespaces or the nearest equivalent optional-string configuration:

  • Rust (embedding the engine directly): QueryOptions.aggregates, as shown throughout this page.

  • Python (purrdf.Store / purrdf.MutableDataset): the aggregate_namespace keyword on query / query_governed / update / update_governed.

    store.query(
        "PREFIX ex: <https://ex.example/> "
        "SELECT (AGG(<https://ex.example/agg#MEDIAN>, ?v) AS ?m) "
        "WHERE { ?s ex:value ?v }",
        aggregate_namespace="https://ex.example/agg#",
    )
    
  • CLI (purrdf query / purrdf update): the --aggregate-namespace IRI flag.

    purrdf query --data data.ttl --aggregate-namespace 'https://ex.example/agg#' \
      'SELECT (AGG(<https://ex.example/agg#MEDIAN>, ?v) AS ?m) WHERE { ?s ?p ?v }'
    
  • WebAssembly (QueryEngine.queryGoverned / updateGoverned): the aggregateNamespace option, alongside the other governed-call options.

    const outcome = engine.queryGoverned(dataset, sparql, {
      aggregateNamespace: "https://ex.example/agg#",
    });
    
  • C ABI (purrdf_query_governed / purrdf_update_governed): a nullable const char *aggregate_namespace parameter, the same optional-C-string convention every other nullable string on this ABI uses.

    purrdf_query_governed(dataset, query, /* base_iri */ NULL,
                           "https://ex.example/agg#", &governors, /* … */);
    

namespace stays caller-supplied configuration with no fabricated default on every one of these surfaces: omitting it (None / not passing the flag / undefined / a null pointer) leaves every one of the ten names an ordinary unregistered custom-aggregate IRI, refused exactly as before this parameter existed.

The entailment-aware query lane (query_with_entailment/ query_with_entailment_governed, and every Python/CLI/WebAssembly/C wrapper around the governed entry point) combines with aggregate_namespace exactly as the raw-view lane does: both take the engine’s QueryOptions, threaded through the closure query’s parse and its evaluation, so AGG(<{NS}MEDIAN>, …) resolves over an entailed closure the same way it resolves over a raw view.

purrdf query --data ent.ttl --entailment rdfs \
  --aggregate-namespace 'https://ex.example/agg#' \
  'SELECT (AGG(<https://ex.example/agg#MEDIAN>, ?w) AS ?m)
   WHERE { ?x <http://example.org/measure> ?w }'

purrdf.Store.query_entailment_governed(..., aggregate_namespace=NS) answers the same query the same way, and the WebAssembly and C-ABI governed entailment entry points take the same parameter.

One structural limit applies everywhere the statistical set is reachable: SPARQL UPDATE’s grammar admits an aggregate only inside a nested SELECT … GROUP BY in a WHERE clause (an ordinary DELETE/INSERT WHERE basic graph pattern has no GROUP BY of its own) — every host’s update entry reaches the statistical set through exactly that nested-subquery shape.

Entailment regimes

SPARQL queries can be answered under an entailment regime by materializing the dataset first with purrdf-entailRegime::from_iri maps a sparql:entailmentRegime IRI to the matching engine.

Conformance

The full W3C SPARQL 1.1 query + update evaluation suites plus the SPARQL 1.2 suite are vendored verbatim and run by purrdf-sparql-conformance; every non-pass is a typed, ledgered expected-failure. See Conformance & Testing and docs/CONFORMANCE.md for the live matrix.

SPARQL: Result Formats

purrdf-sparql-results is the results boundary of the SPARQL stack: the canonical authority for turning a SparqlResult (SELECT solutions, ASK boolean, or CONSTRUCT graph) into the four W3C SPARQL Results formats — JSON (SRJ), XML, CSV, and TSV — plus an additive, caller-named provenance extension where the format can carry one. JSON and XML documents can also be read back (from_json, from_xml).

use purrdf::sparql::{serialize, ResultProvenance, SparqlResultsFormat};

// `result` is the SparqlResult produced by purrdf-sparql-eval (or any engine
// implementing the purrdf-core SparqlEngine seam). `None` here means "carry
// no provenance extension" — see "The provenance extension" below.
let outcome = serialize(&result, SparqlResultsFormat::Json, &ResultProvenance::default(), None)
    .expect("SELECT serializes to SRJ");

assert!(!outcome.provenance_dropped);
let json = String::from_utf8(outcome.bytes).unwrap();

Per-format writers (to_json, to_xml, to_csv, to_tsv) and readers (from_json, from_json_boolean, from_xml, from_xml_boolean) are also exported directly.

Behavior worth knowing before you pick a format

  • Byte-deterministic output — the same result always serializes to the same bytes, like every other PurRDF output path (Codecs & Determinism).
  • The support matrix is enforced, not fudged — XML rejects CONSTRUCT graphs, and CSV/TSV reject both ASK booleans and CONSTRUCT graphs, each as a typed Error::Format, rather than emitting something spec-shaped but wrong.
  • Lossy projections are flagged — CSV/TSV have no extension point, so a populated provenance is trimmed at the exit gate and SerializeOutcome::provenance_dropped is set; the drop is never silent. The same flag is set for JSON/XML when a non-empty ResultProvenance is supplied with no ProvenanceNamespace to anchor it under — see below.
  • RDF 1.2 base direction uses the spec’s own key — a directional literal’s base direction serializes as its:dir (JSON: an additive "its:dir" member beside "value"/"xml:lang"; XML: <literal its:dir="…">), the same spelling the SPARQL 1.2 Query Results specification’s own example uses and the RDF/XML codec already emits. The XML/JSON readers also accept two legacy spellings this crate’s own writer previously produced (a bare dir attribute, and — XML only — a purrdf:dir-namespaced one) for backward compatibility, but its:dir wins when more than one is present on the same literal, and only its:dir is ever written.
FormatSELECTASKCONSTRUCTProvenance extension
JSON (SRJ)yesyesyesyes, with a namespace
XMLyesyesrejectedyes, with a namespace
CSVyesrejectedrejecteddropped, flagged
TSVyesrejectedrejecteddropped, flagged

The provenance extension

The provenance extension is additive and caller-named: a standard SPARQL results consumer can read the JSON/XML documents unchanged, while a provenance-aware consumer can recover per-result data carried alongside the bindings, anchored under an identifier the caller supplies — never a purrdf-minted one (PurRDF mints no vocabulary IRIs of its own; see AGENTS.md’s “NOT an ontology” contract). Supply a ProvenanceNamespace — a prefix (the bare top-level JSON member key, and the XML namespace prefix) plus the XML namespace iri — as serialize’s fourth argument (to_json/to_xml take it as their third: those two have no format parameter to pick). ProvenanceNamespace::new validates prefix as an XML Namespaces NCName (rejecting, among other things, :, whitespace, and the reserved xml/ xmlns names) and iri as an absolute IRI, since prefix is spliced directly into XML element/attribute names and cannot be escaped the way text content can:

use purrdf_sparql_results::{ProvenanceNamespace, ResultProvenance, SparqlResultsFormat, serialize};

let namespace = ProvenanceNamespace::new("prov", "https://example.org/provenance#")
    .expect("valid NCName prefix + absolute IRI");
let provenance = ResultProvenance::default(); // or a populated one
let outcome = serialize(&result, SparqlResultsFormat::Json, &provenance, Some(&namespace))?;

With namespace: None, JSON/XML emit no provenance element/member at all, however populated a ResultProvenance is — the same drop-and-flag contract CSV/TSV always used. Where the format has no extension point at all (CSV/TSV), the provenance is dropped loudly regardless of namespace, per the loss discipline described in Slices, Mappings & Provenance.

One term-syntax authority

The crate depends only on purrdf-core and stays wasm-clean; term and N-Triples syntax come exclusively from the kernel’s emit primitives, so there is exactly one term-syntax authority in the workspace — results, codecs, and diagnostics can never disagree about how a term is written.

Full-Text Search

What it replaces, and where it stops. This is the surface that lets an RDF project drop the PostgreSQL tsvector/tsquery it kept beside its triple store for ranked text search: the question becomes a property-function call in the SPARQL query that already holds the graph, in-process, over the same dataset, and the answer is byte-identical natively and on wasm32. It is BM25 ranking, not a Lucene — Unicode case folding and word-boundary segmentation, no stemming, no stop-word lists, no query dialect (phrase and proximity compose in SPARQL from the term-occurrence relation), fixed k1/b, and an in-memory index built once over a frozen dataset.

purrdf-text (purrdf::text from the umbrella crate) is PurRDF’s deterministic full-text index. It reads RDF 1.2 literals out of a frozen dataset, tokenizes them by the Unicode standard’s own rules, and answers ranked retrieval queries with BM25 scores — from SPARQL, through the evaluator’s property-function seam, under IRIs the caller supplies.

It is a sibling crate outside purrdf-core, not a kernel change: nothing in purrdf-core, purrdf-sparql-algebra or the property-function seam itself was altered to admit it. That is the shape every extension in this chapter takes.

Two relations, one index

An index is built once over a dataset and shared by two property functions, which are two distinct types rather than one type with a mode switch:

?doc <caller-iri>  ( "needle" ?score ?rank ?lang ?matched )   # ranked retrieval
?doc <caller-iri2> ( "term"   ?lang  ?position )              # one row per occurrence
  • Ranked retrieval (TextSearchRelation) emits one row per matching document. ?score is an xsd:decimal carrying the exact BM25 value, ?rank is the document’s 1-based position within its (graph, language) partition, ?lang is the partition’s language tag, and ?matched counts how many distinct needle terms the document holds.
  • Term occurrence (TermOccurrenceRelation) emits one row per occurrence of a single term, with its token ?position.

Phrase and proximity search are delivered by composition in SPARQL rather than by an embedded query dialect — an embedded syntax would have minted one more incompatible vendor language, which is exactly what a carrier must not do:

# "quick" immediately followed by "brown"
SELECT ?doc WHERE {
  ?doc <https://example.org/pf/occurs> ( "quick" ?l ?p1 ) .
  ?doc <https://example.org/pf/occurs> ( "brown" ?l ?p2 ) .
  FILTER(?p2 = ?p1 + 1)
}

FILTER(ABS(?p2 - ?p1) <= 3) is proximity; FILTER(?matched = 2) on the ranked relation is conjunctive retrieval.

Wiring it from Rust

The index takes the predicates whose literals it should read and a graph selector — GraphSelector::Any, Default, or Named(iri) — and PurRDF supplies no default for either. A configuration naming no predicate is a typed TextError::Config, not a guess.

use std::sync::Arc;
use purrdf::sparql::{NativeSparqlEngine, PropertyFunctionRegistry, QueryOptions};
use purrdf::text::{
    GraphSelector, TermOccurrenceRelation, TextIndex, TextIndexConfig, TextSearchRelation,
};
use purrdf::{SparqlRequest, TermValue};

// Which literals to index: the caller names the predicates.
let config = TextIndexConfig::new(
    vec![TermValue::iri("https://example.org/note")],
    GraphSelector::Any,
)?;
let index = Arc::new(TextIndex::from_dataset(&dataset, &config)?);

// Which IRIs a query calls the index by: the caller names those too.
let mut registry = PropertyFunctionRegistry::new();
registry.register(
    "https://example.org/pf/search".to_owned(),
    Arc::new(TextSearchRelation::new(Arc::clone(&index))),
);
registry.register(
    "https://example.org/pf/occurs".to_owned(),
    Arc::new(TermOccurrenceRelation::new(index)),
);

let result = NativeSparqlEngine::new().query_with_options_view(
    &dataset,
    SparqlRequest {
        query: r#"SELECT ?doc ?score ?rank WHERE {
                    ?doc <https://example.org/pf/search> ( "quick brown" ?score ?rank ?lang ?matched )
                  } LIMIT 3"#,
        base_iri: None,
        substitutions: &[],
    },
    QueryOptions { property_functions: &registry, ..QueryOptions::EMPTY },
)?;

A registered IRI is recognized in predicate position exactly, so no parser option is needed to reach it. An IRI a query names that is declared through ParserOptions but not registered hard-fails naming the IRI; an IRI in neither stays an ordinary triple pattern.

This is a Rust-host seam. The index and its relations are host closures, so they do not cross the Python, WebAssembly or C boundary — only the data-shaped property functions (frozen tables, graph-backed tables and path witnesses) do. See Reaching extensions from other hosts.

Every score is exact, and identical on every target

Ranking is done entirely in base-10 fixed-point integer arithmetic (i128, twelve fractional digits). No floating-point value enters the crate: its root denies clippy::float_arithmetic, so none can.

That is a correctness requirement, not a preference. BM25 needs a natural logarithm, and a libm ln may differ by a unit in the last place between implementations — enough to reverse the order of two near-tied documents, so the same query over the same data would return rows in one order from a native build and another from a wasm32-unknown-unknown build of the same engine. The logarithm here is a fixed-length integer series with a fixed iteration count, never a convergence test, so its result is a pure function of its input on every target. The ranking — row order together with every score’s decimal lexical — is pinned by a single test body carrying both #[test] and #[wasm_bindgen_test], so make wasm-test executes it on wasm32 against the same expectations cargo test asserts natively.

The BM25 constants k1 = 1.2 and b = 0.75 are crate constants rather than caller parameters. PurRDF is a carrier, and optionality that changes semantics per consumer is forbidden: two callers must not get different ranks out of the same index and the same needle.

Ordering, and the idioms that reproduce it

Corpus statistics are computed per (graph, language) partition, so a score is a number relative to one corpus and ?rank is a position within that partition, never a global one. Rows are emitted in (partition key ASC, rank ASC) order; within a partition the order is (score DESC, document id ASC), and document ids are assigned after sorting on (graph, subject, language), so the tie-break is reproducible across independently built indexes. A query that wants one ranked list binds ?lang (and ?graph through the selector) or builds a single-partition index.

Two consequences worth knowing:

  • Bare LIMIT k is top-k. Emission order is rank order, so the evaluator’s row-ceiling licence applies and the relation stops after k rows. ORDER BY DESC(?score) LIMIT k is not pushed down — ORDER BY plus LIMIT has no certified lower bound the governor can license — and the ORDER BY is redundant anyway.
  • ORDER BY ?rank is the reproducing idiom. A score reaches the consumer as a decimal of fixed width, so two rows can report the same ?score while carrying different ?rank; ORDER BY DESC(?score) can therefore disagree with the exact internal order, and ?rank cannot.

RDF 1.2 first class

RdfDataset::quads() returns only the asserted triple table; annotations live in a separate side table. The index reads both layers, so text carried only by :s :p :o {| :note "..." |} is searchable, with the reifier as the document subject. An index reading only the asserted layer would index zero annotation literals and report nothing — the crate’s tests guard against exactly that.

Stated limits

  • Document ids are a function of content except through blank-node labels, which are a parsing artifact: two isomorphic datasets with different labels produce different index fingerprints.
  • The index is in-memory and built over a frozen dataset. verify_binding checks that the index a query runs against was built over the dataset in front of it, closing the silent channel where a stale index emits documents that join back to zero rows.

The scoring design record — including the fixed-point logarithm and the Unicode table versions folded into the index fingerprint — is docs/design/purrdf-text-scoring.md.

GeoSPARQL

What it replaces, and where it stops. This is the surface that lets an RDF project drop the PostGIS it kept beside its triple store for spatial predicates: ?a geo:sfWithin ?b and its Simple Features, Egenhofer and RCC8 siblings, plus the geof: functions, answered in-process over the dataset the query already holds, exactly, with no GEOS or PROJ, and byte-identical natively and on wasm32. It is GeoSPARQL 1.1’s topological predicates, accessors, and exactly computable measures and constructors over vector geometry, not a PostGIS: geof:transform hard-errors by name (there is no CRS database), a metric* measure answers only in a CRS the caller declared in metres (there is no ellipsoidal geodesic), and the buffers, the concave hull (geof:convexHull is implemented), the overlay set operations and the GML/KML/DGGS encodings are registered and hard-error by name. No raster.

purrdf-geo (purrdf::geo from the umbrella crate) implements GeoSPARQL 1.1 (OGC 22-047r1) for PurRDF: exact, float-free geometry reached from SPARQL through the evaluator’s two existing extension seams. It parses geo:wktLiteral and geo:geoJSONLiteral lexical forms into an exact geometry model, decides the OGC topological relations over that model, computes the non-topological accessors, measures and constructors, and hands the geof: family to a host as scalar-function registrations and the spatial relations as property-function registrations. There is no GEOS and no PROJ behind it — the DE-9IM engine, WKT and GeoJSON are implemented in-crate, in pure Rust, which is what lets it build for wasm32-unknown-unknown.

It mints no vocabulary

GeoSPARQL’s IRIs are OGC’s, not PurRDF’s. Every IRI the crate reads or writes — the two literal datatypes, the geof: function names, the geo: spatial relations, the Simple Features geometry classes, and the coordinate reference system a WKT literal omits — is supplied by the caller through GeoVocab, which has no Default and never will. A term that is absent makes the feature that needs it a hard error, never a fabricated fallback.

use purrdf::geo::{Crs, GeoVocabBuilder};

let crs = Crs::new("http://www.opengis.net/def/crs/OGC/1.3/CRS84")?;
let vocab = GeoVocabBuilder::new(
    "http://www.opengis.net/ont/geosparql#",       // geo:
    "http://www.opengis.net/def/function/geosparql/", // geof:
    crs.clone(),                                    // the CRS a bare WKT literal means
    crs.clone(),                                    // the CRS GeoJSON is in
)?
.declare_crs_unit(&crs, "http://www.opengis.net/def/uom/OGC/1.0/metre")?
.declare_metre("http://www.opengis.net/def/uom/OGC/1.0/metre")?
.declare_simple_features_namespace("http://www.opengis.net/ont/sf#")?
.build();

The geof: family on the scalar seam

functions::register installs every geof: function into a UserFunctionRegistry under the vocabulary’s function namespace, and the registry is handed to the engine through QueryOptions::functions:

use purrdf::geo::functions;
use purrdf::sparql::{NativeSparqlEngine, QueryOptions, UserFunctionRegistry};
use purrdf::SparqlRequest;

let mut functions_registry = UserFunctionRegistry::new();
functions::register(&mut functions_registry, &vocab);

let result = NativeSparqlEngine::new().query_with_options_view(
    &dataset,
    SparqlRequest {
        query: r#"PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
                  PREFIX geo:  <http://www.opengis.net/ont/geosparql#>
                  SELECT ?a ?b WHERE {
                    ?fa geo:hasGeometry/geo:asWKT ?a .
                    ?fb geo:hasGeometry/geo:asWKT ?b .
                    FILTER(geof:sfWithin(?a, ?b))
                  }"#,
        base_iri: None,
        substitutions: &[],
    },
    QueryOptions { functions: &functions_registry, ..QueryOptions::EMPTY },
)?;

A function’s refusals travel exactly as far as SPARQL says they should. A malformed literal or a domain refusal — mixed CRSs, the measure of an empty geometry, an out-of-range index — is a per-solution expression error: the row is eliminated under FILTER, and the variable is left unbound under BIND/SELECT, while the query continues. An unimplemented function, an undeclared vocabulary term or a wrong argument count holds for every solution alike and stays query-fatal, because answering “no value” there would empty a result set and present that as the answer. A caller that needs the refusal itself, with its message and kind intact, calls functions::compute.

Spatial relations on the property-function seam

GeoSPARQL’s Query Rewrite rules let ?a geo:sfWithin ?b hold between features whose geometries satisfy the relation, not only where a triple asserts it. GeoIndex::from_dataset projects a dataset’s geometry literals once, and relation::register installs one property function per relation of the families the caller names — Simple Features, Egenhofer, RCC8 — against a PropertyFunctionRegistry. An empty family list is refused: registering nothing and returning success would surface much later as a query whose geo:sfWithin was parsed as an ordinary triple pattern and matched nothing.

use std::sync::Arc;
use purrdf::geo::relation::{self, GeoIndex, GeoIndexConfig, GraphSelector};
use purrdf::geo::{GeoTerm, RelationFamily};
use purrdf::sparql::{ParserOptions, PropertyFunctionRegistry};
use purrdf::TermValue;

let config = GeoIndexConfig::new(
    vec![TermValue::iri(vocab.term(GeoTerm::AsWkt))],
    GraphSelector::Any,
)?;
let index = Arc::new(GeoIndex::from_dataset(&dataset, &vocab, &config)?);

let mut relations = PropertyFunctionRegistry::new();
relation::register(&mut relations, &vocab, &index, &[RelationFamily::SimpleFeatures])?;

// The parser must claim `geo:sfWithin` in predicate position; the registry's
// own descriptors are exactly the IRIs it should claim.
let parser_options = ParserOptions {
    extension_fn_namespaces: Vec::new(),
    property_fn_namespaces: Vec::new(),
    property_fn_iris: relations.describe()?.into_iter().map(|d| d.iri).collect(),
};

An asserted geo:sfWithin triple matches whether or not the geometries satisfy it — the rewrite rules are entailments, not definitions — and a relation the index refutes contributes no row beyond what the data asserts.

Every answer is exact, and identical on every target

Geometry is where floating point normally destroys reproducibility: f64 addition is not associative, so a different traversal order gives a different answer, and a native build and a wasm32 build can disagree about a predicate that sits near a boundary. This crate closes that channel rather than mitigating it.

  • Coordinates are read as exact rationals. A lexical decimal is parsed digit by digit into an exact numerator and denominator; nothing is rounded on the way in.
  • Every geometric decision is integer arithmetic. Orientation, segment intersection, point-in-ring, ring winding and the DE-9IM matrix are comparisons of exact rationals over arbitrary-precision integers, which Rust specifies completely and identically on every target.
  • Irrational measures are integer square roots at a fixed internal scale, summed as integers — one rounding, at the end, of a value that was exact until then.
  • The single float boundary is the result literal. An xsd:double result is the correctly rounded nearest double, computed with integer arithmetic and assembled with f64::from_bits. The crate root denies clippy::float_arithmetic, so there is no second float path to find.

The cross-target claim is executed, not argued: make geo-determinism runs the same corpus natively and on wasm32 and compares bytes.

What is here, and what is not

Implemented: the WKT and GeoJSON codecs (with CRS and coordinate-dimension support); every topological relation of the Simple Features, Egenhofer and RCC8 families over an exact DE-9IM; the accessors; and the measures and constructors that are exactly computable.

Registered but hard-erroring by name: the operations that would require facilities the crate deliberately does not have — a coordinate-reference-system database for geof:transform, and an ellipsoidal geodesic for the metric* family. They are never silently absent and never answer a default. A topological predicate that returned false because it was unimplemented would be indistinguishable from one that returned false because the geometries genuinely do not relate, and that is the failure this crate exists to keep out.

Like the full-text index, this is a Rust-host seam: the registrations are host closures and do not cross the Python, WebAssembly or C boundary. The full exactness accounting is docs/design/purrdf-geo-exactness.md.

Embedding Nearest Neighbours

What it replaces, and where it stops. This is the surface that lets an RDF project drop the pgvector it kept beside its triple store for nearest-neighbour search: ?neighbour <space> ( ?seed k ?distance ) answered in-process, exact top-k under the metric the artifact declares, and byte-identical natively and on wasm32. It is an exact scan — every candidate scored, no pruning, no approximate index — bounded by a caller-supplied KnnGuard, under three metrics (cosine, negative dot, squared Euclidean), over a PURREMB embedding space. PurRDF computes no embeddings and runs no ANN payload: the vectors come from a PURREMB artifact the caller fills, and PurRDF writes that carrier itself (EmbeddingBuilder in memory, EmbeddingStreamWriter streaming — Rust only) and opens it fail-closed; the model that produced the vectors is the caller’s.

The PURREMB layer in purrdf-core (see Deterministic embedding companions and docs/PURREMB.md) stores RDF-1.2-addressable vectors, a declared distance metric, and tamper-evident guards binding third-party index payloads to the exact matrix they were built over. It deliberately does not rank anything — ranking is a query operation and PURREMB is an artifact format. The knn module of purrdf-sparql-eval is that query operation, exposed through the property-function seam.

The call shape

PREFIX knn: <https://example.org/space/>
PREFIX d:   <https://example.org/d/>

SELECT ?neighbour ?distance WHERE {
  ?neighbour knn:points ( d:a 3 ?distance )
}

Four flattened positions — one on the subject side, three on the object side:

positionnamerole
0?neighbourout: the RDF term whose vector was retrieved
1?queryin: the term whose vector seeds the search
2kin: how many neighbours to retrieve
3?distanceout: the distance, as xsd:double

The one declared mode is fbbf: the seed and k are inputs the relation cannot enumerate, while ?neighbour and ?distance may each be bound or free. k accepts any integer-derived datatype (xsd:integer, xsd:int, xsd:long, xsd:nonNegativeInteger, …); k = 0 is a valid request for zero neighbours. A seed with no vector in the space is an empty answer, not an abort — otherwise every query ranging a seed over partly-embedded terms would die.

PurRDF mints no IRI for this

The predicate is the caller’s, and it is how a query names a space: a host builds one EmbeddingSpace per (artifact, target set, vector space) triple it wants queryable and registers an EmbeddingKnnRelation over it under whatever IRI its own vocabulary uses. There is no default IRI and no fallback space. Registering one relation per space — rather than one relation taking a space IRI as an argument — is what lets the planner read an honest row bound off the space that will actually be searched.

use std::sync::Arc;
use purrdf::sparql::{EmbeddingKnnRelation, EmbeddingSpace, KnnGuard, PropertyFunctionRegistry};

// The guard is caller-supplied configuration with no default: the largest
// space one invocation may scan, and the largest `k` it may request.
let guard = KnnGuard::new(/* max_candidates */ 100_000, /* max_neighbours */ 64)?;

// `bindings` names the RDF term each PURREMB row stands for. A row with no
// term is a construction-time refusal, not a skipped row — a top-k over a
// silently smaller candidate set would be the top-k of a subset.
let space = EmbeddingSpace::from_artifact(
    &purremb_bytes, target_set_id, vector_space_id, bindings, guard,
)?;

let mut registry = PropertyFunctionRegistry::new();
registry.register(
    "https://example.org/space/points".to_owned(),
    Arc::new(EmbeddingKnnRelation::new(Arc::new(space))),
);

The registry then reaches the engine through QueryOptions::property_functions exactly as on the full-text page. This is a Rust-host seam.

Exact search, ordered under the declared metric

The search is exact: every candidate row is scored under the metric the space’s family contract declares (FamilyView::metric() — cosine or squared Euclidean disagree on real data, and a surface using one kernel for both fails the tests that pin them apart), and the k returned are the true k nearest. There is no candidate pruning anywhere. That is what lets the engine’s row-ceiling pushdown be sound here — emission order is rank order, so the first n rows are the n nearest for every n ≤ k — and what makes “ordered correctly” a property the module is tested for rather than a property of a tuning parameter.

Rank order is (distance ASC, row ASC). Row numbers are distinct, so the order is strict and total, and the tie-break is meaningful: a PURREMB target set is sorted and deduplicated by TargetId, a digest of canonical identity, so ascending row number is ascending canonical content order. Two artifacts built from the same content in opposite insertion orders answer byte for byte, serialized JSON included.

Binary64, in a pinned order

The workspace’s other ranked-retrieval surface forbids floating point entirely, because BM25 needs a logarithm and IEEE-754 does not require ln to be correctly rounded. That reason does not transfer. A kNN kernel needs no transcendental — subtraction, multiplication, addition, division and square root are all correctly rounded — so binary64 gives the same cross-target guarantee here, and it is the arithmetic docs/PURREMB.md states normatively (“binary64, round-to-nearest ties-to-even, in the written order, without a fused multiply-add”). Fixed point would have introduced a quantization step the format does not have.

The two residual hazards are closed structurally: every fold runs over ascending component index in one sequential loop, never split across workers (a test folds [1e16, -1e16, 1], which sums to 1 one way and 0 the other, and pins which), and every product is bound to a named local before it is added so no fused multiply-add can form. Distances are emitted as xsd:double, whose canonical lexical round-trips the exact bits — a rounded decimal would print two adjacent doubles alike and hide exactly the divergence this is about.

One limit, stated rather than hidden: cosine self-distance is not exactly zero (dot(v, v) and |v|·|v| are two roundings of one real number), so a seed ranks strictly ahead of every other direction rather than at a zero that is not there.

What a search costs

The governor’s earlier charge points priced a host relation by invocations driven and rows accepted. For a generator, neither is where the cost is: a scan over a million vectors returning five rows would have been priced like a six-row table scan. The relation therefore reports its internal work through PfCursor::take_work after every pull, and the engine spends one unit of fuel per reported unit — so the search charge follows the space size, not the rows returned, and a KnnGuard is what keeps both the charge and the planner’s row bound honest.

The design record — the four decisions that were not obvious and what fixed each — is docs/design/purrdf-embedding-knn.md.

SHACL

purrdf-shapes (re-exported as purrdf::shapes) is PurRDF’s native SHACL validator: the complete SHACL Core feature set — all constraint components, full property paths, qualified value shapes, property pairs — plus SHACL-SPARQL constraints and targets and the SHACL-AF surface, running entirely on PurRDF’s own interned IR and native SPARQL engine (no oxigraph, no PyO3).

It validates an RDF 1.2 data graph against a SHACL shapes graph with no inference (parity with pySHACL inference="none"); combine with Entailment if you want to validate a materialized closure.

What it covers

  • SHACL Core — every constraint component, full property paths, qualified value shapes, property pairs. The W3C data-shapes suite passes clean (129/129, zero ledgered gaps at the time of writing — the live number is in docs/CONFORMANCE.md).
  • SHACL-SPARQL — SPARQL-based constraints and targets, custom constraint components with pre-binding semantics, user-defined sh:SPARQLFunction calls, and sh:SPARQLTargetType, evaluated on the native SPARQL engine.
  • SHACL-AF — node expressions (including sh:ExpressionConstraintComponent) and SHACL Rules (sh:TripleRule and sh:SPARQLRule, with sh:condition, sh:order, sh:deactivated): rules fire in an iterative fixpoint and the derivation is materialized as a new dataset (base ⊎ derived), leaving the input graph untouched. The surface is aligned with the SHACL 1.2 Working Drafts: the SHACL 1.2 Node Expressions vocabulary (shnex:, http://www.w3.org/ns/shacl-node-expr#) and the older SHACL-AF spelling of a node expression parse to one representation and run through one evaluator; sh:nodeByExpression is validated; SPARQL-based node expressions and expression-bodied functions ride the native engine; and rules execute as sh:order strata with the once/general partition of the SPARQL 1.2 RL draft, each stratum materialized before the next runs, so swapping two rules’ orders can change the closure. sh:condition resolves at shapes-load, so an unresolvable condition is a load error rather than a rule that silently never fires. Every one of those IRIs is defined by a W3C document; PurRDF mints none. Some node-expression conveniences (sh:if, aggregations, ordering wrappers) are DASH/TopBraid conventions with no normative RDF definition; PurRDF documents its adopted reading and pins it with a frozen corpus — see the SHACL-AF section of docs/CONFORMANCE.md.

owl:imports in a shapes graph

A shapes document may carry an owl:Ontology header that owl:imports other documents, and the shapes it constrains with may live entirely in those imports. PurRDF resolves that closure — but it never fetches it. There is no HTTP client in the workspace, every release crate builds for wasm32-unknown-unknown, and a validation verdict that depends on what a URL served today is not reproducible. So the closure is caller-supplied configuration, exactly as it is for entails and shex:

purrdf validate --shapes root.ttl \
  --import https://example.org/shapes-a=a.ttl \
  --import https://example.org/shapes-b=b.ttl \
  data.ttl -

The table is followed transitively — an imported document’s own owl:imports are resolved from the same table — and a cycle terminates rather than looping, because OWL 2 §3.4 defines the imports closure as the transitive one and explicitly permits A to import B to import A. Each imported document’s own @prefix declarations travel with it, so a SHACL-AF sh:select written in an imported file resolves against the prefixes that file declares.

Naming any pair makes the closure mandatory: an owl:imports no pair resolves is refused by name rather than folded in as an empty graph, and a pair the closure never reaches is refused as unused rather than read and ignored.

Naming no pair is not a refusal. Each unresolved import is reported on stderr and the shapes graph validates alone — a shapes document may legitimately carry an ontology header whose imports are irrelevant to its shapes, and refusing those would reject input that is valid. What is gone is the silence: before PurRDF 1.0.1 an unresolved owl:imports was ignored without a word, so a shapes graph whose shapes all lived in an imported document reported conforms true against no shapes at all.

The SHACL 1.2 reifier-shape draft scope

The crate implements a scoped SHACL 1.2 Working Draft feature: sh:reifierShape and sh:reificationRequired for direct IRI property paths, so shapes can constrain the RDF 1.2 reifier metadata attached to statements (see RDF 1.2 Features). The relevant SHACL 1.2 Core draft is dated 2026-06-02. This is not a claim of full SHACL 1.2 conformance — it is one draft feature, explicitly scoped and tested.

Ontology-complete developer schemas

The public compile_schema boundary accepts a SchemaCompileRequest that binds the parsed shapes, exact ontology dataset, caller-owned Namespaces, and an explicit SchemaSurfaceMode. ShapedOnly retains the active SHACL target-class surface. OntologyComplete adds existing caller-vocabulary classes and optional OWL/RDFS-derived properties. The result carries JSON Schema draft 2020-12, OpenAPI 3.1, the normal forward loss ledger, a canonical property-coverage report, and a deterministic pre-compilation cache key. Its CompiledSchema feeds the LinkML, TypeScript, GraphQL, and Pydantic emitters without a second schema-discovery pass.

The bounded theory catalogs only schema evidence: direct IRI sh:path, RDF/OWL property declarations, domain/range declarations, and both endpoints of subproperty, equivalent-property, and inverse-property relations. A predicate seen only on an instance is not promoted. Class admission is likewise explicit, and synthesized definitions are limited to namespaces the caller supplied; PurRDF does not turn builtin compaction prefixes into an ontology boundary.

Subclass/equivalent-class closure determines domain membership. Multiple domains are conjunctive; OWL union members are alternatives and intersection members are conjunctive. Subproperties inherit superproperty domains, ranges, and forward functionality, equivalent properties propagate bidirectionally, and inverse properties exchange domain and range. Strongly connected cycles are condensed deterministically. Multiple ranges remain conjunctive in emitted JSON Schema; union and intersection expressions map to anyOf and allOf.

Direct SHACL remains authoritative. Ontology-only fields are optional; owl:FunctionalProperty gives a scalar representation with approximation provenance, while inverse functionality does not. Closed shapes reject unshaped fields unless they are directly present or ignored. Classes without a target shape are emitted as open carriers, never as fabricated closed models. This is not ABox materialization or unrestricted OWL: property chains and axioms outside the fragment do not create fields.

SchemaCoverageReport accounts for every catalogued property once, including exclusions, with sorted per-class outcomes and source-axiom provenance. SchemaCompileRequest::coverage_report can produce it before emission. The request cache key binds RDFC-1.0 identities for the shapes and ontology graphs, caller namespaces, mode, value-vocabulary marker, compiler/policy salts, and the fixed ceilings: 65,536 properties, 65,536 classes, 1,048,576 relation or coverage cells, and expression depth 64. Malformed OWL lists, contradictory property kinds/ranges, key collisions, and limit exhaustion are typed failures.

Run the complete two-mode and four-emitter example with:

cargo run -p purrdf-shapes --example ontology_schema_surface --locked

Schema → SHACL imports

The schema-projection surface is bidirectional. SchemaImportConfig requires the caller’s namespace table and the complete RDF datatype mapping for JSON scalars; there is no default vocabulary. The five production reverse directions are JSON Schema draft 2020-12 (import_json_schema), native LinkML 1.11 (import_linkml), and verified PurRDF-emitted Pydantic v2, TypeScript 7.0, and GraphQL September 2025 packages (import_*_package). All five lower through one ordered JSON-Schema semantic model and return typed shapes plus an always-computed, located reverse LossLedger.

Malformed values, open or dangling references, identity collisions, generated artifact/map drift, and resource-limit exhaustion fail closed. Valid source constructs without an exact SHACL interpretation are ledgered at their native JSON Pointer. Arbitrary Python, TypeScript, and GraphQL SDL are intentionally outside the inverse boundary because none defines one unique runtime JSON acceptance relation. LinkML does have a native reader; its schema identity and documentation can therefore appear as losses even when the validation-bearing SHACL recompiles byte-exactly.

The executable example constructs caller-owned example.org configuration and exercises all five paths:

cargo run -p purrdf-shapes --example schema_reverse --locked

Pydantic v2 projection

purrdf-shapes can transliterate a compiled SHACL-derived JSON Schema into a deterministic, typed Pydantic v2 package entirely in memory. The public emit_pydantic function consumes CompiledSchema; PydanticConfig requires the caller to supply the package name and package/module prose, so the library does not invent a vocabulary, namespace, or downstream brand.

Every $defs entry gets a stable import path, JSON property names remain exact through Pydantic aliases, and generated classes expose the originating definition through model_json_schema(by_alias=True). Pydantic runtime annotations enforce the representable portion. A JSON Schema assertion with no exact runtime annotation remains visible on that schema surface and produces a located entry in the always-computed json-schemapydantic-v2 LossLedger; a lossless input yields an empty ledger. The renderer itself has no Python dependency and stays wasm-clean. A dev-only Python oracle executes the generated code and checks the live reverse/schema surface. import_pydantic_package separately verifies the retained source schema, generated files, model map, dialect, and forward ledger before importing SHACL.

The optional caller-owned PydanticPackageTopology is a total partition of $defs entries into portable dotted leaf modules. Each route carries the class docstring and a sorted, vocabulary-neutral json_schema_extra map suitable for documentation URLs, content digests, and other caller-defined linkage. An optional PydanticVersionStamp adds an exact PEP 440 __version__ export. Routed packages share schema/runtime support modules, generate intermediate package initializers, use explicit symbol tables for one root-level rebuild, and pass the executable runtime oracle plus strict mypy. Exact route coverage, portable path/symbol uniqueness, and fixed input/config/output limits all fail closed. When both topology and version stamping are omitted, the original flat package bytes remain unchanged. A flat version stamp adds __about__.py and updates the __init__.py exports.

LinkML 1.11 projection

The same CompiledSchema carrier can be projected to canonical LinkML 1.11 with emit_linkml. LinkmlConfig requires the caller’s schema IRI, name, description, default prefix, and complete prefix map, so PurRDF never mints a consumer vocabulary or identity. The returned LinkmlPackage includes the typed document, deterministic YAML, a reversible $defs-key mapping, and a located json-schemalinkml-1.11 loss ledger. It also carries ordered, integrity-checked slot rename and skip-diagnostic reports.

Classes and exact property aliases, types, enums, local references, inline objects, requiredness, homogeneous arrays, patterns, inclusive bounds, and LinkML boolean expressions are represented directly. An unsafe LinkML slot name uses SanitizePolicy::Rename by default; Skip omits only that slot with a located diagnostic/loss, and Fail returns a contextual error. Rename preserves declared CURIE and absolute-IRI identity byte-exactly in slot_uri; an unsafe bare token or exact caller re-home receives a reported identity under the caller-supplied default prefix. All valid IRI schemes remain absolute unless the caller marks that exact token, so custom schemes are not guessed from their spelling. Safe names reserve first and hash-plus-ordinal collision allocation is bounded and deterministic.

Every unsupported assertion is classified by a closed capability table; malformed inputs, external/dynamic/dangling references, stale re-home hints, semantic identity collisions, and fixed-limit breaches fail closed. parse_linkml and write_linkml preserve all JSON-compatible metamodel fields and provide byte-stable read/write round trips while rejecting YAML-only tags, duplicate keys, non-string keys, and non-finite numbers.

import_linkml consumes that validated native document; the emitted-package variant import_linkml_package first verifies canonical YAML and the reversible element map, slot reports, policy losses, aliases, and emitted identities. Both use the same caller-owned SHACL import configuration. Migration adapters should pass CompiledSchema unchanged, configure exact re-homes, and consume slot_renames; rewriting shared property/required keys is unnecessary.

The Rust production path has no LinkML-toolkit dependency. CI uses the locked official LinkML 1.11.1 Python packages only as a differential oracle. It loads safe, lossy, and renamed fixtures through SchemaDefinition and SchemaView, regenerates JSON Schema, and verifies reverse predicates:

make linkml-oracle

TypeScript 7.0 projection

emit_typescript projects the same CompiledSchema into deterministic TypeScript 7.0 declarations. The caller supplies the package name and all package/module prose through TypeScriptConfig. The returned package contains one index.d.ts, a reversible $defs-key to exported-type map, and a located json-schematypescript-7.0 loss ledger; PurRDF invents no consumer identity or vocabulary.

The fixed declaration dialect uses strict plus exactOptionalPropertyTypes. Type aliases preserve JSON primitives and literals, required versus optional fields, explicit null, local recursive references, unions, intersections, homogeneous arrays, and bounded tuples. There are no runtime enums, mergeable interfaces, branded pseudo-validators, or any escape hatches. Invalid keywords, open/dangling references, and name collisions fail before bytes are emitted.

Runtime assertions outside TypeScript structural assignability are never silently erased: integer, numeric/string predicate, closure, pattern-property, dependency, conditional, negation, contains/unique, evaluation-state, and bounded-expansion gaps receive stable codes and JSON Pointer locations. CI classifies instances independently with a draft 2020-12 validator and compiles the generated declarations with the locked TypeScript 7.0.2 compiler, including fresh-literal and through-variable probes:

make typescript-oracle

The projection intentionally has no arbitrary TypeScript reader. TypeScript declarations do not define a unique runtime JSON acceptance relation, and the projection is many-to-one. import_typescript_package is the authoritative reverse surface: it deterministically verifies the retained source schema, declaration, reversible name map, dialect, and forward ledger. TypeScript is only a dev-time oracle dependency; the Rust emitter/importer is filesystem-free and wasm-clean.

GraphQL September 2025 projection

emit_graphql projects CompiledSchema into deterministic GraphQL September 2025 SDL. GraphqlConfig has no defaults: the caller supplies the schema name, package and module prose, and a non-built-in fallback-scalar name. The returned GraphqlPackage contains schema.graphql, canonical name-map.json, the same name map as typed Rust data, a located json-schemagraphql-september-2025 loss ledger, and the production value codec.

The SDL is deliberately a type-system fragment. PurRDF emits paired output type and input input objects, but no query, mutation, or subscription root, resolver, pagination rule, authorization policy, federation directive, or other application behavior. A caller composes the fragment with its own executable schema.

The exact grammar includes GraphQL booleans, strings, numbers, the signed 32-bit Int domain, explicit nullability, finite JSON const/enum sets, closed object fields, requiredness, homogeneous lists, direct local $defs references and aliases, descriptions, and inline object helpers. One global collision-checked namespace covers types, helpers, and the fallback scalar; fields and enum symbols are checked in their GraphQL-local namespaces. The typed/canonical name maps retain the source definition keys, property keys, and finite JSON values.

GraphqlPackage::encode_input maps source JSON keys and finite values to input field names and enum symbols. decode_output performs the inverse for fields present in a GraphQL response, without inventing omitted selections. Unknown or incompatible values fail. This package codec is the precise value boundary; import_graphql_package is the schema reverse boundary and verifies the SDL, typed/canonical maps, identity, retained source schema, and forward ledger. Arbitrary GraphQL SDL has no unique JSON Schema acceptance relation and is not accepted as an inverse format.

GraphQL variable coercion differs from JSON Schema validation at these closed boundaries:

BoundaryLocated loss families
object fields and namesadditional properties, pattern properties, property names/counts
requiredness and recursionnullable-presence widening, one deterministic recursive-input nullability relaxation
listssingleton coercion, cardinality, contains, uniqueness, tuples, unevaluated items
scalar assertionsinteger domain delegation, numeric predicates, string predicates
applicatorsconditionals, dependencies, intersections, unions, oneOf, negation
runtime boundarycustom-scalar and unknown-keyword validation delegation

The caller-named fallback scalar is declared but PurRDF does not invent its parseValue, parseLiteral, or serialization semantics. Every delegated use is therefore ledgered. Loss entries carry stable codes and source JSON Pointer locations; an exact package has an empty ledger.

Emission fails before returning bytes for invalid caller configuration, malformed schema keywords, $id rebasing, external/indirect/dangling $ref, $dynamicRef/$recursiveRef, alias cycles, unsatisfiable closed required fields, and generated-name collisions. The fixed limits are 16 MiB for the input schema, each artifact, and one codec value; 65,536 definitions, fields per object, or finite values; depth 128; and 255 bytes per GraphQL name.

The independent dev oracle classifies source values with boon, builds the SDL with locked official GraphQL.js 16.14.0, and executes real variable coercion. It verifies exact agreement, every closed loss family and location, the name map and production codec, and deliberate corruption failures:

make graphql-oracle

GraphQL.js is dev-only. Emission and value translation remain filesystem-free, wasm-clean Rust.

From Python

from purrdf import shacl

report = shacl.validate(shapes_ttl="...", data_nt="...")
print(report["conforms"])  # True / False
print(report["results"])   # list of violation dicts

Each result dict keeps the stable keys focus, path, value, severity, component, source_shape, and message.

The report is a dataset

ValidationReport::to_dataset() materializes the W3C validation report — the sh:ValidationReport node and its sh:ValidationResults — as a frozen RdfDataset, built straight from the report’s own terms rather than through a to_ntriples()parse_dataset() round-trip. The direct path carries every RDF 1.2 term the report holds (a triple-term focus node included) with the report’s own blank-node labels, and the blank nodes the report mints (the report node, one per result, the interior nodes of a complex sh:path) are guaranteed distinct from every blank node the data graph carries. Rendering the report in any syntax is then serialize_dataset(&report.to_dataset(), …), which is exactly what purrdf validate --format does.

SARIF output

Validation reports stay structured in the engine; the SARIF 2.1.0 boundary is the separate purrdf-validate crate (purrdf::validate), which renders a report — or parser diagnostics — as a source-traced, byte-deterministic SARIF log for editors, CI, and code-scanning dashboards:

use purrdf::validate::{validate_to_sarif_string, SarifOptions};

let shapes = r#"
    @prefix sh:  <http://www.w3.org/ns/shacl#> .
    @prefix ex:  <http://example.org/> .
    @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
    ex:PersonShape a sh:NodeShape ;
      sh:targetClass ex:Person ;
      sh:property [ sh:path ex:age ; sh:datatype xsd:integer ] .
"#;
let data = r#"<http://example.org/alice> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/Person> .
<http://example.org/alice> <http://example.org/age> "nope" .
"#;

let sarif = validate_to_sarif_string(shapes, data, &SarifOptions::default())
    .expect("sarif produced");
assert!(sarif.contains("\"version\": \"2.1.0\""));

Lower-level entry points (build_report_sarif, build_diagnostics_sarif) build a SarifLog value instead of a string, so a host can merge runs before serializing.

Conformance

The validator is gated by the vendored W3C data-shapes suite, a vendored DASH SHACL-AF/rules corpus, and a first-party frozen corpus of 70 cases with byte-frozen expected reports; SHACL Rules output is compared to expected inferred graphs by RDFC-1.0 isomorphism. See Conformance & Testing.

ShEx

purrdf-shex (re-exported as purrdf::shex) is PurRDF’s native Shape Expressions Language 2.1 engine: the schema layer and the shape-map validator, pure Rust and wasm-clean.

Schemas: ShExC and ShExJ

  • ShExC (the compact syntax, spec §6) — a hand-rolled lexer and recursive-descent parser covering the full grammar: directives, start, the AND/OR/NOT shape algebra, node constraints and the facet table, value sets with stems/ranges/exclusions, triple expressions with all cardinality forms, $/& labels and inclusions, ^ inverse, annotations and %…{ … %} semantic actions, with relative-IRI resolution against BASE via purrdf-iri.
  • ShExJ (the JSON wire format, spec Appendix A) — strict, round-tripping serde support matching the shexTest ground truth.
  • Structural checks (spec §5.7) — dangling references, label collisions, reference-only cycles, and the negation-stratification requirement.
use purrdf::shex::{check_structure, parse_shexc, to_shexj};

let schema = parse_shexc(
    "PREFIX ex: <http://example.org/>\n\
     PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>\n\
     ex:S { ex:p xsd:integer? }",
    None,
)?;
check_structure(&schema).expect("well-formed");
let json = to_shexj(&schema);

Hard-fail discipline: every malformed schema is a typed ShexError — no lenient mode, no panics on any input.

Validation with shape maps

Validation (spec §5.2–§5.5) is fixed shape-map validation over the frozen purrdf-core dataset IR, in interned TermId space: you supply (node, shape) associations and the validator decides conformance for each. It covers node constraints (node kind, datatype with lexical-validity checking, string/numeric facets, value sets with stems and exclusions), EXTRA/CLOSED triple-expression matching with EachOf/OneOf partitioning and group cardinalities, inverse constraints, typing-based recursion, and an EXTERNAL resolver hook.

From Python:

from purrdf import shex

results = shex.validate(my_schema_shexc, my_data_ttl,
                        [("https://example.org/alice", "https://example.org/PersonShape")])
print(results[0]["conformant"])

Conformance

The engine is gated against the vendored official shexTest suite, pinned at tag v2.1.0 (vectors/shexTest/): the full validation/ manifest, the schemas/ ShExC/ShExJ pairs and round-trips, and the negative syntax and negative structure suites. At the time of writing the validation manifest passes 1,105/1,105 attempted cases with zero expected-failures and an empty trait-skip list (imports and semantic actions included); the live scoreboard is docs/CONFORMANCE.md.

Reported conformance is logic-level (pass/fail parity), per suite convention; result-structure conformance is upstream-experimental.

SHACL or ShEx?

PurRDF implements both natively over the same IR, so the choice is yours, not the toolkit’s: SHACL suits constraint reporting (violations with severities, SARIF output) and SHACL-SPARQL escape hatches; ShEx suits schema-like conformance decisions over explicit shape maps.

Entailment

purrdf-entail (re-exported as purrdf::entail) is native, wasm32-clean entailment for the PurRDF RdfDataset IR. A family of engines sits behind one facade, each the right tool for its SPARQL entailment regime — closing a dataset to its inferred fixpoint entirely in interned TermId space, with no external reasoner, no async runtime, and no string round-trip.

Surface map

Entry pointRegime(s)Engine
materialize(ds, regime)Simple, RDF, RDFS, OWL-RL, DForward materialization (“chase”) of the regime’s declared clause program via a native semi-naive fixpoint. Returns (closure, ReasoningReport); the report is not optional.
materialize_dl_reported(...), or materialize(ds, Materialization::OwlDirect(bgp))OWL-DirectOpen-world OWL DL over a SHOIQ(D) tableau, directed by the query’s basic graph pattern bgp; materialize delegates to it for this regime rather than restating it.
materialize_rif(...)RIFRIF-Core rule entailment over a parsed RuleSet.
parse_rif_xml(...) / resolve_rif_imports(...)RIFRIF-XML parsing with caller-owned, I/O-free import resolution.
rules(regime) / implemented(regime)The rule table a regime is defined by, and the subset this workspace fires. Their difference is the measurable gap.
calculus_program(regime)The regime’s calculus as DL-clause data — the very program materialize evaluates, so a consumer can recompute its contract hash.
Regime::from_iri(iri)Parse a sparql:entailmentRegime IRI to its enum.
use purrdf::entail::{materialize, Completeness, Materialization};

// Close a frozen dataset to its RDFS fixpoint; the result is a new dataset
// AND a report of what the run did.
let (closed, report) = materialize(&ds, Materialization::Rdfs).expect("materializes");
assert_eq!(report.completeness(), Completeness::ExactWithinBoundaries);

The same engine in five hosts

Entailment is not re-implemented per host. The purrdf command line, Python, WebAssembly, and the C ABI all route through one shared string boundary (purrdf_validate::regime) that wraps the Rust engine, and the surfaces are checked against a single committed golden-vector artifact — so a divergence shows up as one vector failing rather than as several surfaces that quietly stopped agreeing. The regime spellings (simple, rdf, rdfs, owl-rl, owl-direct, rif, d) are the same everywhere.

HostMaterializeDefined rule tableImplemented rules
Rustmaterialize(&ds, Materialization::Rdfs)rules(Regime::Rdfs)implemented(Regime::Rdfs)
CLIpurrdf reason --regime rdfs, purrdf convert --entailment rdfs, purrdf query --entailment rdfs (and purrdf entails asks the conclusion-directed question)
Pythonpurrdf.entail.materialize(dataset, "rdfs", ""), purrdf.entail.materialize_nt(text, "rdfs", "")purrdf.entail.rules("rdfs")purrdf.entail.implemented_rules("rdfs")
JavaScript / WebAssemblyentailMaterialize(doc, "rdfs", "")entailRules("rdfs")entailImplementedRules("rdfs")
Cpurrdf_entail_materialize_to_nquads(...)purrdf_entail_rules(...)purrdf_entail_implemented_rules(...)

Every host materializes every regime; none refuses one. What two regimes need is an INPUT, and each host has a parameter for it: --rules <FILE> on the CLI, a program string on the Python, WebAssembly and C surfaces, and the Materialization value itself in Rust. rif takes a normative RIF-in-XML rule document there; every other regime takes none, and supplying one is an error rather than a discarded argument. owl-direct’s extra input is a query’s class expressions, so a document-in/document-out call runs the query-independent augmentation and a query surface (purrdf::query_with_entailment, purrdf query --entailment owl-direct) is where the query-directed lane lives.

One host-specific note:

  • The WebAssembly module also exports entailCheckGoldenVectors(), which replays the committed tri-host vector artifact inside the module a consumer actually loaded — so agreement with the reference implementation can be checked without trusting this repository’s CI.

Asking a question instead: the conclusion-directed services

Materializing answers “what does this premise entail?”. The three services below answer “does this premise entail that?”, which is a different question and not the membership test in a closure it looks like: a conclusion’s blank nodes are existentials that have to be mapped, an inconsistent premise entails everything, a failure to find a mapping means nothing unless the rule set is complete for the premise it ran on — and a conclusion can be entailed while appearing nowhere in the closure at all, which is what the five mechanisms beyond the rule table exist for (see Conformance).

They run over the same shared boundary, on all five host shapes, and the purrdf command line is one of them. scripts/check-entailment-surface.py is the gate: it derives the service set from purrdf-entail’s own public entry points and fails until every one of them is reachable from every host with the boundary’s whole parameter list, so a service or a parameter cannot land on four hosts and go dark on the fifth. It also mutation-tests itself on every run — one edit per check, applied in memory over the committed tree, each of which must make the gate fail — because a check that cannot withhold a green light is not a check.

HostDoes P entail C?Re-decide the warrantCertain answers of a pattern
Rustentails(&p, &c, Regime::OwlRl, &imports)verify(warrant, &p, &c)certain_answers(&p, &bgp, Regime::OwlRl, &imports)
CLIpurrdf entails --regime owl-rl --premise P --conclusion C… --conclusion C --verify… --pattern BGP
Pythonpurrdf.entail.graph_entails("owl-rl", p, c, imports)purrdf.entail.verify_entailment(...)purrdf.entail.certain_answers("owl-rl", p, bgp, imports)
JavaScript / WebAssemblyentailGraphEntails("owl-rl", p, c, iris, docs)entailVerifyEntailment(...)entailCertainAnswers(...)
Cpurrdf_entail_graph_entails(...)purrdf_entail_verify_entailment(...)purrdf_entail_certain_answers(...)

Two things differ from the materializing table above, and both are consequences of the question rather than of any host:

  • Five regimes, not seven. owl-direct is directed by a query’s class expressions and rif entails under the caller’s rule document, and “premise, conclusion, regime” carries neither. Both are refused by name on every host — never answered under a weaker regime and labelled with the one that was asked for — and both still materialize.
  • The import table is a parameter, on every host. An ontology’s imports closure is the ontology, so a premise carrying an owl:imports the call was not handed is a different premise. PurRDF fetches nothing, so the closure arrives as caller-supplied configuration: an ordered list of (ontology IRI, document) pairs, spelled --import IRI=FILE on the command line. An unresolved import is a refusal naming the document, never a silently truncated premise.

A pattern is N-Triples with ?name (or $name) in any position, the predicate included. RDF reserves that position for an IRI, so the boundary reaches it by rewriting each variable to a term drawn from a namespace it has swept out of the caller’s own text and mapping every occurrence back afterwards; nothing of that namespace reaches a row, a binding or a report. The one slot that admits no variable is a literal’s datatype: "5"^^?d asks for a binding in a position that holds an IRI rather than a term, and it is refused by name — the stand-in must never be left sitting there, matching the boundary’s own namespace instead of the caller’s data. A predicate variable is projected like any other, and under owl-rl it also renders a limit: it ranges over the whole predicate vocabulary, so it ranges over the schema predicates Theorem PR1’s conclusion hypothesis excludes — the table claims no completeness for them, whether or not scm-* derives one — and over the constructs the mechanisms beyond the table decide, for which the closure the rows are drawn from holds nothing.

Every answer arrives with the certificate of the run underneath it — the same purrdf-reasoning-report block a materialization renders, plus a mechanism line naming which of the six reached the verdict.

Rule coverage

rules(regime) is the rule table the specification defines the regime by; implemented(regime) is the subset the evaluator fires. Both are &'static slices in specification table order, so the gap is an executable artifact instead of a sentence:

RegimeRule tableDefinedImplemented
Simple— (identity closure)00
RDFRDF 1.2 Semantics §8.1.133
RDFSRDF 1.2 Semantics §8.1.1 + §9.2.11818
OWL-RLOWL 2 Profiles §4.3 Tables 4–97878
DOWL 2 Profiles §4.3 Table 855
OWL-Direct— (SHOIQ(D) tableau, not a fixed table)00
RIF— (caller-supplied rule set)00

The per-rule breakdown — every rule id, its specification citation, and whether it is fired — is generated from that API and drift-guarded, so it cannot fall behind the code.

Where the numbers stop:

  • The four existential rules fire, but their conclusions are withheld. rdfD1, rdfD1a, rdfs14 and rdfs14a each conclude about a fresh blank node. The restricted chase mints each one as a frontier-addressed Skolem witness and closes under it, so the rules genuinely fire — but every conclusion mentioning a surrogate is dropped when the closure is materialized back, because a SPARQL entailment regime draws its answers from the scoping graph and a surrogate is not in it. The withholding is reported as Construct::Surrogate. Nothing surrogate-free is lost: replacing a term with a fresh blank node only weakens a triple.
  • A complete rule table is not a complete closure. OWL-RL fires all 78 rules, and a run that met a boundary still reports Completeness::ExactWithinBoundaries rather than Exact. The two claims are reported separately on purpose. Nor is a complete rule table entailment conformance: on this vendored W3C corpus of OWL 2 RL entailment tests entails() reaches 27 of 27 published positive entailments, and agrees with W3C on 23 of 23 negative ones — 3 of those 23 refuted, a decided non-entailment, and 20 admitted, the closure computed and observed not to contain the non-conclusion. Read 23 of 23 as “no unsoundness found”, never as “23 non-entailments proved” (see Conformance below). 78 / 78 says every rule of Tables 4–9 is implemented — and the one W3C-published entailment that is reachable only by a sound rule outside those tables is reached by an extension, ext-eq-diff-sym, which extensions(Regime::OwlRl) names, neither rules() nor implemented() names, and every report renders on its own extension line. Eight more are reached by refutation rather than by matching, and those add no rule at all: see the conformance section below.
  • Seventeen OWL 2 RL rules conclude false. “Implemented” for those means decided: a body match becomes EntailError::Inconsistent carrying a witness that names the rule and the asserted triples that satisfied it. That is the only thing a rule with no conclusion can do.

The chase (Simple / RDF / RDFS / OWL-RL / D)

materialize runs a forward-materialization chase: a fixed rule set for the selected regime, applied by a semi-naive fixpoint until no new quads appear. Because it runs over the frozen IR, it is deterministic — a given input and regime always yields the same closure — and because it works in TermId space, no term is ever re-parsed or re-serialized along the way.

Typical use: materialize first, then query with the plain SPARQL engine or validate the closure with SHACL (the SHACL validator itself performs no inference).

The rule set is not written twice. calculus_program(regime) renders it as DL clauses and materialize evaluates exactly those clauses through purrdf-datalog’s semi-naive evaluator, so the contract hash a report carries identifies the clauses that actually ran.

Every run says what it did

materialize returns (closure, ReasoningReport). There is deliberately no report-free variant, because the alternative — two entry points, one of which discards the evidence — is how a partial rule set comes to be described as a complete one. The report carries:

  • Completeness — derived from rules(regime) minus implemented(regime), so it improves by itself as rules are added, and it names the missing rules rather than merely counting them;
  • per-rule firing counts — which rules fired and how many conclusions each contributed;
  • Boundarys — the constructs the run met and could not close over, each with its reason;
  • the evaluation budget — what the run consumed of the evaluator’s fixed ceilings;
  • a contract hashpurrdf-datalog’s digest of the clause program, so a cached closure minted under a different calculus can be refused rather than trusted;
  • an inconsistency witness, when a rule that concludes false matched: the rule id, the asserted triples that satisfied its premises in premise order, and the graph they were read from.

A report cannot claim Exact while naming a boundary. ReasoningReport stores no completeness field at all: completeness() derives the value from the boundary list itself, so the contradictory state is unrepresentable rather than merely checked. That is deliberate — an earlier design stored the field and compared it against a derivation of the same inputs, which is vacuous by construction and could never fail.

The rendering is byte-stable, so the Python, WebAssembly, and C hosts hand back the same report text as Rust for the same input.

OWL-Direct: the tableau

OWL-Direct semantics is open-world Description Logic, which a forward chase cannot answer. materialize_dl_reported runs an SHOIQ(D) tableau instead — answering instance and subsumption queries via classification, realization, and query-directed materialization. Because it needs the query’s class expressions, it takes them as its own query_bgp parameter; materialize reaches the same tableau by delegating to it for Materialization::OwlDirect rather than restating it.

RIF

materialize_rif evaluates RIF-Core rules over a parsed RuleSet, covering the SPARQL RIF entailment regime.

D (datatype) entailment

D is materialized, not refused. PurRDF realizes it as Simple entailment plus the five dt-* rules of OWL 2 Profiles §4.3 Table 8 — the fixed rule table a forward chase can enumerate for it — decided over the XSD value space by purrdf-xsd rather than by comparing lexical forms.

What Table 8 does not cover is the infinite value spaces themselves, and that is reported as a Construct::DatatypeValueSpace boundary on the run rather than claimed. So a D closure is complete within its stated boundary, and the report is where the boundary is stated.

Every host materializes d, the command-line tool included.

There is no unsupported-regime error. materialize takes a Materialization, which carries each regime’s own input — a basic graph pattern for OWL-Direct, a RuleSet for RIF — so all seven inhabitants of that type are served and a caller cannot hand the function a value it accepts and get a refusal instead of an answer. Regime stays as the reporting and identity type that ReasoningReport::regime(), rules(), implemented() and Regime::from_iri speak in.

Invariants

  • No minted vocabulary. Every constant in the crate’s vocab module is a standard rdf:/rdfs:/owl: IRI drawn from the entailment specs themselves — the crate fabricates none, per the toolkit-not-ontology rule.
  • Dependency-lean and wasm-clean. The dependencies are purrdf-core, purrdf-datalog, purrdf-xsd, roxmltree, blake3, and two fixed-key hashers (ahash, hashbrown) — every one of them wasm32-unknown-unknown-clean, so the engines carry into Rust, Python, WebAssembly, and C unchanged, with no threads, filesystem, or RNG dependency.
  • Deterministic. Same input + regime → same closure, always — and the same report, byte for byte.

Conformance

Two corpora measure two different things, and the distinction matters:

  • W3C SPARQL 1.1 entailment-regime group — 70 of 70 cases pass, with zero ledgered residuals: the RDF/RDFS/OWL-RL chase, the OWL-Direct (DL) tableau, the RIF-Core rule engine, and RDF-axiomatic predicate typing, all run through the SPARQL conformance harness.

  • W3C OWL 2 test suite — 258 of 262 cases agree, 4 ledgered, zero unledgered. This corpus is consistency-shaped: all 262 vendored cases are otest:ConsistencyTest (226) or otest:InconsistencyTest (36). It therefore grades the DL/tableau lane’s satisfiability verdicts and says nothing about the OWL 2 RL rule table. Every one of the 4 divergences is named in a typed ledger; an unledgered divergence, and a ledgered case that has started agreeing, are both hard failures.

    Two things this row does not say. First, the upstream material is not free of entailment tests — the W3C manifest holds 206 positive and 23 negative entailment tests; this corpus lacks them because the flattening it was taken from extracted the premise literal and discarded the conclusion literal, which is exactly the half an entailment grade needs. They are vendored and graded by the next bullet. Second, the corpus is a subset: 262 of the 482 consistency-shaped cases upstream. Of the 220 it leaves out, 172 the tableau decided when the exclusion was measured (108 consistent, 64 inconsistent), 0 did not terminate under a 40 s ceiling, 25 were withheld (20 reasoner, 5 parse), and 23 carry no RDF/XML premise — so the exclusion was payload triage, not a capability limit, and “258 of 262” is a number over a corpus rather than over what W3C published.

    Those five figures are a dated measurement, recorded in census.tsv’s dl_probe column and described in that suite’s PROVENANCE.md. The harness reads the column and prints it on every run; it does NOT re-run the reasoner over the 220 excluded cases, so this row cannot detect a regression among them. Re-deriving them means re-running the probe, which is a deliberate act rather than part of the gate.

  • W3C OWL 2 RL entailment tests — 50 of 50 cases agree, 0 ledgered, zero unledgered. This is the independent oracle for the rule table: W3C’s own entailment tests, answered by one call to purrdf_entail::entails() per case under Regime::OwlRl. The two lanes prove different things and are reported separately.

    The negative lane is 23 of 23: no unsoundness. The chase never derived a triple W3C publishes as not entailed. That is the safety result, and it holds over all 23 negative cases — soundness is owed on every case, so none were filtered by profile.

    Those 23 agreements are two different results, and the harness prints the split on a scoreboard line of its own:

    OWL2-RL-NEGATIVE: total 23 = refuted 3 + admitted 20 (premise-outside-rl 5, conclusion-outside-rl 10, construct-not-read 5, refutation-budget 0, freeze-budget 0, data-range-containment 0) + unsound 0 + withheld 0
    

    Three are decided non-entailments — both halves of Theorem PR1’s hypothesis hold, so the closure’s failure to contain the non-conclusion is a proof. The other 20 admit: the closure was computed and does not contain the non-conclusion, which is the whole of the soundness observation, and nothing beyond it is claimed. Both agree, and correctly so; what they differ in is discriminating power, since a reasoner that derived nothing at all would score negative 23 of 23 with refuted 0. Read 23 of 23 as “no unsoundness found”, never as “23 non-entailments proved”.

    The positive lane is 27 of 27 — the 27 positive entailments W3C itself places inside the RL profile under RDF-Based semantics — and the typed divergence ledger purrdf_sparql_conformance::owl2_rl::LEDGER is EMPTY — 0 schema-conclusion, 0 negative-conclusion, 0 construct-outside-rl, 0 imports-unresolved, and 0 are actionable (0 missing-rule).

    Every class it used to hold is closed, and the rule table did not change once to close any of them. entails() reaches a conclusion six ways, and five of the six are not matching:

    • refutation. A negative fact still has no head anywhere in Tables 4–9 — no rule concludes owl:differentFrom, and none concludes membership in an owl:complementOf class. What the table does have is seventeen rules whose conclusion is false, and those seventeen are an inconsistency calculus: assert the conclusion’s negation into the premise, re-run the same seventy-eight rules over a premise whose consistency the first run already established, and read the resulting inconsistency as the proof. An owl:AllDifferent collection is, by OWL 2’s own definition, the conjunction of its n(n−1)/2 pairwise inequalities, so it lowers to the same shape and is entailed exactly when every pair refutes — which is why two entries left the schema-conclusion class with them.
    • freeze-and-chase. p rdf:type owl:TransitiveProperty abbreviates a universally quantified Horn implication, and an implication is decided by generalisation on constants: freeze its body over constants the premise does not mention, re-run the table, and look for the head. chain2trans1’s arrives through prp-spo2, one of the 78. The axiom’s other conjunct — p is an object property — is a lookup in the premise’s own closure, and it is owed: a schema axiom is a conjunction and establishing only the interesting half would claim conclusions the semantics does not license.
    • comprehension. A conclusion may assert that a CLASS EXISTS — an anonymous owl:unionOf, an anonymous owl:Restriction — which the RDF-Based semantics’ own comprehension conditions license, subject to a typing side condition on the operands. Only the scaffolds the conclusion names are minted, over blank nodes checked absent from both documents.
    • reflexivity. owl:ReflexiveProperty is outside the RL syntax, so the profile states no rule for it — and a rule that did would range over every resource, widening a closure every consumer computes by default. The conclusion’s own self-loops are read off the premise’s reflexive typings instead.
    • datatype containment. A property’s declared rdfs:range datatypes intersect, and the intersection may be contained in one the premise never mentions — xsd:byte ⊑ xsd:short, and short ⊓ unsignedInt ⊑ unsignedShort, neither of which a join over triples can discover. Decided over the XSD value spaces, three-valued, with the negative answer gated on the counterexample range being exactly decided.

    The last case needed no mechanism at all, only the document its premise names: webont-imports-011 owl:imports a support ontology the upstream manifest does not inline, so it is vendored beside the cases from W3C’s own URL and supplied to entails() as caller-owned configuration. The library still fetches nothing.

    Nothing about the inventory moves: rules(Regime::OwlRl) and implemented(Regime::OwlRl) are still exactly the same 78, extensions(Regime::OwlRl) is still the one ext-eq-diff-sym, and strict Materialization::OwlRl output is byte-for-byte what it was. The evidence moves instead — each mechanism arrives with its own EntailmentWarrant arm carrying what it actually used (the false-concluding rule that fired and a minimal entailing premise subset; the frozen constants, body and head; the minted triples and the closure triples that license them) and its own checker that re-decides the whole thing without running a reasoner.

    The one case that used to be actionable is closed by an extension, and the extension is labelled rather than absorbed. a owl:differentFrom b entails b owl:differentFrom a, which is sound — owl:differentFrom denotes inequality and inequality is symmetric — and shaped exactly like prp-symp, yet is not among the 78 rules, because Table 4’s owl:differentFrom rules only ever conclude false. PurRDF states it as ext-eq-diff-sym, in a rule family declared to sit outside every specification table: extensions(Regime::OwlRl) returns it, rules() and implemented() are still exactly the same 78 and return none of it, RuleId::is_extension decides which is which, and every rendered report carries an extension ext-eq-diff-sym line beside its missing lines. So the closure a caller gets is Tables 4–9 plus a list it can read and reject, and OWL-RL 78 / 78 remains a claim about Tables 4–9 and nothing else.

The live scoreboard is docs/CONFORMANCE.md.

Entailment Rule Inventory

This file is generated. Do not edit it by hand. It is emitted by cargo run -p purrdf-entail --example gen_rule_inventory from purrdf_entail::RuleId, rules(regime) and implemented(regime), and scripts/check-generated.sh fails the build when the committed copy and a fresh run disagree. Regenerate with make metadata.

Defined is the rule table the specification defines the regime by (rules(regime)). Implemented is the subset this workspace’s evaluator actually fires (implemented(regime)). Their difference is the regime’s gap, and it is the same set a ReasoningReport names under missing.

Neither column counts an extension — a rule this workspace fires that no specification table states. Those are listed in their own section below (extensions(regime)), never folded into a coverage number, so a figure like OWL-RL 78 / 78 stays a claim about OWL 2 Profiles §4.3 Tables 4–9 and about nothing else.

78 / 78 and 50 / 50 are two different measurements

This page is the RULE INVENTORY: 78 / 78 says every rule OWL 2 Profiles §4.3 Tables 4–9 states is one the chase fires. It says nothing about how many published entailments that reaches, and the two figures are measured against different things and can move independently.

The second measurement is ENTAILMENT CONFORMANCE, over the vendored W3C OWL 2 RL entailment corpus: 50 of 50 cases agree with W3C’s published verdict, 27 of 27 positive and 23 of 23 negative, with an empty divergence ledger. That figure is crates/sparql-conformance/entailment-suite/w3c-owl2-rl/’s and is bounded by what is vendored there — see docs/CONFORMANCE.md, which carries it beside the corpus it was measured on.

Fifteen of those 50 are reached by a mechanism that exists because the rule table DECIDES no conclusion of that shape: refutation, freeze-and-chase, comprehension, reflexivity and datatype containment, each documented on purrdf_entail::EntailmentMechanism. NONE of them adds a rule, which is why this inventory is byte-for-byte what it was before they existed — they change how many times the table is run and what its false is read as, not what the table states.

The five COMPOSE. A conclusion graph is a conjunction and entailment is monotone over one, so a conclusion stating a negative fact beside a schema axiom is entailed when each half is: purrdf_entail::entails() threads the residual through every lane in turn and matches only what survives, and an answer that needed two or more of them renders as composite rather than as any single constituent’s name. No vendored case needs it — each of the 50 is reached by one lane or by none — so the corpus does not measure it and the crate’s own tests do.

Coverage by regime

Regime--regimeDefinedImplemented
Simplesimple00
RDFrdf33
RDFSrdfs1818
OWL-RLowl-rl7878
OWL-Directowl-direct00
RIFrif00
Dd55

A regime with a zero-length rule table is one this crate does not enumerate rules for: Simple is the identity closure, and OWL-Direct and RIF are served by a tableau and by a caller-supplied rule set respectively, neither of which is a fixed table.

Extensions

A rule this workspace’s evaluator fires that no specification table states. An extension appears in neither column above, for any regime: rules(regime) and implemented(regime) name only specification rules, and extensions(regime) names only these. RuleId::is_extension decides which is which, and a ReasoningReport renders the list under extension beside the missing list — so a caller that must act only on normative conclusions can tell from the report rather than from prose.

Every entry is sound under the semantics of the vocabulary it reads; that is the only standard a rule with no specification to appeal to can meet.

Regime--regimeRule
OWL-RLowl-rlext-eq-diff-sym

RDF — 3 of 3 rules implemented

RuleSpecificationImplemented
rdfD1RDF 1.2 Semantics §8.1.1 (RDF patterns)yes
rdfD1aRDF 1.2 Semantics §8.1.1 (RDF patterns)yes
rdfD2RDF 1.2 Semantics §8.1.1 (RDF patterns)yes

RDFS — 18 of 18 rules implemented

RuleSpecificationImplemented
rdfD1RDF 1.2 Semantics §8.1.1 (RDF patterns)yes
rdfD1aRDF 1.2 Semantics §8.1.1 (RDF patterns)yes
rdfD2RDF 1.2 Semantics §8.1.1 (RDF patterns)yes
rdfs1RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs2RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs3RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs4RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs5RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs6RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs7RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs8RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs9RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs10RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs11RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs12RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs13RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs14RDF 1.2 Semantics §9.2.1 (RDFS patterns)yes
rdfs14aRDF 1.2 Semantics §9.2.1 (RDFS patterns)yes

OWL-RL — 78 of 78 rules implemented

RuleSpecificationImplemented
eq-refOWL 2 Profiles §4.3 Table 4 (Equality)yes
eq-symOWL 2 Profiles §4.3 Table 4 (Equality)yes
eq-transOWL 2 Profiles §4.3 Table 4 (Equality)yes
eq-rep-sOWL 2 Profiles §4.3 Table 4 (Equality)yes
eq-rep-pOWL 2 Profiles §4.3 Table 4 (Equality)yes
eq-rep-oOWL 2 Profiles §4.3 Table 4 (Equality)yes
eq-diff1OWL 2 Profiles §4.3 Table 4 (Equality)yes
eq-diff2OWL 2 Profiles §4.3 Table 4 (Equality)yes
eq-diff3OWL 2 Profiles §4.3 Table 4 (Equality)yes
prp-apOWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-domOWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-rngOWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-fpOWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-ifpOWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-irpOWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-sympOWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-asypOWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-trpOWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-spo1OWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-spo2OWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-eqp1OWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-eqp2OWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-pdwOWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-adpOWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-inv1OWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-inv2OWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-keyOWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-npa1OWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
prp-npa2OWL 2 Profiles §4.3 Table 5 (Property Axioms)yes
cls-thingOWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-nothing1OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-nothing2OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-int1OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-int2OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-uniOWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-comOWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-svf1OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-svf2OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-avfOWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-hv1OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-hv2OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-maxc1OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-maxc2OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-maxqc1OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-maxqc2OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-maxqc3OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-maxqc4OWL 2 Profiles §4.3 Table 6 (Classes)yes
cls-ooOWL 2 Profiles §4.3 Table 6 (Classes)yes
cax-scoOWL 2 Profiles §4.3 Table 7 (Class Axioms)yes
cax-eqc1OWL 2 Profiles §4.3 Table 7 (Class Axioms)yes
cax-eqc2OWL 2 Profiles §4.3 Table 7 (Class Axioms)yes
cax-dwOWL 2 Profiles §4.3 Table 7 (Class Axioms)yes
cax-adcOWL 2 Profiles §4.3 Table 7 (Class Axioms)yes
dt-type1OWL 2 Profiles §4.3 Table 8 (Datatypes)yes
dt-type2OWL 2 Profiles §4.3 Table 8 (Datatypes)yes
dt-eqOWL 2 Profiles §4.3 Table 8 (Datatypes)yes
dt-diffOWL 2 Profiles §4.3 Table 8 (Datatypes)yes
dt-not-typeOWL 2 Profiles §4.3 Table 8 (Datatypes)yes
scm-clsOWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-scoOWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-eqc1OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-eqc2OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-opOWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-dpOWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-spoOWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-eqp1OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-eqp2OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-dom1OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-dom2OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-rng1OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-rng2OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-hvOWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-svf1OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-svf2OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-avf1OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-avf2OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-intOWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes
scm-uniOWL 2 Profiles §4.3 Table 9 (Schema Vocabulary)yes

D — 5 of 5 rules implemented

RuleSpecificationImplemented
dt-type1OWL 2 Profiles §4.3 Table 8 (Datatypes)yes
dt-type2OWL 2 Profiles §4.3 Table 8 (Datatypes)yes
dt-eqOWL 2 Profiles §4.3 Table 8 (Datatypes)yes
dt-diffOWL 2 Profiles §4.3 Table 8 (Datatypes)yes
dt-not-typeOWL 2 Profiles §4.3 Table 8 (Datatypes)yes

Datalog: the Fixpoint Engine

purrdf-datalog is deterministic, wasm32-clean Datalog evaluation: a columnar relation store, an index-selecting planner, and a semi-naive fixpoint, carrying no ambient I/O, no wall clock, and no RNG.

It exists so that a rule set is data rather than a hand-written loop. purrdf-entail declares the RDF, RDFS, OWL 2 RL, and D calculi as DL-clause programs and evaluates them here, which is what lets a reasoning report carry a contract hash of the exact program that ran instead of a claim about which rules were meant to run.

purrdf-datalog is re-exported by the umbrella purrdf crate as the purrdf::datalog module — the entailment surface CARRIES its types (a ReasoningReport hands out a datalog::cache::ContractHash and a datalog::seminaive::BudgetReport), so a consumer that matches on them needs no second dependency on purrdf-datalog. Depend on the crate directly (purrdf-datalog = "…") only when you want the fixpoint alone, with no other purrdf surface in the build.

One rule IR: the DL-clause

Every rule has the shape

U₁ ∧ … ∧ Uₙ  →  ∃ȳ. (C₁ ∨ … ∨ Cₘ)

where each disjunct Cᵢ is itself a conjunction of head atoms. That single shape holds all five head forms — atomic (an ordinary Datalog rule), existential, disjunctive, conjunctive, and empty (false) — so an axiom like A ⊑ ∃r.C, which lowers to ∃y. (r(x, y) ∧ C(y)) with one shared witness, is one rule rather than two unrelated ones.

The semi-naive evaluator runs the atomic form and refuses the other four by name. The existential form is not lost by that refusal: the restricted chase consumes it, minting frontier-addressed Skolem witnesses, which is how the four existential RDF/RDFS patterns fire and the rule inventory reads complete. What never happens is a surrogate leaking into an answer — the entailment layer above withholds every conclusion that mentions a witness at the materialization boundary and reports the withholding, rather than inventing an answer the caller did not ask for.

Determinism

  • Per-key rows keep insertion order; the arrangement is sorted; no map iteration order reaches an output path.
  • Plans are content-addressed by a BLAKE3 digest over the planner version, the caller’s contract hash, and a canonical digest of the clause program. The cache is owned by the caller, never a process global — a global would make an answer depend on evaluation history.
  • Where work is parallelised it uses indexed par_iter/par_chunks reduced in source order, never par_sort or par_bridge, and degrades to inline-sequential on wasm32-unknown-unknown.

Identical input yields byte-identical output, on every target.

Budgets are constants, not knobs

Three ceilings bound every run, and all three are fixed workspace constants whose consumption is reported, never configured — two callers with the same input always get the same answer:

CeilingBounds
MAX_JOIN_STEPScandidate solutions enumerated
MAX_STORED_FACTSfacts seeded or derived
MAX_TERM_ARENA_BYTESinterned term surface bytes

There is deliberately no wall-clock budget: it would break both wasm32 and reproducibility.

Passing a ceiling is a total refusal, not a truncation. There is no partial fixpoint to hand back with a note attached, and a truncated closure presented as a complete one is exactly the failure a reasoning report exists to prevent. The error names which ceiling was hit and what the run had consumed when it stopped; in purrdf-entail that surfaces as EntailError::Evaluate.

GTS Graph Transport

GTS (Graph Transport Substrate) is an ontology-independent binary container and transport format for RDF 1.2 datasets and content-addressed binary payloads. PurRDF hosts the reference Rust engine, purrdf-gts (re-exported as purrdf::gts).

This chapter is a user-level tour. The wire format itself is specified in docs/GTS-SPEC.md — consult the spec for framing, fold semantics, registries, and conformance classes; nothing here supersedes it.

The container model, at a high level

A GTS file is a CBOR Sequence of one or more append-only segments. Each segment is a deterministic CBOR header followed by deterministic CBOR frames chained by BLAKE3 content identifiers. The logical dataset is obtained by a deterministic fold over the segment sequence: quads, reifiers, annotations, and binary blobs all become rows of the folded container graph.

Properties that fall out of this design:

  • Content-addressed and append-only — history is never rewritten; suppression is itself an appended record. Multi-segment files compose by simple concatenation.
  • Partial readability, total reader — the reader verifies the BLAKE3 chain and folds what it can; undecodable frames (unknown codec, encrypted without a key) degrade to opaque nodes plus a diagnostic instead of aborting.
  • Binary payloads ride along — the blobs a graph references travel in the same file, content-addressed like everything else.
  • RDF 1.2-native — the spec formalizes the triple-term and rdf:reifies mapping, blank-node scoping, and multi-segment value union.

Reading and writing from Rust

The container engine (purrdf-gts) owns the wire-format machinery — reader, writer, fold, verify, COSE, trust policy:

use purrdf::gts::reader;

// Fold GTS bytes into the container graph model, verifying the BLAKE3 chain.
let graph = reader::read(&bytes, /* allow_segments */ true, /* expected_head */ None);

// The fold is total: quads, reifiers, annotations, and blobs are all rows,
// and anything undecodable is preserved as an opaque node plus a diagnostic.
println!("{} quads, {} blobs", graph.quads.len(), graph.blobs.len());

The writer authors frames and produces byte-deterministic single-segment snapshots (Writer::deterministic) — the GTS writer is under the same determinism invariant as every PurRDF serializer.

The RdfDataset import/export path lives one layer up: the umbrella crate’s gts module combines the container engine with the RDF-level adapter (snapshot composition, content-chain verification), so RDF-facing GTS work goes through purrdf directly.

Signing and encryption

Frames can be signed and encrypted with COSE; OpenPGP-based checks and a trust-policy layer are also part of the engine. All cryptography is pure Rust — no C toolchain, threads, or syscall dependencies — which is what keeps the whole engine wasm-friendly. An encrypted frame you cannot decrypt is simply an opaque node in the fold: the container remains readable.

Rust-library-only: compaction certificates, proofs and keyring verification

Beyond reading, writing and folding, purrdf-gts and the umbrella’s gts module carry a verification stack that no other surface reaches — not the CLI, Python, wasm or C:

  • Streamable compaction with a certificatecompact_streamable rewrites an accretive log into one delivery-ordered segment (leading stream index, trailing offset footer, in-band dictionaries), and compact_and_certify / verify_compaction / compose bind a content projection so that the pre- and post-compaction containers fold to byte-identical canonical content; the verifier recomputes from the pre bytes with its own keyring. Compaction never changes content and mints no content signatures.
  • Merkle mountain range proofsmmr::{root, prove, verify_proof, prove_file} (gts-mmr-proof-v1), and verify_content_chain, which walks COSE signature → head replay → digest inclusion. There is no transparency log and no signed checkpoint service.
  • OpenPGP transport keys — armored Ed25519 certificates and unencrypted secret keys (parse_transport_key, parse_secret_signing_key) with verify_file_with_keyring for rotation; other algorithms, encrypted keys and v5/v6 packets are rejected, and there is no keyring or revocation store.

Where it stops, stated plainly: the files/tar profile, the nested GTS-in-blob reader, the trust-policy layer and the deterministic ULID helper have no direct tests in this repository — their oracle is upstream in gmeow-gts — and the whole stack is exercised here only through the frozen vectors and the compaction and certification test files.

Conformance vectors

GTS conformance is defined against a frozen, language-neutral vector corpus (vectors/ in the repository), shared byte-exact across the sibling GTS engines in other languages. The vectors are never regenerated or “fixed” in this repository — the format is governed in the gmeow-gts project, alongside the specification and the other reference engines.

The C ABI’s star-layer round-trip

The GTS star-layer round-trip of a dataset containing quoted triples / reifier bindings succeeds through the kernel to_gtsread_graphimport_gts_graph path, the same as a star-free round-trip; crates/rdf-capi/tests/abi.rs’s gts_star_roundtrip_preserves_the_statement_layer pins it. See Getting Started: C.

Slices, Mappings & Provenance

Beyond the engines, PurRDF carries the plumbing a serious vocabulary or data-pipeline project needs: a slice catalog for organizing authored RDF, an explicit loss ledger for lossy projections, and native codecs for the SSSOM and FnO interchange formats.

The slice catalog

purrdf-slice (re-exported as purrdf::slice) is tooling for ontology/vocabulary repositories organized as slices — directories of authored RDF (slices/<group>/<name>/), each described by a manifest.ttl:

  • Catalog — manifest-based discovery (SliceCatalog::discover), typed slice metadata (SliceRecord, SliceTier), and artifact roles. Slice identity comes from the manifest, not the directory name.
  • Ownership & dependencies — term-ownership analysis (every declared term has exactly one owning slice), dependency edges with evidence, forbidden-edge rules (extension slices depend only on core), and machine-applicable fix suggestions.
  • Content addressing — deterministic artifact digests and cache keys for incremental pipelines.
  • Emitters — projection/mapping emitters and lints: prefix maps, JSON-LD contexts, FnO function catalogs, claim views.

True to the rule that PurRDF mints no vocabulary IRIs, every term the slice framework reads or emits belongs to the caller’s vocabulary: a SliceVocab is caller-constructed (it has no Default) and threaded through every public entry point.

use std::path::Path;
use purrdf::slice::{SliceCatalog, SliceVocab};

// Your vocabulary namespace — PurRDF fabricates none.
let vocab = SliceVocab::for_namespace("https://example.org/vocab/");
assert_eq!(vocab.slice_class(), "https://example.org/vocab/Slice");

// Discover every slice under the repository root from its manifest.ttl.
let catalog = SliceCatalog::discover(Path::new("slices"), vocab)
    .expect("slices discovered");
for slice in catalog.records() {
    println!("{} ({:?})", slice.manifest.slice_iri, slice.manifest.tier);
}

The loss ledger

PurRDF’s projections are allowed to be lossy — but never silently. The kernel carries a machine-readable RDF↔GTS loss matrix (generated/rdf-loss-matrix.json, a generated artifact) and a LossLedger API: when a star-incapable codec drops reifier bindings, or CSV results drop provenance, the realized count is recorded and surfaced to the caller. See Codecs & Determinism and Result Formats.

Provenance

purrdf-core includes a generic provenance sidecar for the frozen IR — attribution, origin sets, and per-quad provenance that engines can carry without polluting the data graph. The SPARQL results extension (Result Formats) and the SARIF boundary both resolve these runtime-only provenance ids to public IRIs at their serialization edges.

SSSOM and FnO

Two native interchange codecs live in the kernel:

  • SSSOMSimple Standard for Sharing Ontological Mappings mapping-set TSV support (SssomMappingSet, SssomMapping, with typed diagnostics), for carrying cross-vocabulary mappings alongside your data. SssomSetComment models set-level ordinary comments and provenance as a lossless document envelope, independently of YAML-like header metadata. Newly appended provenance uses the interoperable metadata-to-table position; parsed after-table comments are retained as an explicit extension. Raw Unicode comment lines and their order are preserved, while physical line endings serialize deterministically as LF.
  • FnO — a Function Ontology function-catalog codec (FnoCatalog, fno_to_quads, fno_to_ntriples), used by the slice emitters to describe function catalogs as RDF.

As with everything else in the toolkit, these are codecs for caller data — PurRDF does not define mappings or functions of its own. SSSOM envelope comments also remain caller-neutral projection data: they do not mint RDF predicates, change mapping validation, or alter the RDF projection.

rdflib Compatibility

Python’s rdflib is the incumbent RDF library of the ecosystem, and PurRDF meets it in two tiers: an explicit compat module, and an opt-in drop-in shadow.

Tier 1: the explicit compat layer

The main purrdf wheel ships an rdflib compatibility layer backed by the native engine:

from purrdf.compat.rdflib import Graph

g = Graph()
g.parse(data="<https://example.org/a> <https://example.org/b> <https://example.org/c> .",
        format="turtle")

This is the recommended path for new code that wants an rdflib-shaped API on the PurRDF engine: the import name is honest, and it coexists with a genuine rdflib installation.

Tier 2: the purrdf[rdflib] shadow distribution

For a literal, zero-change import rdflib, install the opt-in extra:

pip install purrdf[rdflib]

This pulls in the separate purrdf-rdflib distribution, whose top-level rdflib package re-exports the compat surface, so existing third-party code doing import rdflib / from rdflib.namespace import RDF transparently runs on purrdf. Caveat: that shadow claims the rdflib import name and must never be installed alongside the genuine rdflib — the two cannot co-inhabit one environment. It is a separate distribution (never bundled into the main purrdf wheel) precisely so environments that need the real rdflib simply omit it.

How compatibility is kept honest

The compat layer is not “best effort” — it is gated in CI as part of the single conformance matrix (docs/CONFORMANCE.md):

  • The rdflib drop-in (LSP) gate runs rdflib 7.6’s own vendored test suite against the purrdf drop-in.
  • The parity suite runs first-party differential tests of purrdf.compat against the real rdflib 7.6.

Both use strict expected-failure ledgers: every known divergence is listed with a per-test reason, an unexpected failure breaks the build, and a silently fixed divergence also breaks the build until the ledger shrinks. The ledgered residuals cover corners like Graph-subclass identity through set operators, rdf:List/Collection mutation, Result.bindings / SELECT * subselect projection, graph-prefix forwarding, and legacy ConjunctiveGraph semantics — consult the ledgers for the current, exact list.

Performance

A report-only benchmark harness times the native-backed purrdf.compat.rdflib drop-in against the real rdflib on parse, serialize, SPARQL, and triple-pattern iteration (make bench-python). Methodology and a representative (host-dependent) results table live in docs/BENCHMARKS.md — numbers vary by host, so reproduce locally rather than trusting a fixed multiplier. See Performance for the philosophy.

RDF/JS in JavaScript

In JavaScript, PurRDF does not invent its own API shape: the npm package @blackcatinformatics/purrdf implements the RDF/JS community specifications — DataFactory, DatasetCore, and Stream/Sink — over the wasm-compiled native engine. Code written against RDF/JS interfaces works with PurRDF terms and datasets.

The data factory

DataFactory covers the standard RDF/JS term constructors — namedNode, blankNode, literal(value, languageOrDatatype?), variable, defaultGraph, quad, fromTerm, fromQuad — plus the deliberate RDF 1.2 extensions no incumbent RDF/JS library carries:

import { ready, DataFactory } from "@blackcatinformatics/purrdf";
await ready();
const f = new DataFactory();

// RDF/JS standard surface.
const q = f.quad(
  f.namedNode("https://ex/alice"),
  f.namedNode("https://ex/knows"),
  f.namedNode("https://ex/bob"),
);

// RDF 1.2 extensions: quoted triple terms and base-direction literals.
const quoted = f.quotedTriple(q.subject, q.predicate, q.object);
const rtl = f.directionalLiteral("مرحبا", "ar", "rtl");

typedLiteral is a convenience alongside the spec’s overloaded literal.

Datasets

Dataset implements RDF/JS DatasetCore: add, delete, has, match, size, and iteration (for (const quad of dataset)), plus parsing and serialization through the native codecs:

const ds = Dataset.parse("<https://ex/s> <https://ex/p> <https://ex/o> .", "ntriples");
for (const quad of ds.match(null, f.namedNode("https://ex/p"), null)) {
  console.log(quad.subject.value);
}
const trig = ds.serialize("trig");

Accepted format names: turtle, ntriples, nquads, trig, rdfxml, or their media types. Because these are the native codecs, output is byte-deterministic and identical to what the Rust, Python, and C surfaces emit (Codecs & Determinism).

SPARQL

Use QueryEngine when running more than one query or when the caller wants typed results instead of raw strings. The engine owns the native SPARQL plan cache and returns package-root terms and datasets:

import { QueryEngine } from "@blackcatinformatics/purrdf";

const engine = new QueryEngine();
const result = engine.select(
  ds,
  "PREFIX ex: <https://ex/> SELECT ?o WHERE { ex:s ex:p ?o }",
);
console.log(result.rows.take(0)?.o?.value);

const graph = engine.construct(
  ds,
  "PREFIX ex: <https://ex/> CONSTRUCT { ex:copy ex:p ?o } WHERE { ex:s ex:p ?o }",
);

SELECT rows are single-owner and lazy across the wasm boundary. Iterate result.rows, call result.rows.take(index) for indexed consumption, or call result.rows.toArray() to materialize the remaining rows. A row can be consumed once; call result.free() when abandoning a result before exhaustion.

QueryEngine.queryRaw(...) serializes SELECT/ASK results as SPARQL Results JSON/XML/CSV/TSV and graph results through the same graph formats accepted by Dataset.serialize. QueryEngine.update(...) applies SPARQL UPDATE atomically: the dataset changes only after the whole update succeeds.

Streams and sinks

The package speaks the async RDF/JS Stream/Sink protocol over the purrdf-events ingestion seam:

  • Sink — a streaming consumer: push(quad) per quad, finish() returns the accumulated Dataset.
  • datasetToStream(dataset) / streamToDataset(stream) — bridge between a Dataset and RDF/JS streams, for piping into or out of other RDF/JS tooling.

Scope notes

  • The engine is in-memory; there is no persistent store in the wasm build. SPARQL runs over the in-memory dataset; this package provides no network resolver, so remote SERVICE and LOAD fail explicitly.
  • A quoted-triple term as a quad object currently round-trips only through N-Quads (a current native serializer limitation for the other formats).

Design Rules & Invariants

PurRDF must stay fast, deterministic, and boring: one engine, one behavior, carried verbatim into Rust, Python, WebAssembly, and C. That promise is kept by a small set of hard invariants, each enforced by CI rather than by convention. The canonical statement is AGENTS.md in the repository; this chapter explains the why.

No semantic Cargo features, ever

The workspace has zero semantic feature flags. Its sole feature declaration is the empty purrdf-capi:capi = [] compatibility marker that cargo-c unconditionally enables when building the C ABI. The marker gates no code and must never appear in cfg(feature = ...); CI verifies its exact empty shape and scans Rust sources for feature-gated behavior. PurRDF is a data carrier, and optionality changes semantics per consumer — two builds of “the same version” that parse or serialize differently would defeat the whole point. No other [features], no optional dependencies, and no feature-gated behavior. Every consumer gets the same byte-identical semantics.

PurRDF is NOT an ontology — it mints no vocabulary IRIs

Every vocabulary the library reads or writes — slice manifests, statement-metadata downcast, box roles, SPARQL extension-function namespaces, JSON-Schema namespaces — is caller-supplied configuration with no fabricated default. A feature exercised without its vocabulary hard-errors or stays inactive. The library never hardcodes a vendor namespace (the GMEOW ontology is a consumer; the dependency arrow never points from purrdf to it), and test fixtures use example.org. Consumer-config types (SliceVocab, Namespaces, StatementMetadataVocab) are unified behind an OntologyProfile a downstream builds once.

Byte determinism

Serializers and the GTS writer are byte-deterministic. No iteration-order, time, or RNG dependence is permitted in any output path; hot maps use fixed-key ahash for this reason. Changes that alter emitted bytes must update the affected golden files, visibly.

The kernel ring-fence

purrdf-core must never depend on oxigraph or PyO3 — the whole workspace is oxigraph-free, and a hygiene gate asserts the dependency tree. The three foundation leaves (purrdf-iri, purrdf-xsd, purrdf-events) keep zero runtime dependencies. Diagnostics stay structured and SARIF-free in the kernel; the SARIF boundary is the purrdf-validate leaf.

Everything is wasm-able

Every release crate must build for wasm32-unknown-unknown, and CI hard-fails otherwise. No dependency may drag in threads, the filesystem, C toolchains, or wall-clock/RNG syscalls on the wasm path — cryptography stays pure Rust for exactly this reason. This is what makes the JavaScript package the same engine rather than a port.

Hard-fail, never wrong

Across the toolkit, out-of-scope input is a typed error, never a partial answer: malformed RDF is an RdfDiagnostic, an unsupported SPARQL builtin is EvalError::Unsupported, a malformed ShEx schema is a ShexError, an exhausted evaluation ceiling is EntailError::Evaluate rather than a truncated closure, and an unsupported results projection is a typed format error. Lossy-by-design projections are permitted but loud, via the loss ledger.

A hard-fail is owed to input the toolkit cannot handle — never to a value its own signature accepts. purrdf-entail::materialize used to refuse OWL-Direct and RIF because a Regime value carries neither the query’s class expressions nor a rule set; that was a partial function wearing a total signature, and the fix was to change the parameter rather than to document the hole. It takes a Materialization now, which carries each regime’s own input, and there is no unsupported-regime error left to name.

The reasoning side adds a second discipline on top of hard-fail: where a run succeeds but is bounded, it says so. materialize returns a ReasoningReport with every closure — never a bare dataset — carrying the regime’s completeness, the rules that did and did not fire, the boundaries met, and a contract hash of the calculus that ran. A correct-but-incomplete answer delivered silently is the same failure mode as a wrong one.

Conformance corpora are the contract

The W3C and community test suites are vendored, byte-frozen, and SHA-256-verified; harnesses assert exact counts and enforce XPASS discipline on their expected-failure ledgers. See Conformance & Testing.

Supporting rules

  • Measured performance — perf claims require a criterion bench, not an adjective (Performance).
  • One version, lockstep releases — crates.io, PyPI, and npm ship one workspace version (Versioning & Releases).
  • Nightly-free source, stable MSRV — there are no #![feature(...)] attributes anywhere in the workspace and the MSRV floor (currently 1.96, on the stable channel) is enforced by a dedicated CI job that builds on exactly that compiler. Contributors and the CI gates run a dated nightly pinned in rust-toolchain.toml for its sharper clippy and rustdoc lints; release artifacts are built on stable.
  • Brand — the project is PurRDF in prose and purrdf in identifiers (docs/BRAND.md).

Conformance & Testing

Every PurRDF engine is gated by its official test suite. The suites are vendored and byte-frozen in-repo — never hand-edited, SHA-256-verified on every make check so a silent content edit fails the build. The full, live scoreboard is docs/CONFORMANCE.md; this chapter explains how the machine works.

The single conformance matrix

The native Rust W3C harnesses and the Python rdflib drop-in gate are reported together as one scoreboard:

make conformance    # aggregates every suite into one table

The aggregator runs each suite in a fixed order and prints per-suite pass / xfail-or-skip / fail counts with an overall RED/GREEN verdict. It exits non-zero on any unexpected failure, and the rendered matrix in docs/CONFORMANCE.md is itself drift-guarded in CI (the gate fails if the committed block is stale).

What is gated

EngineSuite
IRI (RFC 3987)W3C IRI + RFC 3986 §5.4 resolution vectors
Syntax codecsW3C rdf-tests (Turtle/TriG/N-Triples/N-Quads/RDF-XML)
CSVWW3C RDF-conversion and metadata-validation manifests plus a locked independent implementation
OBO Graphsofficial OBO Graphs 0.3.2 JSON Schema plus corruption probes
Research-object carriersfive adversarial native fixtures, a 5×5 stable semantic-transcode matrix, and the vendored Frictionless Data Package v1 schema
RDFC-1.0W3C rdf-canon fixtures
SPARQL 1.1/1.2full W3C sparql11 + sparql12 + entailment suites, plus first-party CONSTRUCT and DESCRIBE corpora
SPARQL CDT (SEP-0009)the vendored awslabs/SPARQL-CDTs corpus — read with the lexical-space divergence recorded in docs/CONFORMANCE.md, which no upstream vector can express
SPARQL execution governorsa first-party frozen corpus of ceiling band cases and seam cases
SHACLW3C data-shapes + DASH SHACL-AF/rules + a first-party frozen corpus
ShEx 2.1shexTest v2.1.0 (validation, schemas, negative syntax/structure)
Entailmentthe W3C SPARQL entailment-regime cases (via the SPARQL harness), the vendored W3C OWL 2 suite (DL consistency, ledgered), and W3C’s own OWL 2 RL entailment tests (the independent oracle for the rule table, ledgered)
GTSfrozen cross-language vectors — 38 of the 39 fold byte-exactly into their committed expectation, with the 39th a ledgered divergence from an upstream expectation this reader contradicts
rdflib drop-inrdflib 7.6’s own vendored tests + first-party parity

At the time of writing every suite is green — for example 1,105/1,105 attempted shexTest validation cases, 129/129 W3C SHACL, 264/264 codec round-trips, 70/70 W3C SPARQL entailment-regime cases, and 258/262 agreeing verdicts on the vendored W3C OWL 2 DL-consistency corpus — with the remaining non-passes strictly ledgered (five SPARQL fixtures with upstream-errata non-canonical XSD lexicals; 4 typed OWL 2 divergences). Two of those numbers need their scope stated: the OWL 2 DL corpus is a subset, 262 of the 482 consistency-shaped cases W3C published, and rule-table coverage is not entailment conformance — on this vendored W3C corpus of OWL 2 RL entailment tests the chase scores 50/50, being 27 of 27 positive and 23 of 23 negative, the latter meaning no unsoundness was found. Always read the current numbers from docs/CONFORMANCE.md rather than this snapshot.

Ledger discipline

A harness never skips silently. Four mechanisms keep the scoreboard honest:

  1. Exact totals — each harness asserts the number of discovered tests, so corpus drift fails loudly.
  2. XFAIL ledgers — every known gap is listed with a reason string, and the harness asserts it still fails. Fixing a gap without removing its ledger entry breaks the build (XPASS discipline), so the ledgers double as roadmaps.
  3. Trait skips (ShEx only) — whole spec features can be skipped by manifest trait tags, counted exactly. The list is currently empty.
  4. A monotone budget (ratchet) — a committed baseline records the exact allowed count of ledgered gaps per suite. A larger live count fails RED (regression), and a smaller one also fails RED until the budget is lowered to lock the gain in. The budget may only ever be edited downward, which makes “the skip list only shrinks” a mechanical guarantee rather than a convention.

Running the suites locally

make conformance                                     # the single matrix
cargo test -p purrdf-shex                            # all four ShEx suites
cargo test -p purrdf-shapes --test w3c_conformance   # W3C SHACL scoreboard
cargo test -p purrdf-sparql-conformance              # W3C SPARQL
cargo test -p purrdf-rdf                             # RDFC-1.0 + codec goldens
make projection-oracles                             # W3C CSVW + independent CSVW/OBO checks
cargo test -p purrdf-gts                             # GTS vectors

make check — the full local gate (fmt, clippy, build, tests, hygiene) — runs the Rust suites as part of the workspace gate.

Frozen means frozen

The GTS vectors in vectors/ are shared byte-exact with the sibling GTS engines in other languages and are never regenerated in this repository; the wire format is governed in gmeow-gts. The same never-hand-edit rule applies to every vendored corpus and everything under generated/.

Performance

PurRDF’s performance is measured, never asserted. No number in the project’s documentation is a guarantee: benchmarks are report-only, timing-sensitive, and vary by host, CPU, allocator, and build flags. Treat every figure as a host-dependent illustration you reproduce locally — not a promise of “N× faster.” The methodology and a representative results table live in docs/BENCHMARKS.md.

Fast by construction

The engine-level speed comes from the IR design (The Interned Dataset IR): every term stored once in a string arena addressed by copyable NonZeroU32 ids, fixed-key ahash everywhere hot, frozen Box<[QuadRow]> quad tables with lazy ordinal permutation indexes (~4 bytes/quad per axis), and evaluation in TermId space so solution comparison is an integer compare.

Crucially, the layout itself was chosen by benchmark: the crates/rdf-core/benches/ir_layout.rs criterion suite measures array-of-structs vs. struct-of-arrays vs. predicate-adjacency layouts on allocation counts, high-water memory, and end-to-end latency — and the shipped layout is whichever wins.

The two benchmark layers

LayerWhat it measuresHow to run
Rust criterion suitesNative engine hot paths — IR layout, copy-on-write mutation, pack index alternatives, codecs, graph/tabular/research-object projections, SPARQL lexing/evaluation/planning, SHACL validation, entailment chase, GTS authoring, IRI parsing.make bench
Python compat harnesspurrdf.compat.rdflib (the native-backed drop-in) vs. the real rdflib 7.x on parse, serialize, SPARQL, and triple-pattern iteration, over a deterministic example.org corpus.make bench-python

Both layers are report-only: they are never part of make check, and no test gate asserts a speedup.

The discipline for changes

Any change claiming a performance win must extend the criterion benches rather than asserting the speedup in prose. Where a planner or algorithm choice matters for correctness-adjacent behavior, it is gated by deterministic tests instead of timings — for example, the cost-based BGP planner’s win over the retired structural heuristic is asserted by unit tests that count real intermediate rows, and by a differential corpus test, while the criterion bench merely watches for regressions.

NativeSparqlEngine::explain_query exposes the chosen BGP join order so planner decisions can be audited without running the query (SPARQL: Querying).

Reproducing locally

make bench                              # the default criterion set
cargo bench -p purrdf-iri --bench parse # a single package's bench
cargo bench -p purrdf-core --bench pack_index_compare # pack index experiment
cargo bench -p purrdf-rdf --bench projections -- --quick # projection/carrier sample
make bench-python                       # the rdflib comparison harness

Benchmark on a quiet machine, and compare like with like: allocator, CPU scaling, and build flags all move the numbers.

Versioning & Releases

PurRDF ships to three registries — the crates.io crate suite, the PyPI purrdf package, and the npm @blackcatinformatics/purrdf package — from one workspace version, in lockstep. The full process is docs/RELEASE.md.

Semver policy from 1.0.0

From 1.0.0 the suite follows semantic versioning in full:

  • a breaking change bumps the major version. A commit carrying ! or a BREAKING CHANGE: footer is a major-bump trigger, and the changelog marks each such entry BREAKING;
  • a minor bump is additive and API-compatible;
  • a patch bump is bugfix-only.

That is what the version number commits to; it is a policy statement, not a claim of stability beyond what semver means. All three published surfaces share one workspace version and are released together, and a version-coherence check in CI fails the build if the version sources (Cargo.toml, pyproject.toml, package.json, CITATION.cff) disagree.

The one exception is the C ABI. libpurrdf’s purrdf.h carries its own PURRDF_ABI_MAJOR.PURRDF_ABI_MINOR (currently 0.7), bumped on every exported-signature change, pinned by crates/rdf-capi/tests/abi_signatures.rs, and read back at runtime through purrdf_abi_version. It is versioned separately from the workspace and stays 0.x: it is not frozen, and the workspace’s 1.0.0 makes no promise about it.

MSRV policy

The supported minimum Rust is rust-version in the root Cargo.toml — currently 1.96 — on the stable channel, enforced by a dedicated CI MSRV job that sets RUSTUP_TOOLCHAIN explicitly and asserts the compiler it measured really is 1.96. Raising the MSRV is a notable change recorded in the changelog; it rides a minor bump and never ships in a patch release.

The MSRV is a promise to consumers; the development toolchain is a tool choice, and the two are orthogonal. rust-toolchain.toml pins a dated nightly for local work and the CI gates, because nightly clippy and rustdoc carry lints stable lacks — but the source is nightly-free by policy (zero #![feature(...)] attributes, which the MSRV job proves on every change), and the release lanes build every published artifact on stable.

Tag-driven trusted publishing

Releases are tag-driven: rust-v<version> publishes the crate suite to crates.io, py-v<version> publishes to PyPI, and npm-v<version> publishes the wasm package to npm. The lanes share the supply-chain posture of the cargo lane:

  • publication uses Trusted Publishing through GitHub Actions OIDC — no long-lived registry secret;
  • the privileged publish jobs use pinned actions and no dependency cache;
  • every .crate package receives a GitHub build-provenance attestation;
  • the package set receives an SPDX SBOM and SBOM attestation;
  • the release crate set is checked on wasm32-unknown-unknown before publishing;
  • every workspace crate version must match the tag version.

Every version of every crate — all 21 — is published by that lane and nothing else. Each existing crate record is locked on crates.io with Require trusted publishing (trustpub_only), so an API token cannot publish a new version of any of them: crates.io answers with 403 Forbidden: New versions of this crate can only be published using Trusted Publishing. A token has exactly one role left, creating the record of a brand-new crate — the one thing a Trusted Publishing token is refused (Trusted Publishing tokens do not support creating new crates) — and the release process document above is exact about how that bootstrap works: the lane publishes up to the first crate that depends on a new one and stops cleanly, the token creates the new crate’s record, Trusted Publishing is enabled on it, and the same run is resumed.

Four workspace members are deliberately never published to crates.io: purrdf-capi (built via cargo-c, distributed as libpurrdf), purrdf-sparql-conformance (the test harness), purrdf-cli (the purrdf binary), and purrdf-python (the extension crate, which ships to PyPI via maturin instead).

Cutting a release

The coherent flow from main uses the make helpers so the three lanes can never drift:

# 1. Bump all three version sources in lockstep (fails unless they end up equal).
make bump VERSION=0.2.2

# 2. Regenerate the committed C-ABI header from the bumped crate version.
make capi-header

# 3. Regenerate the changelog from the conventional-commit history.
make changelog

# 4. Review, then commit the release bump, generated header, and changelog.
git add -A && git commit -m "chore(release): 0.2.2"

# 5. From an up-to-date main, run every release gate, then push all three tags.
make release-tags VERSION=0.2.2

make release-tags refuses to run unless the working tree is clean, the branch is main and synchronized with origin/main, the version check passes, VERSION matches the tree, the release-notes section exists, and none of the three tags already exists locally or remotely. It then runs the Rust and wasm workspace gate, the generated C-ABI/header check, the native Python binding suite, and the optimized size-gated npm/wasm package tests. Only after every surface passes does it recheck the clean synchronized state and atomically push the rust-v, py-v, and npm-v tags together. No tag is created before the complete cross-surface preflight passes. Each tag triggers its own lane, and the cargo lane additionally publishes a GitHub Release built from the committed CHANGELOG.md.

Citing PurRDF

Releases carry a DOI; if you use PurRDF in research, please cite it — see CITATION.cff in the repository.

Diagnostic Code Reference

Every failure PurRDF reports carries a stable, machine-readable code on its RdfDiagnostic (severity, code, message, detail, location). The code is the contract: tests, the SARIF emitter, the CLI, the Python ValueError text and downstream matchers key on it, while the message is free prose that may change wording. This page lists the codes by family, with the failure each one names and what a caller can do about it. The set was enumerated from the constructor sites in the source tree, not written from memory; if a code you see is missing here, the source is authoritative and the page is stale.

Codes are kebab-case, prefixed by the family that owns them. They are never translated, and a caller should compare the whole string rather than a prefix.

iri-* — IRI parsing and base resolution (purrdf-iri)

IriError::diagnostic_code is the single owner of these strings for the whole workspace; every codec, SPARQL, ShEx and SHACL route an IRI failure through it. The two base-related codes are distinct because their remedies are: one is fixed by supplying a base, the other is not.

CodeMeaningRemedy
iri-emptyThe string is empty where a non-empty IRI/URI was required.Supply a non-empty IRI.
iri-missing-schemeThe string has no scheme, so it cannot be an absolute IRI.Write the IRI in absolute form, with a scheme.
iri-bad-schemeThe scheme is malformed (it must start with a letter and contain only letters, digits, +, - and .).Correct the scheme.
iri-bad-percent-encodingA % at the reported byte offset is not followed by two hexadecimal digits.Percent-encode the % itself, or complete the escape.
iri-disallowed-charA character the IRI grammar does not admit occurs at the reported byte offset.Percent-encode or remove the character.
iri-bad-authorityThe authority component (//host:port) is malformed.Correct the host or port.
iri-non-absolute-baseThe base IRI supplied for resolution is not itself absolute.Supply a base IRI that has a scheme, e.g. http://example.org/dir/.
iri-relative-no-baseA relative IRI reference was met with no base in scope: no in-document directive, no caller-supplied base, no retrieval IRI.Add a base to the document (@base/BASE in Turtle-family syntaxes, xml:base in RDF/XML, @context.@base in JSON-LD) or pass a base IRI to the API.
iri-not-absolute-by-grammarThe reference is not absolute and the syntax admits no relative reference at all (N-Triples, N-Quads), so no base could ever apply.Write the IRI in absolute form; supplying a base will not help.

native-codec-* — the text and XML codecs (purrdf-rdf)

CodeMeaningRemedy
native-codec-parseThe codec (Turtle family, RDF/XML, TriX, HexTuples) could not parse the input; the location names the line and column. Term nesting past the parser’s depth limit is reported under this code too.Fix the document at the reported position.
native-codec-utf8The input bytes are not valid UTF-8.Re-encode the document as UTF-8.
native-codec-panicThe codec panicked while parsing and the panic guard caught it. This is a defect in PurRDF, never in the input.Report it with the input that triggered it.
native-codec-readReading the RDF source through the streaming reader failed with an IO error.Check the source stream or file.
native-codec-writeWriting the serialized output failed with an IO error.Check the destination.
native-codec-serializeThe target format cannot represent the dataset — for example a named graph in a single-graph format — or the writer failed.Choose a format that carries the construct, or serialize through the loss-ledger lane.
native-codec-replayReplaying a parsed dataset into an event sink failed because the sink returned an error.The sink’s own error is carried in the message.
native-codec-unsupported-formatThe media type or format identifier names no codec.Use one of the supported media types or format ids.
native-codec-datatype-not-iriA literal’s datatype term is not an IRI.Give the literal an IRI datatype.
native-codec-direction-without-languageA base direction was given on a literal that has no language tag.Add a language tag, or drop the direction.
native-codec-invalid-directionA literal base direction other than ltr or rtl was given.Use ltr or rtl.
native-codec-iri-missing-valueAn IRI term event carried an empty value.Supply the IRI.
native-codec-missing-reifier-bindingA triple term refers to a reifier that has no binding.Bind the reifier before referring to it.
native-codec-predicate-not-iriA term in an IRI-only position (the predicate) is not an IRI.Use an IRI predicate.
native-codec-reifier-not-tripleA reifier binds something other than a triple term.Bind the reifier to a triple term.
native-codec-term-out-of-rangeA term id in the event stream is outside the range the stream introduced.The producing stream is inconsistent; regenerate it.
native-codec-unbound-triple-termA triple term names neither its components nor a reifier.Give the triple term its components or a reifier.

The last nine arise when the codec lane consumes term events rather than text — a GTS graph being resolved through the codec surface — and mirror the gts-* and rdf-ir-* codes below.

native-jsonld-* and jsonld-* — JSON-LD and YAML-LD

CodeMeaningRemedy
native-jsonld-decodeThe JSON-LD or YAML-LD input is malformed at the surface: invalid JSON/YAML, an encoding error, or a materialized-carrier byte budget exceeded.Fix the surface syntax or reduce the document.
native-jsonld-parseThe input is well-formed JSON-LD that does not map to RDF.Correct the JSON-LD structure.
jsonld-json-inputThe strict JSON reader rejected the input, for example a duplicate object member.Remove the duplicate or correct the JSON.
jsonld-context-invalidA context document, or the strict versioned options document, is invalid.Correct the context or options document.
jsonld-context-limitA context-processing ceiling was exceeded: loaded bytes, work count, definition complexity, or the offline context registry size.Reduce the context, or raise the limit the options document declares.
jsonld-derived-invalidThe deterministic dataset-IRI derived mode could not derive a prefix (an invalid IRI, or a null mapping).Correct the IRI the prefix would be derived from.
jsonld-derived-limitA derived-context work or byte ceiling was exceeded.Reduce the dataset’s IRI vocabulary or raise the declared limit.
jsonld-options-unusedJSON-LD serialization options were supplied for a format that is not JSON-LD or YAML-LD.Drop the options, or serialize to JSON-LD/YAML-LD.

cdt-* — SEP-0009 composite literals

A cdt:List or cdt:Map lexical form denotes blank nodes in the enclosing document’s scope, so a form that does not parse leaves that scope undefined and the whole document is refused rather than the literal being kept opaque.

CodeMeaningRemedy
cdt-literal-malformedA composite literal’s lexical form does not parse.Correct the literal.
cdt-literal-scan-disagreementThe bounded lexical scanner and the full parser disagree about the literal.This is a defect in PurRDF; report it with the literal.

rdf-ir-* — dataset structure (purrdf-core freeze and GTS import)

RdfDatasetBuilder::freeze() validates the structure of the dataset it is about to freeze; the GTS import sink reports the same family for a container whose terms do not form a well-formed dataset.

CodeMeaningRemedy
rdf-ir-term-out-of-rangeA quad references a TermId the builder never interned.Intern the term before pushing the quad.
rdf-ir-predicate-not-iriA quad’s predicate is not an IRI.Use an IRI predicate.
rdf-ir-literal-subjectA literal occupies subject position.RDF admits no literal subject; restructure the statement.
rdf-ir-triple-subjectA triple term occupies subject position.RDF 1.2 admits triple terms in object position only; use a reifier.
rdf-ir-graph-name-invalidA graph name is a literal or a triple term.A graph name must be an IRI or a blank node.
rdf-ir-reifier-not-tripleA reifier binding points at something other than a triple term.Bind the reifier to a triple term.
rdf-ir-triple-cycleA triple term contains itself, directly or through nesting.Remove the cycle.
rdf-ir-triple-nesting-limitTriple-term nesting exceeds the builder’s depth limit.Flatten the nesting.
rdf-ir-dangling-term-refA GTS role references a term id that no term event introduced.The container is inconsistent; regenerate it.
rdf-ir-gts-fold-diagnosticThe GTS fold reported a diagnostic, surfaced through the import.The fold diagnostic’s own code and detail are in the message.
rdf-ir-iri-missing-valueAn imported IRI term has an empty value.Supply the IRI.
rdf-ir-literal-datatype-not-iriAn imported literal’s datatype resolves to a non-IRI.Give the literal an IRI datatype.
rdf-ir-missing-reifier-bindingAn imported triple term references a reifier with no recorded binding.Bind the reifier in the container.
rdf-ir-term-nesting-limitImported triple-term nesting exceeds the depth limit.Flatten the nesting.
rdf-ir-unbound-triple-termAn imported triple term names neither its components nor a reifier.Give the triple term its components or a reifier.

gts-* and rdf-* — GTS graph resolution, verification and writing

CodeMeaningRemedy
gts-term-out-of-rangeA GTS term id is out of range for the graph.The container is inconsistent; regenerate it.
gts-iri-missing-valueA GTS IRI term has an empty value.Supply the IRI.
gts-predicate-not-iriA GTS predicate term is not an IRI.Use an IRI predicate.
gts-literal-datatype-not-iriA GTS literal datatype does not resolve to an IRI.Give the literal an IRI datatype.
gts-direction-without-languageA GTS literal carries a base direction but no language tag.Add a language tag, or drop the direction.
gts-invalid-directionA GTS literal base direction is neither ltr nor rtl.Use ltr or rtl.
gts-missing-reifier-bindingA GTS triple term references a reifier the graph does not bind.Bind the reifier in the container.
gts-unbound-triple-termA GTS triple term names neither its own components nor a reifier.Give the triple term its components or a reifier.
gts-self-reaching-termA GTS term resolves through itself, so no walk of its components can terminate.Remove the cycle.
gts-term-nesting-limitGTS term nesting exceeds the depth limit.Flatten the nesting.
gts-fold-diagnosticThe GTS fold reported one or more diagnostics.Inspect the fold diagnostics listed in the detail.
gts-verify-digest-inclusionContent-addressed terms are not included in the verified chain.The container’s chain does not cover its content; do not trust it.
gts-verify-signatureCOSE signature verification failed.Check the signing key and the container’s integrity.
gts-writer-codecThe GTS writer’s codec reported an error while writing.The codec’s own error is in the message.
rdf-graph-name-not-nodeWhile building a GTS graph, a named-graph name is not an IRI or blank node.Use an IRI or blank node graph name.
rdf-reifier-not-nodeWhile building a GTS graph, an RDF 1.2 reifier is not an IRI or blank node.Use an IRI or blank node reifier.
rdf-term-nesting-limitRDF term nesting exceeded the depth limit while building a GTS graph.Flatten the nesting.

native-sparql-* — the SPARQL engine boundary (purrdf-sparql-eval)

CodeMeaningRemedy
native-sparql-query-parseThe query text does not parse under the SPARQL 1.1/1.2 grammar (including the enforced VERSION declaration).Fix the query at the reported position.
native-sparql-update-parseThe update request does not parse.Fix the update at the reported position.
native-sparql-query-explainEvaluation under --explain failed; the evaluator’s error is in the message.Address the underlying evaluation error.
native-sparql-property-functionThe property-function seam refused the query: a predicate under a declared namespace has no registration, a call site’s arity does not match the relation, no total order can serve a chain, or a prepared plan is being evaluated under a different registry than it was prepared with.Register the relation, correct the arity, or prepare and evaluate under the same registry.
native-sparql-aggregate-functionThe custom-aggregate seam refused the query: an AGG(<iri>, …) names no registered aggregate, or a prepared plan is being evaluated under a different aggregate registry.Register the aggregate, or prepare and evaluate under the same registry.
native-sparql-custom-functionA function or aggregate IRI resolved to no registered custom function, native function, or XSD constructor.Register the function under that IRI, or use a native one.
native-sparql-quoted-triple-term-variableA variable occupies a component of a quoted-triple term in a basic graph pattern or property path; structural triple-term matching is out of scope.Bind the triple term as a whole, or match its components through the reifier surface.
native-sparql-heldin-unconfiguredheldIn was called with no caller-supplied standpoint-predicate configuration.Configure the standpoint predicates before using heldIn.
native-sparql-graph-pattern-depth-exceededA manually constructed graph pattern nests deeper than the parser’s safety bound.Flatten the pattern.
native-sparql-bnode-mint-prefixThe blank-node mint prefix supplied in the options is invalid.Supply a valid prefix.
native-sparql-load-no-resolverLOAD <iri> was requested but no GraphResolver host seam was provided.Inject a resolver, or remove the LOAD.
native-sparql-update-bad-destinationAn ADD/MOVE/COPY/LOAD destination is NAMED or ALL; it must be DEFAULT or a single named GRAPH.Name a single destination graph.
native-sparql-subst-iriA substitution value is not a valid IRI.Supply a valid IRI.
native-sparql-subst-triple-predicateA substituted quoted triple has a predicate that is not an IRI.Use an IRI predicate.

reasoning-* — SPARQL under an entailment regime (purrdf)

CodeMeaningRemedy
reasoning-closure-relation-witnessProperty-function relations derived from the closure cannot be combined with an OWL Direct-Semantics run whose restricted chase minted existential witnesses: a relation walking the closure could return a minted blank node the regime’s scoping graph does not contain.Query under a regime that mints no witnesses (rdf, rdfs, owl-rl, d, rif, simple), or drop the dataset-derived relations from the call.
reasoning-closure-relation-rebuildRebuilding the property-function relations over the closure failed.The relation builder’s own error is in the message.

statements-* — statement-metadata ingestion (purrdf-rdf)

These arise when a document’s rdf:reifies and owl:Axiom statements are read into the statement layer.

CodeMeaningRemedy
statements-turtle-parseThe statement-metadata Turtle failed to parse.Fix the Turtle.
statements-non-iriA term that must be an IRI in this context is not one.Use an IRI.
statements-reifies-non-tripleThe object of rdf:reifies is not a triple term.Reify a triple term.
statements-malformed-axiomAn owl:Axiom lacks its source, property or target.Complete the axiom.
statements-conflicting-structuralOne subject carries two different values for a structural field.Keep one value.

Other single-code families

CodeMeaningRemedy
sssom-tsv-parseAn SSSOM TSV document is malformed: a missing or unreadable header row, a malformed curie_map entry or set comment, a malformed row, or a non-numeric confidence; the location names the line.Fix the TSV at the reported line.
content-id-schemeA content-id scheme prefix is invalid: empty, non-ASCII, or ending in a hexadecimal digit (which would make it ambiguous with the 64-hex-character tail).Choose a prefix that is non-empty ASCII and does not end in 0-9, a-f or A-F.

Where the code reaches you

  • RustRdfDiagnostic::code on the returned error; its Display form is <severity> <code>: <message>.
  • Python — the ValueError message is that Display form (for example error native-codec-parse: …); an IRI failure is rendered as <code>: <message> (for example iri-relative-no-base: …).
  • JavaScript — the thrown Error message carries the same text.
  • C — the error string returned through the C ABI carries the same text.
  • SARIF — the ruleId of each result is the code, with a reportingDescriptor in the run’s rule table (purrdf-validate).