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 (
TermIdspace, 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:reifiestriples) or the first-partypurrdf-rdfc12profile, 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 thatCONSTRUCTinto 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 aSERVICEseam — a host-injected resolver carrying per-service context; no HTTP client and no resolver ship, soSERVICEandLOADfail by name on every shipped surface unless writtenSILENT— 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 needed | Usually from | Now inside PurRDF | Where it stops |
|---|---|---|---|
| Ranked full-text search | PostgreSQL tsvector/tsquery | Full-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 predicates | PostGIS | GeoSPARQL: 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 similarity | pgvector | Embedding 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
- New users: start with Getting Started in your language, then read the Concepts chapters.
- Engine users: jump to SPARQL, Validation, or Entailment.
- Integrators: see Interop and GTS Graph Transport.
- Contributors: read the Project chapters, then AGENTS.md in the repository.
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
- The Interned Dataset IR — how the IR works and why it is fast.
- Graph, Tabular & Research-Object Projections — deterministic LPG, CSVW, OBO Graphs, SKOS, and research-object carriers through the umbrella crate.
- SPARQL: Querying — running queries over a frozen dataset.
- docs.rs/purrdf — the full API reference.
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
- rdflib Compatibility — the drop-in story in depth.
- Validation — SHACL and ShEx from Python.
- Entailment — the regimes, the report, and what each fires.
- GTS Graph Transport — the container format the exports read.
- Graph, Tabular & Research-Object Projections — LPG, CSVW, OBO, SKOS, and five research-object carriers.
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.DataFactory—namedNode,blankNode,literal(value, languageOrDatatype?),typedLiteral,directionalLiteral,variable,defaultGraph,quad,quotedTriple,fromTerm,fromQuad.Dataset(RDF/JSDatasetCore) —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);serializeadditionally acceptsjsonld.- Graph identity —
Dataset.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 carriers —
Dataset.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. - SPARQL —
QueryEnginekeeps the native plan cache alive across calls and exposes typedselect/ask/construct/describe, atomicupdate, andqueryRawserialization.Dataset.query(...)remains the compatibility raw-string helper. - SHACL —
shaclValidateToSarif(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-AFsh:ruleinferences as N-Triples. Sink— a streaming consumer (push(quad)/finish() → Dataset);datasetToStream/streamToDatasetare 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
SERVICEandLOADfail explicitly. - Triple terms per format.
serializeis 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 (asrdf: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 becomesPURRDF_STATUS_PANIC, never a process abort across FFI. int32_tstatus + out-params. Fallible functions return aPurrdfStatusvalue and write results through out-pointers.PURRDF_STATUS_CURSOR_EXHAUSTEDis the (non-error) end-of-rows signal.- SemVer-frozen ABI. The status enum is append-only; new fields and
functions are additive.
purrdf_abi_versionreports the current ABI version at runtime; thePURRDF_ABI_MAJOR/_MINOR/_PATCHmacros 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). FreeingNULLis a no-op. - The C side never
free()s aPurrdfStr.ptr— it borrows library-owned memory; copy the bytes out if they must outlive the borrow. Term views frompurrdf_cursor_nextare valid until the nextpurrdf_cursor_nexton that cursor orpurrdf_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.
PurrdfDatasetis frozen andSend + 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_gts → purrdf_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_gts → read_graph → import_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 ispurrdf-validate). - Canonicalization (RDFC-1.0 on the RDF 1.1 subset; the
purrdf-rdfc12profile over reifiers and annotations), dataset diff, and isomorphism — see Canonicalization & Diff. - Store and engine seams — the narrow parser-ingress, serializer-egress,
and
SparqlEnginetraits 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-algebraand 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
| Feature | IR | Codecs | SPARQL | SHACL | RDF/JS | GTS |
|---|---|---|---|---|---|---|
| Triple terms (object position) | interned term | star-capable formats | <<( s p o )>> | via paths/values | quotedTriple | mapped per spec |
| Reifiers / annotations | side-tables | star-capable formats | reifier surface | sh:reifierShape (draft) | — | rdf:reifies mapping |
| Base-direction literals | literal kind | round-trips | matched/produced | value nodes | directionalLiteral | carried |
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.
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.
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.
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.
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.
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
| Profile | Native artifacts | Direction | RDF fidelity |
|---|---|---|---|
lpg-csv | generic nodes/edges CSV | RDF ↔ carrier | exact RDF sideband; property-graph lowering ledgered |
neo4j-csv | Neo4j Admin Import CSV | RDF ↔ carrier | same canonical LPG authority and ledger |
open-cypher | deterministic CREATE program | RDF ↔ carrier | strict reader accepts exactly the emitted grammar |
graphml | GraphML 1.0 XML | RDF ↔ carrier | exact RDF sideband; strict namespace/key validation |
csvw-exact | CSVW metadata plus RDF 1.2 tables | RDF ↔ carrier | lossless |
csvw-terms | CSVW metadata plus caller-declared entity tables | RDF → view | located, closed loss ledger |
okf-terms | OKF v0.1 concept documents and indexes | RDF → view | located, closed loss ledger |
obo-graphs | OBO Graphs 0.3.2 JSON | RDF → view | located, closed loss ledger |
skos | SKOS Turtle | RDF → view | located, closed loss ledger |
croissant-1.1 | croissant.json | RDF ↔ carrier | shared model; profile loss is located |
ro-crate-1.3 | ro-crate-metadata.json | RDF ↔ carrier | shared model; profile loss is located |
datacite-4.6 | datacite.xml | RDF ↔ carrier | shared model; profile loss is located |
dcat-3 | dcat.jsonld | RDF ↔ carrier | shared model; profile loss is located |
dcat-rdf | dcat.<native extension> | RDF → view | mapped or caller-CONSTRUCTed; blank-free RDF |
void | void.<native extension> | RDF → view | selected-graph statistics, partitions, and linksets |
frictionless-data-package-1 | datapackage.json | RDF ↔ carrier | shared 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:
csvwis the complete normative CSVW context, vocabulary, processing mode, record bound, and package limits;metadata_pathis the safe package-relative metadata member;graph_selectionis either explicitallor 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_limitsbounds 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:
mappedreuses the caller-configured research-object interpretation, then emits direct RDF IR with the caller’s complete DCAT context/role map plus explicitrdf:typeand XSD-string IRIs. The semantic lowering ledger from the shared model is preserved.constructtreats 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:
| Host | Materialized project | Attached RO-Crate | Direct LPG artifacts | Lift |
|---|---|---|---|---|
| Rust | project_archive | project_archive_with_assets | project_lpg_artifacts_to_sink | lift_archive |
| CLI | purrdf project | purrdf project --assets PAYLOADS.tar | — | purrdf lift |
| Python | purrdf.project(...) | purrdf.project(..., assets=archive) | purrdf.project_artifacts(...) | purrdf.lift(...) |
| JavaScript | dataset.project(...) | dataset.projectWithAssets(...) | — | liftProjection(...) |
| C | purrdf_project(...) | purrdf_project_with_assets(...) | — | purrdf_lift(...) |
Runnable examples live at:
crates/rdf/examples/projection_archive.rscrates/rdf/examples/csvw_terms.rscrates/rdf/examples/okf_terms.rscrates/rdf/examples/research_object_roundtrip.rscrates/rdf/examples/attached_ro_crate.rscrates/cli/examples/projection-roundtrip.shcrates/cli/examples/dataset-descriptions.shbindings/python/examples/projection_roundtrip.pybindings/python/examples/projection_stream.pycrates/rdf-wasm/js/examples/projection-roundtrip.mjscrates/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:
| Format | Media type | Star-capable |
|---|---|---|
| Turtle | text/turtle | yes |
| TriG | application/trig | yes |
| N-Triples | application/n-triples | yes |
| N-Quads | application/n-quads | yes |
| RDF/XML | application/rdf+xml | no |
| TriX | application/trix | no |
| HexTuples | application/x-hextuples | no |
| JSON-LD (star) | application/ld+json | yes |
| YAML-LD | application/ld+yaml | yes |
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.
Related
- 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
DatasetViewread seam the pack codec implements alongsideRdfDataset.
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:
| Source | RFC 3986 | Example | |
|---|---|---|---|
| 1 | An in-document base directive | §5.1.1 | Turtle/TriG @base or BASE, SPARQL BASE, RDF/XML xml:base, JSON-LD/YAML-LD @context.@base |
| 2 | A base the caller supplied | §5.1.2 | the base argument to parse_dataset, the CLI’s --base |
| 3 | The document’s retrieval IRI | §5.1.3 | the file:// IRI of the file the CLI read |
| 4 | (none) — hard failure | §5.1.4 | iri-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.
| Surface | Retrieval IRI? | Consequence |
|---|---|---|
purrdf-slice (slice tree, catalog, dependency fixes) | yes — derives it; the workspace’s one implementation of §5.1.3 | a relative IRI in an on-disk slice document resolves with no flags |
purrdf-shapes shape-union loader | yes — consumes purrdf-slice’s | each shape file parses under its own file:// IRI |
purrdf CLI, per input file | yes — consumes purrdf-slice’s | a relative IRI resolves with no flags |
purrdf CLI, stdin (-) | no | a relative IRI is iri-relative-no-base; pass --base |
Rust byte APIs (parse_dataset, purrdf-rdf, purrdf-iri) | no | pass a base, or the document must carry one |
| WebAssembly | no | as above |
| C ABI | no | as above |
| Python | no | as 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:
| Family | Syntaxes | Relative reference |
|---|---|---|
| Admits relative references | Turtle, TriG, RDF/XML, JSON-LD, YAML-LD, SPARQL | resolved against the base in force |
| Absolute-only by grammar | N-Triples, N-Quads, TriX, HexTuples | rejected — 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.
| Code | Condition | What to do |
|---|---|---|
iri-relative-no-base | a relative reference in a syntax that admits one, with no base in scope | add @base/BASE/xml:base/@context.@base to the document, or pass a base to the API (--base on the CLI) |
iri-not-absolute-by-grammar | a 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 relative | write the IRI in absolute form; a base cannot help — this position admits no relative reference |
iri-non-absolute-base | the 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:
| Syntax | Reads a base | Writes a base |
|---|---|---|
| Turtle, TriG | @base / BASE | @base |
| RDF/XML | xml:base | xml:base |
| JSON-LD, YAML-LD | @context.@base | @context.@base |
| N-Triples, N-Quads, TriX, HexTuples | no | no — 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 $? 2The verdict is read off the format registry’s
admits_relative_iriandemits_basecolumns, so a newly registered syntax is classified by its own row rather than by a hand list. It applies toconvert,validate,reason,entails,consistencyandproject.query,update,shex’s shape map anddescribe --iriare deliberately exempt: each has a command-line-text IRI surface with no document of its own, so--baseis 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:
expandedpreserves the byte-frozen empty-@contextrepresentation;contextcompiles a caller prefix map or local JSON-LD 1.1 context once and applies normative compaction;deriveddeterministically assigns neutralns0,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 form —
canonical_flat_nquadsinpurrdf-rdfrewrites the statement layer to plainrdf:reifies/ annotation triples first and canonicalizes that triple set under conformant RDFC-1.0. This is what the CLI’sconvert --canonical, the wasmDataset.canonicalize()and the W3C conformance gate run. - The
purrdf-rdfc12v1 profile —canonicalizeinpurrdf-corekeeps the statement layer and lowers it into a reservedurn: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_VERSIONname the profile at runtime. The normative text isdocs/RDF12-CANON-PROFILE.md.
Isomorphism
datasets_isomorphic(a, b) decides whether two frozen datasets are
RDF-structurally isomorphic: the same quads 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
| Need | Use |
|---|---|
| Same dataset → same bytes | any native serializer (always true) |
| Same graph (up to blank nodes) → same bytes, RDF 1.1 subset | RDFC-1.0 (canonicalize or canonical_flat_nquads, identical here) |
| Same graph with reifiers/annotations → same bytes | canonical_flat_nquads (RDFC-1.0 over the flattened layer) or canonicalize (purrdf-rdfc12 profile) — not interchangeable |
| “Are these two datasets the same graph?” | datasets_isomorphic |
| “What changed between these datasets?” | dataset_diff + describe/normalize |
| Content-addressed transport of a graph | GTS (BLAKE3 content ids) |
API details are on
docs.rs/purrdf-core (the canonicalize,
datasets_isomorphic, and dataset_diff items) and
docs.rs/purrdf-rdf (describe and normalization).
SPARQL: Querying
PurRDF’s SPARQL stack is native and three-layered, gated by the W3C SPARQL 1.1 and 1.2 conformance suites:
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.purrdf-sparql-eval— the multiset evaluator over the frozen IR’sDatasetView, entirely in internedTermIdspace.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 theirFOLDaggregate andUNFOLDgraph pattern (see below). - Update —
INSERT DATA/DELETE DATA, theDELETE/INSERT … WHEREfamily (WITH/USING,DELETE WHERE),LOAD, andCLEAR/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
TermIdonce; 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_queryexposes 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 seam —
SERVICEfederation is evaluated through a host-injectableServiceResolver: 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 anHttpTransporttrait the Rust host implements) and no shipped surface — CLI, Python, wasm or C — installs a resolver, so a non-SILENTSERVICEorLOADthere fails by name; federation is a Rust-host composition. All seven W3Cservicefederation 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_queryreturns aQueryExplanationwhose 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 indocs/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:
| Operands | Result |
|---|---|
date - date | dayTimeDuration |
date + yearMonthDuration | date |
date - yearMonthDuration | date |
date + dayTimeDuration | date |
date - dayTimeDuration | date |
time - time | dayTimeDuration |
time + dayTimeDuration | time |
time - dayTimeDuration | time |
dateTime - dateTime | dayTimeDuration |
dateTime + yearMonthDuration | dateTime |
dateTime - yearMonthDuration | dateTime |
dateTime + dayTimeDuration | dateTime |
dateTime - dayTimeDuration | dateTime |
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:duration
is“–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:yearMonthDuration
alone 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
- through its underlying JAXP calendar and returning a value built on that
fabrication — for example
"---31"^^xsd:gDay + "P1M"^^xsd:yearMonthDurationanswers"---29", clamped against a fabricated leap year.purrdfmatches 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/AVGover durations. SPARQL 1.1 §18.5.1.3 definesSUMby repeatedop:numeric-add, whose domain is the numeric tower only;purrdfextends 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 } }?totalis"P3M"^^xsd:yearMonthDuration. A group mixing numeric and duration values stays unbound — the extension does not widenSUM’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.
purrdfnegates 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) }?negis"-P1Y2M"^^xsd:yearMonthDuration. -
duration ÷ durationby value commensurability. F&O defines only the two same-subtype forms (op:divide-yearMonthDuration-by-yearMonthDuration,op:divide-dayTimeDuration-by-dayTimeDuration).purrdfalso accepts the generalxsd:duration, dispatching on whether the two operands’ values are commensurable (both purely months, or both purely seconds) rather than on their declared tags, so adayTimeDurationand a generalxsd:durationthat 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) }?ratiois30, typedxsd: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 aMINUSright operand is ACCEPTED here at any depth — directly under theLATERALkeyword or nested under aSELECT *sub-select — while Jena rejects it. SPARQL §18.2.1 putsMINUS-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 aMINUS— aSELECT *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?gto 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 aLATERALright-hand side runs,?galready holds an observable, per-row value from the left, the same as any other left-bound variable. ABIND/VALUESon 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:
| Capability | Grants |
|---|---|
Query | resolving this service at all |
Network | performing network I/O to resolve it |
Credentials | attaching 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:
| Outcome | SERVICE | SERVICE SILENT |
|---|---|---|
| The endpoint is unreachable, or its response undecodable | query error | join identity |
| A capability was denied | query error | query error |
| This engine’s own governor tripped | truncation | truncation |
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.
substituteis stated over variable POSITIONS in a pattern, with no defined action on aBgp/Pathleaf term or aGRAPH ?gname 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
MINUSdomain flip. Rewriting aMINUS’s shared variables away as constants erases the very columns its domain-join compares the two operands on, turning a correlatedMINUSinto 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 insubstitute’s pattern-rewriting reading to carry it there. - The assignment restriction. §18.6’s own text states no rule at all
about an
EXISTSbody 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.OPTIONALemits at least one row per left row unconditionally — a padded left row, or one row per compatible match — and never removes one, soLeftJoin(A, B, c)is empty iffAis, regardless ofBor 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, soPnon-empty always leaves the first row’s value present.Slice(0, len ≥ 1)(P) → P. An offset-zeroLIMITwith room for at least one row drops rows only from the end ofP’s bag, soPnon-empty always leaves row zero inside the window.Slice(_, Some(0))(P) → false. A zero-lengthLIMITis empty for everyPand every row — the wholeEXISTSfolds to constantfalse(NOT EXISTStotrue) 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:
- The definition itself — per-row substitution via
Replace, wrapped in aSlice{0, Some(1)}first-witness stop (sound because “does this bag have a first row” is exactly whatEXISTSasks) and backed by a restriction-keyed memo: the memo key is the outer row restricted to the inner’s own correlated-variable columns, sokdistinct restrictions acrossNouter rows evaluate the inner exactlyktimes, neverN. Always correct, for any inner pattern. - 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:
| Call | Digest | Hex characters |
|---|---|---|
SHA3-224(string) | SHA3-224 | 56 |
SHA3-256(string) | SHA3-256 | 64 |
SHA3-384(string) | SHA3-384 | 96 |
SHA3-512(string) | SHA3-512 | 128 |
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:
| Text | Reading |
|---|---|
SHA3-256(?o) | the built-in call — one token, hyphen included |
SHA3 - 256 | a parse error: SHA3 alone is no function or keyword |
STRLEN(SHA3-256(?o)) - 4 | subtraction — 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.
| Call | Arity | Result |
|---|---|---|
cdt:List(…) | any | a cdt:List of the argument terms |
cdt:Map(…) | even | a cdt:Map from alternating key/value arguments |
cdt:concat(…) | any | the argument lists joined end to end |
cdt:contains(list, term) | 2 | xsd:boolean — does the list hold an equal term |
cdt:get(coll, n_or_key) | 2 | one element by 1-based position, or one map value by key |
cdt:head(list) | 1 | the first element |
cdt:tail(list) | 1 | everything but the first element |
cdt:reverse(list) | 1 | the elements in opposite order |
cdt:size(coll) | 1 | xsd:integer element or entry count — the one function that takes either datatype |
cdt:subseq(list, start[, len]) | 2–3 | a contiguous run, start 1-based |
cdt:containsKey(map, key) | 2 | xsd:boolean — is this a key of the map |
cdt:keys(map) | 1 | the map’s keys, as a cdt:List |
cdt:merge(…) | ≥ 2 | the 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–3 | the map with one entry set; an omitted or erroring value stores the null entry |
cdt:remove(map, key) | 2 | the 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:Listor"{}"^^cdt:Map— never to an unbound column.SUMover nothing is0andGROUP_CONCATover nothing is"";FOLDfollows that shape rather thanMIN’s. - A row whose element expression is unbound or erroring contributes a
nullelement, 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.
DISTINCTis RDF-term identity, not value equality, so"1"^^xsd:integerand"01"^^xsd:integersurvive 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 )? ')'
| Operand | first variable | second variable |
|---|---|---|
cdt:List | each element, in list order, duplicates preserved | the 1-based xsd:integer index |
cdt:Map | each entry’s key | that 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 acdt:-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, soUNFOLDnever 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:
| Bound | Value |
|---|---|
| Nesting depth | 64 |
| Total elements, all levels | 2²⁰ (1 048 576) |
| Lexical bytes | 64 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, soINSERT { 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
CONSTRUCTper 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?oto 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 graphs | Does not |
|---|---|
| TriG, N-Quads, TriX, HexTuples, JSON-LD, YAML-LD | Turtle, 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:
-
CLI —
purrdf query … --results-format turtleexits 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
CONSTRUCTis aQueryQuads(whose members areQuads with a livegraph_name) rather than aQueryTriples.QueryQuads.serialize(RdfFormat.TURTLE)raisesValueError, namingRdfFormat.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 fromturtletotriginstead 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 getsturtle, byte for byte. -
C ABI — the shape is different, and deliberately so.
purrdf_queryhands back aPurrdfDatasethandle, 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, andpurrdf_serializeto a single-graph media type succeeds while reporting what it discarded through theout_named_graph_rows_droppedout-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 aCONSTRUCT/DESCRIBEresult 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 ispurrdf’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 explicitserialize("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: ®istry,
..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 sameinteger ⊂ decimal ⊂ float ⊂ doublepromotion tower the built-inSUM/AVGfold uses; a non-numeric input poisons the fold to unbound rather than raising a hard error, matchingSUM’s own discipline. OnlySTDDEV/STDDEV_POP’s final square root leaves the exact decimal tower;VARIANCE/VAR_POPnever do. STDDEV/VARIANCEare the sample statistics (n - 1denominator, unbound under two values);STDDEV_POP/VAR_POPare the population statistics (ndenominator, defined at one value) — SQL’s own naming convention.MEDIANisPERCENTILEatp = 0.5under linear interpolation between the two closest ranks — for an even-sized group this is exactly the mean of the two middle values.PERCENTILEtakes a named scalarval,AGG(<{NS}PERCENTILE>, ?x; P=0.95):Pis resolved once for the whole aggregation (never per row — see “Named scalar-value parameters” above); aPoutside[0, 1]poisons to unbound, the same “poison, don’t abort” discipline every numeric member uses. A missing or non-numericPis refused at prepare time.MODEworks over any term kind, counted by RDF term identity (not value equality —"5"^^xsd:integerand"05"^^xsd:integercount as two different terms). A tie is broken toward the smallest term under the same total orderMIN/ORDER BYuse.FIRST/LASTwork over any term kind, in input row order.TOPKtakes a named scalarval,AGG(<{NS}TOPK>, ?x; K=3):Kmust be a positivexsd:integer, resolved once for the whole aggregation (aK ≤ 0poisons to unbound; a missing or non-integerKis refused at prepare time). Since every SPARQL aggregate yields exactly one RDF term,TOPKanswers the same wayGROUP_CONCATanswers a multi-valued question — the topkvalues, 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): theaggregate_namespacekeyword onquery/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 IRIflag.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): theaggregateNamespaceoption, 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 nullableconst char *aggregate_namespaceparameter, 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-entail — Regime::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_droppedis set; the drop is never silent. The same flag is set for JSON/XML when a non-emptyResultProvenanceis supplied with noProvenanceNamespaceto 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 baredirattribute, and — XML only — apurrdf:dir-namespaced one) for backward compatibility, butits:dirwins when more than one is present on the same literal, and onlyits:diris ever written.
| Format | SELECT | ASK | CONSTRUCT | Provenance extension |
|---|---|---|---|---|
| JSON (SRJ) | yes | yes | yes | yes, with a namespace |
| XML | yes | yes | rejected | yes, with a namespace |
| CSV | yes | rejected | rejected | dropped, flagged |
| TSV | yes | rejected | rejected | dropped, 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.
Related
- SPARQL: Querying — producing the
SparqlResultin the first place. - docs.rs/purrdf-sparql-results — the full API reference.
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.?scoreis anxsd:decimalcarrying the exact BM25 value,?rankis the document’s 1-based position within its(graph, language)partition,?langis the partition’s language tag, and?matchedcounts 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: ®istry, ..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 kis top-k. Emission order is rank order, so the evaluator’s row-ceiling licence applies and the relation stops afterkrows.ORDER BY DESC(?score) LIMIT kis not pushed down —ORDER BYplusLIMIThas no certified lower bound the governor can license — and theORDER BYis redundant anyway. ORDER BY ?rankis the reproducing idiom. A score reaches the consumer as a decimal of fixed width, so two rows can report the same?scorewhile carrying different?rank;ORDER BY DESC(?score)can therefore disagree with the exact internal order, and?rankcannot.
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_bindingchecks 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:doubleresult is the correctly rounded nearest double, computed with integer arithmetic and assembled withf64::from_bits. The crate root deniesclippy::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:
| position | name | role |
|---|---|---|
| 0 | ?neighbour | out: the RDF term whose vector was retrieved |
| 1 | ?query | in: the term whose vector seeds the search |
| 2 | k | in: how many neighbours to retrieve |
| 3 | ?distance | out: 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-shapessuite passes clean (129/129, zero ledgered gaps at the time of writing — the live number is indocs/CONFORMANCE.md). - SHACL-SPARQL — SPARQL-based constraints and targets, custom constraint
components with pre-binding semantics, user-defined
sh:SPARQLFunctioncalls, andsh:SPARQLTargetType, evaluated on the native SPARQL engine. - SHACL-AF — node expressions (including
sh:ExpressionConstraintComponent) and SHACL Rules (sh:TripleRuleandsh:SPARQLRule, withsh: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:nodeByExpressionis validated; SPARQL-based node expressions and expression-bodied functions ride the native engine; and rules execute assh:orderstrata with theonce/generalpartition of the SPARQL 1.2 RL draft, each stratum materialized before the next runs, so swapping two rules’ orders can change the closure.sh:conditionresolves 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-schema → pydantic-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-schema → linkml-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-schema → typescript-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-schema →
graphql-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:
| Boundary | Located loss families |
|---|---|
| object fields and names | additional properties, pattern properties, property names/counts |
| requiredness and recursion | nullable-presence widening, one deterministic recursive-input nullability relaxation |
| lists | singleton coercion, cardinality, contains, uniqueness, tuples, unevaluated items |
| scalar assertions | integer domain delegation, numeric predicates, string predicates |
| applicators | conditionals, dependencies, intersections, unions, oneOf, negation |
| runtime boundary | custom-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, theAND/OR/NOTshape 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 againstBASEviapurrdf-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 point | Regime(s) | Engine |
|---|---|---|
materialize(ds, regime) | Simple, RDF, RDFS, OWL-RL, D | Forward 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-Direct | Open-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(...) | RIF | RIF-Core rule entailment over a parsed RuleSet. |
parse_rif_xml(...) / resolve_rif_imports(...) | RIF | RIF-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.
| Host | Materialize | Defined rule table | Implemented rules |
|---|---|---|---|
| Rust | materialize(&ds, Materialization::Rdfs) | rules(Regime::Rdfs) | implemented(Regime::Rdfs) |
| CLI | purrdf reason --regime rdfs, purrdf convert --entailment rdfs, purrdf query --entailment rdfs (and purrdf entails asks the conclusion-directed question) | — | — |
| Python | purrdf.entail.materialize(dataset, "rdfs", ""), purrdf.entail.materialize_nt(text, "rdfs", "") | purrdf.entail.rules("rdfs") | purrdf.entail.implemented_rules("rdfs") |
| JavaScript / WebAssembly | entailMaterialize(doc, "rdfs", "") | entailRules("rdfs") | entailImplementedRules("rdfs") |
| C | purrdf_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.
| Host | Does P entail C? | Re-decide the warrant | Certain answers of a pattern |
|---|---|---|---|
| Rust | entails(&p, &c, Regime::OwlRl, &imports) | verify(warrant, &p, &c) | certain_answers(&p, &bgp, Regime::OwlRl, &imports) |
| CLI | purrdf entails --regime owl-rl --premise P --conclusion C | … --conclusion C --verify | … --pattern BGP |
| Python | purrdf.entail.graph_entails("owl-rl", p, c, imports) | purrdf.entail.verify_entailment(...) | purrdf.entail.certain_answers("owl-rl", p, bgp, imports) |
| JavaScript / WebAssembly | entailGraphEntails("owl-rl", p, c, iris, docs) | entailVerifyEntailment(...) | entailCertainAnswers(...) |
| C | purrdf_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-directis directed by a query’s class expressions andrifentails 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:importsthe 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=FILEon 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:
| Regime | Rule table | Defined | Implemented |
|---|---|---|---|
Simple | — (identity closure) | 0 | 0 |
RDF | RDF 1.2 Semantics §8.1.1 | 3 | 3 |
RDFS | RDF 1.2 Semantics §8.1.1 + §9.2.1 | 18 | 18 |
OWL-RL | OWL 2 Profiles §4.3 Tables 4–9 | 78 | 78 |
D | OWL 2 Profiles §4.3 Table 8 | 5 | 5 |
OWL-Direct | — (SHOIQ(D) tableau, not a fixed table) | 0 | 0 |
RIF | — (caller-supplied rule set) | 0 | 0 |
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,rdfs14andrdfs14aeach 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 asConstruct::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-RLfires all 78 rules, and a run that met a boundary still reportsCompleteness::ExactWithinBoundariesrather thanExact. 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 testsentails()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. Read23 of 23as “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, whichextensions(Regime::OwlRl)names, neitherrules()norimplemented()names, and every report renders on its ownextensionline. 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 becomesEntailError::Inconsistentcarrying 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 fromrules(regime)minusimplemented(regime), so it improves by itself as rules are added, and it names themissingrules 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 hash —
purrdf-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
falsematched: 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
vocabmodule is a standardrdf:/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 themwasm32-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) orotest: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’sdl_probecolumn and described in that suite’sPROVENANCE.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 underRegime::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 0Three 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 23withrefuted 0. Read23 of 23as “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::LEDGERis EMPTY — 0schema-conclusion, 0negative-conclusion, 0construct-outside-rl, 0imports-unresolved, and 0 are actionable (0missing-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 anowl:complementOfclass. What the table does have is seventeen rules whose conclusion isfalse, 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. Anowl:AllDifferentcollection is, by OWL 2’s own definition, the conjunction of itsn(n−1)/2pairwise inequalities, so it lowers to the same shape and is entailed exactly when every pair refutes — which is why two entries left theschema-conclusionclass with them. - freeze-and-chase.
p rdf:type owl:TransitivePropertyabbreviates 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 throughprp-spo2, one of the 78. The axiom’s other conjunct —pis 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 anonymousowl: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:ReflexivePropertyis 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:rangedatatypes intersect, and the intersection may be contained in one the premise never mentions —xsd:byte ⊑ xsd:short, andshort ⊓ 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-011owl:importsa support ontology the upstream manifest does not inline, so it is vendored beside the cases from W3C’s own URL and supplied toentails()as caller-owned configuration. The library still fetches nothing.Nothing about the inventory moves:
rules(Regime::OwlRl)andimplemented(Regime::OwlRl)are still exactly the same 78,extensions(Regime::OwlRl)is still the oneext-eq-diff-sym, and strictMaterialization::OwlRloutput is byte-for-byte what it was. The evidence moves instead — each mechanism arrives with its ownEntailmentWarrantarm carrying what it actually used (thefalse-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 bentailsb owl:differentFrom a, which is sound —owl:differentFromdenotes inequality and inequality is symmetric — and shaped exactly likeprp-symp, yet is not among the 78 rules, because Table 4’sowl:differentFromrules only ever concludefalse. PurRDF states it asext-eq-diff-sym, in a rule family declared to sit outside every specification table:extensions(Regime::OwlRl)returns it,rules()andimplemented()are still exactly the same 78 and return none of it,RuleId::is_extensiondecides which is which, and every rendered report carries anextension ext-eq-diff-symline beside itsmissinglines. So the closure a caller gets is Tables 4–9 plus a list it can read and reject, andOWL-RL 78 / 78remains a claim about Tables 4–9 and nothing else. - refutation. A negative fact still has no head anywhere in Tables 4–9 —
no rule concludes
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 | --regime | Defined | Implemented |
|---|---|---|---|
| Simple | simple | 0 | 0 |
| RDF | rdf | 3 | 3 |
| RDFS | rdfs | 18 | 18 |
| OWL-RL | owl-rl | 78 | 78 |
| OWL-Direct | owl-direct | 0 | 0 |
| RIF | rif | 0 | 0 |
| D | d | 5 | 5 |
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 | --regime | Rule |
|---|---|---|
| OWL-RL | owl-rl | ext-eq-diff-sym |
RDF — 3 of 3 rules implemented
| Rule | Specification | Implemented |
|---|---|---|
rdfD1 | RDF 1.2 Semantics §8.1.1 (RDF patterns) | yes |
rdfD1a | RDF 1.2 Semantics §8.1.1 (RDF patterns) | yes |
rdfD2 | RDF 1.2 Semantics §8.1.1 (RDF patterns) | yes |
RDFS — 18 of 18 rules implemented
| Rule | Specification | Implemented |
|---|---|---|
rdfD1 | RDF 1.2 Semantics §8.1.1 (RDF patterns) | yes |
rdfD1a | RDF 1.2 Semantics §8.1.1 (RDF patterns) | yes |
rdfD2 | RDF 1.2 Semantics §8.1.1 (RDF patterns) | yes |
rdfs1 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs2 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs3 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs4 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs5 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs6 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs7 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs8 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs9 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs10 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs11 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs12 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs13 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs14 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs14a | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
OWL-RL — 78 of 78 rules implemented
| Rule | Specification | Implemented |
|---|---|---|
eq-ref | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-sym | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-trans | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-rep-s | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-rep-p | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-rep-o | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-diff1 | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-diff2 | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-diff3 | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
prp-ap | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-dom | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-rng | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-fp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-ifp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-irp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-symp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-asyp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-trp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-spo1 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-spo2 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-eqp1 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-eqp2 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-pdw | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-adp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-inv1 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-inv2 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-key | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-npa1 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-npa2 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
cls-thing | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-nothing1 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-nothing2 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-int1 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-int2 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-uni | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-com | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-svf1 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-svf2 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-avf | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-hv1 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-hv2 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-maxc1 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-maxc2 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-maxqc1 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-maxqc2 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-maxqc3 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-maxqc4 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-oo | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cax-sco | OWL 2 Profiles §4.3 Table 7 (Class Axioms) | yes |
cax-eqc1 | OWL 2 Profiles §4.3 Table 7 (Class Axioms) | yes |
cax-eqc2 | OWL 2 Profiles §4.3 Table 7 (Class Axioms) | yes |
cax-dw | OWL 2 Profiles §4.3 Table 7 (Class Axioms) | yes |
cax-adc | OWL 2 Profiles §4.3 Table 7 (Class Axioms) | yes |
dt-type1 | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-type2 | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-eq | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-diff | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-not-type | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
scm-cls | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-sco | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-eqc1 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-eqc2 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-op | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-dp | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-spo | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-eqp1 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-eqp2 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-dom1 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-dom2 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-rng1 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-rng2 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-hv | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-svf1 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-svf2 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-avf1 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-avf2 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-int | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-uni | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
D — 5 of 5 rules implemented
| Rule | Specification | Implemented |
|---|---|---|
dt-type1 | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-type2 | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-eq | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-diff | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-not-type | OWL 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_chunksreduced in source order, neverpar_sortorpar_bridge, and degrades to inline-sequential onwasm32-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:
| Ceiling | Bounds |
|---|---|
MAX_JOIN_STEPS | candidate solutions enumerated |
MAX_STORED_FACTS | facts seeded or derived |
MAX_TERM_ARENA_BYTES | interned 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:reifiesmapping, 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 certificate —
compact_streamablerewrites an accretive log into one delivery-ordered segment (leading stream index, trailing offset footer, in-band dictionaries), andcompact_and_certify/verify_compaction/composebind 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 proofs —
mmr::{root, prove, verify_proof, prove_file}(gts-mmr-proof-v1), andverify_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) withverify_file_with_keyringfor 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_gts → read_graph → import_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.
Related
docs/GTS-SPEC.md— the normative wire format (version 0.9-draft, wire-format major 1).- Slices, Mappings & Provenance — the RDF↔GTS loss ledger.
- Getting Started: Python — reading a container back as relational rows.
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:
- SSSOM — Simple Standard for Sharing Ontological
Mappings mapping-set TSV support
(
SssomMappingSet,SssomMapping, with typed diagnostics), for carrying cross-vocabulary mappings alongside your data.SssomSetCommentmodels 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.
Related
- GTS Graph Transport — the other side of the RDF↔GTS ledger.
- Design Rules & Invariants — the mints-no-IRIs rule in full.
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.compatagainst 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.
Related
- Getting Started: Python
- Conformance & Testing — how the ledger discipline works.
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 accumulatedDataset.datasetToStream(dataset)/streamToDataset(stream)— bridge between aDatasetand 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
SERVICEandLOADfail 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).
Related
- Getting Started: JavaScript / WebAssembly
- RDF 1.2 Features — what the extensions mean.
crates/rdf-wasm— the Rust cdylib behind the package.
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 inrust-toolchain.tomlfor its sharper clippy and rustdoc lints; release artifacts are built on stable. - Brand — the project is PurRDF in prose and
purrdfin 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
| Engine | Suite |
|---|---|
| IRI (RFC 3987) | W3C IRI + RFC 3986 §5.4 resolution vectors |
| Syntax codecs | W3C rdf-tests (Turtle/TriG/N-Triples/N-Quads/RDF-XML) |
| CSVW | W3C RDF-conversion and metadata-validation manifests plus a locked independent implementation |
| OBO Graphs | official OBO Graphs 0.3.2 JSON Schema plus corruption probes |
| Research-object carriers | five adversarial native fixtures, a 5×5 stable semantic-transcode matrix, and the vendored Frictionless Data Package v1 schema |
| RDFC-1.0 | W3C rdf-canon fixtures |
| SPARQL 1.1/1.2 | full 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 governors | a first-party frozen corpus of ceiling band cases and seam cases |
| SHACL | W3C data-shapes + DASH SHACL-AF/rules + a first-party frozen corpus |
| ShEx 2.1 | shexTest v2.1.0 (validation, schemas, negative syntax/structure) |
| Entailment | the 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) |
| GTS | frozen 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-in | rdflib 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:
- Exact totals — each harness asserts the number of discovered tests, so corpus drift fails loudly.
- 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.
- Trait skips (ShEx only) — whole spec features can be skipped by manifest trait tags, counted exactly. The list is currently empty.
- 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
| Layer | What it measures | How to run |
|---|---|---|
| Rust criterion suites | Native 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 harness | purrdf.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 aBREAKING 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
.cratepackage 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-unknownbefore 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.
| Code | Meaning | Remedy |
|---|---|---|
iri-empty | The string is empty where a non-empty IRI/URI was required. | Supply a non-empty IRI. |
iri-missing-scheme | The string has no scheme, so it cannot be an absolute IRI. | Write the IRI in absolute form, with a scheme. |
iri-bad-scheme | The scheme is malformed (it must start with a letter and contain only letters, digits, +, - and .). | Correct the scheme. |
iri-bad-percent-encoding | A % at the reported byte offset is not followed by two hexadecimal digits. | Percent-encode the % itself, or complete the escape. |
iri-disallowed-char | A character the IRI grammar does not admit occurs at the reported byte offset. | Percent-encode or remove the character. |
iri-bad-authority | The authority component (//host:port) is malformed. | Correct the host or port. |
iri-non-absolute-base | The 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-base | A 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-grammar | The 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)
| Code | Meaning | Remedy |
|---|---|---|
native-codec-parse | The 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-utf8 | The input bytes are not valid UTF-8. | Re-encode the document as UTF-8. |
native-codec-panic | The 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-read | Reading the RDF source through the streaming reader failed with an IO error. | Check the source stream or file. |
native-codec-write | Writing the serialized output failed with an IO error. | Check the destination. |
native-codec-serialize | The 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-replay | Replaying 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-format | The media type or format identifier names no codec. | Use one of the supported media types or format ids. |
native-codec-datatype-not-iri | A literal’s datatype term is not an IRI. | Give the literal an IRI datatype. |
native-codec-direction-without-language | A base direction was given on a literal that has no language tag. | Add a language tag, or drop the direction. |
native-codec-invalid-direction | A literal base direction other than ltr or rtl was given. | Use ltr or rtl. |
native-codec-iri-missing-value | An IRI term event carried an empty value. | Supply the IRI. |
native-codec-missing-reifier-binding | A triple term refers to a reifier that has no binding. | Bind the reifier before referring to it. |
native-codec-predicate-not-iri | A term in an IRI-only position (the predicate) is not an IRI. | Use an IRI predicate. |
native-codec-reifier-not-triple | A reifier binds something other than a triple term. | Bind the reifier to a triple term. |
native-codec-term-out-of-range | A term id in the event stream is outside the range the stream introduced. | The producing stream is inconsistent; regenerate it. |
native-codec-unbound-triple-term | A 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
| Code | Meaning | Remedy |
|---|---|---|
native-jsonld-decode | The 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-parse | The input is well-formed JSON-LD that does not map to RDF. | Correct the JSON-LD structure. |
jsonld-json-input | The strict JSON reader rejected the input, for example a duplicate object member. | Remove the duplicate or correct the JSON. |
jsonld-context-invalid | A context document, or the strict versioned options document, is invalid. | Correct the context or options document. |
jsonld-context-limit | A 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-invalid | The 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-limit | A derived-context work or byte ceiling was exceeded. | Reduce the dataset’s IRI vocabulary or raise the declared limit. |
jsonld-options-unused | JSON-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.
| Code | Meaning | Remedy |
|---|---|---|
cdt-literal-malformed | A composite literal’s lexical form does not parse. | Correct the literal. |
cdt-literal-scan-disagreement | The 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.
| Code | Meaning | Remedy |
|---|---|---|
rdf-ir-term-out-of-range | A quad references a TermId the builder never interned. | Intern the term before pushing the quad. |
rdf-ir-predicate-not-iri | A quad’s predicate is not an IRI. | Use an IRI predicate. |
rdf-ir-literal-subject | A literal occupies subject position. | RDF admits no literal subject; restructure the statement. |
rdf-ir-triple-subject | A triple term occupies subject position. | RDF 1.2 admits triple terms in object position only; use a reifier. |
rdf-ir-graph-name-invalid | A graph name is a literal or a triple term. | A graph name must be an IRI or a blank node. |
rdf-ir-reifier-not-triple | A reifier binding points at something other than a triple term. | Bind the reifier to a triple term. |
rdf-ir-triple-cycle | A triple term contains itself, directly or through nesting. | Remove the cycle. |
rdf-ir-triple-nesting-limit | Triple-term nesting exceeds the builder’s depth limit. | Flatten the nesting. |
rdf-ir-dangling-term-ref | A GTS role references a term id that no term event introduced. | The container is inconsistent; regenerate it. |
rdf-ir-gts-fold-diagnostic | The 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-value | An imported IRI term has an empty value. | Supply the IRI. |
rdf-ir-literal-datatype-not-iri | An imported literal’s datatype resolves to a non-IRI. | Give the literal an IRI datatype. |
rdf-ir-missing-reifier-binding | An imported triple term references a reifier with no recorded binding. | Bind the reifier in the container. |
rdf-ir-term-nesting-limit | Imported triple-term nesting exceeds the depth limit. | Flatten the nesting. |
rdf-ir-unbound-triple-term | An 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
| Code | Meaning | Remedy |
|---|---|---|
gts-term-out-of-range | A GTS term id is out of range for the graph. | The container is inconsistent; regenerate it. |
gts-iri-missing-value | A GTS IRI term has an empty value. | Supply the IRI. |
gts-predicate-not-iri | A GTS predicate term is not an IRI. | Use an IRI predicate. |
gts-literal-datatype-not-iri | A GTS literal datatype does not resolve to an IRI. | Give the literal an IRI datatype. |
gts-direction-without-language | A GTS literal carries a base direction but no language tag. | Add a language tag, or drop the direction. |
gts-invalid-direction | A GTS literal base direction is neither ltr nor rtl. | Use ltr or rtl. |
gts-missing-reifier-binding | A GTS triple term references a reifier the graph does not bind. | Bind the reifier in the container. |
gts-unbound-triple-term | A GTS triple term names neither its own components nor a reifier. | Give the triple term its components or a reifier. |
gts-self-reaching-term | A GTS term resolves through itself, so no walk of its components can terminate. | Remove the cycle. |
gts-term-nesting-limit | GTS term nesting exceeds the depth limit. | Flatten the nesting. |
gts-fold-diagnostic | The GTS fold reported one or more diagnostics. | Inspect the fold diagnostics listed in the detail. |
gts-verify-digest-inclusion | Content-addressed terms are not included in the verified chain. | The container’s chain does not cover its content; do not trust it. |
gts-verify-signature | COSE signature verification failed. | Check the signing key and the container’s integrity. |
gts-writer-codec | The GTS writer’s codec reported an error while writing. | The codec’s own error is in the message. |
rdf-graph-name-not-node | While 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-node | While 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-limit | RDF term nesting exceeded the depth limit while building a GTS graph. | Flatten the nesting. |
native-sparql-* — the SPARQL engine boundary (purrdf-sparql-eval)
| Code | Meaning | Remedy |
|---|---|---|
native-sparql-query-parse | The 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-parse | The update request does not parse. | Fix the update at the reported position. |
native-sparql-query-explain | Evaluation under --explain failed; the evaluator’s error is in the message. | Address the underlying evaluation error. |
native-sparql-property-function | The 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-function | The 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-function | A 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-variable | A 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-unconfigured | heldIn was called with no caller-supplied standpoint-predicate configuration. | Configure the standpoint predicates before using heldIn. |
native-sparql-graph-pattern-depth-exceeded | A manually constructed graph pattern nests deeper than the parser’s safety bound. | Flatten the pattern. |
native-sparql-bnode-mint-prefix | The blank-node mint prefix supplied in the options is invalid. | Supply a valid prefix. |
native-sparql-load-no-resolver | LOAD <iri> was requested but no GraphResolver host seam was provided. | Inject a resolver, or remove the LOAD. |
native-sparql-update-bad-destination | An 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-iri | A substitution value is not a valid IRI. | Supply a valid IRI. |
native-sparql-subst-triple-predicate | A substituted quoted triple has a predicate that is not an IRI. | Use an IRI predicate. |
reasoning-* — SPARQL under an entailment regime (purrdf)
| Code | Meaning | Remedy |
|---|---|---|
reasoning-closure-relation-witness | Property-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-rebuild | Rebuilding 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.
| Code | Meaning | Remedy |
|---|---|---|
statements-turtle-parse | The statement-metadata Turtle failed to parse. | Fix the Turtle. |
statements-non-iri | A term that must be an IRI in this context is not one. | Use an IRI. |
statements-reifies-non-triple | The object of rdf:reifies is not a triple term. | Reify a triple term. |
statements-malformed-axiom | An owl:Axiom lacks its source, property or target. | Complete the axiom. |
statements-conflicting-structural | One subject carries two different values for a structural field. | Keep one value. |
Other single-code families
| Code | Meaning | Remedy |
|---|---|---|
sssom-tsv-parse | An 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-scheme | A 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
- Rust —
RdfDiagnostic::codeon the returned error; itsDisplayform is<severity> <code>: <message>. - Python — the
ValueErrormessage is thatDisplayform (for exampleerror native-codec-parse: …); an IRI failure is rendered as<code>: <message>(for exampleiri-relative-no-base: …). - JavaScript — the thrown
Errormessage carries the same text. - C — the error string returned through the C ABI carries the same text.
- SARIF — the
ruleIdof each result is the code, with areportingDescriptorin the run’s rule table (purrdf-validate).