Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This book is also published in Simplified Chinese (简体中文).

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 OR MulanPSL-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. The numeric contracts are explicit: exact text scores, certified geographic results, and deterministic binary64 vector scores. See One engine instead of three databases below.

Why does PurRDF exist?

RDF tooling fragments along two axes.

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

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

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

What’s inside

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

One engine instead of three databases

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

You neededUsually fromNow inside PurRDFWhere it stops
Ranked full-text searchPostgreSQL tsvector/tsqueryFull-Text Search: purrdf-text, an inverted index over RDF 1.2 literals with BM25 ranking in exact i128 fixed point and no floating point in the crate.Exact BM25F, explicit stemming/dictionaries and independent Han retrieval; no stop-word lists or query dialect; an in-memory index over frozen datasets.
Spatial predicatesPostGISGeoSPARQL: purrdf-geo-kernel through purrdf-geo, with exact WKT/GeoJSON coordinates, DE-9IM relations, geographic metrics, buffers and explicit coordinate operations; no GEOS or PROJ.References, units, operation chains, numerical limits and tolerances are explicit. No raster; concave hulls and overlay set operations remain named refusals. See the chapter’s function coverage.
Vector similaritypgvectorEmbedding Nearest Neighbours: exact top-k over a PURREMB embedding space, binary64 in a pinned accumulation order.Exact scan bounded by a caller-supplied KnnGuard, three metrics, no approximate index; PurRDF computes no embeddings — the vectors come from a PURREMB artifact the caller fills, which PurRDF itself writes (EmbeddingBuilder, EmbeddingStreamWriter; Rust only) and opens fail-closed.

Text ranking uses exact fixed point; embedding search uses a pinned binary64 accumulation order and canonical tie-breaks. Geographic requests retain exact coordinate inputs and use explicit numerical certification. Native tests pin numeric and serialized expectations. make wasm builds their release crates for wasm32-unknown-unknown; focused portable tests exercise dispatch, kernels and hosts. Text indexes and embedding spaces use Rust-host registration seams under caller-supplied IRIs, including hosts compiled to wasm32. The packaged geographic host entries are documented in GeoSPARQL.

Two design rules worth knowing on day one

No semantic Cargo features. Every consumer gets the same byte-identical semantics. The sole feature declaration is the empty purrdf-capi:capi = [] compatibility marker required by cargo-c; it gates no code. CI refuses other features, optional dependencies and feature-gated behavior.

Application vocabularies are explicit. PurRDF is a toolkit, not an ontology. Structural Markdown and ordered JSON provide named standard profiles; callers select them deliberately or supply a vocabulary. Other application vocabularies are caller-supplied configuration with no fabricated default. A feature used without its vocabulary hard-errors or stays inactive. 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 1.2 (reifier shapes and triple-term node kinds), the RDF/JS surface, and the GTS transport. See RDF 1.2 Features.

Where PurRDF sits

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

How to read this book

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

Getting Started: Rust

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

cargo add purrdf

The MSRV is Rust 1.98 on the stable channel: by policy the code uses zero nightly (unstable) features, so stable 1.98 builds it. The repository’s own toolchain floats on nightly for its sharper lints; see Versioning & Releases.

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").unwrap();
let knows = b.intern_iri("http://xmlns.com/foaf/0.1/knows").unwrap();
let bob = b.intern_iri("https://example.org/bob").unwrap();
let name = b.intern_iri("http://xmlns.com/foaf/0.1/name").unwrap();
let hi = b.intern_literal(RdfLiteral::simple("Alice")).unwrap();
b.push_quad(alice, knows, bob, None).unwrap();
b.push_quad(alice, name, hi, None).unwrap();
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. Interning and insertion return typed storage failures; propagate those errors in application code rather than ignoring them. 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 IRI layer, which has no third-party dependency, 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-jsonschema, purrdf-shex, purrdf-gts, purrdf-datalog, purrdf-entail, purrdf-geo, purrdf-text, purrdf-validate, purrdf-slice, purrdf-iri, purrdf-lex, purrdf-xsd, purrdf-events, purrdf-hash, purrdf-wasm) exist for consumers that want exactly one engine — for example, a tool that only needs IRI parsing can depend on purrdf-iri alone, whose dependencies are the first-party lexical layer purrdf-lex and the zero-dependency purrdf-hash. The crate map is in the repository README.

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

Next steps

Getting Started: Python

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

pip install purrdf

Parsing

import purrdf

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

Validation: SHACL and ShEx

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

from purrdf import shapes, shex

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

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

SHACL result dicts keep the stable keys focus, path, value, severity, component, source_shape, and messages (every sh:resultMessage, with its language tag and direction). 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. The two [] arguments are the dataset’s owl:imports table — (ontology_iri, nquads) pairs — and the IRIs it was read from: a dataset that imports a document is closed over the merge, and an import the table does not resolve raises ValueError naming it. entail.materialize_nt(text, regime, program, imports, premise_iris) 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

Python API guide

Use the public purrdf package. Its Store, terms and query results call the same native engine as the Rust API. Import validation and entailment through purrdf.shapes, purrdf.shex and purrdf.entail.

The complete signature reference records the canonical stubs for both the main package and purrdf.geo, including properties, overloads, namespace aliases and supporting types. Read the sections below for ownership, configuration, result interpretation and failure behavior. Underscore types in the signatures describe implementation or return types; their presence does not make them public constructors. For example, use purrdf.shapes.PreparedShapes in annotations, rather than importing _PreparedShapes.

TypedDict declarations describe dictionary result schemas; do not assume that each schema name is an exported native constructor. Obtain those records from the documented producer and read their named fields.

Load and query

Store.load(input, format, *, path, base) accepts text or bytes, or a file selected by path. Supply exactly one input source and a format. Its return value is the parsed document’s resolved prefix map, sorted by prefix; it is empty for formats without prefix declarations. Relative references require the document’s own base declaration or an explicit absolute base.

import purrdf

store = purrdf.Store()
prefixes = store.load(
    '@prefix ex: <https://example.org/> . ex:alice ex:name "Alice" .',
    purrdf.RdfFormat.TURTLE,
)
assert prefixes == [("ex", "https://example.org/")]
rows = list(store.query(
    "SELECT ?name WHERE { <https://example.org/alice> "
    "<https://example.org/name> ?name }"
))
assert rows[0]["name"].value == "Alice"

SELECT returns QuerySolutions. A row supports variable names, Variable objects and integer positions, including negative indexing. An unbound projected cell is None; it is not omitted. Missing variable names raise KeyError, and out-of-range positions raise IndexError. ASK returns QueryBoolean. Default-graph CONSTRUCT/DESCRIBE results iterate over Triple; a result carrying named graphs iterates over Quad.

RDF terms and exact values

NamedNode.value is its IRI. Literal.value preserves its lexical form; datatype, language and direction are separate properties. Language-tag identity folds case. Construct typed literals with datatype=NamedNode(iri), language literals with language=, and directional language literals with both language= and direction=.

name = purrdf.NamedNode("https://example.org/alice")
text = purrdf.Literal("你好", language="zh-Hans")
assert name.value == "https://example.org/alice"
assert text.value == "你好"
assert text.language == "zh-Hans"

The default preserves every digit when a quotient terminates. Otherwise it rounds to 18 fractional digits, half to even. division="exact" leaves a non-terminating quotient unbound as an expression error; division="N" truncates to N fractional digits, and division="N:ROUNDING" selects explicit rounding. exact-or-N[:ROUNDING] keeps exact terminating quotients and rounds only repeating ones. An invalid policy raises ValueError before execution.

result = store.query("SELECT ((1 / 1048576) AS ?value) {}")
assert list(result)[0]["value"].value == "0.00000095367431640625"
result = store.query("SELECT ((2 / 3) AS ?value) {}", division="2:half-even")
assert list(result)[0]["value"].value == "0.67"
xsd = "http://www.w3.org/2001/XMLSchema#"
assert purrdf.xsd_canonical_lexical("+00042", xsd + "integer") == "42"
assert purrdf.xsd_value_compare("1.00", xsd + "decimal", "1", xsd + "integer") == 0

result = store.query(
    'PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> '
    'SELECT (xsd:dateTimeStamp("2024-02-29T12:30:00.123456789012345678901Z"'
    '^^xsd:dateTime) AS ?value) {}'
)
calendar = list(result)[0]["value"]
assert calendar.value == "2024-02-29T12:30:00.123456789012345678901Z"
assert calendar.datatype.value == xsd + "dateTimeStamp"

Use lexical forms for exact decimal and temporal values rather than converting through Python float or a representation with fewer fractional digits. The XSD helpers return None for unsupported datatypes or malformed lexical forms; comparison also returns None when the value space does not order the pair, such as NaN.

Prepared queries

Declare parameter names with Store.prepare(..., parameters=[...]). Every run must bind every declared name to an RDF term. An unknown name or a missing binding raises an error. Configuration is fixed at preparation; source rows are read from the store for each run. Parameter names are supplied without ?.

prepared = store.prepare(
    "SELECT ?name WHERE { ?person <https://example.org/name> ?name }",
    parameters=["person"],
)
assert prepared.parameters == ["person"]
rows = list(prepared.run(person=purrdf.NamedNode("https://example.org/alice")))
assert rows[0]["name"].value == "Alice"

Governors and incomplete answers

query_governed adds fuel, deadline_ms, max_answers, max_intermediate_cells, max_scratch_bytes, max_remote_requests, no_ceiling and cancel to the query options. Ceilings are inclusive. Omitted ceilings retain metering. no_ceiling=True declines accounting and resource caps, retains deadline/cancellation, and conflicts with explicitly supplied caps.

stopped = store.query_governed("SELECT ?s ?p ?o WHERE { ?s ?p ?o }", fuel=0)
assert not stopped.is_complete
assert stopped.result is None
assert stopped.tripped is not None
complete = store.query_governed(
    "SELECT ?name WHERE { <https://example.org/alice> <https://example.org/name> ?name }")
assert complete.is_complete
assert list(complete.result)[0]["name"].value == "Alice"

A complete result is available only when is_complete is true. On interruption inspect tripped, partial and evidence; never treat partial answers as the whole result. update_governed returns its own outcome, and an interrupted update applies nothing.

Named graphs and engine configuration

The default Store remembers declared empty named graphs. remember_empty_graphs=False selects the explicit row-driven alternative. These modes change graph existence, not the interpretation of populated RDF statements.

remembered = purrdf.Store()
remembered.update("CREATE GRAPH <https://example.org/empty>")
assert remembered.named_graphs() == [purrdf.NamedNode("https://example.org/empty")]
implicit = purrdf.Store(remember_empty_graphs=False)
implicit.update("CREATE GRAPH <https://example.org/empty>")
assert implicit.named_graphs() == []

Query, update and prepared entry points accept explicit extension namespaces, property-function namespaces, standpoint predicates, relation tables, aggregate namespace, dated XPath regex profile, geo configuration and division policy. Namespace-dependent extensions need caller-supplied configuration. Relation declarations cross the boundary as data, not Python callbacks.

Relation tables, graph tables and traversals

relations maps an exact predicate IRI to (input_arity, output_arity, rows). Rows contain native RDF terms and must have the flattened width the declaration names. relations_from_graph supplies a list head and the same arities, reading the table from the query’s dataset. path_relations supplies the permitted predicate/direction steps, minimum and maximum hops, path and expansion guards, and either walk or shortest. These are data declarations, not Python callbacks; evaluation can therefore release the GIL.

A traversal emits one row per hop, binding the endpoint, path identity, length, step, node and traversed statement. The statement is an RDF 1.2 triple term that can join back to ordinary graph patterns. Every traversal limit is explicit; zero minimum hops, invalid or duplicated steps and unusable guards raise ValueError. A valid alternative with no matching edge contributes no row.

The optional attestation is (generation, incompleteness), each a string or None. It records the index version and the host’s own reason for incomplete data, independently. None means no declaration, not certification of a whole index. A governed query returns these declarations in relation_witness; an ungoverned call cannot report an attested shortfall and refuses it. Duplicate registration across the three relation maps, ragged tables and torn RDF lists are errors. An exact registered IRI needs no namespace setting; declaring property_fn_namespaces makes an unregistered IRI under those prefixes a hard error instead of an ordinary graph pattern.

aggregate_namespace registers the ten built-in statistical aggregates: MEDIAN, PERCENTILE, STDDEV, STDDEV_POP, VARIANCE, VAR_POP, MODE, FIRST, LAST and TOPK. It does not register arbitrary Python accumulator callbacks. xpath_regex selects one of XPATH_REGEX_PROFILES; omission retains the compatibility law. Pattern/flag rejection is an expression error, while a native xpath-* resource refusal raises ValueError. standpoint_predicates supplies the caller’s two predicate IRIs; the library invents none.

Reading execution evidence

CancellationToken.cancel() is monotone; inspect cancelled and create a new token for another independent cancellation lifetime. A governor trip returns an outcome, while KeyboardInterrupt remains an exception. TrippedGovernor exposes kind, label, dimension, limit, consumed, estimate and cause; an admission estimate is not measured consumption.

GovernorEvidence.consumed and limits are per-dimension mappings, also readable through consumed_in() and limit_for(). expression_errors counts absorbed numeric expression errors by qualified XPath code, with zero entries retained. For example, a projected division error can produce an unbound cell while the query itself completes; COALESCE may recover it.

PartialAnswers.certainty distinguishes certain (every returned row is an answer), at-most (every answer is among the returned candidates) and unknown (no rows may be used). is_positional_prefix is a separate property; a sound submultiset need not be the first rows of the complete result. barrier names the operator that blocked certification. Read result only through its certificate. UpdateOutcome.is_applied=False means no mutation was published.

The always-present QueryOutcome.relation_witness maps invoked relation IRIs to their invocation count, distinct generations and declared incompleteness reasons. An empty mapping does not certify completeness. Invocation counts describe the execution schedule and must not be compared as stable index identities across thread counts or budgets. See the governor profile for accounting, precedence and interval guarantees.

Containers, mutation and snapshots

TypeUse and ownership
StoreMutable quad store with query, update, load, prepare and checkpoint operations. Its add and remove return None.
MutableDatasetThe same shared query/update interface, with boolean add/remove, contains, quads_for_pattern and explicit compact.
DatasetA mutable quad collection with iteration, length and in-place blank-node canonicalization. It is not the frozen RdfDataset.
RdfDatasetAn immutable parsed RDF 1.2 dataset with counts and canonical/text/GTS/columnar export.
QuadIterThe quad iterator returned by the mutable containers; consume it through the normal iterator protocol.

load and bulk_load return the document’s final resolved prefix map, not the number of statements. add_graph implements the constructor’s graph-existence mode; silent=True suppresses an existing remembered slot, not an invalid graph name. named_graphs excludes the default graph and includes declared empty slots when the store remembers them.

After bulk loading, Store.checkpoint() folds the current delta into the frozen base without changing contents. change_size() then reports additions and removals since that checkpoint. Checkpoint once after loading before an incremental validation sequence; otherwise the initial load is itself the pending change. Compaction is explicit work, not something to perform after every individual mutation.

A prepared query retains its originating store and reads that store’s current contents on each run. Graph-derived relation tables and traversals are rebuilt for that run’s dataset and the plan is admitted under them again. Fixed configuration does not mean a frozen copy of the data. A PreparedQuery is not thread-safe and cannot be re-entered while a run is in flight.

Queries materialize owned results before returning. QuerySolutions, QueryTriples and QueryQuads are iterators; copy their rows when a later pass needs them again. QuerySolutions.variables supplies the ordered schema. QueryQuads.graph_names returns sorted graph tokens in N-Triples term syntax. Serializing a graph-carrying query result to a single-graph syntax raises ValueError; it does not silently erase graph names. Quad-template CONSTRUCT is a first-party extension, not defined by SPARQL 1.2.

Terms, formats and serialization

NamedNode and BlankNode expose value; Variable.value is its variable name. Triple exposes subject, predicate and object; a Quad adds graph_name. RDF 1.2 permits triple terms in object position, not subject position. A default graph is represented by DefaultGraph; the quad constructor also accepts None for that graph. Terms support their documented equality and hashing; language-tag equality folds case, while Literal.value preserves its lexical form. A plain literal’s datatype getter is xsd:string; language and directed language literals expose rdf:langString and rdf:dirLangString respectively.

The RdfFormat members are TURTLE, N_TRIPLES, N_QUADS, TRIG, TRIX, HEXTUPLES, JSON_LD, YAML_LD and RDF_XML. A document’s own base declaration wins over a supplied base. A relative reference with neither raises ValueError carrying iri-relative-no-base; no retrieval IRI is fabricated. On output, formats without a base declaration emit absolute IRIs.

parse returns a list of quads. serialize and the store dump overloads either write bytes to a binary output or return bytes when no output is passed. from_graph selects the dump’s graph; base selects the output base and composes with that selection. CompiledJsonLdContext accepts strict options JSON, can be built with from_prefixes, and exposes canonical_context_json for retention. Supply either the supported options or compiled context as declared; do not assume ordinary Python dictionaries are interchangeable with JSON text.

dump_with_loss returns SerializeLoss: bytes plus statement_rows_dropped, directional_literals_dropped, named_graph_rows_dropped and empty_named_graphs_dropped. The first three partition lost rows or decorations by cause; the fourth counts declaration-only graphs, which have no row to count. Read all counters. They are realized loss for this output, unlike the static format-pair matrix returned by loss_matrix_json() or rdf_gts_loss_matrix_json().

to_json_ld, serialize_jsonld, from_json_ld, to_rdf_xml and from_rdf_xml bridge RDF bytes and their text forms. statement_vocab is an explicit caller-supplied metadata vocabulary; its class, subject, predicate, object and objectLiteral values are absolute IRIs. There is no invented namespace fallback. project_statements_rdf12 and normalize_rdf12_to_owl provide the statement-codec conversions; canonicalize_turtle returns canonical bytes with optional extra prefix mappings.

Dataset.canonicalize relabels blank nodes in place under CanonicalizationAlgorithm.RDFC_1_0 or UNSTABLE. RdfDataset.to_nquads() uses the shipped flat canonicalization profile: it agrees with RDFC-1.0 on the RDF 1.1 subset, while the RDF 1.2 profile has its own reserved-vocabulary, statement-layer and refusal contract. Do not label an RDF 1.2 digest as RDFC-1.0. Canonicalization refusal raises ValueError. Read the canonicalization chapter before using bytes as identities.

The XSD helpers work on lexical strings and datatype IRIs: xsd_value_compare returns an order or None when unsupported, malformed or unordered; xsd_canonical_lexical, xsd_decode_binary and xsd_normalize_whitespace return their value or None. Avoid converting an exact decimal or fractional second through Python float first.

SPARQL Results and provenance

serialize_sparql_solutions takes the ordered variable list and rows with one cell per variable, retaining None for unbound cells. serialize_sparql_boolean handles ASK. Both emit deterministic bytes in JSON, XML, CSV or TSV, accepting the aliases and media types in SparqlResultsFormat. parse_sparql_results reads JSON or XML and returns a discriminated tuple: ("SELECT", variables, rows) or ("ASK", boolean). CSV and TSV have no native reader here.

Provenance is additive and requires provenance_namespace=(prefix, iri); query_hash supplies the caller’s identity. provenance_from_json and provenance_from_xml read that namespace back as query_hash and engine. Absent provenance yields None fields, not an invented identity.

SHACL validation, preparation and rules

Use purrdf.shapes; purrdf.shacl is the compatibility alias. The one-call shapes.validate takes Turtle shapes and N-Triples data and returns a report dictionary. shapes.Shapes parses reusable shapes. validate_nt returns a ValidationReport; validate_store reads a native snapshot from Store or MutableDataset without a text round trip. A later mutation changes the next report, not a report already returned.

ValidationReport exposes conforms, shapes_graph_well_formed, diagnostics and results, plus to_ntriples() and to_sarif(). Result messages retain their text, language, direction and datatype. Empty sh:in or sh:xone lists produce mandatory diagnostics beside results; they do not change conformance or the report graph. The one-call report records conformance_disallows: omission uses SHACL’s Violation/Warning/Info default set, which excludes Debug and Trace. An empty disallow set or non-IRI severity raises.

shapes_ttl = '''
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <https://example.org/> .
ex:PersonShape a sh:NodeShape;
    sh:targetNode ex:alice;
    sh:property [ sh:path ex:name; sh:minCount 1 ] .
'''
shapes = purrdf.shapes.Shapes(shapes_ttl)
assert shapes.validate_store(store).conforms
prepared_shapes = shapes.prepare()
product = purrdf.shapes.ShapesProduct.open(prepared_shapes.to_product())
restored = product.admit_expecting(product.identity_digest())
assert restored.validate_nt(
    '<https://example.org/alice> <https://example.org/name> "Alice" .'
).conforms

Imports and execution environment

Every shapes entry point resolves imports from the supplied ordered imports table of (IRI, Turtle document) pairs. It fetches nothing. Unresolved or invalid imports, unused entries and incompatible versions raise shapes.ShapesImportError; branch on kind and inspect iris. Data-graph sh:shapesGraph links must resolve from material in hand. Previously prepared shapes cannot silently acquire an additional linked graph when data arrives; an unheld link is refused.

shapes_base (or Shapes(..., base=...)) resolves relative shapes IRIs. shapes_graph names the graph read by SHACL-SPARQL through $shapesGraph; omission invents no graph. This is the SHACL 1.0 pre-binding removed from SHACL 1.2. subclass_of_in_shapes_graph=True also reads shapes-graph subclass triples for class membership; rdf:type still comes from the data graph. xpath_regex applies the selected dated law to facets and embedded SPARQL. Resource refusal raises rather than returning an empty or conforming report.

Shapes.extension_usage inspects declarations before validation: sites distinguishes calls from data predicates under the supplied namespaces and registrations; unreadable and complete expose admission failure. This distinguishes an unrecognized relation from a valid relation over no rows. shapes.lint_shapes performs a cold audit and reports findings, load errors, mandatory diagnostics, superseded validators, unexecuted query declarations, import anchoring and deterministic report text. Neither is data validation.

Prepared products and incremental reports

Shapes.prepare() returns PreparedShapes; to_product() writes deterministic bytes. shapes.pack_product() combines parsing, preparation and writing. ShapesProduct.open() verifies the envelope and reads its self-description, without admitting executable preparation. Inspect format_version, stage_id, stage_known, identity_digest, identity_components and explain; to_bytes returns the envelope. admit restores a known stage; rebuild reconstructs it when necessary. Their _expecting forms bind the expected identity and reject mismatches with dimension shapes-graph. certify checks the product’s certification obligations. Branch on ShapesProductError.dimension, not exception text. PreparedShapes.provenance() distinguishes parsed, restored-admitted and restored-rebuilt preparations.

PreparedShapes.validate_store_changes(store) returns ChangeValidation. Read bounded before report.conforms: when true, the report covers affected focus nodes only, with no claim about unreachable old violations. When false, it covers a full validation of the mutated graph. focus_nodes is a count on the bounded arm and None otherwise; reason identifies the construct requiring a full run. The pending change comes from the caller’s last checkpoint; validation does not redefine that checkpoint.

Rules and node expressions

shapes.entail applies a shapes graph’s default rule set and returns base plus inferred triples. It differs from purrdf.entail.materialize, which uses a regime’s rule table without a shapes graph. shapes.apply_rules runs an SRL document and returns only the inference graph, with proof text when explain=True. check_rules admits a rule set at the selected checking level: level accepts syntax, well-formed or stratified; omission selects stratified. It reports declarations, imports, versions and strata. Stored-fact, join-step, generated-term and generating-round controls are explicit keyword arguments; exhaustion raises a limit-specific ValueError.

eval_node_expr selects exactly one expression: a named IRI/blank label, a unique walk from expr_at through expr_via, or a one-root inline expr_turtle document. focus and scope supply terms; the reserved focusNode cannot be rebound. Ambiguous selectors, missing/multivalued walk steps and evaluation failures raise. Outputs are ordered N-Triples term strings beside mandatory diagnostics. See SHACL and prepared products for the full contracts.

ShEx

purrdf.shex.parse converts ShExC or ShExJ into canonical ShExJ text. shex.validate takes a schema, data and a fixed shape map. Each (node, shape) pair is echoed in ShexResultEntry, with conformant and optional reason. Nodes may be bare/bracketed IRIs, _: labels or Turtle literal tokens; START selects the start shape. Schema formats are shexc and shexj; data formats are turtle, ntriples and nquads. base resolves relative IRIs in both inputs. A pattern rejected by the selected XPath law makes its entry nonconformant; a native resource refusal raises for the whole map. See ShEx.

Entailment, decisions and proofs

purrdf.entail.Regime names SIMPLE, RDF, RDFS, OWL_RL, OWL_DIRECT, RIF and D; functions also accept the lowercase CLI spellings. Unknown spellings raise. materialize returns (RdfDataset, report) and materialize_nt returns (canonical_text, report); the report is mandatory. program is the RIF document and must be empty for other regimes. Required imports and premise_iris describe the premise’s import closure; [] means none. Non-absolute premise IRIs, unresolved imports and unreachable table entries raise rather than being fetched or ignored. rules, implemented_rules and extensions expose the specification table, implemented subset and named non-normative additions.

Chase reports describe rule coverage; OWL Direct-Semantics services return decision certificates. An incomplete rule table differs from a decision that exhausted its work cap. Unpack and retain the certificate with the answer.

ServiceQuestion
consistencyWhether the knowledge base has a model; false and unknown are distinct.
classifyNamed-class equivalence, transitive subsumption, direct edges and unsatisfiable classes.
realizeEntailed individual types and most-specific direct types.
instancesNamed instances of one class, supplied as an N-Triples term.
entailsWhether one OWL RDF-mapping axiom is entailed; different from graph_entails.
profileSyntactic OWL profile certification and blockers, without decision search.
extract_moduleA locality module for a seed signature under bot, top or star.
justifyA sufficient minimal axiom subset, with sufficiency/minimality re-decided.
explain_conclusionA checkable chase derivation, not a tableau justification.

step_cap and work_cap only narrow the decision engine’s derived caps; zero selects its own cap, not zero work. Certificates report rounds, work, decision count, peak nodes, case splits and depth. Budget exhaustion is unknown, not a negative theorem. Except for consistency, decision services refuse an ontology without a model instead of presenting its vacuous entailments as ordinary answers. An unmentioned class can have an empty instance answer without an error.

entail.Reasoner retains parsed data, lazily builds a knowledge base when needed and reuses both across corresponding service methods. Caps are fixed at construction. proofs=True opts into traces; records_proofs reports that choice. prove returns answer, certificate and proof; proof_services lists supported service names. A non-recording session refuses proof requests. check_proof checks against the caller’s ontology, question, answer and certificate; an unrecorded proof is never verified. Empty answer/certificate requests a weaker check whose report says so. A justification and an execution proof answer different questions.

certain_answers asks for substitutions true in every model, not just matches in one closure. Predicate variables and variables inside triple terms bind; blank nodes are non-distinguished. Read limit lines for non-exhaustive boundaries. graph_entails asks about a conclusion graph, with distinct entailed, not-entailed and undecided verdicts. verify_entailment rechecks a present warrant against the premise as written. These calls require imports and premise IRIs, and refuse regimes whose inputs their signatures cannot express. A variable in a literal’s datatype position is refused.

Store.query_entailment_governed has two phases. Stopped closure construction produces EntailmentQueryOutcome.phase == "closure-stopped", without a query outcome or closure report. Otherwise phase is answered, with an ordinary governed query outcome and report. Graph-backed relations read the closure. An OWL Direct closure exposing generated existential witnesses through a traversal is refused with reasoning-closure-relation-witness. See entailment for the full service and certificate grammars.

Geographic sessions

purrdf.geo.GeoProfile owns an immutable strict geographic profile; to_json returns its canonical version-one record with explicit limits. GeoSession accepts that profile, another session, profile JSON or None; omission selects the standard CRS84 profile. profile() returns the selected profile. Query, update and prepared methods accept these same choices through geo.

GeoSession.call evaluates a strict scalar/batch JSON request; point_index constructs immutable native point buckets and returns GeoPointIndex, whose call refines searches through the same comparison law. Semantic outcomes remain canonical JSON; operational refusal raises ValueError. These paths use the shared Rust codec, with no Python geometry implementation. See GeoSPARQL.

GTS containers and immutable datasets

The GTS functions are available at the package root and grouped under purrdf.gts; the namespace aliases refer to the same operations and types. gts_from_quads and gts_from_rdf12_bytes ingest RDF bytes into a snapshot under the requested profile, transform and base. snapshot_content_id_native reads the corresponding snapshot identity.

compile_gts_native accepts the base document, optional RDF 1.2 layer, named graphs and their scopes, document/report blobs and slice artifacts. Signing inputs and the rsyncable threshold are explicit compiler arguments. Blob rows carry bytes, media type and representation; slice artifact rows additionally name the slice IRI, name, role and logical path. Keep these tuple positions as declared instead of guessing from the final element’s type.

compile_gts_with_report returns GtsCompileResult, pairing snapshot_bytes with the ingestion receipt from that same parse and intern operation. gts_ingest_report is the receipt-only accessor. GtsIngestReport records rows_consumed, terms_interned, declarations_omitted and scratch_bytes. Declaration-only named graphs have no row in the frozen distribution payload; their IRIs are reported as omitted instead of being silently interned and changing the snapshot identity. Do not treat an empty quad table as evidence that no declaration was lost. feedback_bundle_native attaches report blobs to the supplied RDF input.

RdfDataset owns a frozen parsed dataset. Its quad_count, term_count and length are inspection methods. to_nquads provides the canonical flat text described above; serialize_jsonld supports the declared JSON-LD/YAML-LD options and compiled context. to_gts, columnar_rows and to_parquet_files export the same native carrier. A dictionary’s numeric term identifiers are local to that owner; resolve a term when crossing independent datasets.

frozen = purrdf.RdfDataset(
    '<https://example.org/alice> <https://example.org/name> "Alice" .',
    purrdf.RdfFormat.N_TRIPLES,
)
container = frozen.to_gts()
view = purrdf.GtsFoldViewNative.from_bytes(container)
assert view.quad_count() == 1
alice_id = view.tid_of_iri("https://example.org/alice")
assert alice_id is not None
assert view.public_text(alice_id, "https://example.org/name") == "Alice"

Fold views and language selection

GtsFoldViewNative.from_bytes reads a container; from_parts admits caller-supplied term, quad, reifier and annotation tables. Both refuse a term that reaches itself with gts-self-reaching-term, rather than exposing a renderer that recurses forever. curie_prefixes supplies the host’s CURIE mappings; only the documented W3C namespaces are built in.

The four count methods report table sizes. term_tuple, is_iri, is_bnode, is_literal, iri, lex, lang, datatype, nq_token and python_value inspect a local term; tid_of_iri performs reverse lookup and curie compacts an IRI. quads, subjects_by_type, objects, value, predicate_objects, has and rdf_list query the selected scope. reifiers and annotations return their statement-layer tables. Do not compare their integer IDs with those of another independently constructed view.

Omitted scope or None selects the default graph. purrdf.GTS_ALL_SCOPE selects all graphs; a named-graph IRI selects that graph, and an unknown IRI has no matching rows. The all-graph sentinel is not a named-graph IRI.

tag_map and available_languages expose the view’s language choices. public_text and public_literal select the public value; the _with_fallback forms also take requested languages and report whether fallback was used. public_texts returns the requested alternatives with that metadata. Retain the language and fallback fields when the presentation depends on whether the requested translation actually existed.

Relational and Parquet exports

relational_rows and gts_relational_rows_from_bytes return GtsRelationalRows: terms, quads, reifiers, annotations and blobs, with the directions array positionally parallel to terms. Direction is not an extra field appended to the positional term tuple. ColumnarRows instead carries the native v1 schema and projected five-table rows, whose dense value-order identifiers need not equal the original GTS IDs.

gts_columnar_rows_from_bytes returns that projection; gts_columnar_parquet_from_bytes and RdfDataset.to_parquet_files return filename-to-bytes mappings. gts_to_sqlite, gts_to_duckdb and gts_to_parquet write the five-table projection to the requested destination. SQLite uses the standard library, DuckDB needs its optional extra, and Parquet writing uses the native codec; PyArrow is an interoperability reader, not the writer. See GTS and the Columnar profile.

Graph and tabular projection packages

project takes RDF text/bytes, its RdfFormat, an explicit ProjectionProfile and strict configuration JSON. It returns ProjectionPackage with the selected profile, deterministic USTAR archive and structured losses. Each ProjectionLoss has code, source, target, note and optional location; inspect the records rather than assuming every profile round-trips. The optional assets input belongs to the package configuration.

The profiles are lpg-csv, neo4j-csv, open-cypher, graphml, csvw-exact, csvw-terms, okf-terms, obo-graphs, skos, croissant-1.1, ro-crate-1.3, datacite-4.6, dcat-3, dcat-rdf, void and frictionless-data-package-1. lift supports the LiftProfile subset and returns ProjectionLift.dataset plus losses; a one-way profile is not made bidirectional by passing its name to lift.

For streamed LPG artifacts, project_artifacts invokes the artifact callback with (event, path, chunk). The ArtifactEvent values delimit the lifecycle: begin-package, begin-artifact, chunk, finish-artifact, commit-package and abort-package. Publish retained artifacts only after commit, and handle abort. The optional progress callback receives immutable ProjectionProgress snapshots with phase and node/edge/artifact/byte counts. The returned ProjectionStream reports its profile and losses. These are explicit artifact callbacks, unlike the data-only SPARQL relation declarations. See projections for each profile’s strict configuration and round-trip contract.

SSSOM mapping documents

validate_sssom returns SssomDiagnostic dictionaries containing severity, code, message, check and optional instance. A clean document yields []; a parse failure is a FATAL diagnostic with check parse, not a successful empty validation. sssom_to_rdf produces RDF from TSV; sssom_roundtrip_tsv writes the parsed mapping document, and sssom_default_validation_types exposes the native default validation types. These are the native SSSOM codec, not an import of the external sssom package.

Slice catalogs and ownership

purrdf.slice.SliceCatalog.discover(root, namespace) reads manifests using the caller’s namespace. records contains SliceRecord values with manifest, manifest path, slice directory and artifact inventory; core_slice_iris reports the core members. ManifestView exposes identifier, slice IRI, label, title, tier, creators and consumers. ArtifactRecord exposes role, logical path, media type, raw digest, semantic digest and content.

fix_deps() returns ManifestPatch values with original and proposed text; the caller decides when to write those patches. OwnershipAnalyzer performs the analysis eagerly at construction; analyze() returns the retained OwnershipReport, and analysis_graph_turtle() serializes its graph. The report exposes ownership errors, a defect flag and DependencyEdge values with source, destination, semantic/physical status and reconciliation. This is manifest/artifact ownership, not an inference from directory names. See slices.

Ranked retrieval and exact fusion

purrdf.retrieval exposes plan, compile and search, plus cold document checks and fusion-law design queries. plan executes nothing. It records a canonical request, per-stratum depths, consulted statistics, producer decisions and unserved terms. compile admits that plan and emits its per-stratum SPARQL. search runs the full plan/compile/execute/fuse ladder. There are no separate Python stream-handle entry points for execute and fuse.

Text producers map a producer IRI to (stratum, predicate, graph, analyzer). The analyzer requires its lexicon choice and declares the documented lexicon artifacts or entries, scalar guards, accent/stemming/input/projection modes and substring budgets as needed. A graph selector is any, default or a named graph IRI. Optional trailing positions are domains, index attestation and search fidelity, in that order; an ambiguous or unsupported tuple width raises. The index attestation is (generation, incompleteness), while fidelity is (completeness, order). These pairs describe different facts.

knn_producers and hnsw_producers register exact scan and approximate graph search over caller-supplied (IRI, vector) rows, seeded by an entity request. Vectors have the same finite width and unique valid IRIs; cosine refuses a zero vector. Both candidate and neighbor guards are explicit and positive. The producer metrics are cosine, negative_dot and squared_euclidean. HNSW additionally declares (m, m0, ef_construction, ef_search) and validates m >= 2, m0 >= m, ef_construction >= m0 and ef_search >= 1. A producer IRI or stratum cannot be registered twice across the three maps. The caller also supplies the statistics source and revision; cardinalities and integer parts-per-million selectivities are optional. Unknown measurements are None, not zero.

The request is a sequence of typed tuples; producer applicability is reported by planning, so writing a request kind does not invent a producer for it.

KindTuple
Lexical("lexical", text, language_or_none, predicate_or_none)
Vector("vector", components, metric, hint_or_none), with request metric cosine, dot or euclidean
Spatial("spatial", geometry, predicate, max_distance_raw_or_none)
Temporal("temporal", predicate, lower_or_none, upper_or_none)
Numeric("numeric", predicate, lower_raw_or_none, upper_raw_or_none)
Entity("entity", term), including an N-Triples IRI term for a vector-space seed

Candidate domains are either unrestricted (None) or one distinct host domain tag. Empty or multiple distinct tags are refused; repeated copies of one tag are one domain. A domain declaration licenses exclusion, so a producer naming a candidate in disjoint domains is an error, not a declaration silently widened after scores have been certified. Repeated candidates from a unique stream are also refused.

retrieval = purrdf.retrieval
notes = '''
<https://example.org/a> <https://example.org/note> "the quick brown fox" .
<https://example.org/b> <https://example.org/note> "a quick red fox" .
'''
ranking = retrieval.search(
    notes,
    [("lexical", "quick fox", None, "https://example.org/note")],
    text_producers={
        "https://example.org/pf/search": (
            "https://example.org/stratum/lexical",
            "https://example.org/note", "any", {"lexicon": "empty"},
        )
    },
    weights={"https://example.org/stratum/lexical": retrieval.SCALE},
    statistics={"source": "host-statistics", "revision": "r1"},
    k=60, decay="reciprocal_rank", top_k=10,
)
assert len(ranking["rows"]) == 2
assert isinstance(ranking["rows"][0]["score"], str)

Law, depths and document checks

Weights are Python integers in raw fixed-point units: SCALE is one whole unit and SCALE_DIGITS its exponent. Scores and contributions return exact decimal strings, never floating-point approximations. k, decay and top_k are explicit. reciprocal_rank truncates the reciprocal before applying weight; weighted_reciprocal_rank rounds the weighted quotient once. They are different content-addressed laws. Mixing a raw weight of 1 with weights expressed as multiples of SCALE changes the weighting by that factor.

top_k is also a planning input. Over disjoint candidate domains it can narrow reads while preserving the answer. A compiled unit’s depth is the reportable row bound; its SPARQL LIMIT includes one extra probe row. Do not report that probe as a value or read the bound from the text. declared_rows=None means no row count was declared, not zero. On compile, weights, k and decay are supplied together or omitted together; without a law, no rank-resolution claim is invented.

canonical_bytes and plan_id identify a plan. certify_plan checks a received document’s recorded depth derivations and consulted-statistics projections; it does not certify that external statistics are current. explain_depth reads the recorded limiting cause and is not the certification operation. Branch on retrieval.PlanDocumentError.refusal for rejected documents.

The design-time queries are weight_for_depth, class_width, deepest_rank_within_width and crossing_rank_at. They ask respectively for the minimum separating weight, a rank’s equal-contribution class width, the deepest prefix within a width tolerance, and the head rank that clears a fusion threshold. None means no finite result within the expressible rank domain, not an enormous measured number. Inputs must satisfy the law’s positive weight/rank/smoothing/tolerance constraints. The width-tolerance query walks the nonmonotone curve and holds the GIL; use it for design, not in a hot loop.

Reading ranking evidence

Keep the three identities: plan_id names the question and planning inputs, profile_id names the fusion law, and evidence_id names the answering index generations and exclusion evidence. planned_resolution describes the depths before execution; observed_resolution reports ranks pulled, collisions, exclusion lookups and rows materialized. Early certification can make the observed work smaller; that is not a correction to the plan.

Read each stratum’s terminal statuses with its fidelities. exhausted means its search produced no more rows, not that a lossy search found every possible match. depth_reached, row_bound_reached and ceiling_reached name different stopping conditions. The shared vocabulary also includes supplied_query_ended, execution_failed and terms_rejected for the broader Rust producer/bundle seam; do not collapse these to an empty-result flag.

attestations records index generation and admitted incompleteness; fidelities independently records complete/lossy search and faithful/perturbed order, with the producer’s own reasons. Silence about index completeness does not certify a whole corpus. HNSW remains a lossy producer even if its graph was built exactly from the supplied vectors.

exactness names deficit, inflation and unbounded strata. Missing candidates can move other candidates up in rank, so degraded evidence can make scores too high as well as too low. Each row’s interval quantifies a bounded error or names perturbed-order strata; certain_prefix counts leading rows whose places survive the declared degradation. unemitted_ceiling bounds what a candidate outside the answer could be worth, or is None when perturbed order prevents a finite bound. These claims concern membership, not proof that every unreturned item was absent. Keep domains and exclusion_bases with the evidence that licensed skipped streams. See the retrieval design.

Errors and boundary choices

Invalid configuration, resource refusal, expression error and certified partial results are different outcomes. Preserve structured diagnostic attributes when provided: message_id and presentation identify native failures; ShapesImportError.kind, ShapesProductError.dimension and PlanDocumentError.refusal are their respective stable discriminants. Do not parse localized message text to decide whether a result is complete. An unbound SELECT cell is neither a missing column nor necessarily an exception.

Use the rdflib compatibility chapter when porting an rdflib consumer. query_rdflib accepts its named/default graph controls, while the native entry points retain the contracts described here. The separate purrdf-rdflib shadow distribution claims the rdflib import name and must not share an environment with the real rdflib. Direct public calls go through purrdf, never the internal purrdf_native extension module.

Python API signatures

This reference records every declaration in the canonical Python stubs, including classes, properties, overloads, type aliases and namespace members. It describes the public package; underscore names also declare supporting types used in the public signatures. They do not promise separately importable constructors.

The installed-package signature test compares both complete inventories using Python syntax trees. Parameter kinds, annotations, defaults, overloads and property decorators therefore cannot drift silently. The accompanying native parameter comparison covers the engine namespaces; inventory parity alone does not prove that every native callable implements its declared signature.

Read Getting Started: Python for usage and the linked engine chapters for their semantic contracts. This reference preserves Python identifiers in every language.

__init__.pyi

from __future__ import annotations
import builtins
from collections.abc import Iterator, Mapping, Sequence
from types import CapsuleType
from typing import IO, Any, Callable, Required, TypeAlias, TypedDict, overload
from typing import Literal as TypingLiteral
from . import geo as geo
type GeoConfiguration = geo.GeoProfile | geo.GeoSession | str | None

def project_statements_rdf12(owl_ttl: str) -> str:
    ...

def normalize_rdf12_to_owl(rdf12_ttl: str) -> str:
    ...

def loss_matrix_json() -> str:
    ...

def rdf_gts_loss_matrix_json() -> str:
    ...

def canonicalize_turtle(turtle_bytes: bytes, extra_prefixes: list[tuple[str, str]]=...) -> bytes:
    ...
XPATH_REGEX_PROFILES: tuple[str, ...]
type ProjectionProfile = TypingLiteral['lpg-csv', 'neo4j-csv', 'open-cypher', 'graphml', 'csvw-exact', 'csvw-terms', 'okf-terms', 'obo-graphs', 'skos', 'croissant-1.1', 'ro-crate-1.3', 'datacite-4.6', 'dcat-3', 'dcat-rdf', 'void', 'frictionless-data-package-1']
type LiftProfile = TypingLiteral['lpg-csv', 'neo4j-csv', 'open-cypher', 'graphml', 'csvw-exact', 'croissant-1.1', 'ro-crate-1.3', 'datacite-4.6', 'dcat-3', 'frictionless-data-package-1']
type ArtifactEvent = TypingLiteral['begin-package', 'begin-artifact', 'chunk', 'finish-artifact', 'commit-package', 'abort-package']

class ProjectionLoss:

    @property
    def code(self) -> str:
        ...

    @property
    def source(self) -> str:
        ...

    @property
    def target(self) -> str:
        ...

    @property
    def note(self) -> str:
        ...

    @property
    def location(self) -> str | None:
        ...

class ProjectionPackage:

    @property
    def profile(self) -> str:
        ...

    @property
    def archive(self) -> bytes:
        ...

    @property
    def losses(self) -> list[ProjectionLoss]:
        ...

class ProjectionProgress:

    @property
    def phase(self) -> str:
        ...

    @property
    def input_records(self) -> int:
        ...

    @property
    def model_records(self) -> int:
        ...

    @property
    def nodes(self) -> int:
        ...

    @property
    def edges(self) -> int:
        ...

    @property
    def artifacts(self) -> int:
        ...

    @property
    def bytes(self) -> int:
        ...

    @property
    def path(self) -> str | None:
        ...

class ProjectionStream:

    @property
    def profile(self) -> str:
        ...

    @property
    def losses(self) -> list[ProjectionLoss]:
        ...

    @property
    def input_records(self) -> int:
        ...

    @property
    def model_records(self) -> int:
        ...

    @property
    def nodes(self) -> int:
        ...

    @property
    def edges(self) -> int:
        ...

class ProjectionLift:

    @property
    def dataset(self) -> RdfDataset:
        ...

    @property
    def losses(self) -> list[ProjectionLoss]:
        ...

def project(data: bytes | str, *, format: RdfFormat, profile: ProjectionProfile, config: bytes | str, assets: bytes | None=...) -> ProjectionPackage:
    ...

def project_artifacts(data: bytes | str, *, format: RdfFormat, profile: TypingLiteral['lpg-csv', 'neo4j-csv', 'open-cypher', 'graphml'], config: bytes | str, artifact_callback: Callable[[ArtifactEvent, str | None, bytes], None], progress_callback: Callable[[ProjectionProgress], None] | None=...) -> ProjectionStream:
    ...

def lift(archive: bytes, *, profile: LiftProfile, config: bytes | str) -> ProjectionLift:
    ...

class RdfFormat:
    TURTLE: RdfFormat
    N_TRIPLES: RdfFormat
    N_QUADS: RdfFormat
    TRIG: RdfFormat
    TRIX: RdfFormat
    HEXTUPLES: RdfFormat
    JSON_LD: RdfFormat
    YAML_LD: RdfFormat
    RDF_XML: RdfFormat

class CompiledJsonLdContext:

    def __init__(self, options_json: str) -> None:
        ...

    @staticmethod
    def from_prefixes(prefixes: dict[str, str]) -> CompiledJsonLdContext:
        ...

    def canonical_context_json(self) -> str:
        ...

class CanonicalizationAlgorithm:
    RDFC_1_0: CanonicalizationAlgorithm
    UNSTABLE: CanonicalizationAlgorithm

class NamedNode:

    def __init__(self, value: str) -> None:
        ...

    @property
    def value(self) -> str:
        ...

    def __hash__(self) -> int:
        ...

    def __eq__(self, other: object) -> bool:
        ...

class BlankNode:

    def __init__(self, value: str) -> None:
        ...

    @property
    def value(self) -> str:
        ...

    def __hash__(self) -> int:
        ...

    def __eq__(self, other: object) -> bool:
        ...

class Literal:

    def __init__(self, value: str, *, datatype: NamedNode | None=..., language: str | None=..., direction: str | None=...) -> None:
        ...

    @property
    def value(self) -> str:
        ...

    @property
    def language(self) -> str | None:
        ...

    @property
    def direction(self) -> str | None:
        ...

    @property
    def datatype(self) -> NamedNode:
        ...

    def __hash__(self) -> int:
        ...

    def __eq__(self, other: object) -> bool:
        ...

class Triple:

    def __init__(self, subject: _Subject, predicate: NamedNode, object: _Term) -> None:
        ...

    @property
    def subject(self) -> _Subject:
        ...

    @property
    def predicate(self) -> NamedNode:
        ...

    @property
    def object(self) -> _Term:
        ...

    def __hash__(self) -> int:
        ...

    def __eq__(self, other: builtins.object) -> bool:
        ...

class DefaultGraph:

    def __init__(self) -> None:
        ...

class Quad:

    def __init__(self, subject: _Subject, predicate: NamedNode, object: _Term, graph_name: NamedNode | BlankNode | DefaultGraph | None=...) -> None:
        ...

    @property
    def subject(self) -> _Subject:
        ...

    @property
    def predicate(self) -> NamedNode:
        ...

    @property
    def object(self) -> _Term:
        ...

    @property
    def graph_name(self) -> NamedNode | BlankNode | DefaultGraph:
        ...

    def __hash__(self) -> int:
        ...

    def __eq__(self, other: builtins.object) -> bool:
        ...

class Variable:

    def __init__(self, value: str) -> None:
        ...

    @property
    def value(self) -> str:
        ...

    def __hash__(self) -> int:
        ...

    def __eq__(self, other: object) -> bool:
        ...
_Subject = NamedNode | BlankNode
_Term = NamedNode | BlankNode | Literal | Triple
_Attestation = tuple[str | None, str | None]
_Relation = tuple[int, int, Sequence[Sequence[_Term]]] | tuple[int, int, Sequence[Sequence[_Term]], _Attestation]
_RelationFromGraph = tuple[_Term, int, int] | tuple[_Term, int, int, _Attestation]
_PathStep = tuple[_Term, str]
_PathRelation = tuple[Sequence[_PathStep], int, int, int, int, str] | tuple[Sequence[_PathStep], int, int, int, int, str, _Attestation]

class QuerySolution:

    def __getitem__(self, key: str | Variable | int) -> _Term | None:
        ...

    def __len__(self) -> int:
        ...

    def __iter__(self) -> Iterator[_Term | None]:
        ...

class _QuerySolutionIterator:

    def __iter__(self) -> _QuerySolutionIterator:
        ...

    def __next__(self) -> _Term | None:
        ...

    def __length_hint__(self) -> int:
        ...

class QuerySolutions:

    @property
    def variables(self) -> list[Variable]:
        ...

    def __iter__(self) -> QuerySolutions:
        ...

    def __next__(self) -> QuerySolution:
        ...

    def __len__(self) -> int:
        ...

class QueryTriples:

    def __iter__(self) -> QueryTriples:
        ...

    def __next__(self) -> Triple:
        ...

    def __len__(self) -> int:
        ...

    def serialize(self, format: RdfFormat, *, base: str | None=...) -> bytes:
        ...

class QueryQuads:

    def __iter__(self) -> QueryQuads:
        ...

    def __next__(self) -> Quad:
        ...

    def __len__(self) -> int:
        ...

    @property
    def graph_names(self) -> list[str]:
        ...

    def serialize(self, format: RdfFormat) -> bytes:
        ...

class SerializeLoss:

    @property
    def bytes(self) -> bytes:
        ...

    @property
    def statement_rows_dropped(self) -> int:
        ...

    @property
    def directional_literals_dropped(self) -> int:
        ...

    @property
    def named_graph_rows_dropped(self) -> int:
        ...

    @property
    def empty_named_graphs_dropped(self) -> int:
        ...

class QueryBoolean:

    def __bool__(self) -> bool:
        ...
type GovernorKind = TypingLiteral['budget', 'stopped', 'refused', 'unknown']
type PartialCertainty = TypingLiteral['certain', 'at-most', 'unknown']

class CancellationToken:

    def __init__(self) -> None:
        ...

    def cancel(self) -> None:
        ...

    @property
    def cancelled(self) -> bool:
        ...

class TrippedGovernor:

    @property
    def kind(self) -> GovernorKind:
        ...

    @property
    def label(self) -> str:
        ...

    @property
    def dimension(self) -> str | None:
        ...

    @property
    def limit(self) -> int | None:
        ...

    @property
    def consumed(self) -> int | None:
        ...

    @property
    def estimate(self) -> int | None:
        ...

    @property
    def cause(self) -> str | None:
        ...

    def __str__(self) -> str:
        ...

class GovernorEvidence:

    @property
    def consumed(self) -> dict[str, int]:
        ...

    @property
    def limits(self) -> dict[str, int]:
        ...

    @property
    def tripped(self) -> TrippedGovernor | None:
        ...

    @property
    def is_complete(self) -> bool:
        ...

    @property
    def expression_errors(self) -> dict[str, int]:
        ...

    def consumed_in(self, dimension: str) -> int:
        ...

    def limit_for(self, dimension: str) -> int:
        ...

class PartialAnswers:

    @property
    def certainty(self) -> PartialCertainty:
        ...

    @property
    def is_certain(self) -> bool:
        ...

    @property
    def result(self) -> QuerySolutions | QueryTriples | QueryQuads | QueryBoolean | None:
        ...

    @property
    def is_positional_prefix(self) -> bool | None:
        ...

    @property
    def barrier(self) -> str | None:
        ...

class RelationAttestations(TypedDict):
    invocations: int
    generations: list[str | None]
    incompleteness: list[str]

class QueryOutcome:

    @property
    def is_complete(self) -> bool:
        ...

    @property
    def result(self) -> QuerySolutions | QueryTriples | QueryQuads | QueryBoolean | None:
        ...

    @property
    def partial(self) -> PartialAnswers | None:
        ...

    @property
    def tripped(self) -> TrippedGovernor | None:
        ...

    @property
    def evidence(self) -> GovernorEvidence:
        ...

    @property
    def relation_witness(self) -> dict[str, RelationAttestations]:
        ...

class EntailmentQueryOutcome:

    @property
    def phase(self) -> TypingLiteral['answered', 'closure-stopped']:
        ...

    @property
    def is_complete(self) -> bool:
        ...

    @property
    def outcome(self) -> QueryOutcome | None:
        ...

    @property
    def report(self) -> str | None:
        ...

    @property
    def tripped(self) -> TrippedGovernor | None:
        ...

class UpdateOutcome:

    @property
    def is_applied(self) -> bool:
        ...

    @property
    def tripped(self) -> TrippedGovernor | None:
        ...

    @property
    def evidence(self) -> GovernorEvidence:
        ...

class PreparedQuery:

    @property
    def parameters(self) -> list[str]:
        ...

    def run(self, **bindings: _Term) -> QuerySolutions | QueryTriples | QueryQuads | QueryBoolean:
        ...

class QuadIter:

    def __iter__(self) -> QuadIter:
        ...

    def __next__(self) -> Quad:
        ...

class _QuadStore:

    def __iter__(self) -> QuadIter:
        ...

    def add_graph(self, graph: NamedNode | BlankNode, *, silent: bool=False) -> None:
        ...

    def named_graphs(self) -> list[NamedNode | BlankNode]:
        ...

    def query(self, query: str, *, substitutions: dict[Variable, _Term] | None=..., extension_namespaces: list[str] | None=..., property_fn_namespaces: list[str] | None=..., standpoint_predicates: tuple[str, str] | None=..., relations: dict[str, _Relation] | None=..., relations_from_graph: dict[str, _RelationFromGraph] | None=..., path_relations: dict[str, _PathRelation] | None=..., aggregate_namespace: str | None=..., xpath_regex: str | None=..., geo: GeoConfiguration=..., division: str | None=...) -> QuerySolutions | QueryTriples | QueryQuads | QueryBoolean:
        ...

    def query_rdflib(self, query: str, *, substitutions: dict[Variable, _Term] | None=..., extension_namespaces: list[str] | None=..., property_fn_namespaces: list[str] | None=..., standpoint_predicates: tuple[str, str] | None=..., relations: dict[str, _Relation] | None=..., relations_from_graph: dict[str, _RelationFromGraph] | None=..., path_relations: dict[str, _PathRelation] | None=..., aggregate_namespace: str | None=..., xpath_regex: str | None=..., geo: GeoConfiguration=..., division: str | None=..., named_graphs: bool=..., default_graph: NamedNode | BlankNode | None=..., default_union: bool=...) -> QuerySolutions | QueryTriples | QueryQuads | QueryBoolean:
        ...

    def query_governed(self, query: str, *, substitutions: dict[Variable, _Term] | None=..., extension_namespaces: list[str] | None=..., property_fn_namespaces: list[str] | None=..., standpoint_predicates: tuple[str, str] | None=..., relations: dict[str, _Relation] | None=..., relations_from_graph: dict[str, _RelationFromGraph] | None=..., path_relations: dict[str, _PathRelation] | None=..., aggregate_namespace: str | None=..., xpath_regex: str | None=..., geo: GeoConfiguration=..., division: str | None=..., fuel: int | None=..., deadline_ms: int | None=..., max_answers: int | None=..., max_intermediate_cells: int | None=..., max_scratch_bytes: int | None=..., max_remote_requests: int | None=..., no_ceiling: bool=False, cancel: CancellationToken | None=...) -> QueryOutcome:
        ...

    def query_entailment_governed(self, query: str, entailment: str, *, program: str=..., imports: Sequence[tuple[str, str]] | None=..., premise_iris: Sequence[str] | None=..., max_stored_facts: int | None=..., max_join_steps: int | None=..., max_term_arena_bytes: int | None=..., substitutions: dict[Variable, _Term] | None=..., extension_namespaces: list[str] | None=..., property_fn_namespaces: list[str] | None=..., standpoint_predicates: tuple[str, str] | None=..., relations: dict[str, _Relation] | None=..., relations_from_graph: dict[str, _RelationFromGraph] | None=..., path_relations: dict[str, _PathRelation] | None=..., aggregate_namespace: str | None=..., xpath_regex: str | None=..., geo: GeoConfiguration=..., division: str | None=..., fuel: int | None=..., deadline_ms: int | None=..., max_answers: int | None=..., max_intermediate_cells: int | None=..., max_scratch_bytes: int | None=..., max_remote_requests: int | None=..., no_ceiling: bool=False, cancel: CancellationToken | None=...) -> EntailmentQueryOutcome:
        ...

    def update(self, update: str, *, extension_namespaces: list[str] | None=..., property_fn_namespaces: list[str] | None=..., standpoint_predicates: tuple[str, str] | None=..., relations: dict[str, _Relation] | None=..., relations_from_graph: dict[str, _RelationFromGraph] | None=..., path_relations: dict[str, _PathRelation] | None=..., aggregate_namespace: str | None=..., xpath_regex: str | None=..., geo: GeoConfiguration=..., division: str | None=...) -> None:
        ...

    def update_governed(self, update: str, *, extension_namespaces: list[str] | None=..., property_fn_namespaces: list[str] | None=..., standpoint_predicates: tuple[str, str] | None=..., relations: dict[str, _Relation] | None=..., relations_from_graph: dict[str, _RelationFromGraph] | None=..., path_relations: dict[str, _PathRelation] | None=..., aggregate_namespace: str | None=..., xpath_regex: str | None=..., geo: GeoConfiguration=..., division: str | None=..., fuel: int | None=..., deadline_ms: int | None=..., max_intermediate_cells: int | None=..., max_scratch_bytes: int | None=..., max_remote_requests: int | None=..., no_ceiling: bool=False, cancel: CancellationToken | None=...) -> UpdateOutcome:
        ...

    def dump_with_loss(self, format: RdfFormat) -> SerializeLoss:
        ...

    def __len__(self) -> int:
        ...

    def _store_capsule(self) -> CapsuleType:
        ...

class Store(_QuadStore):

    def __init__(self, *, remember_empty_graphs: bool=True) -> None:
        ...

    def load(self, input: bytes | str | None=..., format: RdfFormat | None=..., *, path: str | None=..., base: str | None=...) -> list[tuple[str, str]]:
        ...

    def bulk_load(self, input: bytes | str | None=..., format: RdfFormat | None=..., *, path: str | None=..., base: str | None=...) -> list[tuple[str, str]]:
        ...

    def add(self, quad: Quad) -> None:
        ...

    def remove(self, quad: Quad) -> None:
        ...

    def checkpoint(self) -> None:
        ...

    def change_size(self) -> tuple[int, int]:
        ...

    def prepare(self, query: str, *, parameters: list[str] | None=..., extension_namespaces: list[str] | None=..., property_fn_namespaces: list[str] | None=..., standpoint_predicates: tuple[str, str] | None=..., relations: dict[str, _Relation] | None=..., relations_from_graph: dict[str, _RelationFromGraph] | None=..., path_relations: dict[str, _PathRelation] | None=..., aggregate_namespace: str | None=..., xpath_regex: str | None=..., geo: GeoConfiguration=..., division: str | None=...) -> PreparedQuery:
        ...

    @overload
    def dump(self, output: IO[bytes], format: RdfFormat, *, from_graph: NamedNode | BlankNode | DefaultGraph | None=..., jsonld_options: str | None=..., jsonld_context: CompiledJsonLdContext | None=..., yaml_schema_url: str | None=..., base: str | None=...) -> None:
        ...

    @overload
    def dump(self, output: None=..., *, format: RdfFormat, from_graph: NamedNode | BlankNode | DefaultGraph | None=..., jsonld_options: str | None=..., jsonld_context: CompiledJsonLdContext | None=..., yaml_schema_url: str | None=..., base: str | None=...) -> bytes:
        ...

class MutableDataset(_QuadStore):

    def __init__(self, *, remember_empty_graphs: bool=True) -> None:
        ...

    def load(self, input: bytes | str | None=..., format: RdfFormat | None=..., *, path: str | None=..., base: str | None=...) -> list[tuple[str, str]]:
        ...

    def add(self, quad: Quad) -> bool:
        ...

    def remove(self, quad: Quad) -> bool:
        ...

    def contains(self, quad: Quad) -> bool:
        ...

    def quads_for_pattern(self, subject: _Subject | None=..., predicate: NamedNode | None=..., object: _Term | None=..., graph_name: NamedNode | BlankNode | DefaultGraph | None=..., *, any_graph: bool=...) -> list[Quad]:
        ...

    @overload
    def dump(self, output: IO[bytes], format: RdfFormat, *, from_graph: NamedNode | BlankNode | DefaultGraph | None=..., jsonld_options: str | None=..., jsonld_context: CompiledJsonLdContext | None=..., yaml_schema_url: str | None=..., base: str | None=...) -> None:
        ...

    @overload
    def dump(self, output: None=..., *, format: RdfFormat, from_graph: NamedNode | BlankNode | DefaultGraph | None=..., jsonld_options: str | None=..., jsonld_context: CompiledJsonLdContext | None=..., yaml_schema_url: str | None=..., base: str | None=...) -> bytes:
        ...

    def compact(self) -> None:
        ...

class Dataset:

    def __init__(self, quads: object | None=...) -> None:
        ...

    def add(self, quad: Quad) -> None:
        ...

    def canonicalize(self, algorithm: CanonicalizationAlgorithm) -> None:
        ...

    def __iter__(self) -> QuadIter:
        ...

    def __len__(self) -> int:
        ...

def parse(input: bytes | str, format: RdfFormat, *, base: str | None=...) -> list[Quad]:
    ...

@overload
def serialize(input: QueryTriples | QueryQuads, output: IO[bytes], format: RdfFormat, *, base: str | None=...) -> None:
    ...

@overload
def serialize(input: QueryTriples | QueryQuads, output: None=..., *, format: RdfFormat, base: str | None=...) -> bytes:
    ...

def xsd_value_compare(left_lexical: str, left_datatype: str, right_lexical: str, right_datatype: str) -> int | None:
    ...

def xsd_canonical_lexical(lexical: str, datatype: str) -> str | None:
    ...

def xsd_decode_binary(lexical: str, datatype: str) -> bytes | None:
    ...

def xsd_normalize_whitespace(lexical: str, datatype: str) -> str | None:
    ...
_ResultRow = list[_Term | None]
_ProvenanceNamespace = tuple[str, str]
type SparqlResultsFormat = TypingLiteral['json', 'srj', 'sparql-json', 'application/sparql-results+json', 'xml', 'sparql-xml', 'application/sparql-results+xml', 'csv', 'text/csv', 'tsv', 'text/tab-separated-values']

def serialize_sparql_solutions(format: SparqlResultsFormat | str, variables: list[str], rows: list[_ResultRow], *, provenance_namespace: _ProvenanceNamespace | None=..., query_hash: str | None=...) -> bytes:
    ...

def serialize_sparql_boolean(format: SparqlResultsFormat | str, value: bool, *, provenance_namespace: _ProvenanceNamespace | None=..., query_hash: str | None=...) -> bytes:
    ...

def parse_sparql_results(format: SparqlResultsFormat | str, data: bytes) -> tuple[Any, ...]:
    ...
_ProvenanceDict = dict[str, str | None]

def provenance_from_json(data: bytes, prefix: str, iri: str) -> _ProvenanceDict:
    ...

def provenance_from_xml(data: bytes, prefix: str, iri: str) -> _ProvenanceDict:
    ...
_BlobRow = tuple[bytes, str, str]
_SliceArtifactRow = tuple[str, str, str, str, bytes]
_NamedGraphRow = tuple[bytes, RdfFormat, str | None, str | None]

def gts_from_quads(data: bytes, *, format: RdfFormat, profile: str=..., transform: list[str] | None=..., base: str | None=...) -> bytes:
    ...

def gts_from_rdf12_bytes(data: bytes, *, format: RdfFormat, profile: str=..., transform: list[str] | None=..., base: str | None=...) -> bytes:
    ...

def compile_gts_native(base_data: bytes, base_format: RdfFormat, *, base_scope: str | None=..., rdf12_data: bytes | None=..., rdf12_format: RdfFormat | None=..., rdf12_graph_name: str | None=..., rdf12_scope: str | None=..., named_graphs: list[_NamedGraphRow] | None=..., transform: list[str] | None=..., doc_blobs: list[_BlobRow] | None=..., report_blobs: list[_BlobRow] | None=..., slice_artifacts: list[_SliceArtifactRow] | None=..., signer_secret: bytes | None=..., signer_kid: str | None=..., public_key_armor: str | None=..., rsyncable_threshold: int=..., base: str | None=...) -> bytes:
    ...

def snapshot_content_id_native(data: bytes, *, format: RdfFormat, base: str | None=...) -> str:
    ...

class GtsIngestReport(TypedDict):
    rows_consumed: int
    terms_interned: int
    declarations_omitted: list[str]
    scratch_bytes: int

def gts_ingest_report(base_data: bytes, base_format: RdfFormat, *, base_scope: str | None=..., rdf12_data: bytes | None=..., rdf12_format: RdfFormat | None=..., rdf12_graph_name: str | None=..., rdf12_scope: str | None=..., named_graphs: list[_NamedGraphRow] | None=..., base: str | None=...) -> GtsIngestReport:
    ...

class GtsCompileResult(TypedDict):
    snapshot_bytes: bytes
    ingest_report: GtsIngestReport

def compile_gts_with_report(base_data: bytes, base_format: RdfFormat, *, base_scope: str | None=..., rdf12_data: bytes | None=..., rdf12_format: RdfFormat | None=..., rdf12_graph_name: str | None=..., rdf12_scope: str | None=..., named_graphs: list[_NamedGraphRow] | None=..., transform: list[str] | None=..., doc_blobs: list[_BlobRow] | None=..., report_blobs: list[_BlobRow] | None=..., slice_artifacts: list[_SliceArtifactRow] | None=..., signer_secret: bytes | None=..., signer_kid: str | None=..., public_key_armor: str | None=..., rsyncable_threshold: int=..., base: str | None=...) -> GtsCompileResult:
    ...

def to_json_ld(data: bytes, *, format: RdfFormat, options_json: str | None=..., context: CompiledJsonLdContext | None=..., base: str | None=...) -> str:
    ...

def serialize_jsonld(data: bytes, *, format: RdfFormat, output_format: str, options_json: str | None=..., context: CompiledJsonLdContext | None=..., yaml_schema_url: str | None=...) -> str:
    ...

def from_json_ld(text: str, *, statement_vocab: dict[str, str] | None=..., base: str | None=...) -> bytes:
    ...

def to_rdf_xml(data: bytes, *, format: RdfFormat, base: str | None=...) -> str:
    ...

def from_rdf_xml(text: str, *, base: str | None=...) -> bytes:
    ...

def feedback_bundle_native(data: bytes, *, format: RdfFormat, report_blobs: list[_BlobRow] | None=..., base: str | None=...) -> bytes:
    ...
_TermRow = tuple[int, int, str | None, int | None, str | None, int | None, tuple[int, int, int] | None]
_QuadRow = tuple[int, int, int, int | None]
_ReifierRow = tuple[int, int, int, int, int | None]
_AnnotationRow = tuple[int, int, int, int | None]
_FoldReifierRow = tuple[int, tuple[int, int, int], int | None]
_BlobExportRow = tuple[str, bytes]
_InputTermRow = tuple[int, str | None, int | None, str | None, str | None, int | None, tuple[int, int, int] | None]
GTS_ALL_SCOPE: str

class GtsRelationalRows(TypedDict):
    terms: list[_TermRow]
    directions: list[str | None]
    quads: list[_QuadRow]
    reifiers: list[_ReifierRow]
    annotations: list[_AnnotationRow]
    blobs: list[_BlobExportRow]

class GtsFoldViewNative:

    @staticmethod
    def from_bytes(data: bytes, curie_prefixes: list[tuple[str, str]]=...) -> GtsFoldViewNative:
        ...

    @staticmethod
    def from_parts(terms: list[_InputTermRow], quads: list[_QuadRow], reifiers: list[_FoldReifierRow], annotations: list[_AnnotationRow], curie_prefixes: list[tuple[str, str]]=...) -> GtsFoldViewNative:
        ...

    def term_count(self) -> int:
        ...

    def quad_count(self) -> int:
        ...

    def reifier_count(self) -> int:
        ...

    def annotation_count(self) -> int:
        ...

    def term_tuple(self, tid: int) -> _InputTermRow:
        ...

    def is_iri(self, tid: int) -> bool:
        ...

    def is_bnode(self, tid: int) -> bool:
        ...

    def is_literal(self, tid: int) -> bool:
        ...

    def iri(self, tid: int) -> str | None:
        ...

    def lex(self, tid: int) -> str:
        ...

    def lang(self, tid: int) -> str | None:
        ...

    def datatype(self, tid: int) -> str:
        ...

    def nq_token(self, tid: int) -> str:
        ...

    def python_value(self, tid: int) -> object:
        ...

    def tid_of_iri(self, iri: str) -> int | None:
        ...

    def curie(self, iri: str) -> str:
        ...

    def quads(self, scope: str | None=...) -> list[_QuadRow]:
        ...

    def subjects_by_type(self, class_iri: str, scope: str | None=...) -> list[int]:
        ...

    def objects(self, s_tid: int, p_iri: str, scope: str | None=...) -> list[int]:
        ...

    def value(self, s_tid: int, p_iri: str, scope: str | None=...) -> int | None:
        ...

    def predicate_objects(self, s_tid: int, scope: str | None=...) -> list[tuple[int, int]]:
        ...

    def has(self, s_tid: int, p_iri: str, o_tid: int, scope: str | None=...) -> bool:
        ...

    def rdf_list(self, head_tid: int, scope: str | None=...) -> list[int]:
        ...

    def reifiers(self) -> list[_FoldReifierRow]:
        ...

    def annotations(self) -> list[_AnnotationRow]:
        ...

    def tag_map(self) -> dict[str, str]:
        ...

    def available_languages(self) -> list[str]:
        ...

    def public_text(self, s_tid: int, p_iri: str, scope: str | None=...) -> str:
        ...

    def public_literal(self, s_tid: int, p_iri: str, scope: str | None=...) -> tuple[str, str | None]:
        ...

    def public_literal_with_fallback(self, s_tid: int, p_iri: str, requested: list[str], scope: str | None=...) -> tuple[str, str | None, bool]:
        ...

    def public_text_with_fallback(self, s_tid: int, p_iri: str, requested: list[str], scope: str | None=...) -> tuple[str, bool]:
        ...

    def public_texts(self, s_tid: int, p_iri: str, requested: list[str], scope: str | None=...) -> list[tuple[str, str | None, bool]]:
        ...

    def relational_rows(self) -> GtsRelationalRows:
        ...

def gts_relational_rows_from_bytes(data: bytes) -> GtsRelationalRows:
    ...

class ColumnarRows(TypedDict):
    schema: dict[str, list[tuple[str, str, bool]]]
    terms: list[tuple[int | str | bytes | None, ...]]
    quads: list[tuple[int | str | bytes | None, ...]]
    reifiers: list[tuple[int | str | bytes | None, ...]]
    annotations: list[tuple[int | str | bytes | None, ...]]
    blobs: list[tuple[int | str | bytes | None, ...]]

def gts_columnar_rows_from_bytes(data: bytes) -> ColumnarRows:
    ...

def gts_columnar_parquet_from_bytes(data: bytes) -> dict[str, bytes]:
    ...

def gts_to_sqlite(data: bytes, path: str) -> str:
    ...

def gts_to_duckdb(data: bytes, path: str) -> str:
    ...

def gts_to_parquet(data: bytes, out_dir: str) -> list[str]:
    ...

class RdfDataset:

    def __init__(self, data: bytes | str, format: RdfFormat, *, base: str | None=...) -> None:
        ...

    def quad_count(self) -> int:
        ...

    def term_count(self) -> int:
        ...

    def __len__(self) -> int:
        ...

    def to_nquads(self) -> str:
        ...

    def serialize_jsonld(self, output_format: str, *, options_json: str | None=..., context: CompiledJsonLdContext | None=..., yaml_schema_url: str | None=...) -> str:
        ...

    def to_gts(self, profile: str=...) -> bytes:
        ...

    def columnar_rows(self) -> ColumnarRows:
        ...

    def to_parquet_files(self) -> dict[str, bytes]:
        ...

class SssomDiagnostic(TypedDict):
    severity: str
    code: str
    message: str
    check: str
    instance: str | None

def validate_sssom(text: str) -> list[SssomDiagnostic]:
    ...

def sssom_to_rdf(text: str) -> str:
    ...

def sssom_roundtrip_tsv(text: str) -> str:
    ...

def sssom_default_validation_types() -> list[str]:
    ...

class ShexResultEntry(TypedDict):
    """One fixed-shape-map verdict: the input `(node, shape)` echoed verbatim."""
    node: str
    shape: str
    conformant: bool
    reason: str | None

class shex:

    @staticmethod
    def validate(schema: str, data: str, map: list[tuple[str, str]], *, schema_format: str=..., data_format: str=..., base: str | None=..., xpath_regex: str | None=...) -> list[ShexResultEntry]:
        ...

    @staticmethod
    def parse(schema: str, *, format: str=..., base: str | None=...) -> str:
        ...

class _ValidationReport:
    """A SHACL validation report."""

    @property
    def conforms(self) -> bool:
        ...

    @property
    def shapes_graph_well_formed(self) -> bool | None:
        ...

    @property
    def diagnostics(self) -> list[dict[str, str]]:
        ...

    @property
    def results(self) -> list[dict[str, builtins.object]]:
        ...

    def to_ntriples(self) -> str:
        ...

    def to_sarif(self) -> str:
        ...

class _Shapes:
    """Parsed SHACL shapes, reusable across many data graphs."""

    def __init__(self, shapes_ttl: str, *, base: str | None=None, imports: Sequence[tuple[str, str]]=..., shapes_graph: str | None=None, subclass_of_in_shapes_graph: bool=False, shacl_profile: str | None=None) -> None:
        ...

    def validate_nt(self, data_nt: str, *, xpath_regex: str | None=None) -> _ValidationReport:
        ...

    def validate_store(self, data: Store | MutableDataset, *, xpath_regex: str | None=None) -> _ValidationReport:
        ...

    def prepare(self) -> _PreparedShapes:
        ...

    def extension_usage(self, *, relation_iris: Sequence[str] | None=None, relation_namespaces: Sequence[str] | None=None, extension_namespaces: Sequence[str] | None=None) -> dict[str, Any]:
        ...

class _ShapesProductError(ValueError):
    """A refusal from the prepared-shapes-product admission boundary.

    `dimension` is one of the codec's pinned kebab-case labels, or `None` when the
    failure happened before any product existed (a shapes or data document that did
    not parse was never admitted). Branch on it, never on `str(exc)`.
    """
    dimension: str | None

class _ShapesImportError(ValueError):
    """A shapes graph's `owl:imports` closure is not in hand, or the `imports` table
    cannot be used — the one refusal every shapes-graph entry point raises, on every
    PurRDF host alike.

    `kind` is `unresolved-import` (pass the named documents in `imports`),
    `unreached-import` (a table entry neither an import nor a data-graph link names),
    `invalid-import` (a key that is not an absolute IRI, a key named twice, or a
    document that is not Turtle), `incompatible-import-versions` (the closure holds two
    versions of one series, or a graph another declares `owl:incompatibleWith`, SHACL
    1.2 Core sections 1.3 and 6.1 — `iris` are the conflicting documents),
    `unresolved-shapes-graph-link` (the data graph links a
    graph with `sh:shapesGraph`, SHACL 1.2 Core section 6.4, that nothing in hand
    resolves — pass it in `imports`) or `invalid-shapes-graph-link` (a data-graph
    `sh:shapesGraph` value that is not an IRI); `iris` are the IRIs (or values) it
    names. Branch on `kind`, never on `str(exc)`.

    A shapes graph prepared before the data graph was known (`Shapes`,
    `PreparedShapes`, a product) cannot take a linked graph in: validating a data graph
    that links one it does not hold — one that is not its base, a document its import
    closure absorbed, or a graph it declares — raises this class with kind
    `unheld-shapes-graph-link`.
    """
    kind: str
    iris: list[str]

class _PreparedShapes:
    """An immutable shape preparation, reusable across data graphs and writable as
    a prepared product."""

    def to_product(self) -> bytes:
        ...

    def provenance(self) -> str:
        ...

    def validate_nt(self, data_nt: str, *, xpath_regex: str | None=None) -> _ValidationReport:
        ...

    def validate_store_changes(self, store: Store, *, xpath_regex: str | None=None) -> _ChangeValidation:
        ...

class _ChangeValidation:
    """The outcome of `PreparedShapes.validate_store_changes`: the report, plus the
    SCOPE the report describes.

    Two facts rather than one, because a `ValidationReport` alone cannot say which
    question it answered. Read `bounded` first.
    """

    @property
    def report(self) -> _ValidationReport:
        ...

    @property
    def bounded(self) -> bool:
        ...

    @property
    def focus_nodes(self) -> int | None:
        ...

    @property
    def reason(self) -> str | None:
        ...

class _ShapesProduct:
    """A prepared product whose envelope is verified and whose self-description is
    decoded — but which has NOT been admitted."""

    @staticmethod
    def open(data: bytes) -> _ShapesProduct:
        ...

    def to_bytes(self) -> bytes:
        ...

    def explain(self) -> str:
        ...

    def format_version(self) -> int:
        ...

    def stage_id(self) -> str:
        ...

    def stage_known(self) -> bool:
        ...

    def identity_digest(self) -> str:
        ...

    def identity_components(self) -> list[tuple[str, str]]:
        ...

    def admit(self) -> _PreparedShapes:
        ...

    def admit_expecting(self, expected_identity: str) -> _PreparedShapes:
        ...

    def rebuild(self) -> _PreparedShapes:
        ...

    def rebuild_expecting(self, expected_identity: str) -> _PreparedShapes:
        ...

    def certify(self) -> None:
        ...

class shapes:
    ValidationReport: TypeAlias = _ValidationReport
    Shapes: TypeAlias = _Shapes
    PreparedShapes: TypeAlias = _PreparedShapes
    ChangeValidation: TypeAlias = _ChangeValidation
    ShapesProduct: TypeAlias = _ShapesProduct
    ShapesProductError: TypeAlias = _ShapesProductError
    ShapesImportError: TypeAlias = _ShapesImportError

    @staticmethod
    def pack_product(shapes_ttl: str, *, shapes_base: str | None=None, imports: Sequence[tuple[str, str]]=..., shapes_graph: str | None=None) -> bytes:
        ...

    @staticmethod
    def validate(shapes_ttl: str, data_nt: str, *, shapes_base: str | None=None, conformance_disallows: Sequence[str] | None=None, imports: Sequence[tuple[str, str]]=..., shapes_graph: str | None=None, subclass_of_in_shapes_graph: bool=False, xpath_regex: str | None=None, shacl_profile: str | None=None) -> dict[str, builtins.object]:
        ...

    @staticmethod
    def entail(shapes_ttl: str, data_nt: str, *, shapes_base: str | None=None, imports: Sequence[tuple[str, str]]=..., shapes_graph: str | None=None, max_term_generating_rounds: int | None=None, max_generated_terms: int | None=None, max_stored_facts: int | None=None, max_join_steps: int | None=None, xpath_regex: str | None=None) -> dict[str, builtins.object]:
        ...

    @staticmethod
    def apply_rules(data_nt: str, shapes_ttl: str | None=None, *, srl: str | None=None, shapes_base: str | None=None, srl_base: str | None=None, explain: bool=False, max_term_generating_rounds: int | None=None, max_generated_terms: int | None=None, max_stored_facts: int | None=None, max_join_steps: int | None=None, imports: Sequence[tuple[str, str]]=..., shapes_graph: str | None=None, xpath_regex: str | None=None) -> dict[str, builtins.object]:
        ...

    @staticmethod
    def check_rules(srl: str, *, srl_base: str | None=None, imports: Sequence[tuple[str, str]]=..., level: str | None=None) -> dict[str, builtins.object]:
        ...

    @staticmethod
    def eval_node_expr(shapes_ttl: str, data_nt: str, expr: str | None, focus: str, *, expr_at: str | None=None, expr_via: Sequence[str]=..., expr_turtle: str | None=None, scope: Mapping[str, str] | None=None, shapes_base: str | None=None, imports: Sequence[tuple[str, str]]=..., xpath_regex: str | None=None) -> dict[str, builtins.object]:
        ...

    @staticmethod
    def lint_shapes(shapes_ttl: str, *, shapes_base: str | None=None, imports: Sequence[tuple[str, str]]=..., shapes_graph: str | None=None) -> dict[str, builtins.object]:
        ...
shacl = shapes

class _Regime:
    """A SPARQL entailment regime (`purrdf.entail.Regime.OWL_RL`)."""
    SIMPLE: _Regime
    RDF: _Regime
    RDFS: _Regime
    OWL_RL: _Regime
    OWL_DIRECT: _Regime
    RIF: _Regime
    D: _Regime
type RegimeLike = _Regime | str

class entail:
    Regime: TypeAlias = _Regime

    @staticmethod
    def materialize(dataset: RdfDataset, regime: RegimeLike, program: str, imports: Sequence[tuple[str, str]], premise_iris: Sequence[str], *, max_stored_facts: int | None=None, max_join_steps: int | None=None, max_term_arena_bytes: int | None=None) -> tuple[RdfDataset, str]:
        ...

    @staticmethod
    def materialize_nt(data: str, regime: RegimeLike, program: str, imports: Sequence[tuple[str, str]], premise_iris: Sequence[str], *, max_stored_facts: int | None=None, max_join_steps: int | None=None, max_term_arena_bytes: int | None=None) -> tuple[str, str]:
        ...

    @staticmethod
    def rules(regime: RegimeLike) -> list[str]:
        ...

    @staticmethod
    def implemented_rules(regime: RegimeLike) -> list[str]:
        ...

    @staticmethod
    def extensions(regime: RegimeLike) -> list[str]:
        ...

    @staticmethod
    def consistency(data: str, imports: Sequence[tuple[str, str]], premise_iris: Sequence[str], step_cap: int=..., work_cap: int=...) -> tuple[str, str]:
        ...

    @staticmethod
    def classify(data: str, step_cap: int=..., work_cap: int=...) -> tuple[str, str]:
        ...

    @staticmethod
    def realize(data: str, step_cap: int=..., work_cap: int=...) -> tuple[str, str]:
        ...

    @staticmethod
    def instances(data: str, class_: str, step_cap: int=..., work_cap: int=...) -> tuple[str, str]:
        ...

    @staticmethod
    def entails(data: str, axiom: str, step_cap: int=..., work_cap: int=...) -> tuple[str, str]:
        ...

    @staticmethod
    def profile(data: str) -> tuple[str, str]:
        ...

    @staticmethod
    def extract_module(data: str, signature: str, method: str) -> tuple[str, str]:
        ...

    @staticmethod
    def justify(data: str, axiom: str) -> tuple[str, str]:
        ...

    @staticmethod
    def explain_conclusion(data: str, regime: RegimeLike, conclusion: str) -> tuple[str, str]:
        ...

    @staticmethod
    def prove(data: str, service: str, argument: str=..., step_cap: int=..., work_cap: int=...) -> tuple[str, str, str]:
        ...

    @staticmethod
    def check_proof(data: str, service: str, argument: str, answer: str, certificate: str, proof: str) -> str:
        ...

    @staticmethod
    def proof_services() -> list[str]:
        ...

    @staticmethod
    def certain_answers(regime: RegimeLike, data: str, pattern: str, imports: Sequence[tuple[str, str]], premise_iris: Sequence[str], *, max_stored_facts: int | None=None, max_join_steps: int | None=None, max_term_arena_bytes: int | None=None) -> tuple[str, str]:
        ...

    @staticmethod
    def graph_entails(regime: RegimeLike, premise: str, conclusion: str, imports: Sequence[tuple[str, str]], premise_iris: Sequence[str], *, max_stored_facts: int | None=None, max_join_steps: int | None=None, max_term_arena_bytes: int | None=None) -> tuple[str, str]:
        ...

    @staticmethod
    def verify_entailment(regime: RegimeLike, premise: str, conclusion: str, imports: Sequence[tuple[str, str]], premise_iris: Sequence[str], *, max_stored_facts: int | None=None, max_join_steps: int | None=None, max_term_arena_bytes: int | None=None) -> tuple[str, str]:
        ...

    class Reasoner:

        def __init__(self, data: str, step_cap: int=..., work_cap: int=..., proofs: bool=...) -> None:
            ...

        @property
        def records_proofs(self) -> bool:
            ...

        def prove(self, service: str, argument: str=...) -> tuple[str, str, str]:
            ...

        def consistency(self) -> tuple[str, str]:
            ...

        def classify(self) -> tuple[str, str]:
            ...

        def realize(self) -> tuple[str, str]:
            ...

        def instances(self, class_: str) -> tuple[str, str]:
            ...

        def entails(self, axiom: str) -> tuple[str, str]:
            ...

        def profile(self) -> tuple[str, str]:
            ...

        def extract_module(self, signature: str, method: str) -> tuple[str, str]:
            ...

        def justify(self, axiom: str) -> tuple[str, str]:
            ...

        def explain_conclusion(self, regime: RegimeLike, conclusion: str) -> tuple[str, str]:
            ...

        def __repr__(self) -> str:
            ...
_gts_from_quads = gts_from_quads
_gts_from_rdf12_bytes = gts_from_rdf12_bytes
_compile_gts_native = compile_gts_native
_compile_gts_with_report = compile_gts_with_report
_gts_ingest_report = gts_ingest_report
_snapshot_content_id_native = snapshot_content_id_native
_feedback_bundle_native = feedback_bundle_native
_to_json_ld = to_json_ld
_from_json_ld = from_json_ld
_to_rdf_xml = to_rdf_xml
_from_rdf_xml = from_rdf_xml
_gts_relational_rows_from_bytes = gts_relational_rows_from_bytes
_gts_to_sqlite = gts_to_sqlite
_gts_to_duckdb = gts_to_duckdb
_gts_to_parquet = gts_to_parquet
_RdfDataset = RdfDataset
_GtsFoldViewNative = GtsFoldViewNative

class gts:
    gts_from_quads = _gts_from_quads
    gts_from_rdf12_bytes = _gts_from_rdf12_bytes
    compile_gts_native = _compile_gts_native
    compile_gts_with_report = _compile_gts_with_report
    gts_ingest_report = _gts_ingest_report
    snapshot_content_id_native = _snapshot_content_id_native
    feedback_bundle_native = _feedback_bundle_native
    to_json_ld = _to_json_ld
    from_json_ld = _from_json_ld
    to_rdf_xml = _to_rdf_xml
    from_rdf_xml = _from_rdf_xml
    gts_relational_rows_from_bytes = _gts_relational_rows_from_bytes
    gts_to_sqlite = _gts_to_sqlite
    gts_to_duckdb = _gts_to_duckdb
    gts_to_parquet = _gts_to_parquet
    RdfDataset: TypeAlias = _RdfDataset
    GtsFoldViewNative: TypeAlias = _GtsFoldViewNative

class _ArtifactRecord:

    @property
    def role(self) -> str:
        ...

    @property
    def logical_path(self) -> str:
        ...

    @property
    def media_type(self) -> str:
        ...

    @property
    def raw_digest(self) -> str:
        ...

    @property
    def semantic_digest(self) -> str:
        ...

    @property
    def content(self) -> builtins.bytes:
        ...

class _ManifestView:

    @property
    def identifier(self) -> str:
        ...

    @property
    def slice_iri(self) -> str:
        ...

    @property
    def label(self) -> str | None:
        ...

    @property
    def title(self) -> str | None:
        ...

    @property
    def tier(self) -> str | None:
        ...

    @property
    def creators(self) -> list[str]:
        ...

    @property
    def consumers(self) -> list[str]:
        ...

class _SliceRecord:

    @property
    def manifest(self) -> _ManifestView:
        ...

    @property
    def manifest_path(self) -> str:
        ...

    @property
    def slice_dir(self) -> str:
        ...

    @property
    def artifacts(self) -> list[_ArtifactRecord]:
        ...

class _DependencyEdge:

    @property
    def from_slice(self) -> str:
        ...

    @property
    def to_slice(self) -> str:
        ...

    @property
    def is_semantic(self) -> bool:
        ...

    @property
    def reconciliation(self) -> str:
        ...

class _ManifestPatch:

    @property
    def manifest_path(self) -> str:
        ...

    @property
    def original_text(self) -> str:
        ...

    @property
    def patched_text(self) -> str:
        ...

class _OwnershipReport:

    @property
    def edges(self) -> list[_DependencyEdge]:
        ...

    @property
    def has_ownership_defect(self) -> bool:
        ...

    @property
    def ownership_errors(self) -> list[str]:
        ...

class _SliceCatalog:

    @staticmethod
    def discover(root: str, namespace: str) -> _SliceCatalog:
        ...

    @property
    def records(self) -> list[_SliceRecord]:
        ...

    @property
    def core_slice_iris(self) -> list[str]:
        ...

    def fix_deps(self) -> list[_ManifestPatch]:
        ...

class _OwnershipAnalyzer:

    def __init__(self, catalog: _SliceCatalog) -> None:
        ...

    def analyze(self) -> _OwnershipReport:
        ...

    def analysis_graph_turtle(self) -> str:
        ...

class slice:
    ArtifactRecord: TypeAlias = _ArtifactRecord
    ManifestView: TypeAlias = _ManifestView
    SliceRecord: TypeAlias = _SliceRecord
    DependencyEdge: TypeAlias = _DependencyEdge
    ManifestPatch: TypeAlias = _ManifestPatch
    OwnershipReport: TypeAlias = _OwnershipReport
    SliceCatalog: TypeAlias = _SliceCatalog
    OwnershipAnalyzer: TypeAlias = _OwnershipAnalyzer
_Fidelity: TypeAlias = tuple[str | None, str | None]

class _TextAnalyzerSpec(TypedDict, total=False):
    lexicon: Required[TypingLiteral['empty', 'baseline', 'dictionary']]
    artifacts: list[bytes]
    entries: list[tuple[str, int]]
    max_token_scalars: int
    accent: TypingLiteral['preserve', 'latin-greek-cyrillic', 'latin-greek-cyrillic-arabic-hebrew'] | list[TypingLiteral['latin', 'greek', 'cyrillic', 'hebrew', 'arabic']]
    stemming: TypingLiteral['none', 'english']
    input_mode: TypingLiteral['plain', 'html-text', 'html-attribute']
    code_length: int
    edit_distance: int
    projection: TypingLiteral['lexical', 'han']
    substring_posting_operations: int
    substring_candidate_spans: int
    substring_verification_bytes: int
_TextProducerSpec: TypeAlias = tuple[str, str, str, _TextAnalyzerSpec] | tuple[str, str, str, _TextAnalyzerSpec, list[str] | None] | tuple[str, str, str, _TextAnalyzerSpec, list[str] | None, _Attestation] | tuple[str, str, str, _TextAnalyzerSpec, list[str] | None, _Attestation, _Fidelity]
_VectorRows: TypeAlias = list[tuple[str, list[float]]]
_VectorMetric: TypeAlias = str
_VectorGuard: TypeAlias = tuple[int, int]
_HnswProducerSpec: TypeAlias = tuple[str, _VectorRows, _VectorMetric, _VectorGuard, tuple[int, int, int, int], list[str] | None, _Attestation, str | None]
_KnnProducerSpec: TypeAlias = tuple[str, _VectorRows, _VectorMetric, _VectorGuard, list[str] | None, _Attestation, _Fidelity]

class _PlanDocumentError(ValueError):
    """A refusal from the plan-document boundary: `certify_plan`, `explain_depth`.

    `refusal` is one of the engine's pinned kebab-case names. Decode side:
    `version`, `truncated`, `trailing-bytes`, `invalid-tag`, `invalid-utf8`,
    `invalid-iri`, `non-ascending-keys`, `non-ascending-selectivity-terms`,
    `duplicate-stratum-depth`, `duplicate-stratum-derivation`,
    `duplicate-statistics-subject`, `duplicate-selectivity-term`. Certificate
    side: `depth-not-derivable`, `depth-without-derivation`,
    `derivation-without-depth`, `derivation-without-statistics-entry`,
    `statistics-entry-contradicts-derivation`, `selectivity-term-out-of-range`,
    `unconsulted-statistics-subject`. Branch on it, never on `str(exc)`.
    """
    refusal: str

class retrieval:
    SCALE_DIGITS: int
    SCALE: int
    PlanDocumentError: TypeAlias = _PlanDocumentError

    @staticmethod
    def plan(data: str, request: list[tuple[builtins.object, ...]], *, text_producers: dict[str, _TextProducerSpec], statistics: dict[str, builtins.object], top_k: int, hnsw_producers: dict[str, _HnswProducerSpec] | None=None, knn_producers: dict[str, _KnnProducerSpec] | None=None, data_format: str='turtle', base: str | None=None) -> dict[str, builtins.object]:
        ...

    @staticmethod
    def certify_plan(plan_bytes: bytes) -> dict[str, builtins.object]:
        ...

    @staticmethod
    def explain_depth(plan_bytes: bytes, stratum: str) -> str | None:
        ...

    @staticmethod
    def compile(data: str, request: list[tuple[builtins.object, ...]], *, text_producers: dict[str, _TextProducerSpec], statistics: dict[str, builtins.object], top_k: int, hnsw_producers: dict[str, _HnswProducerSpec] | None=None, knn_producers: dict[str, _KnnProducerSpec] | None=None, weights: dict[str, int] | None=None, k: int | None=None, decay: str | None=None, data_format: str='turtle', base: str | None=None) -> dict[str, builtins.object]:
        ...

    @staticmethod
    def search(data: str, request: list[tuple[builtins.object, ...]], *, text_producers: dict[str, _TextProducerSpec], weights: dict[str, int], statistics: dict[str, builtins.object], k: int, decay: str, top_k: int, hnsw_producers: dict[str, _HnswProducerSpec] | None=None, knn_producers: dict[str, _KnnProducerSpec] | None=None, data_format: str='turtle', base: str | None=None) -> dict[str, builtins.object]:
        ...

    @staticmethod
    def weight_for_depth(depth: int, k: int, *, decay: str) -> int:
        ...

    @staticmethod
    def class_width(weight_raw: int, k: int, rank: int, *, decay: str) -> int | None:
        ...

    @staticmethod
    def deepest_rank_within_width(weight_raw: int, k: int, max_width: int, *, decay: str) -> int | None:
        ...

    @staticmethod
    def crossing_rank_at(naming_raw: list[int], at_rank: int, sharing_raw: list[int], k: int, *, decay: str) -> int | None:
        ...

geo.pyi

from __future__ import annotations

class GeoProfile:
    """Immutable strict native profile; omitted input selects standard CRS84."""

    def __init__(self, profile: str | None=...) -> None:
        ...

    def to_json(self) -> str:
        ...

class GeoSession:
    """One Rust session over the strict version-one geographic records."""

    def __init__(self, geo: GeoProfile | GeoSession | str | None=...) -> None:
        ...

    def call(self, request: str) -> str:
        """Return canonical semantic outcomes; raise ValueError on operational refusal."""
        ...

    def point_index(self, request: str) -> GeoPointIndex:
        ...

    def profile(self) -> GeoProfile:
        ...

class GeoPointIndex:
    """Immutable ordered point buckets, constructed by GeoSession.point_index."""

    def call(self, request: str) -> str:
        """Search through the same native comparison law and refusal channel."""
        ...

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; it also accepts a compiled WebAssembly.Module.
  • DataFactory — namedNode, blankNode, literal(value, languageOrDatatype?), typedLiteral, directionalLiteral, variable, defaultGraph, quad, quotedTriple, fromTerm, fromQuad.
  • Dataset (RDF/JS DatasetCore) — Dataset.parse(input, format, base?), serialize(format), add/delete/has/match/quads/size, and iteration (for (const quad of dataset)). Formats: turtle, ntriples, nquads, trig, rdfxml (or their media types); serialize additionally accepts jsonld.
  • Graph 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 — QueryEngine keeps the native plan cache alive across calls and exposes typed select / ask / construct / describe, atomic update, and queryRaw serialization. Dataset.query(...) remains the compatibility raw-string helper. Each evaluating method has a Promise-returning twin that takes host handlers for SERVICE and LOAD — see below.
  • 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-AF sh:rule inferences and returns a ShaclEntailment: ntriples holds the result as N-Triples, and diagnostics holds the shapes graph’s mandatory diagnostics. Call free() on it.
  • Sink — a streaming consumer (push(quad) / finish() → Dataset); datasetToStream / streamToDataset are the async RDF/JS Stream/Sink helpers.

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

Asynchronous queries and federation

The synchronous methods are the offline lane: they install no SERVICE or LOAD source, so a SERVICE or LOAD fails by name unless written SILENT, which succeeds with nothing fetched, as SPARQL 1.1 requires for an invocation that fails. Every evaluating method also has a Promise-returning twin — queryAsync, selectAsync, askAsync, constructAsync, describeAsync, queryRawAsync, queryRawBytesAsync, queryRawWithContextAsync, queryGovernedAsync, queryEntailmentGovernedAsync, updateAsync, updateGovernedAsync and explainQueryAsync on QueryEngine, and Dataset.queryAsync — plus queryGovernedNegotiatedAsync, which answers a governed query as a document in the format an HTTP Accept header negotiates. A twin runs the same evaluator over a snapshot of the dataset taken when the call starts, as a job that suspends while the host answers a SERVICE or LOAD and gives the event loop back while it evaluates, and it resolves to exactly what its synchronous twin returns. The host does the I/O and owns its policy; PurRDF keeps the parsing, the evaluation, the joins, the SILENT semantics and the result encoding.

EXPLAIN is one of these evaluating methods: its charge ledger is measured by running the query, not predicted from its text. explainQuery therefore refuses a SERVICE query for want of a source, while explainQueryAsync explains it over the host’s answer. Its measuring run yields and stops on its signal like any other job, and it takes no ceiling, because the run is metered, never bounded.

SHACL validation evaluates SPARQL as well: sh:SPARQLTarget queries, SHACL-SPARQL constraints, and SHACL-AF node expressions and rules. The SHACL functions therefore have twins too: shaclValidateToSarifAsync, shaclValidateChangesToSarifAsync, shaclEntailAsync, shaclApplyRulesAsync, shaclEvalNodeExprAsync, and shaclProductValidateToSarifAsync with its Rebuild, Expecting and RebuildExpecting forms. Each takes exactly its synchronous twin’s arguments followed by the host options and returns exactly what the synchronous twin returns; a refused product rejects with the same ShaclProductRefusal, and an owl:imports closure not in hand with the same ShaclImportError. The signal is polled between focus nodes as well as inside queries, so a validation with no SPARQL in it still yields and stops. SHACL-SPARQL admits no SERVICE in any query, a sh:SPARQLTarget’s included; a query that uses it is refused, on either lane.

The twins run over WebAssembly JavaScript Promise Integration (JSPI), on by default in Chrome and Edge 137+, Firefox 139+, Safari 27, Node 24.20+ and Cloudflare Workers (workerd). hasAsyncQueries() reports whether the current engine has it; where it does not, every twin rejects with one error before it touches wasm, and the synchronous API works as before.

Answering SERVICE

resolveService(request, ctx) receives the SPARQL 1.1 Protocol POST to send: endpoint, queryText, contentType (application/sparql-query), accept (application/sparql-results+json), userAgent, timeoutMs, headers — the catalog profile’s headers and credential as [name, value] pairs in sending order — and cacheable (false for a request carrying a credential). ctx carries signal (fires on cancellation, the request’s timeout or the deadline), remainingDeadlineMs, silent and maxIntermediateCells. The handler answers with SPARQL Results JSON (bytes or a string), a Response (a non-2xx status is a transport failure), { kind: "transport", message } or { kind: "denied", message }. A handler that throws, rejects, returns anything else or names a failure kind the protocol does not define has faulted, and the fault is the failure of the one invocation it was answering, { kind: "fault" }. Any failure fails the query — a fault with native-sparql-host-fault, its message carrying the handler’s words; under SERVICE SILENT it is the join identity, and the evidence’s silenced records the endpoint and the failure’s kind (transport, denied, fault, …). ctx.silent is for information only: an empty answer is not the handler’s to invent.

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

await ready();

// One SERVICE request, sent as a SPARQL 1.1 Protocol POST.
async function resolveService(request, { signal }) {
  try {
    // A Response is an answer as it stands: a 2xx body is read as SPARQL Results
    // JSON, and any other status is a transport failure.
    return await fetch(request.endpoint, {
      method: "POST",
      headers: [
        ["Content-Type", request.contentType], // application/sparql-query
        ["Accept", request.accept], // application/sparql-results+json
        ...request.headers, // the catalog profile's headers, in sending order
      ],
      body: request.queryText,
      // Fires at the request's timeout, the job's deadline or its cancellation.
      signal,
    });
  } catch (error) {
    // A network error is the endpoint's failure; a throw would be reported as this
    // handler's own fault instead.
    return { kind: "transport", message: String(error) };
  }
}

const engine = new QueryEngine();
const dataset = Dataset.parse(
  "<https://example.org/a> <https://example.org/p> <https://example.org/o1> .\n",
  "nquads",
);
const { rows } = await engine.selectAsync(
  dataset,
  `SELECT ?s ?x WHERE {
     ?s <https://example.org/p> ?o
     SERVICE <https://remote.example.org/sparql> { ?o <https://example.org/q> ?x }
   }`,
  { resolveService },
);
for (const row of rows) console.log(row.s.value, row.x.value);

A ServiceCatalog passed as catalog authorizes every request — every SERVICE, every LOAD source and every location a LOAD is redirected to — before the handler is called (deny by default, one profile per endpoint, an optional fallback), and its profile’s timeoutMs bounds each request; a denial fails the query, and under SERVICE SILENT is the join identity. localServices answers named endpoints in process from a Dataset. resolveLoad answers one hop of a LOAD the same way, with a document and its media type, a Response, a Dataset, { kind: "redirect", location } (the job resolves and re-authorizes the location, up to five hops), or a typed failure: each failure fails the request, and LOAD SILENT succeeds over it with nothing loaded. Every error the package throws carries its stable code as error.code.

In a browser, the remote endpoint’s CORS policy governs whether fetch can read its answer: an endpoint that does not allow the page’s origin surfaces as a network error, which the handler above reports as a transport failure. Write SERVICE SILENT where an endpoint may be unreachable and its rows are optional.

A variable endpoint, SERVICE ?e { … }, answers every row with the ?e of the endpoint that produced it. ?e must be bound in every solution that reaches the clause, and where it is bound decides how many requests the clause makes. Bound by a pattern earlier in the same group (a triple pattern, VALUES, BIND, or LATERAL { SERVICE ?e { … } }), the clause is evaluated once per solution with that solution’s bindings substituted into the forwarded query: one request per solution, so two solutions that name the same IRI send it two requests, which differ in the bindings they carry. Bound by the left side of the OPTIONAL, MINUS or group join whose right side holds the clause — ?g ex:endpoint ?e OPTIONAL { SERVICE ?e { … } }, and likewise with MINUS or { ?g ex:endpoint ?e } { SERVICE ?e { … } } — or, since a join is commutative, by the other side of the group join holding it, as in { SERVICE ?e { … } ?g ex:endpoint ?e } (not an OPTIONAL or MINUS right side, which need not bind it), the right side is still evaluated on its own, the left side supplies only the list of endpoints to ask, and each distinct IRI is asked once. A left row whose endpoint answers nothing keeps its bindings under OPTIONAL and is not removed under MINUS. Under SERVICE SILENT, an endpoint that fails is the join identity for its own left rows alone: under a group join or OPTIONAL it contributes one row binding only ?e, so its left rows survive unextended and no other endpoint’s rows change; under MINUS the left rows are subtracted one endpoint at a time, so it removes none of its own rows while an answering endpoint still removes its matches. An ?e bound to a literal or a blank node names no endpoint: an error, and under SILENT the join identity for its rows. A clause for which no solution binds ?e — bound nowhere, bound in only some left solutions, or bound only outside a further OPTIONAL or MINUS right side, an EXISTS, a LIMIT/OFFSET, an aggregate not grouped by ?e, or a sub-SELECT that does not project it — is refused, SILENT or not: no invocation is made for SILENT to absorb. The error names the rewrite: bind ?e before the clause, as in ?s ex:endpoint ?e . SERVICE ?e { … }.

Yielding, cancellation and concurrency

  • A job gives the event loop one turn every yieldEveryPolls governor polls (65 536 by default; 0 yields at every poll). Every turn is one setTimeout(…, 0) task, on every host, so the requests, timers and fetch responses already waiting run between a job’s turns. Only evaluation yields: freezing the dataset and serializing the result run to completion, and evidence.async reports what each phase cost.
  • signal: AbortSignal cancels a job at its next yield or host effect. On the governed twins, deadlineMs includes the time spent waiting for the handlers, and a trip — a deadline or a cancellation included — is an outcome, not a rejection.
  • A query reads its own snapshot. One asynchronous update of a dataset may be in flight at a time: another begun meanwhile is refused with native-sparql-update-in-flight, not queued, and an update is applied only if the dataset was not mutated while it ran. configureAsync({ maxConcurrentJobs }) bounds the jobs in flight (16 by default).
  • Each job evaluates on its own stack region, exactly as large as the module’s own shadow stack — the stack the synchronous lane runs on; no option sizes it. evidence.async.stackHighWaterBytes reports how deep the job went, and a request too deep for the region fails with the same typed stack refusal as one too deep for the synchronous lane. Each lane spends a little stack on its own frames, so the deepest request each evaluates can differ by a few levels.
  • A region is the size of the shadow stack only. V8 gives a job’s own call stack the same size as the synchronous lane’s (984 KiB by default, set for the whole process), and PurRDF keeps a fixed budget of it for evaluating a request, so on both lanes a request’s graph patterns nest at most 284 levels deep. Past that is native-sparql-host-stack-exhausted, refused before evaluation on either lane: nest the request less deeply. Parsing keeps a request’s nesting in linear memory and spends neither stack on it.
  • A job whose frames overwrite the canary word at its region’s base fails with a fault naming the region. A trap, a Rust panic or a JavaScript exception thrown through the module’s frames poisons the instance, on either lane: from then on every call into the package — synchronous ones and objects created before the trap included — throws, the asynchronous twins and ready() reject, and releasing an object (free(), [Symbol.dispose]()) does nothing. Only a fresh JavaScript realm (a new page, Worker isolate or process) can load it again.

Cloudflare Workers

@blackcatinformatics/purrdf/cloudflare provides createFetchServiceResolver, createFetchLoadResolver and handleSparqlRequest, which answers one SPARQL 1.1 Protocol request with a Response: 200 with the negotiated document, 413 when the body exceeds maxRequestBytes (1 MiB by default — a query or update’s text is a program, not a payload), 422 or 503 when a governor stopped it (never a 200 with a partial body), application/problem+json errors, Server-Timing from the job’s evidence, and CORS when asked for. A request the engine refuses to evaluate as written — an unsupported construct, an unregistered function, nesting past the host-stack budget — is a 400 whose code is the engine’s diagnostic code, and so is an operation that does not parse. A SERVICE endpoint or LOAD source the host refuses to contact — the catalog withholds a capability, or the resolver’s own policy refuses — is a 403, an update begun while another update of the dataset is in flight is a 409, and an endpoint or source that was contacted and gave no usable answer is a 502; each carries the engine’s diagnostic code as its code and a fixed detail, never the engine’s message, which would echo the catalog’s policy or a resolver’s or remote’s own words. A 500’s detail is the engine’s own words for the query’s own failures (a parse, an evaluation, a tripped governor); a fault this endpoint cannot attribute to the query — no resolver reaching a named endpoint, a resolveService/resolveLoad that throws, or any other exception the adapter did not otherwise classify — never reaches the response as its own message or stack: the client gets a fixed generic detail and a correlationId, and the real error goes to onInternalError (one console.error line by default) alone. A throwing handler under SERVICE SILENT or LOAD SILENT is the clause’s own answer instead — the response is that answer (200, or 204 for an update), and the bug still goes to onInternalError under its correlation id. A Content-Length over the bound is refused before anything is read; a missing or understated one is still caught by counting bytes as the body streams in, so a lying header never buys a larger body than an honest one would. Both resolvers fetch with redirect: "manual": a SERVICE request never follows a redirect (a 3xx is a typed transport failure, so a catalogued endpoint’s headers and credential can never reach a different origin), and a LOAD resolver hands a redirect back to the job, which re-authorizes the location against the catalog before every hop, up to five. A complete Worker:

import wasm from "@blackcatinformatics/purrdf/purrdf_wasm_bg.wasm";
import { ready, Dataset, QueryEngine, ServiceCatalog } from "@blackcatinformatics/purrdf";
import { createFetchServiceResolver, handleSparqlRequest } from "@blackcatinformatics/purrdf/cloudflare";

await ready(wasm);
const engine = new QueryEngine();
const dataset = Dataset.parse(
  "<https://example.org/a> <https://example.org/p> <https://example.org/o1> .\n",
  "nquads",
);
const catalog = new ServiceCatalog();
catalog.addService(
  "https://remote.example.org/sparql",
  JSON.stringify({ capabilities: ["query", "network"], timeoutMs: 5_000 }),
);

export default {
  fetch(request, env) {
    const resolveService = createFetchServiceResolver({
      bindings: { "https://remote.example.org": env.REMOTE },
      cache: caches.default,
      cacheTtlSeconds: 300,
    });
    return handleSparqlRequest(request, {
      engine,
      dataset,
      catalog,
      resolveService,
      cors: { origins: "*" },
      governors: { deadlineMs: 10_000, maxRemoteRequests: 40 },
    });
  },
};

Workers limits how many subrequests one invocation may make, and maxRemoteRequests is the exact control for it: every SERVICE request and every LOAD is charged before it reaches the handler, so a request never makes more subrequests than the ceiling. A SERVICE ?e bound by a pattern earlier in its group is charged once per solution that reaches it, not once per endpoint, so size the ceiling to those solutions. The Cache API does nothing on workers.dev hostnames, so caching is effectively off there; on a custom domain it works. A runtime started without a cache configured (a locally run workerd, for example) rejects cache.match and cache.put with “No Cache was configured”, and a failed cache call is that request’s transport failure — the join identity under SERVICE SILENT, the query’s failure without it — so configure no cache there. A request carrying a credential never touches the shared cache. On Workers Date.now() does not advance during CPU-bound execution, so a synchronous deadlineMs cannot trip during CPU-bound work there; the asynchronous lane observes the deadline at every yield and every effect.

The package README is the complete reference for these contracts.

Scope and current limitations

  • In-memory only. SPARQL queries run over the in-memory dataset. The synchronous methods install no SERVICE or LOAD source, so there a remote SERVICE or LOAD fails explicitly unless written SILENT; the asynchronous twins reach remote endpoints only through the handlers the host passes them.
  • Triple terms per format. serialize is the writer-native lane: an object-position quoted-triple term and the RDF 1.2 statement layer survive Turtle, N-Triples, N-Quads and TriG (as <<( … )>>), RDF/XML (as rdf:parseType="Triple"), and JSON-LD / YAML-LD (as @triple). TriX and HexTuples have no triple-term surface, so serializing a dataset that carries one to either of them throws rather than dropping the layer silently. Single-graph targets (Turtle, N-Triples, RDF/XML) emit the default graph alone.

Building from source

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

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

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

Getting Started: C

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

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

Building

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

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

A first program

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

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

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

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

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

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

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

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

The ABI contract

  • No unwinding across the boundary. Fallible entry points use catch_unwind; a caught unwinding panic becomes PURRDF_STATUS_PANIC. Entry points without an error channel return their guard’s default value after a caught panic. Aborting panics and other process aborts cannot be converted into status codes.
  • int32_t status + out-params. Fallible functions return a PurrdfStatus value and write results through out-pointers. PURRDF_STATUS_CURSOR_EXHAUSTED is the (non-error) end-of-rows signal.
  • SemVer-frozen ABI. The status enum is append-only; new fields and functions are additive. purrdf_abi_version reports the current ABI version at runtime; the PURRDF_ABI_MAJOR/_MINOR/_PATCH macros give the same triple for the header you compiled against, and comparing the two is how you check a library you did not build. The minor number tracks the exported signatures: any change to a parameter list, a return contract, or the documented behaviour of an exported symbol bumps it, additive parameters included, because an additive parameter is still a recompile for every C consumer.

Ownership and lifetimes

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

GTS star-layer round-trip

The GTS star layer round-trip (purrdf_to_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

PurRDF’s resident carriers share one value-interned RDF 1.2 term representation, owned by the ring-fenced purrdf-core kernel.

Terms are interned once

Every term — IRI, blank node, literal, triple term — is interned once and addressed by a copyable TermId (a niche-optimized NonZeroU32). Textual payloads use the string arena; triple terms retain their component IDs. 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 the fixed-key FixedHasher — 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:

#![allow(unused)]
fn main() {
use purrdf::{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);
Ok::<(), Box<dyn std::error::Error>>(())
}

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

An explicit graph-free carrier

Choose TripleDatasetBuilder when the data has no named graphs. Its asserted rows store subject, predicate and object in 12 bytes; reifier bindings take 8 bytes and annotations take 12 bytes. It shares term interning and validation with RdfDatasetBuilder, but accumulates no quad rows or graph table.

TripleDataset::try_from moves the original term owner out of an all-default quad dataset. It refuses any named row or named graph declaration, including an empty graph, with TripleReadError::NamedGraphsUnsupported.

Open TripleDataset::read() for a fallible view. Named-graph query requests refuse even when the carrier is empty or the graph name is absent. All children of that read retain the same first failure; a new read over the unchanged carrier starts an independent operation. The engine’s ordinary default-graph query path uses the compact carrier’s SPO, POS and OSP orders.

For rule materialization, purrdf::shapes::srl::infer_triples accepts that read and returns Inference<TripleDataset>. Its Datalog rows have three positions and predicate-only partition keys. The ordinary quad srl::infer API keeps its existing result type. Text indexing selects TextIndexConfig::for_triples explicitly; document keys become subject/language and partition keys language, with distinct graph-free identities.

Copy-on-write mutation

“Immutable” does not mean “static”. MutableDataset accumulates edits in a copy-on-write delta over a frozen base. Its snapshot_view() shares the unchanged base rows, terms and indexes. Its freeze() instead compacts the effective dataset into a new frozen dataset, re-interning surviving terms and rebuilding dense IDs. SPARQL UPDATE and the C ABI’s mutable PurrdfGraph handle both use this mutation layer.

What else lives in the kernel

Beyond the IR itself, purrdf-core owns:

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

Text codecs are not in the kernel — parsing and serialization live one layer up in purrdf-rdf. The split keeps the kernel small and its invariants enforceable at the crate boundary: no 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 bench crates/rdf-core/benches/ir_layout.rs compares array-of-structs, struct-of-arrays, and predicate-adjacency layouts on allocation counts, high-water memory, and end-to-end latency — the shipped layout is whichever wins. See Performance.

RDF 1.2 Features

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

Triple terms

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

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

Reifiers and annotations

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

Reifier bindings and annotations survive every star-capable codec round-trip; projections into star-incapable formats drop them loudly, with the realized count handed to the loss ledger (see Slices, Mappings & Provenance). SHACL support for validating reified statements — the SHACL 1.2 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, and so is SHACL 1.2: the W3C SHACL 1.2 test suite, reifier shapes included, passes in full. Where a feature is scoped, the scope is stated explicitly and gated by tests — never left as a silent partial implementation. The live per-feature status is the conformance matrix in docs/CONFORMANCE.md.

Where each feature shows up

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

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

RDF 1.2 Visualization

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

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

Ordinary shared resources

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

Open compact SVG

Ordinary RDF graph with shared resources

Exact statement incidence view

Open exact SVG

Ordinary RDF exact statement incidence graph

Statement table

Open table SVG

Ordinary RDF statement table

Asserted, reified, and annotated statements

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

Open compact SVG

Asserted RDF statements with reifiers and annotations

Exact statement incidence view

Open exact SVG

Exact incidence graph for asserted and reified statements

Statement table

Open table SVG

Statement table for asserted and reified RDF

Quoted-only and nested triple terms

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

Open quoted-only compact SVG

Quoted-only RDF triple terms and their reifiers

Quoted-only exact view and table

Open exact SVG · Open table SVG

Quoted-only exact incidence graph

Quoted-only statement table

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

Open nested compact SVG

Nested triple terms with RDF dialect diagnostics

Nested exact view and table

Open exact SVG · Open table SVG

Nested exact statement incidence graph

Nested statement table

Dense connected data

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

Open dense exact SVG

Dense RDF exact statement incidence graph

Dense compact view and table

Open compact SVG · Open table SVG

Dense compact RDF resource graph

Dense RDF statement table

Regenerating the samples

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

make book-samples
make book

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

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

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

Every operation has four non-negotiable properties:

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

Profiles

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

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

One canonical LPG model

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

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

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

LPG scope, limits, progress, and memory

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

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

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

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

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

CSVW

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

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

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

A CsvwTermsConfig contains only mandatory policy:

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

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

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

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

Research-object carriers

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

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

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

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

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

Native RDF dataset descriptions

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

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

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

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

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

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

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

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

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

OBO Graphs and SKOS views

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

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

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

Configuration

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Rust archive API

#![allow(unused)]
fn main() {
use purrdf::{
    LiftProfile, LpgConfig, LpgExecutionLimits, LpgScope, ProjectionConfig,
    ProjectionLimits, ProjectionProfile, lift_archive, parse_dataset,
    project_archive, datasets_isomorphic,
};

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);
assert!(datasets_isomorphic(&dataset, &lifted.dataset));
Ok::<(), Box<dyn std::error::Error>>(())
}

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

Other production surfaces

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

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

Runnable examples live at:

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

Determinism and verification

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

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

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

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

Codecs & Determinism

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

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

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

#![allow(unused)]
fn main() {
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");
assert_eq!(nq.as_slice(), b"<https://example.org/cat> <https://example.org/says> \"meow\" .\n");
}

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 the fixed-key FixedHasher for exactly this reason), and golden-file tests pin the emitted bytes.

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

Diagnostics, not partial parses

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

Lossy projections are loud

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

The succinct pack codec

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

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

Deterministic embedding companions

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

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

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

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

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

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

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

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

The columnar Parquet codec

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

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

Conformance

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

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

Base IRIs & Relative References

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

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

Where the base comes from

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

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

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

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

Which surfaces have a retrieval IRI

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

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

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

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

Two grammar families

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

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

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

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

Beyond documents: the IR boundary

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

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

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

When you see the error

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

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

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

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

One exception you may rely on

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

The diagnostic codes

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

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

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

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

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

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

Writing a base out

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

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

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

--base on the command line

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

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

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

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

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

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

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

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

If data.ttl declares its own base, keep that parsing base while writing a new output base:

purrdf convert data.ttl --to turtle --base http://example.org/v2/

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

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

Conformance

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

JSON-LD Contexts & Compaction

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

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

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

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

Rust

#![allow(unused)]
fn main() {
use purrdf::{
    JsonLdSerializeOptions, SerializeGraph, parse_dataset, serialize_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)?;
assert!(jsonld.contains("\"schema:name\""));
let reopened = parse_dataset(jsonld.as_bytes(), "application/ld+json", None)?;
assert_eq!(
    serialize_dataset(&reopened, "application/n-quads", SerializeGraph::Dataset)?,
    serialize_dataset(&dataset, "application/n-quads", SerializeGraph::Dataset)?,
);
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:

#![allow(unused)]
fn main() {
use purrdf::{canonicalize, parse_dataset};

let ds = parse_dataset(b"_:source <https://example.org/name> \"Alice\" .", "application/n-triples", None)?;
let renamed = parse_dataset(b"_:renamed <https://example.org/name> \"Alice\" .", "application/n-triples", None)?;
let canon = canonicalize(&ds);
// Canonical labels are stable across runs, hosts, and language bindings.
assert_eq!(canon.nquads, canonicalize(&renamed).nquads);
Ok::<(), Box<dyn std::error::Error>>(())
}

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

Native relabeling with source attribution

Use canonical_relabel when the next stage consumes a native dataset. It keeps RDF 1.2 terms, reifiers, annotations, graph declarations and source locations without rendering and reparsing a canonical document.

When a compiler or analyzer must associate source nodes with that result, use canonical_relabel_with_mapping. It records the actual term mapping during the same rebuild, after one canonical-label search:

#![allow(unused)]
fn main() {
use purrdf::{canonical_relabel_with_mapping, datasets_isomorphic, parse_dataset, TermRef};

let source = parse_dataset(b"<https://example.org/alice> <https://example.org/knows> _:friend .", "application/n-triples", None)?;
let source_node = source.term_id_by_iri("https://example.org/alice").ok_or("source IRI not interned")?;
let result = canonical_relabel_with_mapping(&source)?;
let output_node = result.map_term(source_node).ok_or("source node not retained")?;
let canonical_value = result.dataset().resolve(output_node);
assert!(matches!(canonical_value, TermRef::Iri("https://example.org/alice")));
// Associate source receipts and owner metadata with canonical_value here.
let canonical_dataset = result.into_dataset();
assert!(datasets_isomorphic(source.as_ref(), &canonical_dataset));
Ok::<(), Box<dyn std::error::Error>>(())
}

source_node must belong to the exact input dataset; output_node belongs to this result. A same-index ID from another dataset cannot be detected. The map includes nested triple components, literal datatypes, composite embedded blanks and already-interned IRIs, reifier and annotation terms, and empty named graphs. Unused dictionary entries omitted by the rewrite return None.

The map uses four bytes per source term, with constant-time lookup. The existing canonical_relabel API does not allocate it. The map is not a durable identity or proof certificate: persist resolved RDF identities with authenticated input and output receipts, never raw TermId values. Source roles and standpoint ownership remain the caller’s metadata, not consequences of canonical labels.

Over RDF 1.2 constructs: two forms, named apart

RDFC-1.0 has no notion of reifiers, annotations or triple terms. A dataset that is plain RDF 1.1 canonicalizes identically everywhere in PurRDF; one that carries those constructs canonicalizes in one of two forms, and which one you are holding is part of the identity:

  • The flat form — canonical_flat_nquads in purrdf-rdf rewrites the statement layer to plain rdf:reifies / annotation triples first and canonicalizes that triple set under conformant RDFC-1.0. This is what the CLI’s convert --canonical, the wasm Dataset.canonicalize() and the W3C conformance gate run.
  • The purrdf-rdfc12 v2 profile — canonicalize in purrdf-core keeps the statement layer and lowers it into a reserved urn:purrdf:rdfc: namespace instead (any input already carrying that namespace is refused, unless it is written in one of the two shapes the profile itself emits — those fold back into the statement layer, which is what makes canonicalization idempotent over its own output). It agrees with RDFC-1.0 byte for byte only on the RDF 1.1 subset, and a digest taken over its output must not be labelled RDFC-1.0; CANON_PROFILE_ID / CANON_PROFILE_VERSION name the profile at runtime. The normative text is docs/RDF12-CANON-PROFILE.md.

Isomorphism

datasets_isomorphic(a, b) decides whether two frozen datasets are RDF-structurally isomorphic: the same quads, reifier bindings, and annotations under a single blank-node bijection. Canonicalization gives the equivalent verdict — two datasets are isomorphic iff their canonicalizations are equal — but the direct check is the convenient form for tests and harnesses. PurRDF’s own conformance harnesses use RDFC-1.0 isomorphism to compare, for example, SHACL Rules output graphs against expected inferred graphs.

Diff

dataset_diff(a, b) produces a structural diff between two frozen datasets, including an isomorphic verdict. For a human-facing review flow, purrdf-rdf additionally provides per-subject Symmetric-CBD extraction (“describe”) and a review-friendly Turtle normalizer, so a graph change reads like a code change.

Choosing the right tool

NeedUse
Same dataset → same bytesany native serializer (always true)
Same graph (up to blank nodes) → same bytes, RDF 1.1 subsetRDFC-1.0 (canonicalize or canonical_flat_nquads, identical here)
Same graph with reifiers/annotations → same bytescanonical_flat_nquads (RDFC-1.0 over the flattened layer) or canonicalize (purrdf-rdfc12 profile) — not interchangeable
“Are these two datasets the same graph?”datasets_isomorphic
“What changed between these datasets?”dataset_diff + describe/normalize
Content-addressed transport of a graphGTS (BLAKE3 content ids)

API details are on docs.rs/purrdf-core (the canonicalize, datasets_isomorphic, and dataset_diff items) and docs.rs/purrdf-rdf (describe and normalization).

SPARQL: Querying

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

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

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

A first query

#![allow(unused)]
fn main() {
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").expect("subject");
let says = b.intern_iri("https://example.org/says").expect("predicate");
let meow = b.intern_literal(RdfLiteral::simple("meow")).expect("object");
b.push_quad(cat, says, meow, None).expect("quad");
let ds = b.freeze().expect("freeze");

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

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

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

What the front-end covers

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

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

Named graph lifetime

The default, GraphExistenceMode::RememberEmpty, remembers a named graph from its creation until it is dropped, whether or not it holds a row. CREATE registers an empty slot and refuses a duplicate unless SILENT; CLEAR and the removal of a graph’s last row keep the slot, and DROP removes it. A missing CLEAR or DROP target, or ADD, COPY or MOVE source, is refused before anything changes, unless SILENT. Both constant and variable GRAPH queries see declared empty graphs. The default graph always exists.

GraphExistenceMode::Implicit is the opt-out, and keeps the 3.x row-based lifetime: CREATE registers no slot, and CLEAR and DROP both remove the named graph. Select it with MutableDataset::new_with_graph_existence or QueryOptions::with_graph_existence.

Python selects the same policy with Store(remember_empty_graphs=False) or MutableDataset(remember_empty_graphs=False); the keyword defaults to True. Their shared add_graph and named_graphs methods use native named or blank graph terms. UPDATE, governed UPDATE, Store.checkpoint and MutableDataset.compact preserve the selection. WebAssembly selects it with new Dataset(false) or Dataset.parse(input, format, base, false); the C ABI with PURRDF_GRAPH_EXISTENCE_IMPLICIT as the graph_existence argument of purrdf_graph_from_dataset, purrdf_update_governed, purrdf_update_governed_xpath_regex or purrdf_update_governed_with_geo; and the command line with purrdf update --graph-existence implicit. Loading and dumping declared empty graphs through TriG, JSON-LD and TriX works in both modes; mode is mutation policy rather than serialized data. A stopped UPDATE publishes nothing, including declarations made by earlier operations.

How the evaluator works

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

Numeric limits

PurRDF computes xsd:integer and xsd:decimal exactly, at every size. XSD 1.1 lets a processor limit these two value spaces, provided it supports at least 16 decimal digits and documents its limits. PurRDF does not limit them:

DatatypeValues
xsd:integer, xsd:nonNegativeInteger, xsd:positiveInteger, xsd:nonPositiveInteger, xsd:negativeIntegerevery integer of the type’s sign, of any length
xsd:long, xsd:int, xsd:short, xsd:byte and the unsigned typesthe type’s own range
xsd:decimalevery decimal, of any length, with up to 4,294,967,295 digits after the point

A value that fits a signed 128-bit integer, or a signed 128-bit mantissa with at most 18 digits after the point, is computed in machine arithmetic. Every other value is computed on an arbitrary-precision representation, and the answer is the same. +, - and * are exact: "170141183460469231731687303715884105727"^^xsd:integer + 1 is 170141183460469231731687303715884105728, and 0.1 * 0.1 * 0.1 is 0.001.

Division and numeric casts use these precision rules:

  • Division. F&O leaves the precision of a decimal quotient to the implementation. By default PurRDF keeps every digit of a terminating quotient, even beyond 18 places: 1 / 1048576 is 0.00000095367431640625. A repeating quotient is rounded to 18 fractional digits, half to even: 2 / 3 is 0.666666666666666667. The caller chooses another precision with QueryOptions::with_division in Rust, --division in the command line, or the division setting of the C, WebAssembly and Python bindings. The setting is written exact, N (N digits, truncated toward zero), N:ROUNDING (for example 5:half-even) or exact-or-N[:ROUNDING]. Under exact every terminating quotient is exact. A non-terminating quotient, such as 1 / 3, is an expression error (err:FOAR0002) and leaves the result unbound. AVG divides under the same setting.
  • To xsd:float or xsd:double. The value is rounded once, to the nearest value, ties to even, from its exact value at any size.
  • From xsd:float or xsd:double to xsd:decimal. Every binary value is a finite decimal, so the cast is its exact value: xsd:decimal("0.1"^^xsd:double) is 0.1000000000000000055511151231257827021181583404541015625.

Everything else is an error, and the expression is unbound: 1 / 0 is err:FOAR0001, a value outside a derived type (xsd:byte(300)) or a malformed lexical form is err:FORG0001, NaN or an infinity cast to xsd:decimal or an integer type is err:FOCA0002, and a non-numeric operand is err:XPTY0004. A governed query counts each code it absorbed in its evidence (GovernorEvidence::expression_errors; a governed purrdf query writes the counts to stderr), and every error message purrdf_xsd reports ends with its code, for example (err:FORG0001). The negation and the absolute value of a derived integer type are xsd:integer, so -("5"^^xsd:unsignedByte) is -5 typed xsd:integer. A decimal with more than 4,294,967,295 digits after the point is err:FOAR0002; no such value fits in memory.

Comparison at any size

Comparison is exact for numbers of any length: the comparison operators, =, !=, IN, ORDER BY, MIN, MAX and the elements of a cdt:List. A number compared with an xsd:float or xsd:double follows SPARQL’s promotion to that type, except in ORDER BY, which compares the exact values so that its order stays transitive.

PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?big WHERE {
  VALUES ?big { "100000000000000000000000000000000000000001"^^xsd:integer }
  FILTER(?big > "100000000000000000000000000000000000000000"^^xsd:integer)
}

This query binds ?big. SHACL range constraints, ShEx numeric facets, CSVW datatype facets and OWL 2 value identity compare numbers the same exact way. ShEx compares a facet against its bound exactly as written, so MININCLUSIVE 100000000000000000001 and MAXEXCLUSIVE 0.30000000000000001 keep every digit. OWL 2 RL value identity compares an exact number with a double exactly too, so 100000000000000000000000000000000000000000 and 100000000000000000000000000000000000000001 are both different from the double 1.0E41.

SUM and AVG

SUM adds its values exactly and returns the exact total, however large. AVG is SUM divided by COUNT under the division setting, on every group, so the two always agree. The average of the integers 170141183460469231731687303715884105727 and 170141183460469231731687303715884105726 is "170141183460469231731687303715884105726.5"^^xsd:decimal. Every result is a value of its datatype, and reads back as itself.

Resource governance

A long number costs more to compute with than a short one, and a short query can make one: squaring 0.1 twenty times gives a decimal with more than a million digits. Every operation on a value past the machine-word representations is charged to the governor before it runs: its work against the fuel ceiling, and its working memory against the scratch-byte ceiling. The rendering of a result is charged too, so the million-digit square is paid for when it is written. A query whose numbers all fit the machine-word representations charges nothing extra. See docs/SPARQL-GOVERNOR-PROFILE.md.

Pre-bound variables

A prepared execution’s parameters and a request’s substitutions bind variables before evaluation. A pre-bound variable is one value for the whole evaluation, at every depth: an OPTIONAL or MINUS right arm, a sub-SELECT and an EXISTS body see it, and it survives a GROUP BY as a constant, so SELECT $this (COUNT(*) AS ?c) answers the bound node. Two rules follow.

  • An assignment joins with the bound value. Where ?p is already in scope, a BIND(… AS ?p) is no SPARQL query (§18.2.1) and fails to parse, bound or not. Every other assignment joins with the bound value where it is made (§18.5), by one rule at every depth, a sub-SELECT that does not project ?p and an EXISTS body included: no scope has a ?p of its own. With ?p bound to :a, an assigned :z leaves the assigning pattern no row, so ?x :p ?o BIND(:z AS ?p) and { SELECT ?o WHERE { ?x :p ?o BIND(:z AS ?p) } } answer nothing, alone or beside any other pattern, and OPTIONAL { BIND(:z AS ?p) } keeps each left row unextended. Assigning :a itself keeps the rows, and an assignment whose expression errors leaves ?p the bound value.
  • MINUS sees the bound value on both sides, whether or not a side mentions ?p, so every right row shares ?p with every left row and subtracts it: ?x :p ?o MINUS { ?s :q ?w } answers no row once :q has a triple, as ?x :p ?o MINUS { ?p :q ?w } does, and a right side with no row subtracts nothing.

rdflib 7.6’s initBindings is not the reference for this behaviour: it rebinds a pre-bound name wherever the query assigns it, so an assignment there overrides the bound value instead of joining with it. The native query APIs follow the single join rule above at every depth.

A VALUES ?p { … } joins with the bound value where it is written, by the same rule at every depth: it keeps the rows that agree with the bound value, so VALUES $this { ex:b }, with $this bound to ex:a, has no row. In the query’s WHERE group that answers no row; in an OPTIONAL arm it keeps each left row unextended; in a MINUS operand it subtracts nothing; and in a sub-SELECT that does not project the name it leaves the sub-SELECT no row, alone or beside any other pattern. Every row a sub-SELECT makes carries the bound value before it is deduplicated or grouped, so SELECT DISTINCT and GROUP BY ?p there answer alike beside another pattern and alone.

SHACL is stricter. SHACL 1.2 SPARQL Extensions, Appendix A forbids MINUS, a VALUES that mentions a pre-bound name and an AS over one in a query executed with pre-bound variables, and the SHACL lanes refuse them when a shapes graph loads. The VALUES rule differs by lane:

  • A node expression and sh:expression, like a sh:SPARQLFunction body and a sh:SPARQLTargetType query, refuse only a VALUES that mentions a pre-bound name, as Appendix A words it: VALUES ?v { true } loads.
  • A sh:sparql constraint, like a SPARQL-based component’s validators, a sh:SPARQLTarget’s sh:ask and a SPARQL rule, refuses every VALUES. The vendored W3C SHACL case unsupported-sparql-002 requires the refusal of VALUES ?any { true }, where ?any is not pre-bound.

See SHACL validation.

The rdflib compatibility shim compiles contextual mappings in Rust through the shared native evaluator. purrdf.compat.rdflib.Graph.query(..., initBindings=...) keeps initial bindings separate from returned mappings and permits RDFLib’s variable reassignment, including nested groups, OPTIONAL, MINUS, EXISTS and grouping. Public results retain the original variable names and suppress empty projected mappings. Store.query, Store.prepare and SHACL retain their native pre-binding and assignment rules.

Numeric casts

An XSD constructor function such as xsd:double(?x) follows the casting rules of XPath and XQuery Functions and Operators 3.1 §19. SPARQL 1.1 §17.5 defines its casting table by reference to the earlier XQuery 1.0 and XPath 2.0 Functions and Operators; PurRDF applies the 3.1 text. When the argument is a numeric or boolean literal, the cast converts its value. It does not read the literal’s digits again as the new type. The float written 0.1 is 0.100000001490116119384765625, so xsd:double("0.1"^^xsd:float) is 1.0000000149011612E-1. A string argument is parsed as text: xsd:double("0.1") is 1.0E-1.

FromToResult
floatdoublethe same value, exactly
doublefloatthe nearest float, ties to even (see below)
integer, decimalfloat, doublethe nearest value, rounded once
float, doubledecimalthe exact value; NaN and INF are errors
float, double, decimalinteger typesthe fractional part discarded, at any size; NaN, INF and values outside the target type are errors
booleannumeric1 or 0
numericbooleanfalse for zero and NaN, otherwise true

Casting a float or double to xsd:string uses plain notation for magnitudes from 0.000001 up to, but not including, 1000000 ("0.1", "123456.5", "100"). Other values use scientific notation with one digit after the point at least ("1.0E7", "1.0E-7"). The special values are "NaN", "INF", "-INF" and "-0". The digits are the fewest that read back as the same value, at the source’s own precision, so xsd:string("0.1"^^xsd:float) is "0.1". STR does not cast: it returns a literal’s lexical form unchanged.

Double to float: round to nearest

PurRDF deliberately differs from the text of F&O 3.1 §19.1.2.1 here. That text narrows a double to a float by truncating the binary mantissa. PurRDF rounds to the nearest float, with ties going to the even value, as IEEE 754 does. The reasons:

  • Accuracy. The result is never more than half a unit in the last place from the double, and the error is unbiased. Truncation can be almost a whole unit off, always toward zero.
  • One rounding everywhere. XSD 1.1 maps a lexical form to a float with this rounding (floatingPointRound), and PurRDF already uses it for strings, decimals and integers. Under truncation, a double would become a different float from the same number written as a string.
  • Agreement with common engines. Common SPARQL and XPath engines round.
  • The text is defective for very small values. Its rule sends every result below the smallest normal exponent to zero, even when a float subnormal is much closer. xsd:float("1e-40"^^xsd:double) is 1.0E-40 here, not zero.

Two values show where the rules differ:

ExpressionPurRDF (round to nearest)F&O 3.1 text (truncation)
xsd:float("1.0000000894069671630859375"^^xsd:double)1.0000001E01.0E0
xsd:float("3.4028235677973366e38"^^xsd:double)INF3.4028235E38

The second double lies exactly halfway between the largest float and the next power of two, so rounding to even gives INF.

How computed numbers are spelled

The specifications fix the value of a computed number and its datatype, not the lexical form of the literal that carries it. PurRDF spells them as follows.

  • A decimal that an operator, a function or an aggregate computes carries a decimal point and at least one fractional digit: 84 / 2 is 42.0, CEIL(3.2) is 4.0, SUM over 4.2 and 37.8 is 42.0, and AVG of 2 and 4 is 3.0. A decimal with a fraction is unchanged (2.5).
  • Casts and constructors, xsd:integer results and xsd:float/xsd:double results keep the XSD 1.1 canonical text: xsd:decimal(3) is 3, CEIL(3) over an integer is 3.
  • Unary plus is the identity and returns its operand as written, so it keeps the operand’s spelling: a stored "4.0"^^xsd:decimal stays 4.0, and +(1.5 + 2.5) stays 4.0, because the computed operand was 4.0. Unary minus computes a new number, so minus a decimal 4 is -4.0.
  • STR returns a literal’s lexical form unchanged, so STR(1.5 + 2.5) is "4.0". xsd:string(...) and the other casts to string use the XPath fn:string text of the value, so xsd:string(1.5 + 2.5) is "4". Use STR when the spelling of the stored or computed literal matters.
  • SECONDS returns the seconds field of its argument’s lexical form exactly as written, as the SPARQL text defines it: 00 for "2023-12-25T10:30:00"^^xsd:dateTime, 05.500 for "2023-12-25T10:30:05.500"^^xsd:dateTime, 00.250 for "10:30:00.250"^^xsd:time. Every argument is an RDF term, so it has a lexical form: a dateTime that an expression computed (for example a dateTime plus a duration) is a literal whose lexical form is the canonical one, and SECONDS reads that. The SPARQL 1.1 seconds-01 test expects "1", "2", "0", "3" for the fields 01, 02, 00, 03 and is graded by value; no spelling satisfies it term for term and also the SHACL node-expression seconds-example, which expects "00".
  • The rule above covers the arithmetic in purrdf-xsd and the SPARQL evaluator. Other computed decimals have their own spelling: the scores of the text-search and retrieval extensions (purrdf-text, purrdf-retrieval) are xsd:decimal literals with a fixed 12-digit fraction.

Compare numeric results by value, not by spelling: "3"^^xsd:decimal and "3.0"^^xsd:decimal are the same number but different RDF terms.

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.

Temporal seconds retain every decimal place. For example, adding "PT0.000000000000000000009S"^^xsd:dayTimeDuration to "2024-01-02T03:04:05.123456789012345678901Z"^^xsd:dateTime yields "2024-01-02T03:04:05.12345678901234567891Z"^^xsd:dateTime, and SECONDS returns 05.12345678901234567891. The result does not stop at eighteen fractional places. A time shifted by whole days wraps to the same time of day, even when the duration’s day count exceeds a machine integer.

The operator table

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

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

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

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

Result datatype

A +/- between two durations 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 whenever the operands’ declared tags do not name the same duration subtype:

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 year xsd:gMonthDay does not carry — that is an example of the refused class, not the rule itself. RDF4J answers these by fabricating the missing field (year 0, January, or day 1) through its underlying JAXP calendar and returning a value built on that fabrication — for example "---31"^^xsd:gDay + "P1M"^^xsd:yearMonthDuration answers "---29", clamped against a fabricated leap year. purrdf matches RDF4J on every case whose answer does not depend on the fabricated field — including "2012-10"^^xsd:gYearMonth + P1Y1M = "2013-11", the one Gregorian case RDF4J’s own test suite pins — and returns a typed error exactly where the answer would depend on which fabricated value RDF4J’s calendar happened to pick:

PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?bound WHERE {
  BIND("---31"^^xsd:gDay + "P1M"^^xsd:yearMonthDuration AS ?maybe)
  BIND(BOUND(?maybe) AS ?bound)
}

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

Extensions beyond SEP-0002 and F&O

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

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

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

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

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

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

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

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

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

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

ADJUST

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

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

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

LATERAL (SEP-0006)

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

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

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

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

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

The scope restriction

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

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

Points of disagreement with Jena

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

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

In UPDATE

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

SERVICE forwarding

A pattern containing a written LATERAL clause — anywhere in the forwarded body, including nested inside another SERVICE — is forwarded with its LATERAL { … } text intact, SILENT or not, 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 a failure from one that does not implement it — surfaces the same way any other forwarded construct’s rejection would: an error, or under SILENT the join identity recorded on the evidence. A custom scalar function call is forwarded the same way. 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 — and SERVICE SILENT does not change that, because no invocation is made for SILENT to absorb. 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: one request per incoming solution, so two solutions that name the same IRI each send their own. When the left side of the OPTIONAL, MINUS or group join holding the clause binds ?g in every solution, the right side is still evaluated on its own and the clause is sent once to each distinct IRI that left side binds ?g to. A join is commutative, so the same holds when the other side of a group join binds ?g — { SERVICE ?g { … } ?s ex:endpoint ?g } — while an OPTIONAL or MINUS takes its endpoints from its left side alone.

Per-service context: the ServiceResolver seam

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

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

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

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

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

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

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

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

The SILENT contract

SPARQL 1.1 Federated Query §3.2 defines a SERVICE clause as the endpoint’s answer “in case of a successful service invocation according to the SPARQL protocol, and otherwise Ω0 in case SilentOp is true, and otherwise error”. Ω0 is the single empty solution — the join identity, so the surrounding query proceeds unchanged. PurRDF reads “otherwise” as written:

OutcomeSERVICESERVICE SILENT
The invocation fails, for any reason: the endpoint is unreachable or its response undecodable; no source reaches it; a capability was denied or the host refused the request; the host’s handler faulted; a variable endpoint is bound to a term that is not an IRIquery errorjoin identity, recorded on the evidence
This engine’s own governor tripped, or its stack ran outtruncation, or the stack refusaltruncation, or the stack refusal

SILENT is a statement about the invocation, 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. A denial is an invocation that did not succeed, and the query’s author wrote SILENT knowing the service might not answer.

The answer SILENT gives is indistinguishable from an endpoint that answered with nothing to add, so every silenced invocation is recorded: GovernorEvidence::silenced lists one SilencedInvocation per failure — the endpoint, the kind of failure (transport, decode, disabled, unconfigured, denied, host-denied, not-an-iri, fault) and the message the error would have carried. Every governed query carries it; an ungoverned call returns a bare result, so run it under QueryGovernors::METERED to read the record. A denial raised by a SERVICE nested inside a body an InProcessServiceResolver evaluates travels back out as the structured denial it is, so the enclosing clause reports it — or records it — as a denial at every depth.

LOAD SILENT follows SPARQL 1.1 Update §3.1.4, under which the operation “will still return success” when it fails: a document that cannot be fetched or parsed, a refusal, a fault, and the absence of a GraphResolver are each a success with nothing loaded, recorded on the update’s evidence as a SilencedInvocation naming the source IRI. A GraphResolver reports why it produced no document as a typed LoadError, whose code() is the diagnostic code a LOAD without SILENT fails with.

Under MINUS, a variable endpoint’s left solutions are partitioned by endpoint and each partition is subtracted by the right side evaluated with its own endpoint alone, so a silenced endpoint — Ω0, which binds nothing — removes none of its own rows while every answering endpoint still removes its matches.

EXISTS under SEP-0007

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

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

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

The one definition

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

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

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

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

Existential Normal Form

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

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

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

Two strategies, one answer

purrdf carries exactly two implementations of the one definition above:

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

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

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

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

The Part 3 assignment restriction

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

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

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

SHA-3 hashing (SEP-0008)

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

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

The argument contract

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

The accepted arguments are a simple literal and an explicitly xsd:string-typed literal, as SPARQL 1.1 §17.4.6 requires of every hash built-in: SHA3-256("abc") and SHA3-256("abc"^^xsd:string) are the same digest.

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 language-tagged or RDF 1.2 directional string (SHA3-256("abc"@en)),
  • a non-string literal (SHA3-256(7)).

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

The hyphen is part of the name

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

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

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

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

Taking a SHA-3 query to another engine

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

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

Reaching it from other hosts

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

Composite datatypes (SEP-0009)

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

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

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

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

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

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

The function library

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

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

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

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

FOLD: a group becomes one term

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

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

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

Accepted spellings, as a catalogue:

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

and one whole query:

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

Four behaviours are worth knowing before you rely on one:

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

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

UNFOLD: one term becomes rows

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

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

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

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

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

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

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

Ordering, MIN and MAX

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

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

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

Blank nodes inside a composite

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

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

Limits, and what a composite refuses

A composite is bounded by memory in its nesting, and by two bounds that are an invariant of the value — the programmatic constructors enforce them exactly as the parser does, and every nesting level counts as one element:

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

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

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

Taking a composite query to another engine

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

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

Quad templates: CONSTRUCT into named graphs

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

Provenance: a purrdf extension, not a SPARQL 1.2 feature

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

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

Taking one of these queries to another engine

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

Two portable rewrites:

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

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

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

GRAPH blocks inside the template

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

A variable graph name

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

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

Several graphs, and mixed default-graph triples

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

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

The whole-template shorthand

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

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

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

Skip semantics

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

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

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

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

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

Which output formats can carry the result

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

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

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

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

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

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

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

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

A result carrying only default-graph statements is untouched everywhere: every SPARQL 1.1 CONSTRUCT and every DESCRIBE serializes to Turtle exactly as before. CLI, Python and WebAssembly refuse a mixed result as a whole when explicitly asked for a single-graph syntax, because emitting the default-graph half would report a partial answer as a complete one. The C ABI reports the discarded row count instead, as described above.

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:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use purrdf::sparql::{
    AggregateAccumulator, AggregateRegistry, AlgebraicClass, Arity, CustomAggregate, EvalError,
    ExtensionEnv, NativeSparqlEngine, QueryOptions, Volatility,
};
use purrdf::{RdfDatasetBuilder, SparqlRequest, SparqlResult, TermValue};
use purrdf::xsd::datatype::XSD_INTEGER;

/// A checked nonnegative-integer total (at most i64::MAX) — `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> {
        let [TermValue::Literal { lexical_form, datatype, .. }] = args else {
            return Err(EvalError::function("total requires one xsd:integer"));
        };
        if datatype != XSD_INTEGER {
            return Err(EvalError::function("total requires one xsd:integer"));
        }
        let n = lexical_form.parse::<i64>()
            .map_err(|_| EvalError::function("total requires a signed-64-bit integer"))?;
        if n < 0 {
            return Err(EvalError::function("total requires a nonnegative integer"));
        }
        self.sum = self.sum.checked_add(n)
            .ok_or_else(|| EvalError::function("total overflow"))?;
        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 = self.sum.checked_add(other.sum)
            .ok_or_else(|| EvalError::function("total overflow"))?;
        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(),
            XSD_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::default();
registry.register(
    "https://example.org/agg#total",
    Arc::new(TotalAggregate),
);
// The environment a query text is read in: `AGG(<iri>, …)` is admitted against
// this registry.
let env = ExtensionEnv::over_aggregates(registry).expect("aggregate environment");

let ds = RdfDatasetBuilder::new().freeze().expect("empty dataset");
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 { VALUES ?x { 1 2 3 } }",
        base_iri: None,
        substitutions: &[],
    },
    QueryOptions::EMPTY.with_env(&env),
).expect("custom total");
let SparqlResult::Solutions { rows, .. } = result else {
    panic!("SELECT result");
};
assert_eq!(rows, vec![vec![Some(TermValue::typed_literal("6", XSD_INTEGER))]]);
}

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:

#![allow(unused)]
fn main() {
use purrdf::sparql::AggregateRegistry;

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

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

Per-member semantics:

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

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

Path witnesses: binding the derivation, not the endpoints

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

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

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

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

There is no default relation IRI and no default traversal envelope. PurRDF mints no vocabulary IRIs, so the name a query spells in predicate position is caller-supplied; and a zero-hop path has no witness while an unbounded walk has no end, so the minimum and maximum hop counts and the two resource guards are stated every time rather than invented by the library. Any max_hops a u32 holds is accepted: the traversal keeps its per-depth state on the heap, and the two resource guards bound the work a walk does.

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 preserves the same groups 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.

What a relation attests about itself, and the one lane that can record it

Two facts about an invocation are known to a relation’s cursor and to nothing else: which generation of its index answered, and whether that index was whole. Every Python relation declaration therefore accepts one trailing position, (generation, incompleteness), each a string or None and each recorded verbatim. Declaring neither is the default and means the relation said nothing — which is silence, not a claim that the index was whole.

A relation that declares itself incomplete is refused at any entry point with nowhere to put that declaration: the ungoverned query and every update lane raise native-sparql-relation-incomplete, and the update applies nothing. That refusal is the reason the governed lane exists rather than an obstacle to it — a short answer whose receipt nobody can read is precisely what the hard-fail doctrine forbids. So the governed lane answers, and its outcome carries:

outcome.relation_witness
# {relation_iri: {"invocations": int,
#                 "generations": [str | None],
#                 "incompleteness": [str]}}

The mapping is always present and may be empty — a governed query that invoked no relation reports {}, which is a fact rather than an absence. None inside generations is an invocation that attested nothing, and it is deliberately a member of the set rather than dropped: without it, “declared one generation every time” and “declared it sometimes and said nothing the rest” would read identically. Keys and both lists are emitted in the engine’s own sorted order, so the DECLARATIONS of two runs over one snapshot compare byte for byte. A tripped governor still reports what the relations attested before it tripped.

invocations is the one field that does not: it counts entries into host code, which is a fact about the schedule rather than about the index. Under a FILTER EXISTS, the parallel row loop evaluates each chunk of driving rows on a worker whose EXISTS memo starts cold, so the inner pattern — and the relation inside it — is re-entered once per chunk, and the chunk count comes from the runtime’s thread count. The same query over the same data can therefore report a different count while every declaration beside it is identical, including between two runs that differ only in the budget they were given. Read it as “did this relation run at all” (0 versus non-zero) or as a rough magnitude for a log line; never compare it between two receipts.

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

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

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

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

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

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

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

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

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

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

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

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

Dated XPath regex laws

REGEX and REPLACE run on the compatibility regular expressions unless the caller selects a dated native XPath law by its stable name:

NameLaw
xpath-2.0-2010-12-14XPath and XQuery Functions and Operators 2.0 (Second Edition), 14 December 2010
xpath-3.1-2017-03-21XPath and XQuery Functions and Operators 3.1, 21 March 2017

Both laws admit backreferences, which the compatibility expressions refuse. Only 3.1 admits non-capturing groups such as (?:a) and the q flag. Under either law, this query keeps every code whose first two characters are the same:

SELECT ?code WHERE {
  ?item <http://example.org/code> ?code
  FILTER(REGEX(?code, "^(.)\\1"))
}

Every host selects the law in its own spelling:

HostSpelling
RustNativeSparqlEngine::with_xpath_regex(profile, limits) for every request the engine runs, or QueryOptions::with_xpath_regex(profile, limits) for one request, with Profile and Limits from purrdf_core::xsd_regex::xpath; purrdf_validate::xpath_regex::{parse_profile, sparql_engine, query_options} select by name with the production limits
CLI--xpath-regex <NAME> on query and update
Pythonxpath_regex= on query, query_governed, query_entailment_governed, update, update_governed and Store.prepare, and on the rdflib layer’s Graph.query and Graph.update; purrdf.XPATH_REGEX_PROFILES lists the names
WebAssemblyan xpathRegex option on every evaluating QueryEngine method and its asynchronous twin, and a trailing xpathRegex on Dataset.query, queryRawConfigured and queryRawWithContext
Cregex_profile on purrdf_query_xpath_regex, purrdf_query_json_xpath_regex, purrdf_query_governed_xpath_regex, purrdf_query_entailment_governed_xpath_regex and purrdf_update_governed_xpath_regex

Names match exactly, with no case folding or undated alias, so a name such as xpath-3.1 is refused with a list of the accepted names. With no selection (no option, no flag, or a NULL regex_profile), the compatibility regular expressions behave exactly as before. A prepared query keeps its law for every run, and an unset request inherits the engine’s selection.

A pattern or flag the selected law does not define is an ordinary expression error, so a FILTER drops the row and a BIND leaves its variable unbound. A selected law also runs under finite resource bounds, 64 KiB of pattern source among them. Exceeding one is an operational failure carrying the resource’s xpath-* code, whose page lists the measured classes of work that reach each bound. It is never a partial answer or a false filter, and a refused update changes nothing.

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

#![allow(unused)]
fn main() {
use purrdf::{RdfDatasetBuilder, TermValue};
use purrdf::sparql::{from_json, serialize, ResultProvenance, SparqlResult, SparqlResultsFormat};

let result = SparqlResult::Solutions {
    variables: vec!["s".to_owned()],
    rows: vec![vec![Some(TermValue::Iri("https://example.org/alice".to_owned()))]],
    aux: RdfDatasetBuilder::new().freeze().expect("empty auxiliary dataset").into(),
};
let outcome = serialize(&result, SparqlResultsFormat::Json, &ResultProvenance::default(), None)
    .expect("SELECT serializes to SRJ");

assert!(!outcome.provenance_dropped);
let decoded = from_json(&outcome.bytes).expect("emitted JSON parses back");
assert_eq!(decoded.variables, ["s"]);
assert_eq!(decoded.rows, [vec![Some(TermValue::Iri("https://example.org/alice".to_owned()))]]);
}

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

Behavior worth knowing before you pick a format

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

The provenance extension

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

#![allow(unused)]
fn main() {
use purrdf::sparql::{from_json_boolean, ProvenanceNamespace, ResultProvenance, SparqlResult, SparqlResultsFormat, serialize};

let result = SparqlResult::Boolean(true);
let namespace = ProvenanceNamespace::new("prov", "https://example.org/provenance#")
    .expect("valid NCName prefix + absolute IRI");
let provenance = ResultProvenance {
    query_hash: Some("example-query".to_owned()),
    ..ResultProvenance::default()
};
let outcome = serialize(&result, SparqlResultsFormat::Json, &provenance, Some(&namespace))
    .expect("ASK with caller-named provenance serializes");
assert!(!outcome.provenance_dropped);
assert!(from_json_boolean(&outcome.bytes).expect("emitted ASK parses back"));
let without_namespace = serialize(&result, SparqlResultsFormat::Json, &provenance, None)
    .expect("ASK without a provenance namespace serializes");
assert!(without_namespace.provenance_dropped);
}

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 on purrdf-core, purrdf-lex and purrdf-iri, and stays wasm-clean. Shared lexical readers and IRI validation serve the result formats; term and N-Triples syntax come from the kernel’s emit primitives and the shared lexical term writer. Results, codecs and diagnostics therefore use the same term-syntax authority.

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, explicit English stemming and costed dictionary analysis, no stop-word lists, no query dialect (phrase and proximity compose in SPARQL from the term-occurrence relation), fixed k1 with explicit per-field length normalization, and an in-memory index built over a frozen dataset.

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

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

Two relations, one index

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

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

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

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

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

Wiring it from Rust

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

use std::sync::Arc;
use purrdf::sparql::{ExtensionEnv, 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,
    purrdf::text::Analyzer::empty_lexicon(), // Explicitly choose grapheme fallback.
)?;
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)),
);
// The environment a query text is read in: every registered IRI is claimed in
// predicate position, and resolves against this registry.
let env = ExtensionEnv::over_relations(registry)?;

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::new().with_env(&env),
)?;

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

Scores use a base-10 fixed-point integer representation (i128, twelve fractional digits). Scoring and ranking use no floating-point arithmetic; the crate root declares #![deny(clippy::float_arithmetic)].

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 against independent expectations by native Rust tests. make wasm separately builds the crate for WASM; focused WASM execution covers actual target paths and host interfaces.

k1 = 1.2 is fixed. RankingProfile::single_field() selects one field with weight one and b = 0.75; an explicit RankingProfile declares each field’s b and weight. Those parameters are bound into the immutable profile’s fingerprint. The same identified profile, index and query produce the same ranks and score lexicals on every target.

Ordering, and the idioms that reproduce it

For the default GraphLanguage profile, 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. GraphFree instead uses (subject, language) document order and language-only partitions. To obtain one ranked list with GraphLanguage, bind ?lang and restrict ?graph through the selector; with GraphFree, bind only ?lang. A single-partition index also gives one ranked list.

Within one selected partition, two consequences are worth knowing:

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

RDF 1.2 first class

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

Stated limits

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

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

GeoSPARQL

PurRDF evaluates spatial predicates, geographic metrics, buffers and explicit coordinate operations in process, over the dataset a query already holds. purrdf-geo is the Rust facade: it reexports the geometry and geographic types from purrdf-geo-kernel and the native evaluator’s scalar and property-function adapters. The computational kernel depends on no SPARQL evaluator, GEOS, PROJ or external geographic runtime. The same kernel serves Rust, Python, C, WebAssembly and the CLI.

References are explicit

The standard geographic profile assigns unprefixed WKT, OGC CRS84 and GeoJSON to WGS84 longitude followed by latitude. Original decimal coordinates remain exact rationals. Longitude outside [-180, 180] and latitude outside [-90, 90] refuse before conversion; valid inputs are never wrapped or clamped.

EPSG:4326 requires an explicit WGS84 latitude/longitude registration. Other references, units and operation chains require caller registrations. An ellipsoid does not establish a datum transformation, and the engine does not guess zones, height, epoch, control points or regional transformation parameters. Standard OGC terms have their published meanings; application namespaces and custom registrations remain caller supplied.

Geographic profiles and requests specifies the versioned JSON records, reference axes, operation parameters, limits, identity rules and every host entry.

Scalar functions in the native query engine

The default NativeSparqlEngine installs the immutable standard geof: functions. Registering a conflicting standard function is a hard error before execution. A query selects additional bindings and independent numerical limits with QueryOptions::with_geo:

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

let profile = GeoProfile::standard();
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 (geof:metricDistance(
                    "POINT(0 0)"^^geo:wktLiteral,
                    "POINT(1 0)"^^geo:wktLiteral) AS ?metres)
                  WHERE {}"#,
        base_iri: None,
        substitutions: &[],
    },
    QueryOptions::new().with_geo(&profile),
)?;

Geographic metricDistance, metricLength, metricPerimeter, metricArea and metricBuffer, and their explicit-unit counterparts, use the declared ellipsoid. Projected metre systems retain their planar law. Length includes polygon boundaries and preserves the declared mixed-dimension rule; area subtracts holes and squares unit factors. Z and M are retained without inventing an elevation.

Malformed arguments and geometric domain errors retain SPARQL expression-error behavior. Operational precision, work, memory, convergence, cancellation and floating-environment failures remain fatal through FILTER and BIND. Governed evaluation retains its original incomplete-outcome and work evidence. The direct geographic request API preserves the same typed causes instead of turning a refused invocation into an empty result.

Geometry and geographic laws

WKT and GeoJSON retain their original coordinate-linear edges, including a written long route across longitude. Explicit shortest-geodesic and azimuth/length arcs retain their selected branches; ambiguous endpoint-only arcs refuse. Prepared regions represent holes, complements and the whole surface. Metrics, topology, continuous coordinate-operation images, buffers and covers use those same prepared curves and regions.

Point distance is the correctly rounded shortest ellipsoidal distance at six decimal metre places, using half-even rounding. A complete certified enclosure must select one rounded answer; acceptable approximation error alone does not authorize a result. Direct coordinates use the declared angular grid. Geometric distance, length, perimeter, area and outward buffers carry their own declared certificates and rounding laws. The numerical primitives have one home in purrdf-xsd; no platform libm or reassociated reduction selects an answer.

Explicit coordinate chains cover projections, geographic/geocentric conversion, the named GCJ/BD equations, and caller-parameter Helmert, similarity, polynomial and in-memory grid models. Bounded inverses isolate all admissible roots and refuse ambiguity. These mathematical laws do not claim surveying accuracy or official provider certification.

Spatial relations and cells

The property-function adapter projects caller-selected geometry predicates into a reusable GeoIndex and registers the selected Simple Features, Egenhofer or RCC8 relations. Asserted relation triples retain their RDF meaning; calculated relations add the entailed matches. The actual query’s selected geographic context and original allocation owners remain live through the cursor. An empty relation-family registration refuses.

The native cubed-sphere hierarchy assigns profile-qualified cell IDs at levels 0–30. Parent/child operations, big-endian keys and ordered ranges share that definition. Closed disk, box and prepared-region covers are conservative; caller limits refuse a complete cover rather than truncate or silently coarsen it. Reusable point indexes keep original coordinates and refine candidates with the requested physical or reported-distance law. h3 v4.0 compatibility is an architecture document, not an implemented family.

Host ownership and qualification

GeoSession and GeoPointIndex expose the same versioned requests on every host. Python query and update entries accept geo=. WebAssembly engines snapshot the selected profile for suspended operations. C provides opaque create/call/free handles and context-taking query entries. The CLI accepts --geo-profile for geographic requests, queries and updates.

A semantic refusal has a complete response record. An operational refusal keeps its original failure channel and creates no unadmitted response under the failed invocation. Prepared data is immutable and shareable; live numerical contexts stay thread bound. Native storage is admitted before birth and retained until its actual payload is destroyed.

Frozen numerical, geometry and hierarchy transcripts compare completed native and WebAssembly answers. The public GeodTest collector checks the complete 500,000-row source by digest, while allocator, backend and host fixtures check their separate contracts. Mathematical derivations and exactness boundaries are recorded in Geodesic numerics and Geometry exactness.

Embedding Nearest Neighbours

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

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

The call shape

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

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

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

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

The ranked-search 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. A second mode, bbff, binds the neighbour and seed while leaving k free: it asks whether the space holds that neighbour, without ranking it. Binding k always selects ranked search, even when the neighbour is bound.

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 an ExtensionEnv (ExtensionEnv::over_relations, handed to the query with QueryOptions::with_env) 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 full-text ranked-retrieval surface uses no floating-point arithmetic, 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. docs/PURREMB.md fixes the artifact’s own folds normatively (“binary64, round-to-nearest ties-to-even, in the written order, without a fused multiply-add”), and the L2 norm is one of them, so the kNN surface uses purrdf_core’s normative norm fold rather than a copy of it. Fixed point would have introduced a quantization step the format does not have.

The two residual hazards are closed structurally. Every dot product and squared Euclidean sum follows one pinned law, purrdf_core::distance::Exact (binary64-lane16-tree-v1): sixteen binary64 lanes over the whole sixteen-element chunks, a fixed pairwise tree, then the remaining terms in ascending index, never split across workers. It is the same order on every target and dispatch path, and its independent lanes are what lets it vectorize without licensing any reordering (a test folds a vector whose lane-tree sum is 1 and whose sequential sums are 0, and pins which). Every product is bound to a named local before it is added, so no fused multiply-add can form, and a thread whose float environment flushes subnormals is refused by name. 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 need not be exactly zero (dot(v, v) and |v|·|v| are two roundings of one real number). Do not assume zero self-distance; ranking follows the computed distance and the canonical row tie-break.

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 and rules engine. It implements SHACL 1.2: Core, SPARQL Extensions, Node Expressions, Inference Rules and the SPARQL 1.2 RL rule language, plus the SHACL-AF 1.0 spellings those documents supersede. It runs entirely on PurRDF’s own interned IR and native SPARQL engine (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, or declare sh:entailment sh:RulesEntailment to run the shapes graph’s own rules first, or name a sh:RuleSet of the shapes graph as an sh:entailment value to run only that rule set’s rules (its included rule sets’ too); several such values run together as one rule set. Any other sh:entailment value is refused, as SHACL requires of a processor that does not support it. A rule set as an sh:entailment value is first defined by the SHACL 1.2 editor’s draft of 2026-10-16, so the default law and shacl-ed-2026-10-16 read it, and shacl-wd-2026-09-18 and shacl-rec-2017-07-20 refuse it.

What it covers

  • SHACL 1.2 Core — every constraint component the SHACL 1.2 vocabulary declares is evaluated natively: full property paths, qualified value shapes, the list components (sh:minListLength, sh:maxListLength, sh:uniqueMembers, sh:memberShape, with sh:detail results), list-valued sh:class, sh:datatype and sh:nodeKind (including sh:TripleTerm), sh:singleLine, sh:rootClass, sh:someValue, path-valued property pairs and sh:subsetOf, sh:uniqueValuesFor, sh:closed sh:ByTypes, sh:reifierShape and sh:reificationRequired, and sh:uniqueLang over language tag and base direction. A property shape’s sh:values and sh:defaultValue compute value nodes. The targets include implicit class targets (sh:ShapeClass too), sh:shape in the data graph, sh:targetWhere and node-expression sh:targetNode. A reifier of a constraint triple can carry sh:deactivated, sh:severity or sh:message for that one constraint. The severities include sh:Debug and sh:Trace, and conformance follows the request’s sh:conformanceDisallows set. Every sh:message is reported with its language tag and direction.
  • Exact numeric ranges — sh:minInclusive, sh:maxInclusive, sh:minExclusive, sh:maxExclusive, sh:lessThan and sh:lessThanOrEquals compare numbers with the SPARQL operators, as SHACL defines them. An xsd:integer or xsd:decimal literal of any size is compared exactly, never through a double, so 9007199254740992 is below a bound of 9007199254740993. A literal whose lexical form its datatype rejects, such as "1.5"^^xsd:integer, cannot be compared and violates the constraint. See Numeric limits.
  • SHACL 1.2 SPARQL Extensions — SPARQL-based constraints and targets, custom constraint components with pre-binding semantics, user-defined sh:SPARQLFunction calls, sh:SPARQLTargetType, sh:sparqlExpr, and sh:prefixes resolved as that document specifies (see below), all on the native SPARQL engine. A SPARQL-based constraint’s sh:deactivated is a node expression under the default law and shacl-ed-2026-10-16; the working draft and the 2017 Recommendation allow true or false alone there. A blank node passed to a custom SPARQL function is an error under the same two laws, and an ordinary argument under the working draft.
  • SHACL 1.2 Node Expressions — the shnex: vocabulary and the older SHACL-AF sh: spelling of a node expression parse to one representation and run through one evaluator. Each expression kind keeps the order and multiplicity its evaluation clause defines: an ordered sequence, a multiset or a set. sh:if takes sh:then only when its condition is the list ( true ). The shnex-sparql.ttl function library is native. sh:nodeByExpression and sh:ExpressionConstraintComponent are validated.
  • SHACL 1.2 Inference Rules — sh:TripleRule and sh:SPARQLRule, with sh:condition, sh:layer, sh:order, sh:runOnce, sh:deactivated, rule sets and sh:RulesGraph, SPARQL rule templates, temporary triples, sh:expectedPredicate and sh:ruleProcessor. Layers run in ascending order. In each layer the run-once rules run once, then the iterating rules repeat until an iteration infers nothing. Within an iteration, rules run in ascending sh:order, rules of the same order run together, and each group sees the inferences of the groups before it, so swapping two rules’ orders can change the inferences. An unresolvable sh:condition is a load error, not a rule that silently never fires.
  • SPARQL 1.2 RL — the rule language’s text syntax is parsed, checked for well-formedness, stratified, imported and evaluated (purrdf_shapes::srl). SHACL rules lower to the same rule-set representation, and both run on one rules engine, purrdf-datalog.

A shapes graph is either loaded faithfully or refused. An unknown term, an ill-typed parameter value, or a construct the engine does not evaluate is a load error that names it, never a constraint that silently drops out. The same check covers the nodes of SHACL-SPARQL: a SPARQL-based constraint, a validator and a sh:SPARQLTarget may carry only the terms their specification gives them, so an sh:ask beside a constraint’s sh:select, or a misspelled sh:mesage, is refused rather than ignored.

The terms the SHACL vocabularies define and the engine refuses by name are:

  • the SHACL JavaScript Extensions (sh:js, sh:JSConstraint and the rest), which are not part of SHACL 1.2; this engine has no JavaScript engine to evaluate them. The refusal is ShapesError::ShaclJs, and it applies where a shape reaches SHACL-JS: sh:js or a SHACL-JS type on the shape, a sh:JSTarget or an instance of a sh:JSTargetType as its target, a sh:JSRule among its rules, and a call to a sh:JSFunction from a node expression or from SPARQL the shape runs. A library that only declares sh:JSLibrarys and sh:JSFunctions nothing calls loads, and those declarations are inert. A sh:JSValidator attached to a constraint component is not inert: it is neither an ASK- nor a SELECT-based validator, so as a value of sh:validator (validator-class), sh:nodeValidator (nodeValidator-class) or sh:propertyValidator (propertyValidator-class) it makes the shapes graph ill-formed, and the load fails with ShapesError::IllFormed whether or not a shape uses the component;
  • sh:describe and sh:update on a shape, a node expression, a SPARQL-based constraint, a validator or a rule. The SHACL 1.2 vocabulary declares them as the queries of sh:SPARQLDescribeExecutable and sh:SPARQLUpdateExecutable, but no SHACL specification executes either class, so in those positions they would otherwise be silently ignored. A resource that is only such an executable, and that no shape reads, loads.

Two of the terms the engine evaluates need a note:

  • SHACL-SPARQL result annotations. A sh:resultAnnotation on a SPARQL-based constraint or on a validator of a SPARQL-based constraint component adds its sh:annotationProperty to every result the query produces. The value is the solution’s binding of sh:annotationVarName, or of the property’s local name when no name is given. When that variable is unbound, the sh:annotationValue defaults are used. An ASK validator’s annotations read this, value and the component’s parameters. The annotations appear on ValidationResult::annotations, in the report graph, in the SARIF property shaclResultAnnotations, in prepared products and in the Python result dicts. A variable name that no SPARQL query could bind is refused at load. So is a SHACL report property such as sh:focusNode used as the annotation property.
  • sh:minus. The SHACL Advanced Features 1.1 minus expression, [ sh:nodes N ; sh:minus M ], evaluates exactly as SHACL 1.2’s [ shnex:nodes N ; shnex:remove M ]: the nodes of N not in M, in N’s order. Its sh:nodes is required.

Every IRI the engine implements is defined by a W3C document; PurRDF mints none.

The W3C SHACL 1.0 data-shapes suite passes as approved (129/129, zero ledgered gaps at the time of writing), and the approved W3C SHACL 1.2 suite passes as approved (590/591, the one exception being hasLang-example, which contradicts the SPARQL 1.2 grammar’s arity); the live numbers are in docs/CONFORMANCE.md, and SHACL 1.2 conformance below says how to reproduce them.

A parsed shapes graph can also be compiled once and written out as a deterministic, authenticated byte artifact, so a later process restores a prepared validator instead of re-parsing Turtle — see Prepared Shapes Products.

owl:imports in a shapes graph

A shapes document may carry an owl:Ontology header or a sh:ShapesGraph declaration 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.

The shapes document parses under its own file:// retrieval IRI, or under --shapes-base IRI when given. That flag is the same one shacl pack --base is, and it is separate from validate --base, which sets only the DATA graph’s base. An @base inside the shapes document still wins inside it, as Turtle specifies.

Not every owl:imports triple is an import. OWL 2 reads a document’s imports off its ontology header (Mapping to RDF Graphs §3.1.2, Table 4: x rdf:type owl:Ontology . x owl:imports y). SHACL 1.2 Core follows them from the shapes graph’s own IRI along ^owl:versionIRI?/owl:imports, and names the class that declares a shapes graph: “The sh:ShapesGraph class MAY be used as an rdf:type of the IRI of a graph that typically acts in the role of a shapes graph.” PurRDF takes OWL 2 as the floor of the rule and SHACL’s shapes graphs on top of it. So an owl:imports is an import only when its subject is one of these:

  • the shapes document’s own IRI (its file:// retrieval IRI, --shapes-base, shacl pack --base or an in-document @base; for an imported document, the IRI it was imported by);
  • a node the document types owl:Ontology;
  • a node the document types sh:ShapesGraph. Every such node counts, and so does a sh:RulesGraph or a node of a class the document declares rdfs:subClassOf sh:ShapesGraph;
  • a node naming one of those as its owl:versionIRI.

On any other node it is data: it stays in the shapes graph as written, and no document is looked for. A node that is only a sh:DataGraph is such a node. SHACL 1.2 Core §6.2 says “owl:imports in the data graph is not enacted”, and its note asks a data graph that means an import to be typed owl:Ontology as well. The data graph’s own owl:imports are never read at all. purrdf shapes lint lists every owl:imports that is data under unanchored-imports. The same rule decides entailment’s imports. Import anchors and the implicit SPARQL prefix sources below share one graph-role classifier, with different selections: a data-graph role supplies prefixes without making its owl:imports an import.

SHACL-SPARQL’s prefix path is such data. A query collects its prefixes along sh:prefixes/owl:imports*/sh:declare within the shapes graph, and the W3C test suite writes

<http://example.com/ns#> sh:declare [ sh:prefix "ex" ; sh:namespace "http://example.com/ns#"^^xsd:anyURI ] .
ex:TestPrefixes owl:imports <http://example.com/ns#> ;
  sh:declare [ sh:prefix "test" ; sh:namespace "http://test.com/ns#"^^xsd:anyURI ] .

ex:TestPrefixes is neither the document’s IRI nor an ontology header nor a shapes graph, so that owl:imports is a prefix edge, not an import, and the test validates as written. So does sparql/component/validator-001, whose owl:imports <http://datashapes.org/dash> sits on a node that is neither.

An import whose document or ontology is already loaded needs no pair. It is resolved when it names a document that was read: the shapes document’s own base (its file:// retrieval IRI, --shapes-base, or shacl pack --base), an in-document @base, or an --import document’s IRI. An import is also resolved when the graph holds <X> a owl:Ontology, <X> a sh:ShapesGraph, or an ontology whose owl:versionIRI is <X>.

Every one of these rules applies across the whole closure, so a declaration that arrives in an imported document counts too. A shapes document that merges the W3C SHACL 1.2 vocabularies — shnex.ttl imports sh:, and shacl.ttl beside it declares sh: — is complete as written.

Every other owl:imports no pair resolves is refused (exit 1). The refusal names each missing IRI and the --import IRI=FILE pair that resolves it, and suggests --shapes-base IRI for a document that imports its own published IRI and is read here from a local file. Validating without the imported document would be a verdict about a smaller shapes graph than the one named, so there is no warn-and-continue path. A pair the closure never reaches is refused as unused (exit 2) rather than read and ignored.

A closure must also hold one version of each series. SHACL 1.2 Core §1.3 makes a shapes graph ill-formed when its import closure holds two graphs that “are different versions of the same series (i.e., they share the same shapes graph IRI but have different owl:versionIRI values), or one contains an owl:incompatibleWith annotation whose value is equal to either the shapes graph IRI or the owl:versionIRI of the other”, and §6.1 says the closure “SHOULD NOT” hold them. PurRDF reads that as a MUST NOT. Such a closure is refused with the typed import error, kind incompatible-import-versions, naming both graphs, on every host. Entailment refuses the same closure (OWL 2 §3.4 states the same two conditions).

A data graph names its shapes graphs

SHACL 1.2 Core §6.4 lets the DATA graph suggest shapes graphs:

<http://example.com/myDataGraph> a sh:DataGraph ;
  sh:shapesGraph ex:graph-shapes1 ;
  sh:shapesGraph ex:graph-shapes2 .

“Every value of sh:shapesGraph is an IRI representing a graph that SHOULD be included into the shapes graph used to validate the data graph”, and PurRDF reads the SHOULD as a MUST. A sh:shapesGraph triple is such a link when its subject is the data graph’s own IRI — --base, or the data file’s file:// retrieval IRI, on the command line — or a sh:DataGraph node (N-Triples text handed to a binding has no IRI, so there the type is the anchor). Each linked graph is resolved through the same import table as an owl:imports: a table entry, a graph the shapes graph already declares, or an ontology whose owl:versionIRI is the link — “the same strategy of resolving a shapes graph IRI from a version IRI … applies here” — and the linked graph’s own owl:imports are followed. The linked graphs are unioned into the supplied shapes graph; an empty shapes document makes them the whole shapes graph. A table entry only a link names is used, not unreached.

A link nothing resolves is refused as unresolved-shapes-graph-link, naming the IRI (and, on the command line, the --import IRI=FILE pair that resolves it). A link value that is not an IRI is invalid-shapes-graph-link. A sh:shapesGraph on any other node is data, like any other triple. The data graph’s own owl:imports stay unenacted (§6.2): a link names a graph for the shapes graph, and nothing about it turns the data graph’s imports into directives.

A shapes graph prepared before the data graph was known — a parsed Shapes validated against many graphs, a PreparedShapes, a prepared product — cannot take a graph in. Validating with one checks that every link is a graph it already holds: its base, a document its closure or links folded in (a product records those IRIs, so it holds them after a restore too), or a shapes graph, ontology or version IRI it declares. Any other link is refused as unheld-shapes-graph-link rather than validated against a smaller shapes graph than the data graph names. On the incremental change path, a change that adds or retracts a link changes the shapes graph itself, so the run validates the whole mutated graph and its scope says why.

The same rule on every host

The command line is one caller of a rule that lives in the engine. Every way a shapes graph becomes a validator — parsing it, validating with it, running its rules, evaluating one of its node expressions, linting it, packing it into a product — resolves the closure through one helper, purrdf_shapes::imports::resolve_shapes_imports, over the rule in purrdf_core::imports that entailment applies too. The same shapes graph therefore gets the same verdict on every host, and each host takes the import table in its own spelling:

HostImport tableRefusal
Rusta ShapesImports (from_turtle, insert, declare_loaded, link_data_graph) passed to parse_shapes_with_config, from_dataset_with_base, validate_graphs_with_options, lint::lint or FreeExpression; the purrdf-validate boundary takes (IRI, Turtle) pairsShapesError::Imports(ShapesImportError::Unresolved | Unreached | InvalidEntry | UnresolvedLink | UnheldLink | InvalidLink | IncompatibleVersions)
CLI--import IRI=FILE, repeatableexit 1 for an unresolved import; exit 2 for a pair nothing imports or a malformed pair
Pythonimports=[(iri, turtle), ...] on validate, entail, apply_rules, eval_node_expr, lint_shapes, pack_product and Shapes(...)purrdf.shapes.ShapesImportError (a ValueError) with .kind and .iris
WebAssemblytrailing importIris, importDocuments arrays on every shacl* function that takes a shapes graphShaclImportError with kind, iris and message
C ABIimport_iris, import_documents, import_count before the out-parametersPURRDF_STATUS_SHAPES_IMPORT_ERROR, read with purrdf_shapes_import_error_kind, _iri_count and _iri

Each imported document is Turtle, parsed with its ontology IRI as its base. The kind is unresolved-import, unreached-import (a table entry neither an import nor a data-graph link names — refused on every host, because its shapes would be read and never applied) or invalid-import (a key that is not an absolute IRI, a key named twice, or a document that does not parse). Conflicting versions use incompatible-import-versions; the three *-shapes-graph-link kinds above belong to the data graph’s links. Branch on the kind, not on the message.

import purrdf

report = purrdf.shapes.validate(
    shapes_ttl, data_nt, imports=[("https://example.org/lib", lib_ttl)]
)

A lint certifies the whole closure: an imported document’s shapes are loaded and checked against shacl-shacl.ttl like the importing document’s, and a shapes graph whose imports are not in hand is refused rather than reported clean.

IMPORTS in a SPARQL 1.2 RL rule set

A SPARQL 1.2 RL rule set can IMPORTS other rule sets. PurRDF fetches none of them. The rules tool takes the imported rule sets from the same import table it takes for a shapes graph, with rule-set text in place of Turtle: --import IRI=FILE on purrdf rules --srl, imports=[(iri, text), ...] on Python’s apply_rules(srl=...), importIris / importDocuments on shaclApplyRules, and import_iris / import_documents / import_count on purrdf_shacl_apply_rules. Every host resolves the table through RuleSetDocument::resolve_import_table. The imports are followed transitively and each IRI is read once. An imported rule set is parsed with its IRI as its base, and its rules run after the importer’s.

Every host refuses an import that no table entry supplies, with the same message, which names the IRI. It also refuses a table entry that the import closure never names, because that rule set would be read and never used. The command line exits 1 for the first case and 2 for the second.

import purrdf

inferred = purrdf.shapes.apply_rules(
    data_nt, srl=rules_srl, imports=[("https://example.org/more", more_srl)]
)["inferred"]

Prefixes in SHACL-SPARQL queries

A SPARQL query in a shapes graph (sh:select, sh:ask, sh:construct, sh:sparqlExpr, a validator, a target or a function body) gets a PREFIX header before it is compiled. The header follows SHACL 1.2 SPARQL Extensions, “Prefix Declarations for SPARQL Queries”:

  • A query with sh:prefixes uses the prefix declarations on the path of the selected law, read from the query node and from the shape or component that carries it. The default law and shacl-ed-2026-10-16 read the editor’s draft path sh:prefixes/(^owl:versionIRI)?/(owl:imports/(^owl:versionIRI)?)*/sh:declare. shacl-wd-2026-09-18 reads the working draft path sh:prefixes/(^owl:versionIRI?/owl:imports)*/sh:declare, which does not read the declarations of the graph a version IRI leads to. shacl-rec-2017-07-20 reads sh:prefixes/owl:imports*/sh:declare, with no owl:versionIRI step.
  • A query without sh:prefixes uses every sh:declare of every SHACL instance of owl:Ontology, sh:DataGraph, sh:ShapesGraph or sh:RulesGraph in the shapes graph. A subclass of one of those classes counts too. Prefix collection and import resolution share one graph-role classifier; a node whose only role is sh:DataGraph supplies implicit prefixes but is not an import anchor.
  • Two different namespaces for one prefix in the declarations a query reaches make the shapes graph ill-formed. The load fails, and the error names the prefix and both namespaces. The same prefix declared twice with the same namespace is fine. Declarations that no query reaches are not checked.
  • A malformed declaration that a query reaches also fails the load. A declaration needs exactly one xsd:string sh:prefix and exactly one sh:namespace.

The shapes document’s own @prefix / PREFIX directives are a fallback. They only supply a prefix the declarations above leave unbound, and they never replace one the declarations bind. They come from the Turtle parser’s own record of the document, so a PREFIX line inside a query’s string literal is never mistaken for a directive. If the document declares a prefix twice, the fallback uses the last declaration. A PREFIX in a query’s own text applies to that query only.

The shapes graph as a named graph: $shapesGraph

SHACL 1.0 pre-binds $shapesGraph in every SHACL-SPARQL query to the IRI of the shapes graph, so a query can read the shapes graph with GRAPH $shapesGraph { … }. SHACL 1.2 removed that pre-binding, and there $shapesGraph is an ordinary variable. PurRDF keeps the SHACL 1.0 behaviour as a caller’s choice. Name the shapes graph’s IRI and $shapesGraph is pre-bound to it, with the shapes graph exposed under that name. Name none and $shapesGraph stays an ordinary, unbound variable. A relative IRI resolves against the shapes document’s base, so it names what sh:shapesGraph <that reference> in the document would name. A relative IRI with no base in scope names no graph and is refused (iri-relative-no-base).

Every host takes the IRI:

HostSpelling
CLI--shapes-graph IRI on validate, shapes lint, shacl pack and rules
Pythonshapes_graph= on validate, Shapes(...), pack_product, lint_shapes, apply_rules and entail
WebAssemblya trailing shapesGraph on shaclValidateToSarif, shaclValidateChangesToSarif, shaclPackProduct, shaclLintShapes, shaclApplyRules and shaclEntail
Cshapes_graph_iri after shapes_base_iri on purrdf_shacl_validate_to_sarif, purrdf_shacl_validate_changes_to_sarif, purrdf_shapes_product_encode, purrdf_shacl_lint_shapes, purrdf_shacl_apply_rules and purrdf_shacl_entail_to_ntriples
Rustengine::parse_shapes_with_graph, engine::validate_graphs_with_shapes_graph, engine::entail_graphs_with_shapes_graph, RulesRequest::shapes_graph and the purrdf-validate *_with_shapes_graph entry points

A Shapes parsed under the IRI carries it into every validation, every PreparedShapes binding and every prepared product, whose identity binds it. SHACL-AF SPARQL rules run in the same shapes-graph context: a sh:SPARQLRule’s $shapesGraph is pre-bound to the IRI a rules run names, and is an ordinary variable when it names none. A SPARQL 1.2 RL rule set has no shapes graph, so an IRI named beside one is refused rather than ignored. The W3C SHACL 1.0 test sparql/pre-binding/shapesGraph-001 shows the difference. With the IRI named, it reports the one approved result. With none, $shapesGraph is unbound, the query selects nothing, and the data graph conforms.

Class hierarchies kept in the shapes graph: subClassOfInShapesGraph

SHACL type — whether a node is a SHACL instance of a class, which sh:targetClass, implicit class targets, sh:class, sh:rootClass and shnex:instancesOf all ask — is read from the data graph’s rdf:type and rdfs:subClassOf triples. The class hierarchy often lives beside the shapes instead, so SHACL 1.2 Core §6.3 says “SHACL processors SHOULD offer a parameter subClassOfInShapesGraph that, if set to true, should alter the definition of SHACL Type so that the rdfs:subClassOf triples are queried from the shapes graph in addition to the data graph.” PurRDF offers it on every host. It is off by default, which is the specification’s default.

With it on, the shapes graph’s rdfs:subClassOf triples (its whole import closure’s) join the data graph’s wherever SHACL type is decided, and nowhere else. They do not become data-graph triples, so a path, a sh:targetSubjectsOf rdfs:subClassOf or a SPARQL pattern still reads the data graph alone. rdf:type triples are always read from the data graph.

HostSpelling
CLI--subclass-of-in-shapes-graph on validate (document and product routes)
Pythonsubclass_of_in_shapes_graph=True on validate and Shapes(...)
WebAssemblya trailing subClassOfInShapesGraph on shaclValidateToSarif
Cbool subclass_of_in_shapes_graph after the import table on purrdf_shacl_validate_to_sarif
RustValidationOptions::with_subclass_of_in_shapes_graph

Built-in declarations and the W3C vocabularies

The SHACL 1.2 vocabularies (shacl.ttl, shnex.ttl and shnex-sparql.ttl) declare every built-in constraint component and node-expression function, for example sh:SPARQLExprExpression a sh:NamedParameterExpressionFunction. None of those declarations has a body or a validator, because the engine provides the implementation. A shapes graph may merge the vocabularies, and loading resolves each declaration against the engine’s table of what it implements:

  • a bare declaration of a built-in binds to the native implementation and adds nothing to the custom-function index or the component registry;
  • a built-in declared again with a body is a duplicate definition, and so is a built-in component given an sh:ask or sh:select of its own; one declared under the wrong class or with a contradicting signature is a mismatch; all of these fail the load;
  • validators declared for a built-in component are alternatives the native implementation supersedes. SHACL 1.2 SPARQL Extensions selects “one of the values” of a component’s validators, so each is an implementation of the same component, and the engine’s own is the one that runs. Vocabularies such as DASH declare them for SHACL Core components. A SPARQL alternative must be a well-formed SPARQL validator of its attachment: an ASK validator under sh:propertyValidator (propertyValidator-class), a SHACL-JS sh:JSValidator under any attachment (validator-class, nodeValidator-class, propertyValidator-class) or an unparsable query (ask-sparql) fails the load. It is never executed, so its query may call a function the engine does not have, and a MINUS in its pre-bound query does not fail the load: purrdf shapes lint lists it under unexecuted;
  • any other sh: statement on a built-in’s declaration fails the load, except sh:message, sh:labelTemplate and the non-validating characteristics (sh:name, sh:description, …): sh:severity on sh:MinCountConstraintComponent, for example, would ask the native implementation for something it does not do;
  • a function the engine does not implement still needs its body, whatever its namespace: a bodiless ex:f fails the load, and so does a bodiless sh:NotABuiltin;
  • a custom function whose key parameter is a built-in’s key parameter fails the load, because the key parameters of all node expression functions must be disjoint.

So merging the W3C vocabularies into a shapes graph is a no-op. A test holds this over every shapes graph in three corpora (the first-party corpus, the W3C SHACL 1.0 suite and the SHACL 1.2 sht:Validate entries): the validation report is byte-identical with and without the merge. purrdf_shapes::spec exposes what the vocabularies declare and what the engine implements, and a test pins the difference at zero. purrdf shapes lint reports, per function call site, whether the call bound natively, to a custom body, to a SPARQL registration or to a host extension, and lists every validator declared for a built-in component as superseded-by-native. Those lines are never findings.

A SHACL-SPARQL or SHACL-AF declaration that violates a syntax rule fails the load whether or not any shape reaches it. SHACL 1.2 Core says “A SHACL processor SHOULD produce a failure in this case” and does not limit that to what a shape uses. The rules are those of SHACL 1.2 SPARQL Extensions and SHACL Advanced Features, and the refusal names each one by its id. A value of sh:nodeValidator or sh:propertyValidator must be a SELECT validator and a value of sh:validator an ASK validator (nodeValidator-class, propertyValidator-class, validator-class). A component parameter must not be named this, path, PATH or value (parameter-name-not-in). A sh:SPARQLFunction parameter must not have one of those names, nor shapesGraph or currentShape. A sh:SPARQLFunction needs exactly one sh:ask or sh:select (SPARQLFunction-query). A sh:select body returns the binding of its one result variable, and SHACL Advanced Features says “such SELECT queries should only return at most one solution”; PurRDF reads that as a must, so a call whose body returns a second solution fails the validation rather than answering with whichever row came first. The refusal is ShapesError::IllFormed and lists every violation in the graph, so a shapes graph that imports a library with ill-formed declarations, as DASH has, fails with all of them named.

A pre-binding violation is judged where the query runs. SHACL 1.2 SPARQL Extensions requires a failure for a query “executed with pre-bound variables” that contains a MINUS, a VALUES that mentions a pre-bound variable, or an AS ?var for one. A sh:sparql constraint, a component validator, a sh:SPARQLTarget’s sh:ask and a SPARQL rule refuse every VALUES, pre-bound or not, because the vendored W3C case unsupported-sparql-002 requires the refusal of VALUES ?any { true }; a node expression, a sh:SPARQLFunction body and a sh:SPARQLTargetType query refuse only one that mentions a pre-bound name. The load therefore fails, with ShapesError::Prebinding, when a shape’s sh:sparql constraint violates one, when a use of a custom component selects such a validator, when a node expression or a query a shape reaches calls such a sh:SPARQLFunction, when a shape instantiates such a sh:SPARQLTargetType, or when a shape reaches such a sh:select node expression. A function’s or target type’s parameters are its potentially pre-bound variables, an optional one included, since a call that supplies it binds it. A node expression’s are $this and every name its context binds: $value inside sh:expression, a custom function’s arguments inside its body and a free evaluation’s scope names. Each is held to the same rule as $this, and a node expression’s query that breaks it fails the load when a shape reaches the expression. This is stricter than the engine’s own lanes: a prepared execution or a request’s substitutions admit VALUES and MINUS over a pre-bound name (see Pre-bound variables). The grouping constraint is narrower: inside a custom function’s body it treats as constants only the arguments every call binds, the required ones, because a call may omit an optional one. A validator of a built-in component never runs, and neither does a validator no use selects, a function nothing calls or a target type no shape instantiates. Those load, and purrdf shapes lint lists each one as a finding.

The same appendix says “SPARQL queries SHOULD not contain a federated query (SERVICE)”, and “Implementations that do not permit SERVICE MUST report a failure”. PurRDF permits SERVICE in no SHACL-SPARQL query, including a sh:SPARQLTarget and a function with no parameters, which pre-bind nothing. A verdict that depended on what a remote endpoint answered would not be a verdict about the data graph, and PurRDF fetches nothing. Such a query fails the load where it runs, with ShapesError::Prebinding, and is listed by lint where nothing runs it. The word inside a string literal is not a SERVICE.

A shape whose sh:target is a custom target PurRDF cannot compute is refused with ShapesError::UnsupportedTarget, naming the shape and the target. PurRDF computes a sh:SPARQLTarget and an instance of a declared sh:SPARQLTargetType. SHACL Advanced Features says an engine that “cannot handle a given custom target SHOULD at least report a warning”. A warning beside a report about focus nodes nobody computed would still be that report, so PurRDF refuses, which is stronger. A SHACL-JS target is refused as ShapesError::ShaclJs.

A sh:SPARQLTarget may also carry one sh:ask. SHACL Advanced Features §3.1 says “SPARQL-based targets have at most one value for the property sh:ask”, for a SELECT that cannot be turned into an equivalent ASK: “A SHACL engine can then determine whether a given shape applies to a given node by executing the ASK query with the variable this pre-bound to the node.” PurRDF does exactly that wherever it checks given nodes instead of enumerating the target: PreparedValidator::validate_focus_nodes and its id-native twin. (A change-path validation over a shapes graph with a SPARQL target has no bounded footprint, so it validates the whole graph.) The candidate is pre-bound to $this, so the ASK must meet the pre-binding restrictions, and a node the data graph does not hold is answered too. The ASK does not decide alone: its answer is confirmed against the target’s own SELECT results for the same data graph, because the two state one target. A node they answer differently for is refused with ShapesError::SparqlTargetDisagreement, naming the shape, both queries, the node and both answers, since checking it by either answer would not give the verdict a whole validation gives. A whole validation still enumerates the target with its SELECT, and never runs the ASK. A second sh:ask, a value that is not an ASK query, or an ASK that breaks a pre-binding restriction is refused at load. A target without sh:ask checks a candidate by looking it up in its SELECT’s results.

SHACL 1.2 conformance

The complete W3C shacl12-test-suite is vendored byte-exact under vectors/shacl12/ and run through the library API: 592 tests, covering 213 sht:Validate, 143 sht:EvalNodeExpr and 27 sht:Infer tests, and 209 SPARQL 1.2 RL syntax, well-formedness, stratification and evaluation tests. An upstream manifest lists 591 of them. Of those, 590 pass as approved and the expected-failure ledger is empty. The remaining 1 entry belongs to a vendored file that no manifest includes and is reported separately. The one approved entry that does not pass, node-expr/shnex-sparql/hasLang-example, is reported failed with its rationale: it calls sparql:hasLang with two arguments, and the SPARQL 1.2 grammar’s hasLANG takes exactly one. A negative SPARQL 1.2 RL test must fail at the stage its test type names, not at an earlier one. Reproduce it with:

cargo test -p purrdf-shapes --test w3c12_conformance -- --nocapture

The 213 sht:Validate tests also run through the built purrdf validate --shacl-profile shacl-ed-2026-10-16 binary, graded against the approved results by the same grader, so the result holds on a shipped surface:

cargo test -p purrdf-cli --test w3c12_validate_cli -- --nocapture

The last line of the approved suite’s scoreboard is W3C12 TOTAL: passed 590, spec-contradicted 1, xfailed 0, ledger 0, and the unlisted files’ own line is W3C12 UNLISTED: passed 1, exact 1, with-delta 0, total 1.

One vendored file carries test entries that no upstream manifest includes: inference-rules/rdfs/rdfs1.ttl. It is not part of the approved suite, so it is never counted among its passes. A separate test, w3c_shacl12_unlisted_vendored_files, grades it exactly as written and reports it under its own label.

A sh:reifierShape or sh:reificationRequired result carries the value node as sh:value. The textual definition in SHACL 1.2 Core section 7.8.5 uses the name t for two things. It opens “Let t be the triple term (focus node, $path, value node)”, then reports “For each reifier t that does not conform to $reifierShape, there is a validation result with t as sh:value” and, for a missing reification, “there is a validation result with t as sh:value”. The approved tests core/property/reifierShape-001 and -002 both expect the value node, and PurRDF follows the approved test suite’s reading. A result for a non-conforming reifier also carries the reifier’s own validation results as sh:detail, each with the reifier as its focus node, so the report still names the reifier and says why it fails. A missing reification has no reifier and carries no details.

In six node-expression tests the approved result spells a computed xsd:decimal as "4.0", "3.0", "42.0" and "00". The specifications leave the lexical form of a computed literal to the processor, so PurRDF computes a decimal with a point and a fractional digit (4.0), and SECONDS as the seconds part of the lexical form of its argument (00). All six are graded term for term against the approved result, like every other entry.

sparql/component/validator-001, in this suite and in the SHACL 1.0 suite, passes by name with no import supplied. Its owl:imports <http://datashapes.org/dash> sits on a node that is neither the document’s own IRI nor an owl:Ontology nor a sh:ShapesGraph, nor names one as its owl:versionIRI, so under OWL 2’s mapping to RDF (§3.1.2) and SHACL 1.2 Core the triple is data, not an import (see owl:imports in a shapes graph). The harness loads the case with an empty import table.

SPARQL 1.2 RL grammar rule [2], RuleOrDataBlock ::= Prologue ( RuleOrData+ ( Prologue1 RuleOrData? )* )?, is implemented as its evident intent: after the initial prologue, declarations and rule or data blocks interleave freely, so any number of rule or data blocks may follow each declaration. Read literally, the rule would refuse RULE … PREFIX … RULE … RULE … while accepting the same rules with one more declaration between the last two.

JSON-LD instance projection and JSON Schema

json_schema::compile and instance::project_graph are one pair. The schema describes the JSON-LD @graph document the projection writes, and a projected node validates exactly when it conforms to the shapes, except for the recorded losses listed below. The projection drops no triple. Every carried term keeps what a constraint can judge:

  • An @id is the full IRI, or _:label for a blank node. It is never a compact IRI, so a lexical constraint judges str() of an IRI directly. Keys and @type values stay compacted.
  • A well-formed RDF list is the JSON-LD list object {"@list": [...]}, and its cells are not @graph nodes. rdf:nil is {"@list": []}. The list conversion of JSON-LD 1.1 Processing Algorithms and API section 8.4.2 decides which lists convert, read strictly so that no triple is dropped. A branching, cyclic, shared, IRI-named or annotated list (a cell typed rdf:List included) keeps the node-graph form. A list that is a member of another list carries its head cell’s label as @index. @index is not RDF-significant, and it keeps two distinct member lists distinct.
  • A bare JSON scalar appears only where it denotes the literal exactly (JSON-LD 1.1 section 8.6): a canonical xsd:integer within 64 bits, and xsd:boolean true or false. Every other numeric literal keeps its {"@value", "@type"} object.
  • An rdf:dirLangString literal carries @direction.

The schema projects the SHACL 1.2 list components onto the @list array. sh:minListLength becomes minItems, sh:maxListLength becomes maxItems, sh:uniqueMembers true becomes uniqueItems, and sh:memberShape becomes items, compiled as one value’s schema. Each of them requires a list. On a node shape they judge the focus node’s rdf:first and rdf:rest. Every sh:flags letter, i included, is written into the ECMA-262 pattern. i follows XPath’s case variants, not simple case folding. Lexical constraints judge an IRI’s @id. They judge the constants rdf:nil, true and false at compile time, and a bare integer’s numeral length as an integer range. Numeric datatypes are told apart over their lexical and value spaces. A range bound compares a typed integer-family or decimal literal by an order pattern on its lexical form, with SPARQL’s numeric promotion against a double or float bound. A temporal bound on xsd:dateTime, xsd:date or xsd:time compares a typed literal of its datatype on the XSD timeline, by order patterns on the lexical form. A timezone makes the value an instant, and 24:00:00 is the next day’s midnight. A zoned value and a local bound, or the reverse, compare only when they are more than 14 hours apart. Closer than that they are incomparable, which the component reports as a violation. A value of any other datatype violates the bound. A dateTime or time bound’s pattern states each of the 1,681 minutes within ±14:00 of it, so it is large: about 100 KB, and more for a bound with fractional seconds. A node shape’s node kind, sh:in, sh:hasValue and lexical constraints judge the focus node’s @id.

What remains is recorded on the forward ledger, each case with its reason:

  • A list kept as linked @graph nodes is not judged. A JSON Schema keyword judges only the instance location it applies to and the locations beneath it, so none reaches another node’s rdf:first.
  • A node shape’s sh:uniqueMembers does not compare the focus node’s own member with its tail’s members. Those are two instance locations.
  • A pattern over a bare integer’s numeral is not judged. The integers whose numerals start with 1 are no finite union of the intervals and residue classes minimum, maximum and multipleOf state.
  • A range bound over xsd:double or xsd:float lexical forms is not judged. Those forms on one side of a bound are no regular language.
  • A node shape’s class membership is not judged. It runs through rdfs:subClassOf* triples on other nodes.

The Pydantic package enforces the list components, uniqueness included, with a validator over the raw JSON items. TypeScript states the length bounds exactly at any size, and distinct elements when the members are finitely many scalars. It has no type for distinct elements of an unconstrained member, or for an integer minimum. LinkML states all four on the @list slot. GraphQL carries a list value as a @oneOf input object of a node reference and a list object, and each member as a @oneOf input object of the member shape’s alternatives, so member types are checked. GraphQL list types have no length or uniqueness constraint and its numeric types have no bound, so those are recorded where they stand. Each emitter oracle runs these components over projected instances of real data.

A property shape that judges its values against shapes projects each shape as one value’s schema, compiled as sh:memberShape’s is. sh:node and sh:and become allOf, sh:or becomes anyOf, sh:xone becomes oneOf, and sh:not becomes not. sh:someValue becomes contains over an array of values, and the value schema itself on a lone value, which it then requires. A shape’s property shapes judge the value’s own properties, which live on the value’s own @graph node, so they are recorded. oneOf and not reject a value when a member schema accepts more than its shape does. They are therefore emitted only when every member shape is exact, that is, built from sh:datatype, sh:nodeKind, sh:in, sh:hasValue and further exact shapes, with nothing recorded. Otherwise the sh:xone or sh:not is recorded and left out. sh:class is not exact, because a value’s types live on its own node. When one value is written unwrapped beside the array of several, the value schema also rejects an array, so every value of an array is judged.

Pydantic enforces allOf, oneOf, not and contains with its runtime check over the raw JSON input, so it agrees with validation on each of these. LinkML states them as all_of, any_of, exactly_one_of, none_of and has_member, whose expression is the whole value schema, class ranges included, so nothing is recorded. The official LinkML 1.11.1 JSON Schema generator, which the LinkML validator also runs, turns a has_member into contains only for its scalar constraints. Its output therefore accepts an array with no conforming value, although the LinkML schema states the constraint. TypeScript states intersections and unions, and records oneOf, not and contains, which it has no type for. GraphQL delegates the composition to its custom scalar. Each emitter oracle runs these constraints over projected instances of real data.

Each emitter oracle also runs the temporal bounds over projected instances. Pydantic and LinkML agree with validation on each of them. TypeScript and GraphQL record the bound’s negation where it stands. Neither has a complement type. TypeScript cannot write the admitted lexical forms positively either, because the dates of four-digit years alone exceed its union limit.

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.

Anonymous class expressions are part of the theory. A restriction (owl:someValuesFrom, owl:allValuesFrom, owl:hasValue, owl:hasSelf, and the unqualified and qualified cardinalities over owl:onClass or owl:onDataRange) or a boolean form (owl:unionOf, owl:intersectionOf, owl:complementOf, owl:oneOf) that a named class is rdfs:subClassOf or owl:equivalentClass to is recorded with its source axiom and inherited by every subclass; asserted of owl:Thing, it holds of every class, and a universal restriction on owl:Thing is the property’s range. The property it names joins the catalog and is emitted on the class that carries the restriction, whatever its declared domain, and the axiom is provenance on the coverage rows of its owner and of the classes that reference it. A restriction on the inverse of a property whose inverse is named is a restriction on that named property. Data ranges built with owl:onDatatype and owl:withRestrictions, owl:datatypeComplementOf, or a literal owl:oneOf are read as fillers and ranges, and [ owl:inverseOf p ] is read wherever a property expression may stand. The OWL 2 datatype map and RDF 1.2 datatypes outside XSD (owl:real, owl:rational, rdf:PlainLiteral, rdf:XMLLiteral, rdf:dirLangString, rdf:HTML, rdf:JSON) are datatypes. An equivalence between an IRI and a data range, or another datatype, defines a datatype: a value is then held to the defining range, or typed with the datatype by name. Each named member of a union a class is equivalent to becomes its subclass, and an existential restriction places a class within the restricted property’s domain, so that the domain’s other properties reach it.

What a developer schema can state is projected onto the class definition. owl:allValuesFrom holds every value to the filler; a class filler is checked only as a node reference, an approximation, and owl:Nothing forbids the property. An existential, owl:hasValue or a minimum makes the property required, with contains and minContains where the filler narrows the values. A maximum becomes maxItems, or maxContains over a qualifier whose value schema is exact. A datatype restriction’s numeric, temporal, length and pattern facets are compiled by the SHACL value-constraint compiler, with the XSD pattern anchored; a datatype complement is judged on the literal’s datatype tag rather than its value space, an approximation. An owl:oneOf of named individuals restricts @id, the complement of a named class excludes it from @type, and a union of restrictions becomes anyOf over the members’ property constraints. Requiring a value and counting distinct terms read open-world axioms closed-world, so the coverage rows they touch are representation approximations.

An inherited restriction is referenced, not copied: the class that owns it holds it in a <Class>.Restriction.<property> fragment definition, and each subclass and each descendant fragment references the nearest owner’s fragment through allOf (disjunctions likewise through <Class>.Disjunctions), so schema, provenance and manifest grow with the restrictions rather than with the hierarchy’s depth. A class constructor on an IRI, such as ex:Day owl:oneOf ( … ) or an IRI typed owl:Restriction, is the equivalence OWL gives it.

What no schema keyword states is reported, never dropped: owl:hasSelf, a restriction on an unnamed inverse or on several properties, a maximum over a class qualifier, the complement of an anonymous expression, a union with a named member the class does not already entail, the sufficient-condition direction of owl:equivalentClass, a general class inclusion with an anonymous subclass, anonymous classes in owl:disjointWith, owl:AllDisjointClasses, owl:disjointUnionOf and class assertions, and owl:hasKey on an anonymous class. SchemaCompileRequest::class_expression_report and compile_schema_with_class_expressions return the class-expression manifest: every such axiom once, with each component’s outcome (projected, approximated, unrepresented or excluded) and reason, on the axiom itself or on each class that owns it; a class below an owner has its own row only where its outcome differs. The class definition names its unrepresented components in $comment. Nothing the surface accepted before is refused: a malformed anonymous part of an axiom it used to skip is reported as unrepresented. A malformed expression in an axiom it always read is refused: a restriction without a property, a cardinality that is no non-negative integer, a qualifier mismatch, owl:hasSelf other than true, a construct-less blank node or one that is both a class expression and a data range, an ill-formed enumeration, list or datatype restriction, an expression that contains itself, or a data range where a class is required. A blank node with several readings (several facets, facet values, properties, qualifiers or class constructs) is their conjunction, and a facet over a defined datatype restricts its definition’s values. An empty, single-member or repeated union or intersection, an empty enumeration, an empty facet list and a cardinality beyond 64 bits are read by their meaning, and a punned property by the RDF-Based Semantics. Every property’s values are read that way too, whatever its declared kind: a value is any node (an IRI may denote a data value) or any well-typed literal (one whose lexical form is in its datatype’s lexical space; "abc"^^xsd:integer is none). A property no range constrains states that exactly. A data range holds literals to its value space and admits a node unjudged, and a class range or filler admits a well-typed literal unjudged, both reported as approximations, except that a class disjoint with rdfs:Literal rejects every literal exactly. A cross-kind owl:hasValue is ∃p.{v}, and owl:hasSelf on a datatype property is the self restriction; each cell carrying an owl:hasSelf is reported as an approximation. owl:Nothing, and an expression empty by its form (¬owl:Thing, the empty enumeration), admits no value in any range or filler position, reported exactly: an existential or a qualified minimum over it leaves the class no instance. One request expands at most 1,048,576 expression nodes.

OWL ranges, fillers, data ranges, enumerations and owl:hasValue judge a literal by its value, as OWL 2 reads a datatype, where sh:datatype judges its tag: xsd:decimal admits the integer 1, xsd:string admits xsd:token, and an enumeration of 1 matches "01"^^xsd:integer. A decimal or integer range admits a literal typed owl:rational unjudged and is reported as an approximation; a length or pattern facet holds of every string literal’s lexical form, its value. Under a hash namespace the schema $id drops the fragment, as draft 2020-12 requires.

Direct SHACL remains authoritative. Ontology-only fields are optional unless a class restriction requires them; 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, 16,777,216 class-hierarchy memberships, 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.

A range bound reads back as itself. An order pattern states the set of lexical forms on one side of a bound, not the bound, and different bounds can state the same set: < 150 and ≤ 149 admit the same integers. So each rejection the compiler writes from a bound carries that bound in its $comment, as the facet’s SHACL term and the bound as an N-Triples term, such as sh:minInclusive "2020-01-01"^^<http://www.w3.org/2001/XMLSchema#date>. The importer trusts a comment only after checking it. It regenerates the rejections the named bound projects to, narrowed by the value’s sh:datatype, and they must be exactly the ones that carry the comment. For a numeric bound, the minimum and maximum beside it must also be the integers the bound admits. A comment that fails either check is recorded as schema-applicator-dropped. So sh:maxExclusive 150, sh:minInclusive 1.5 and a bound over xsd:date, xsd:dateTime or xsd:time survive the round trip, and the re-emitted schema is byte-identical.

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.

A JSON Schema not has no annotation equivalent. The package checks it at run time instead: a before-validator evaluates the negated schema over the raw JSON input. The check follows JSON Schema’s own semantics. Numbers compare by exact value, string lengths count code points, and pattern runs through Pydantic’s own regex engine. The check covers a closed keyword table, $ref included. A negated schema that uses any other keyword, such as propertyNames, a format, or a pattern outside the common grammar, stays a recorded loss.

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, arrays and 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.

Array length bounds are exact at any size. A tuple element states each position-specific item. A length beyond those positions is stated as an element property: an array literal whose contextual type is tuple-like is typed as a tuple, and a tuple of length n has exactly the properties "0" to "n-1". So minItems: m is a required property "m-1", and maxItems: n is an optional never property "n". uniqueItems over items that admit finitely many JSON scalars is exact too. The generated JsonDistinct helper enumerates the distinct sequences as a union of tuple types. The enumeration stops at the compiler’s limits: an instantiation depth of 100 (TS2589), and 100,000 members in a union that the compiler spreads into a tuple or distributes an intersection over (TS2590). Numeric keywords over a finite const or enum leave out the numbers that fail them.

Runtime assertions outside TypeScript structural assignability are never silently erased: integer, numeric/string predicate, closure, pattern-property, dependency, conditional, negation, contains, uniqueness and evaluation-state gaps receive stable codes and JSON Pointer locations. A numeric bound over infinitely many numbers and distinct elements of an infinite item type have no type. The only proper subtypes of number are unions of literals, and a tuple type constrains each position independently. The oracle compiles the facts these losses rest on: Exclude<number, -1> still admits -1, and each enumeration limit is accepted at its edge and refused one step past it. 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. An anyOf is exact when every value selects one alternative: by its JSON kind, or, among object alternatives, by a required key no other alternative declares. It becomes a @oneOf input object with one field per alternative, and an output union. An alternative that is not an object type is a union member through a wrapper type whose value field carries it. 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, and wraps a union value in its alternative’s @oneOf field. A non-object value of a kind no alternative admits passes unchanged, and GraphQL coercion rejects it. decode_input is the inverse for a coerced argument. encode_output writes the value a resolver returns, where a union value names its member in __typename. decode_output performs the inverse for fields present in a GraphQL response, without inventing omitted selections. Unknown or incompatible values fail. This package codec is the precise value boundary; import_graphql_package is the schema reverse boundary and verifies the SDL, typed/canonical maps, identity, retained source schema, and forward ledger. Arbitrary GraphQL SDL has no unique JSON Schema acceptance relation and is not accepted as an inverse format.

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

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

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

Emission fails before returning bytes for invalid caller configuration, malformed schema keywords, $id rebasing, external/indirect/dangling $ref, $dynamicRef/$recursiveRef, alias cycles, unsatisfiable closed required fields, and generated-name collisions. The input schema has no fixed size limit; each artifact is bounded at four bytes per input byte, and at least 16 MiB. The fixed limits are 16 MiB for 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 purrdf-jsonschema, 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. Each valid value is also returned through its output type, and GraphQL.js must serialize it unchanged:

make graphql-oracle

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

Rules, node expressions and certifying a shapes graph

Four tools sit beside validation. Every host reaches the same library entry point, so the command line, Python, WebAssembly and C cannot disagree about the answer.

ToolRustCLIPython (purrdf.shapes)WebAssemblyC ABI
Run rules, write the inference graphpurrdf_shapes::infer, srl::inferpurrdf rulesapply_rulesshaclApplyRulespurrdf_shacl_apply_rules
Check a SPARQL 1.2 RL rule set, run nothingsrl::checkpurrdf rules --srl FILE --checkcheck_rulesshaclCheckRulespurrdf_shacl_check_rules
Evaluate one node expressionfree_expression::evaluatepurrdf node-expreval_node_exprshaclEvalNodeExprpurrdf_shacl_eval_node_expr
Certify a shapes graphlint::lintpurrdf shapes lintlint_shapesshaclLintShapespurrdf_shacl_lint_shapes

Rules. The rule source is either a shapes graph, whose SHACL 1.2 rules run (its default rule set), or a SPARQL 1.2 RL rule set, but not both. The result is the inference graph: the inferred triples only, never the data graph, as N-Triples 1.2 in one canonical order. On request, each host also returns the proof of every inferred triple. The proof is one block per triple: derived S P O ., then rule R and one premise S P O . line for each fact the rule’s body matched. A SPARQL 1.2 RL data-block triple has data-block instead.

Every host takes the same four rule-evaluation limits, on both of its SHACL rules entry points: the rules run above and SHACL-AF entailment (Python shapes.entail, WebAssembly shaclEntail, C purrdf_shacl_entail_to_ntriples), which bounds its run exactly as the rules run does. In WebAssembly both entry points take them contiguously and in this order, after the import table and shapesGraph: shaclApplyRules(dataNt, shapesTtl?, srl?, shapesBase?, srlBase?, explain?, importIris?, importDocuments?, shapesGraph?, maxTermGeneratingRounds?, maxGeneratedTerms?, maxStoredFacts?, maxJoinSteps?) and shaclEntail(shapesTtl, dataNt, shapesBase?, importIris?, importDocuments?, shapesGraph?, maxTermGeneratingRounds?, maxGeneratedTerms?, maxStoredFacts?, maxJoinSteps?).

  • The term-generating round limit bounds the evaluation rounds that infer a term the graph did not already hold. The default is 16384 rounds. A counter stepping to 10,000 completes. A counter with no bound infers one new term per round and remains subject to this limit.
  • The generated-term budget bounds the terms inferred beyond the input’s. The default is max(65536, 4 × N), where N is the number of distinct terms in the data graph (and in a SPARQL 1.2 RL rule set’s data blocks). A rule set whose new terms double every iteration reaches it within a few iterations, long before the round limit.
  • The stored-fact limit bounds the facts the evaluation store holds: the data graph, a rule set’s data and every inferred triple. The default is 4,194,304 facts natively and 131,072 in WebAssembly, where the store lives in one linear memory. A rule that copies each of 70,000 ex:p triples to ex:q, or the transitive closure of a thousand-node chain (500,500 triples), completes natively.
  • The join-step limit bounds the candidate solutions the rule bodies enumerate. The default is 1,048,576 on every host. It bounds a rule body that enumerates far more candidates than it infers triples: a body’s candidates for one round are held before the limit is checked, so a much larger default would let a Cartesian body exhaust memory first. A rule set that needs more work — the non-linear transitive closure of a thousand-node chain enumerates over 67 million candidates — states a larger limit.

A run past any limit is refused. The error names the limit, the numbers, the knob that raises it and, for the two term limits, the rules that inferred a new term in the last iteration. The knob is spelled the way the calling host spells it: --max-term-generating-rounds, --max-generated-terms, --max-stored-facts and --max-join-steps on the command line; the max_term_generating_rounds, max_generated_terms, max_stored_facts and max_join_steps keyword arguments in Python; maxTermGeneratingRounds, maxGeneratedTerms, maxStoredFacts and maxJoinSteps in WebAssembly; and the max_term_generating_rounds, max_generated_terms, max_stored_facts and max_join_steps parameters in C. The entailment entry point names its own knob: entail(max_stored_facts=...) in Python, shaclEntail's maxStoredFacts in WebAssembly, purrdf_shacl_entail_to_ntriples's max_stored_facts in C. Whether a rule set that keeps inferring new terms would stop is undecidable, so the error never calls it divergent. A stated limit is exact, and a limit can only refuse: a run it admits infers exactly what it would under any larger limit.

A shape rule (a rule linked to a shape, run once per focus node) is re-run each iteration only for the focus nodes whose answer can have changed: the new ones, and those a triple added in the previous iteration is about, as the rule’s triple patterns and paths read it. A triple the rule reads a join step away from $this re-runs it for every focus node, and a rule whose answer is not repeatable — one that mints blank nodes, calls RAND(), NOW(), UUID(), STRUUID() or a function PurRDF does not read, or picks solutions with LIMIT, OFFSET, SAMPLE, GROUP_CONCAT or FOLD — runs for every focus node every iteration, as the specification reads. The graph the rules read is extended by what each iteration added rather than rebuilt. The inferred triples, their blank-node labels and their proofs are the same as running every focus node every iteration. What changes is the cost: a shape rule that mints a new focus node every iteration pays for one run per iteration, not one per focus node so far, so a rule that never stops is refused by a term limit — the round limit when its new terms stay short, the fixed 16 MiB term-arena ceiling when each is longer than the last — and never by the join-step limit.

A global SPARQL rule whose query is a conjunctive pattern (triple patterns, FILTER and BIND, with no OPTIONAL, UNION, MINUS, sub-query or graph access) runs as rule elements. An iteration then costs only what is new, instead of a pass over the whole evaluation graph, and infers the same triples. A BIND variable must appear in every template triple, and a template must have no blank node. Otherwise the rule runs as a producer over the whole graph, as before.

import purrdf

out = purrdf.shapes.apply_rules(data_nt, shapes_ttl, explain=True,
                                max_term_generating_rounds=1024)
print(out["inferred"])   # the inference graph, N-Triples
print(out["proof"])      # the proof text
out = purrdf.shapes.apply_rules(data_nt, srl=srl_text)  # SPARQL 1.2 RL

Checking a SPARQL 1.2 RL rule set. A rule set can be checked without being run. The check applies the static checks up to a level, and each level includes the ones before it. syntax checks the SPARQL 1.2 RL grammar, for the rule set and every document its IMPORTS closure reads. well-formed also checks that every rule, imported ones included, is well formed. stratified also checks that the combined rule set can be stratified. It is the default, and it is exactly the set of checks a rules run applies before it evaluates: every host’s rules run passes through the same check first. The imports resolve from the same import table a run takes. No data graph is read and no rule runs. A rule set that passes returns its rules, data-block triple count, imported rule sets, VERSION labels and strata, and a one-line summary every host prints alike. A refused rule set fails with the error of the stage that refused it. The W3C SPARQL 1.2 RL suite’s syntax, well-formedness and stratification tests are graded through this check. A positive syntax test is valid “regardless of well-formedness and stratification”, so it is graded at syntax. Fifteen of them fail the full check, by a later stage.

The syntax stage also holds a rule set to where it announces its version. SPARQL 1.2 RL §7.1 says “The version announcement SHOULD be made early in the document”, and PurRDF reads that as a must. A rule set whose first VERSION directive follows a RULE or DATA block is refused at syntax, naming the sentence. A rule set may announce no version, and a later VERSION after an early one is still a directive for the part of the document that follows it.

purrdf rules --srl closure.srl --check              # every static check
purrdf rules --srl closure.srl --check=syntax       # the grammar alone

Node expressions. One node expression of a shapes graph is evaluated against a focus node of a data graph, as SHACL 1.2 Node Expressions’ evalExpr(expr, focusGraph, focusNode, scope). The expression is parsed by the shapes parser itself, so custom functions, shape references and sh:prefixes bind as they do inside a shape. Scope variables are bound by name and read by shnex:var. The output nodes come back as N-Triples terms in the order the expression’s sequence semantics define, beside the shapes graph’s mandatory diagnostics, which an evaluation reports like every other run (see the report is a dataset): purrdf node-expr writes a shacl diagnostic RULE SHAPE line to stderr after its node-expr outputs N line, Python’s eval_node_expr returns {"outputs", "diagnostics"}, WebAssembly’s shaclEvalNodeExpr returns a ShaclNodeExprOutcome with outputs and diagnostics, and C’s purrdf_shacl_eval_node_expr writes diagnostic RULE SHAPE lines to a non-NULL out_diagnostics.

Every host names the expression in one of three ways:

  • The node itself: an IRI, or _:label for a blank node the shapes document labels. An IRI that is the subject of no triple is a constant expression and evaluates to itself.
  • A walk from a named node: a start node and one or more predicates, followed in order. Each step must reach exactly one value; a step that reaches none or several is refused, and the error names the step and the count. This is how an anonymous [ … ] expression is named, which is how most node expressions are written. ex:Label then sh:values is that property shape’s computed-values expression. A W3C sht:EvalNodeExpr test entry’s expression is the entry, then mf:action, then sht:nodeExpr. All 143 of the suite’s node-expression tests run through purrdf node-expr this way.
  • Inline Turtle: a Turtle document read under the shapes document’s prefixes and base, where its own directives take precedence. It is merged into the shapes graph, so its shape references and function calls bind there, and its blank nodes are kept apart from the shapes document’s. The expression is the document’s one root: the blank node that is the subject of a triple and the object of none. A document with no root or several is refused.
HostNodeWalkInline Turtle
CLI--expr--expr-at NODE --expr-via PREDICATE…--expr-turtle, --expr-turtle-file
Pythonexprexpr_at=, expr_via=[…]expr_turtle=
WebAssemblyexprexprAt, exprViaexprTurtle
C ABIexprexpr_at, expr_via, expr_via_countexpr_turtle
Rust (purrdf_validate)ExprSelector::NodeExprSelector::AtExprSelector::Turtle

Naming none of the three, or more than one, is refused.

purrdf.shapes.eval_node_expr(shapes_ttl, data_nt, "_:suffix",
                             "http://example.org/a", scope={"suffix": '"!"'})
purrdf.shapes.eval_node_expr(shapes_ttl, data_nt, None, "http://example.org/a",
                             expr_at="http://example.org/Label",
                             expr_via=["http://www.w3.org/ns/shacl#values"])
purrdf.shapes.eval_node_expr(shapes_ttl, data_nt, None, "http://example.org/a",
                             expr_turtle="[ sh:path ex:name ] .")
purrdf node-expr --shapes shapes.ttl --expr-turtle '[ sh:path ex:name ] .' \
  --focus http://example.org/a data.ttl

Certifying a shapes graph. Loading a shapes graph is the hot path: it refuses the first construct it cannot evaluate faithfully, and does not pay for validating the graph against the W3C shacl-shacl.ttl. Linting pays that cost once, on request, and reports seven sections:

  1. load: the loader’s verdict, accepted or the refusal it raised.
  2. shacl-shacl: every result of validating the shapes graph against the vendored shacl-shacl.ttl. That file predates some SHACL 1.2 Core relaxations (a list-valued sh:nodeKind, a path-valued sh:equals, a node-expression sh:targetNode). A result SHACL 1.2 Core makes well-formed is marked superseded and is not a finding, but only when the loader accepted the graph.
  3. functions: which implementation every node-expression function call binds to — native, custom, sparql-registered or host-extension. For a shapes graph that merges the W3C SHACL 1.2 vocabularies, a sh:sparqlExpr call binds native even though the graph declares sh:SPARQLExprExpression.
  4. validators: every validator the shapes graph declares for a built-in constraint component, which the native implementation supersedes and never runs. These lines are never findings.
  5. unexecuted: every query that violates a pre-binding restriction and that nothing runs: a validator of a built-in component, a validator no use of its component selects, a sh:SPARQLFunction nothing calls, or a sh:SPARQLTargetType no shape instantiates. The load accepts them, and each is a finding here. A declaration that breaks a syntax rule is never listed here, because it fails the load.
  6. diagnostics: every mandatory diagnostic, one diagnostic RULE SHAPE line per shape whose sh:in or sh:xone list is empty. RULE is the syntax rule id, in-minListLength or xone-minListLength (“Each such list SHOULD have at least one member”). Such a shapes graph is well-formed and validates (see below), but the rule tells the author something, so lint always reports it, whether or not the load succeeded, and each line is a finding. shacl-shacl.ttl warns about the same empty list; that result is listed marked diagnosed RULE and is not counted a second time.
  7. unanchored-imports: every owl:imports triple of the closure that is not an import, because its subject is no anchor of its document (see owl:imports in a shapes graph). Each is data, so no document was looked for. These lines are never findings. The case the W3C flags is reported by shacl-shacl instead: its shsh:DataGraphImportsShape reports, at severity sh:Info, a sh:DataGraph that uses owl:imports without the type owl:Ontology.

A report is clean when the loader accepted the graph, every shacl-shacl result is superseded (an sh:Info result counts like any other), no unexecuted query violates a pre-binding restriction and no mandatory diagnostic applies. unanchored-imports never affects it. Every host renders the same deterministic text; the CLI reference shows it.

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 messages (every sh:resultMessage, each a dict with text and its language / direction / datatype when present). A result that carries SHACL-SPARQL result annotations also has annotations, a list of tuples, each a property IRI and a value in N-Triples syntax.

Dated SHACL laws

Unselected, SHACL validation runs under the compatibility law. A caller selects one exact, dated SHACL law by its stable id: shacl-rec-2017-07-20 (the 2017 Recommendation), shacl-wd-2026-09-18 (the SHACL 1.2 working draft) or shacl-ed-2026-10-16 (the data-shapes editor’s draft that the W3C SHACL 1.2 test suite is taken from, and the law the PurRDF EARL report is graded under). Every host selects the same law the same way, and every host decodes the id once, exactly, through purrdf_validate::complete_profile.

HostSpelling
CLI--shacl-profile <ID> on validate, with --shapes, --shapes-product (a prepared product stores no SHACL law, so the restore takes it from the request) or --changes
Pythonshacl_profile= on shapes.validate and the Shapes constructor
WebAssemblya trailing shaclProfile on shaclValidateToSarif; its asynchronous twin takes it in its options object
Cshacl_profile on purrdf_shacl_validate_to_sarif_law, which adds it to purrdf_shacl_validate_to_sarif_xpath_regex and changes no existing signature
purrdf validate --shapes shapes.ttl --shacl-profile shacl-ed-2026-10-16 data.ttl

An id that names no law, shacl-ed-2026-10-17 among them, is refused with the unsupported-profile status, and no nearby edition is substituted. A shapes graph the selected law’s text refuses is refused at load, never validated under another law. The same pair shows it on every host. The compatibility law refuses sparql/pre-binding/pre-binding-009 at load, because it contains a VALUES over a variable that is not pre-bound, and the editor’s draft validates it. The working draft refuses sparql/node/prefixes-003, which reaches a prefix declaration through a version IRI, and the editor’s draft validates it.

A dated law carries its own XPath bundle (see Dated XPath regex laws). Naming another XPath law beside it, such as --shacl-profile shacl-ed-2026-10-16 --xpath-regex xpath-2.0-2010-12-14, is refused as a usage error, and naming the same law is accepted. Node-expression and SPARQL 1.2 RL records are law-independent and take no SHACL law.

Dated XPath regex laws

sh:pattern with its sh:flags, and the REGEX and REPLACE of SHACL-SPARQL targets, constraints and SHACL-AF functions, run on the compatibility regular expressions unless the caller selects a dated native XPath law by name: xpath-2.0-2010-12-14 (XPath and XQuery Functions and Operators 2.0, Second Edition) or xpath-3.1-2017-03-21 (Functions and Operators 3.1). Both admit backreferences, so sh:pattern "^(a)\\1$" accepts "aa". Only 3.1 admits non-capturing groups and the q flag. The shapes-graph tools that evaluate patterns take the law too. For rules, it covers the REGEX/REPLACE of SHACL rules, SHACL-AF functions, node expressions and SPARQL 1.2 RL filters and assignments, and the sh:pattern of rule conditions. For node expressions, it covers filter-shape sh:patterns and the REGEX/REPLACE of function calls and SPARQL-based expressions. A lint compiles and matches no pattern, so it takes no law.

HostSpelling
RustPreparedShapes::with_xpath_regex(profile, limits), which returns an XPathPreparedShapes, and the free purrdf_shapes::xpath::validate_dataset; the purrdf-validate *_with_xpath_regex twins (validate_to_sarif_string, validate_changes_to_sarif_string, validate_with_shapes_product, validate_with_rebuilt_shapes_product, entail_to_ntriples, apply_rules_to_ntriples and eval_node_expr) take the profile xpath_regex::parse_profile returns
CLI--xpath-regex <NAME> on validate (with --shapes, --shapes-product or --changes), rules and node-expr; rules --check refuses it and shapes lint does not offer it
Pythonxpath_regex= on shapes.validate, Shapes.validate_nt, Shapes.validate_store, PreparedShapes.validate_nt, PreparedShapes.validate_store_changes, shapes.entail, shapes.apply_rules and shapes.eval_node_expr
WebAssemblya trailing xpathRegex on shaclValidateToSarif, shaclValidateChangesToSarif, the four shaclProductValidateToSarif… entries, shaclEntail, shaclApplyRules and shaclEvalNodeExpr; their asynchronous twins take it in their options object
Cregex_profile on purrdf_shacl_validate_to_sarif_xpath_regex, purrdf_shacl_validate_changes_to_sarif_xpath_regex, purrdf_shapes_product_admit_xpath_regex, purrdf_shapes_product_admit_expecting_xpath_regex, purrdf_shapes_product_rebuild_xpath_regex, purrdf_shapes_product_rebuild_expecting_xpath_regex, purrdf_shacl_entail_to_ntriples_xpath_regex, purrdf_shacl_apply_rules_xpath_regex and purrdf_shacl_eval_node_expr_xpath_regex
import purrdf

report = purrdf.shapes.validate(
    shapes_ttl, data_nt, xpath_regex="xpath-3.1-2017-03-21"
)

Names match exactly, so xpath-3.1 is refused with a list of the accepted names. With no selection (no keyword, option or flag, or a NULL regex_profile), the compatibility regular expressions behave exactly as before. A prepared product needs no re-packing, because the law belongs to the evaluation and not to the prepared shapes.

A pattern or flag the selected law does not define is reported the way an invalid pattern always is: a result of the declaring shape. A selected law also runs under finite resource bounds, 64 KiB of pattern source among them. Exceeding one fails the validation with the resource’s xpath-* code and writes no report, dataset or output. A resource refusal never becomes a conforming graph.

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). Rendering the report in any syntax requires handling the to_dataset() result, then passing the returned dataset to serialize_dataset. This is what purrdf validate --format does.

A report carries blank nodes from two graphs: a focus node, a value and an annotation value come from the data graph, and a source shape from the shapes graph. Blank-node labels are local to the graph that uses them, so the two documents often use the same label for different nodes. The report therefore writes each graph’s blank nodes in its own label space: _:dg0, _:dg1, … for the data graph and _:sg0, _:sg1, … for the shapes graph, numbered in the order they first appear. Two different nodes never share a label, and one node keeps one label everywhere it appears, including in the SARIF projection. The labels do not depend on which parser read the documents, so the same shapes graph written in Turtle, TriG or RDF/XML gives the same report bytes. ValidationReport::with_report_blank_labels() returns the relabelled report with a ReportBlankLabels map from each report label back to its graph and original label. The in-memory results keep the original labels. The blank nodes the report mints (the report node, one per result, and the interior nodes of a complex sh:path) are distinct from both label spaces.

Every report a validation produces also states sh:shapesGraphWellFormed. SHACL 1.2 Core §6.7.1.4 says a processor that checks the shapes graph “SHOULD use the property sh:shapesGraphWellFormed to inform the consumer of the validation report about this fact”, and that true means “the processor was certain that the shapes graph that was used for the validation process is well-formed”. PurRDF refuses an ill-formed shapes graph before validating, so the value is always true. An empty sh:in or sh:xone list does not change that. Appendix A’s in-minListLength and xone-minListLength say “Each such list SHOULD have at least one member”, and PurRDF applies them as a mandatory diagnostic: the shapes graph is well-formed, validation proceeds as the approved W3C tests core/node/in-002, in-003, xone-002 and xone-003 require, and purrdf shapes lint (and every host’s lint entry point) always reports each empty list as a finding naming the rule id. ValidationReport::shapes_graph_well_formed carries the value, and the SARIF run carries it as properties.shaclShapesGraphWellFormed.

Every run reports the same diagnostics, not only the lint. A validation report carries them in ValidationReport::diagnostics — one MandatoryDiagnostic per empty list, the rule id and the shape — beside the results and never among them: a diagnostic is not a sh:ValidationResult, it changes neither sh:conforms nor any result, and the W3C grading is unaffected. It is not written into the report graph either. SHACL defines no report term for a statement about the shapes graph, and PurRDF mints no vocabulary, so the RDF report is exactly what it would be without the diagnostic. Each host carries it in its own slot instead. The SARIF log puts each in invocations[0].toolExecutionNotifications as a notification at level note (“The notification is purely informational. There is no required action”, SARIF 2.1.0 §3.58.6), whose descriptor names the rule in tool.driver.notifications. The command line writes shacl diagnostic RULE SHAPE to stderr after the verdict lines. Python’s validate dict and ValidationReport object have a diagnostics list of {"rule", "shape"} dicts. A rules, entailment or node-expression run reports the same diagnostics: purrdf rules and purrdf node-expr write the same stderr line, Python’s apply_rules, entail and eval_node_expr dicts carry "diagnostics", WebAssembly’s ShaclRulesInference, ShaclEntailment and ShaclNodeExprOutcome have a diagnostics array of ShaclDiagnostic values (rule, shape), and C’s purrdf_shacl_apply_rules, purrdf_shacl_entail_to_ntriples and purrdf_shacl_eval_node_expr write diagnostic RULE SHAPE lines to a non-NULL out_diagnostics. The encoding is one across hosts: the rule and the shape are separate fields — dict keys in Python, getters in WebAssembly — and C’s line carries the same two fields in the same order, the rule first.

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, None, 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 SHACL 1.0 data-shapes suite, the vendored W3C SHACL 1.2 suite, a vendored DASH SHACL-AF/rules corpus, and a first-party frozen corpus of 73 cases with byte-frozen expected reports; SHACL Rules output is compared to expected inferred graphs by RDFC-1.0 isomorphism. The parser is also held to the SHACL 1.2 specification’s own SHACL-for-SHACL shapes graph: over every first-party and W3C shapes graph and a set of generated mutants, it refuses a graph exactly when shacl-shacl.ttl reports a violation, apart from named cases. See Conformance & Testing.

Prepared Shapes Products

A shapes graph is parsed, linked and analyzed before a single focus node is looked at, and that work is identical on every validation of the same document. A prepared shapes product does it once and writes the result out as bytes: a deterministic, versioned, authenticated artifact carrying the compiled SHACL model plus its reusable analysis, so a process can restore a prepared validator instead of re-parsing Turtle.

purrdf shacl pack writes one. purrdf validate --shapes-product restores one instead of parsing a shapes document. purrdf shacl verify and purrdf shacl explain are the admission surface for those bytes.

A product that comes back from disk is untrusted, and decoding it is admission rather than parsing. Nothing in a product is evidence of its own provenance. The stage id, the profile and the complete input binding are all checked before any of it reaches the validator, and a mismatch is refused on a named dimension rather than validated under the wrong configuration. The failure that design exists to prevent is silent: a product prepared under one prefix map, base, vocabulary or function registry does not crash when restored under different ones — it validates, and returns a well-formed report about a shapes graph nobody asked for.

The design record behind this surface is docs/design/purrdf-prepared-products.md.

Producing a product

purrdf shacl pack \
  --shapes shapes.ttl \
  --base https://example.org/shapes \
  --out shapes.purrshp

--shapes and --out are required; --out - writes the raw bytes to stdout. A receipt goes to stderr after the artifact, so it never contaminates a piped product:

$ purrdf shacl pack --shapes shapes.ttl --out shapes.purrshp
shacl product bytes 4213

--shapes names a path, or - for standard input when an explicit --base is supplied. A product records the base its shapes graph was parsed under, and standard input has no retrieval IRI to derive one from; passing --shapes - without --base is a usage error. For a path with no explicit --base, the document’s own file:// retrieval IRI is derived — the same base validate --shapes parses it under, so packing and direct validation cannot disagree about what a relative reference resolves to.

The shapes document is read as Turtle unless --shapes-from names another RDF syntax. The prefix map its codec reports (Turtle’s and TriG’s @prefix/PREFIX, RDF/XML’s xmlns) is the fallback prefix environment every SHACL-SPARQL and SHACL-AF query resolves against, and the product records it. The same shapes graph in any of those syntaxes packs to the same verdicts.

owl:imports is folded at pack time, the same way validate --shapes folds it

shacl pack accepts the identical --import IRI=FILE table validate --shapes does, resolves the same transitive closure, and packs the merged graph — see owl:imports in a shapes graph for the closure semantics, which are shared code, not a parallel re-implementation:

purrdf shacl pack \
  --shapes root.ttl \
  --import https://example.org/lib=lib.ttl \
  --out shapes.purrshp

Only an owl:imports on the shapes document’s own IRI, on an owl:Ontology header, on a sh:ShapesGraph (sh:RulesGraph and subclasses included), or on a node naming one of those as its owl:versionIRI is an import; on any other node, a node that is only a sh:DataGraph included, it is data. An import of the shapes document’s own IRI (--base, its file:// retrieval IRI, or an in-document @base) or of a graph already in the shapes graph (<X> a owl:Ontology, <X> a sh:ShapesGraph, or an ontology whose owl:versionIRI is <X>) needs no pair. Every other owl:imports no pair resolves is refused by name, exactly as it is for validate --shapes, and a pair the closure never reaches is refused as unused. The product is never packed from a smaller shapes graph than the one named.

This did not always hold. shacl pack used to read the shapes document through a route with no import table and no diagnostic channel at all, so an unresolved owl:imports was dropped with nothing printed, and the resulting product carried FEWER shapes than the document it was packed from — a decided, well-formed, wrong verdict every time it was restored. Packing with --import and validating the document with the same --import now reach the byte-identical report, by construction: the two commands read and fold the closure through the same function.

The Python, C-ABI and WebAssembly bindings pack through the same resolution with their own import table — imports= in Python, importIris / importDocuments in JavaScript, import_iris / import_documents / import_count in C — and a closure that is not in hand is refused with the same typed import error their validation functions raise. See The same rule on every host.

--shapes-graph is resolved and recorded at pack time, the same way validate --shapes resolves it

SHACL-SPARQL exposes the shapes graph itself as a named graph — a sh:select body may read GRAPH $shapesGraph { … } — and --shapes-graph IRI names what that graph is called, overriding a sh:shapesGraph the document declares:

purrdf shacl pack \
  --shapes shapes.ttl \
  --shapes-graph https://example.org/shapes \
  --out shapes.purrshp

A relative value resolves against the SAME base the shapes document parses under — the one --base names, or the document’s own file:// retrieval IRI — which is the identical derivation validate --shapes --shapes-graph spends the flag on. That agreement is the whole point: a product packed with --shapes-graph IRI and a document validated with --shapes --shapes-graph IRI expose $shapesGraph under the same absolute IRI and reach the byte-identical report. Before this flag existed, shacl pack had no way to record ANY override at all, so a shapes graph whose SHACL-SPARQL bodies depend on $shapesGraph validated one answer through --shapes and a different one through a restored product, with nothing on the command line able to close the gap.

Omitting --shapes-graph packs exactly as it always has: the product records whatever sh:shapesGraph the shapes graph itself declares, or none at all.

Every other host records it the same way: Python’s pack_product(..., shapes_graph=IRI) and Shapes(..., shapes_graph=IRI).prepare().to_product(), WebAssembly’s shaclPackProduct(..., shapesGraph), and C’s purrdf_shapes_product_encode(..., shapes_graph_iri, ...). Each resolves a relative value against the shapes document’s base, as the command line does.

--box-role-vocab is resolved and recorded at pack time, the same way validate --shapes resolves it

The graph-box role annotation feature takes a caller-supplied vocabulary — six term IRIs derived by concatenation from one namespace — and PurRDF mints no vocabulary IRIs of its own, so there is no default: without --box-role-vocab the feature is simply inactive, exactly as it always has been.

purrdf shacl pack \
  --shapes shapes.ttl \
  --box-role-vocab https://example.org/meta/ \
  --out shapes.purrshp

--box-role-vocab NS derives NSgraphBoxRole, NSboxABox, NSboxTBox, NSboxRBox, NSboxCBox and NSboxConfigBox, records the namespace into the product’s identity (the box-role-vocab component — see The refusal dimensions), and is the identical derivation validate --shapes --box-role-vocab NS spends the flag on: a product packed with the flag and a document validated with the same flag parse to the same box_role_vocab and therefore restore or run under the identical vocabulary component. Omitting the flag packs exactly as it always has: the component records absent, and no role annotation is collected or stamped.

The writer is byte-deterministic. No wall clock, no randomness and no hash-iteration order reach it, so two runs over the same document, base and import table produce identical bytes and a content-addressed cache key over a product is stable.

From Rust:

use purrdf::shapes::engine::{parse_shapes, PreparedShapes};
use purrdf::shapes::product::ShapesProfile;
use std::sync::Arc;

let shapes = parse_shapes(shapes_ttl, Some("https://example.org/shapes"))?;
let prepared = PreparedShapes::new(Arc::new(shapes));
let product: Vec<u8> = prepared.to_product(&ShapesProfile::CORE)?;

ShapesProfile::CORE is named explicitly and there is no default. It is the one profile this build implements — SHACL Core plus SHACL-SPARQL plus SHACL-AF with zero host-injected dependencies — and a caller cannot mint a profile of its own, because a profile a caller can spell would be a claim about a product rather than a fact about it.

“Zero host-injected dependencies” is a statement about what a product needs, not a ban on host wiring. A Rust host that injects native SPARQL functions or custom aggregates packs and restores through the entry points described in Host-injected implementations; every other caller, and every non-Rust binding, uses the plain path above.

What a product carries, and what it does not

A product carries three sections, always all three:

SectionCarries
identitythe preparation stage id, the profile id, and the parse inputs (base, prefix map, sh:shapesGraph IRI, box-role vocabulary)
datasetthe shapes dataset itself, in PurRDF’s succinct pack container
modelthe declarative SHACL model — node shapes, constraints, paths, node expressions, SHACL-AF rules, sh:SPARQLTargetType declarations — and, as its last field, the reusable class analysis derived from it

Each section carries its own SHA-256; the envelope adds an identity-region digest and a whole-container digest in a sealed trailer that also restates the total length, so a product truncated anywhere or appended to anywhere is refused.

The shapes dataset travels inside the product for two independent reasons. SHACL-SPARQL exposes the shapes graph as a named graph — $shapesGraph is bound for every sh:select body — so a product that dropped it would restore a validator that answers GRAPH $shapesGraph { … } with zero rows and reports that as success. And it is what rebuild re-derives from when a product was written by another build; see Restoring a product.

Targets are not carried, and there is no section for them. Target resolution is a property of a data graph: a product that cached resolved targets would validate a new snapshot against the focus nodes of an old one, producing a report in which every finding is correct and everything added since the product was written is simply absent. Targets are resolved per bound dataset, every time. What a product removes from the restore path is the parse, the model construction and the shape analysis — reusable preparation and per-dataset binding are different lines in the budget.

The shape analysis really is removed, not merely checked. The class analysis — a cycle-safe walk collecting every reachable sh:class, sh:targetClass and shnex:instancesOf IRI and assigning each a binding-row position — is carried as the last field of the model section, and admit reads it rather than walking the shape tree again. It is bound rather than trusted: the class-catalog component of the identity is the digest of exactly those pairs, positions included, so a carried analysis that is not the one the product was packed with is refused. And it cannot go stale across builds, because the preparation stage id is digested from the class walk’s own source — a build whose reachability rule differs cannot share a stage id with the one that packed the product, so admit refuses it and rebuild re-derives instead.

It shares the model section rather than taking a fourth section of its own so that the section directory and the container format version did not have to move when it started travelling; a fourth section would have made every product ever packed fail to open, which is the one state rebuild cannot rescue.

Nor is the provenance of a restore carried, for the same kind of reason. A product records the inputs that decided what its shapes mean; how a particular preparation was obtained is a fact about one restore in one process, so a product that encoded it would carry a value that is false for every reader except the one that wrote it. It would also move the preparation stage id — a digest over the model’s meaning — for a change that alters no meaning at all, invalidating every product ever written. The answer lives on the restored preparation instead, where it is true: see Which shapes produced this verdict.

RDF 1.2 term identity survives the round trip intact, including quoted triples, reifier bindings, statement annotations, and rdf:dirLangString literals whose base direction is part of their identity ("x"@en--ltr and "x"@en--rtl are two distinct terms).

SPARQL functions the shapes graph declares are carried, and they are carried by the dataset. A SHACL-AF §5 sh:SPARQLFunction is an IRI, an ordered sh:parameter list, a sh:select/sh:ask body and a sh:returnType — all of it stated by the shapes graph — so a restore re-derives the declaration from the shapes dataset the product already carries rather than from a second transcription of it in the model. There is one parser for those declarations, and a restore runs it, which is what keeps a restored function identical to the parsed one. The SHACL 1.2 expression-bodied declarations (sh:ListParameterExpressionFunction) are carried too, from the model, because their bodies are node expressions.

One capability is refused at pack time rather than lost at restore, on unsupported-capability:

  • a declared function that nothing in the model reaches — an expression-bodied declaration called only from sh:sparql query text, say. The model is what carries those declarations, so one the model never reaches is not in it, and a product written from it would resolve that call site to nothing and validate green. Call the function from the shapes model and it packs.

An incomplete owl:imports closure is not an admission dimension: no product exists yet when it is refused, so every host reports it as the typed import error its validation functions raise, and the product is only ever packed from the merged closure — see owl:imports in a shapes graph.

A data graph’s sh:shapesGraph links are not an admission dimension either, and a product cannot take a linked graph in: it was prepared before the data graph existed. Validating with a product checks each link against what the product holds — its base, every document its owl:imports closure absorbed (the product records their IRIs in its identity section, so a table-supplied document with no ontology header of its own is held too), or a shapes graph, ontology or version IRI its merged graph declares — and refuses any other with the typed import error, kind unheld-shapes-graph-link, naming the link. To validate a data graph that links further shapes, pack the linked graph in (import it from the shapes graph), or validate from the shapes document with the link’s document in the import table — see A data graph names its shapes graphs.

Shipping a product

A product is an artifact of the PurRDF build that wrote it, not a stable interchange format. Ship it beside the binary that will restore it — in the same container image, the same release bundle, the same build cache entry — and treat its bytes as a cache key rather than as a document.

Two version facts govern whether a restore succeeds:

  • The container format version is refused outright when it is not the one this build decodes. Re-pack with the build that will execute the product.
  • The preparation stage id is a content-derived digest over the declarative model and the tables its meaning depends on. It is never hand-incremented, so it cannot fall out of step with what the model means. A product carrying a stage id this build does not know is not dead: rebuild restores it.

Because the writer is deterministic, a build system can address products by content: pack, hash the bytes, and skip the work when the hash is already present. purrdf shacl explain reads a product’s own identity digest back out without admitting anything, which is the cheap way to ask “is this the product for this configuration?” before deciding to restore it.

Restoring a product

purrdf validate --shapes-product shapes.purrshp --format sarif data.ttl

--shapes and --shapes-product are mutually exclusive, and exactly one is required. The verdict is the identical one --shapes shapes.ttl reaches: the same engine entry point runs, over the same restored shapes.

Four flags that describe a shapes parse are refused by name against a product rather than accepted and ignored, because that parse does not happen here:

FlagWhy it is refusedWhere it belongs
--shapes-fromnames the syntax a shapes document is read as, and a product is not a documentnot applicable
--shapes-graphthe product records its own sh:shapesGraph IRIpass --shapes-graph IRI to purrdf shacl pack and re-pack
--importthe owl:imports closure is folded at pack timepass --import IRI=FILE to purrdf shacl pack
--box-role-vocabthe product records its own box-role vocabulary (or its deliberate absence)pass --box-role-vocab NS to purrdf shacl pack and re-pack

Everything describing the data graph stays live: --from, --base, --format, the governor flags (--fuel, --deadline, --max-intermediate-cells, --max-scratch-bytes, --max-remote-requests), and the positional IN/OUT.

Which shapes produced this verdict

Every validate run writes one shacl shapes-provenance line to stderr, before the verdict, naming where its shapes came from:

$ purrdf validate --shapes-product shapes.purrshp data.ttl
shacl shapes-provenance restored-admitted 9f2c…64 lowercase hex…1b
shacl conforms false
shacl results 2
$ purrdf validate --shapes shapes.ttl data.ttl
shacl shapes-provenance parsed
shacl conforms false
shacl results 2

The token is total — there is always one, and none of them means “unknown”. A restored product renders the artifact’s own input binding, the same 64 hexadecimal digits shacl explain prints on its identity-digest line, so a report and the artifact behind it are compared on one spelling:

purrdf validate --shapes-product shapes.purrshp data.ttl 2> receipt
awk '/^shacl shapes-provenance restored-/{print $3}' receipt   # the artifact

The three tokens are parsed, restored-admitted <digest> and restored-rebuilt <digest>. The two restore tokens stay distinct because the digest means something different on each: admitted says the product’s binding was checked against this process before anything reached a validator, while rebuilt says it was read off the artifact and deliberately not checked — see rebuild, the forward-compatibility path for why checking it there would refuse exactly the products that path rescues.

This answers the half of the question authentication does not. Admission asks may this process execute these bytes?; the provenance line answers which artifact did this report come out of?, which is the question a consumer has afterwards, looking at a verdict in a log.

Binding a restore to the product you meant

Every check above asks about this process — is this the build that wrote the memo, are these the registries the product was prepared against, is the class analysis the product carries the one its own identity pins. Not one of them asks whether the file named on the command line is the product you wanted, because nothing in a product states which product was meant. So naming the wrong one is not an error:

$ purrdf validate --shapes-product WRONG.purrshp data.ttl
shacl conforms true

That is a decided, well-formed, authenticated verdict about a shapes graph nobody asked about, and it exits 0. --expect-identity HEX is how you say which product you meant:

$ purrdf validate --shapes-product WRONG.purrshp \
    --expect-identity 9f2c…64 lowercase hex…1b data.ttl
shacl dimension shapes-graph
purrdf: --shapes-product WRONG.purrshp: shapes-graph: this product was prepared
from the shapes graph whose input binding is 4b81…, and the caller required the
product bound to 9f2c…; …

HEX is the product’s input binding: the 64 hexadecimal digits of the identity that covers the shapes dataset, the sh:shapesGraph IRI, the prefix map, the base, the profile, the box-role vocabulary, all three registries and the class catalog at once. Read it off the product you intend with either verb that prints it — shacl explain’s identity-digest line, or shacl verify’s stdout — and pass it back unchanged:

WANT=$(purrdf shacl explain shapes.purrshp | awk '/^identity-digest /{print $2}')
purrdf validate --shapes-product shapes.purrshp --expect-identity "$WANT" data.ttl

The check is the first thing the restore does, ahead of the profile and the stage id: a caller who named the wrong artifact is told that, rather than sent to re-pack a product that was never the one they wanted. It costs a 32-byte comparison, because the identity digest is already decoded by the time the bytes have opened.

A satisfied expectation changes nothing else. --expect-identity with the product’s own digest produces the report byte-identical to the run without the flag — the flag changes the door, not the answer.

Two spellings are refused as usage errors (exit 2) rather than accepted: --expect-identity against --shapes, because a shapes document has no prepared binding to require; and a value that is not 64 hexadecimal digits, which names no dimension because no product was ever inspected. Case is not significant on the way in, so a selector that passed through a manifest or a CI variable in upper case still names its product.

The writer is byte-deterministic, so the binding is a property of the shapes graph and its parse inputs, not of a particular pack run: re-packing the same shapes graph under the same base and prefixes yields the same digest, which is what makes the selector worth writing down in a deployment manifest beside the product it names.

Under the hood there are two ways a product becomes a validator again, and they are two entry points at one boundary rather than a flag on one:

  • admit — the common path. It re-derives nothing expensive: verify the profile, verify the stage id, check the host-supplied registries, restore the dataset, decode the model and the class analysis carried with it, link it, and check the complete input binding — the carried analysis included.
  • rebuild — the forward-compatibility path, taken when the stage id is one this build does not know. It ignores the model section entirely — the carried class analysis with it — and re-derives the shapes, and their analysis, from the dataset the product carries, under the product’s own recorded parse inputs. That is what makes it the remedy: a product it must rescue is by definition one whose carried analysis came from a different reachability rule.

Every binding reaches both paths, not only Rust: the CLI’s validate --shapes-product --rebuild, Python’s ShapesProduct.rebuild(), the C ABI’s purrdf_shapes_product_rebuild, and WebAssembly’s shaclProductValidateToSarifRebuild are the identical two-entry-point seam described here, each restoring the SAME carried dataset admit would restore a memo of — so rebuilding a CURRENT product (one whose stage id this build does know) reaches the byte-identical report admit does, on every surface. rebuild composes with the “which product did you mean” expectation too: --rebuild --expect-identity, rebuild_expecting, and the identical Rust ShapesProductView::rebuild_expecting all check the expectation FIRST, exactly as admit_expecting does, so re-deriving from the dataset never bypasses the binding a caller required.

rebuild is not a Turtle fallback. No RDF text is parsed and no file is read; the dataset travels inside the product under the envelope’s digests. The alternative — carrying only the compiled model and telling callers to keep the source document for the day the format moves — would make forward compatibility depend on a file the product does not own and cannot authenticate.

use purrdf::shapes::product::{HostBindings, ShapesProduct, ShapesProfile, STAGE_ID};

// Ask what the bytes are, without admitting them.
let known = ShapesProduct::open(&product)?.stage_id() == &STAGE_ID;

// Both restore seams consume the view, so re-open to take one.
let view = ShapesProduct::open(&product)?;
let prepared = if known {
    view.admit(&ShapesProfile::CORE, &HostBindings::empty())?
} else {
    view.rebuild(&ShapesProfile::CORE, &HostBindings::empty())?
};

// Or state WHICH product you meant, and fail closed when it is not that one.
// `wanted` is the digest `declared_identity().digest()` returns for the product
// you intend — the same value `shacl explain` prints. `admit_expecting` and
// `rebuild_expecting` check it FIRST, ahead of everything else either restore
// seam checks, so the choice of repair strategy never bypasses the binding.
let prepared = ShapesProduct::open(&product)?
    .admit_expecting(&ShapesProfile::CORE, &HostBindings::empty(), &wanted)?;
let prepared = ShapesProduct::open(&product)?
    .rebuild_expecting(&ShapesProfile::CORE, &HostBindings::empty(), &wanted)?;

ShapesProduct::open runs the container’s cheap integrity tier — magic, format version, section directory, canonical offsets, zero padding, every section’s SHA-256, the identity region, the trailer, the whole-container digest — and admits nothing. Holding the resulting view is a statement about framing, never about fitness, which is exactly what makes reading a refused product’s declared identity useful.

Host-injected implementations

Everything above passes HostBindings::empty(), which is what a product of ShapesProfile::CORE needs: every capability such a product exercises is declared by the shapes graph itself. A Rust host may still wire native SPARQL functions or custom aggregates of its own, and when it does, the product binds two facts about that host rather than one:

  • the registries’ declarations — each injected entry’s IRI, arity and volatility — which is all a fingerprint reproducible in another process can ever cover; and
  • an implementation identity: an opaque byte string the caller uses to name the build those declarations resolve to, such as a release version or a commit digest.

The second is not belt-and-braces. Two builds of one host can register the same IRI to two different closures that declare identical arity and volatility and compute different answers — indistinguishable by every fact a registry can state about itself. A binding over declarations alone would admit a product prepared against one build under the other and validate green, which is the silent wrong answer this codec exists to rule out.

So a preparation whose injected population is not empty cannot be packed without an identity at all: the writer refuses on unsupported-capability rather than emitting a product whose host half nothing could check. And a restore that supplies the same declarations under a different identity is refused on the registry dimension that carries it.

use purrdf::validate::{
    admit_shapes_product_with_implementations, prepared_to_product_with_implementations,
};

// The byte string that tells this build of the host's natives apart from every
// other build of them. PurRDF never interprets it.
const BUILD: &[u8] = b"example.org/host@1";

// `prepared` carries the host's natives in `Shapes::functions`.
let product = prepared_to_product_with_implementations(&prepared, BUILD)?;

// ...and the restore names the same build, beside the registries it wires.
let restored = admit_shapes_product_with_implementations(
    &product,
    &functions,
    &aggregates,
    &property_functions,
    BUILD,
)?;

What the mechanism does not do is verify the identity. PurRDF cannot read a host’s machine code and confirm the bytes name it; the identity is the caller’s claim about its own build, and what the codec enforces is that a restore claiming a different one stops at the boundary. A host that spells two different builds with one identity has told the binding they are the same build.

An absent identity is the empty byte string, and it encodes as nothing at all: the three host rows stay the bare declaration fingerprints. That is why a product with nothing injected — which is every product the paths above write — is byte-identical to what a build that had never heard of implementation identities would produce.

Corroborating a product

$ purrdf shacl verify shapes.purrshp
9f2c…64 lowercase hex…1b

verify is the codec’s cold path and is deliberately not reachable from a restore. It re-canonicalizes the shapes graph’s blank nodes and compares the result against the digest the product’s identity claims — a graph-isomorphism computation that can cost more than the shapes parse a product exists to eliminate. Call it from a build step, a release check or a conformance harness; never before every validation.

What admit establishes about the dataset is that the section is the one the container sealed — the per-section SHA-256 and the whole-container digest both cover its bytes, so a swapped section is refused. What only verify establishes is that those bytes canonicalize to the digest the identity records. A product whose stored canonical digest has been tampered with will open, will admit, and will fail verify; that split is pinned by test and is not an accident of layering.

It prints the product’s 64-character lowercase-hex identity digest on stdout and exits 0. A refusal names its dimension on stderr and exits 1.

Reading a product without admitting it

$ purrdf shacl explain shapes.purrshp
format-version 1
stage-id 7416f78d…
stage-known true
identity-digest 9f2c…
identity-components 13
identity source-dataset 0x1f0a…
identity shapes-graph "(present) https://example.org/shapes"
identity doc-prefixes 0x02657808…
identity base "(present) https://example.org/shapes"
identity profile "purrdf-shacl-core-v1"
identity box-role-vocab 0x00
identity user-functions-declared 0x4c1d…
identity user-functions-injected 0x4c1d…
identity aggregate-registry 0xb7e0…
identity property-function-registry 0xb7e0…
identity class-catalog 0x63aa…
identity parse-configuration 0x17657874…
identity included-graphs 0x0800000000000000…
parse-base https://example.org/shapes
parse-shapes-graph https://example.org/shapes
parse-prefixes 3
parse-prefix ex https://example.org/
parse-prefix sh http://www.w3.org/ns/shacl#
parse-prefix xsd http://www.w3.org/2001/XMLSchema#

Deterministic key value lines in a fixed order, terminated by a newline, so a consumer can split on whitespace without a parser. An identity component’s value is rendered as "text" when it is printable UTF-8 and 0x… lowercase hex otherwise.

stage-known is the fact to act on: false says this build’s admit will refuse these bytes and rebuild is the path that still restores them.

identity box-role-vocab 0x00 above is the encoding of absent — the product this example packed with no --box-role-vocab. A product packed with the flag renders a longer 0x… run instead (the six derived term IRIs, length-prefixed and key-sorted), never 0x00; the two are deliberately distinguishable so a restore can never confuse “the feature is inactive” with “the feature is active over a vocabulary of empty IRIs”.

This is what makes a named refusal actionable rather than a log line. A restore refused on prefixes is answered by reading which prefix map the product actually carries and fixing the configuration — not by guessing, and not by re-packing blindly. Comparing that answer across two products — the one you have and the one you meant — is shacl diff, rather than running explain twice and comparing the rendering by eye.

Comparing two products

$ purrdf shacl diff a.purrshp b.purrshp
diff-count 1
diff box-role-vocab 0x00 0x0108626f78…

shacl diff opens both products WITHOUT admitting either — the same framing-and-integrity-only tier shacl explain reads through — and prints every identity component whose value differs, one diff <label> <value-in-a> <value-in-b> line per component, in A’s own component order. diff-count 0 and no diff lines means the two products declared identical identities.

This is what makes a NAMED refusal actionable between two artifacts rather than just one. shacl explain answers “what does THIS product say it was compiled from”; before diff existed, an operator whose restore was refused on a named dimension had to run explain twice — on the product in hand and on the one they meant to restore — and compare the rendering by eye to find which declaration moved. diff is that comparison, done once, over the identical decoded components explain renders, so the two can never disagree about what a component’s value is.

Exit codes mirror shacl verify’s certified/refused split rather than validate’s conforms/non-conforms one, because a diff that finds a difference is a decided answer about two artifacts, not a validation verdict about data: exit 0 when the two identities are identical, exit 1 when they differ (the difference is printed on stdout either way), and exit 2 for a usage error.

diff never admits either side, so it works on a product whose preparation stage id this build does not recognize — the stage id lives in the product’s separate preparation memo, not in the identity diff compares, and is exactly the situation an operator reaches for a diff to make sense of: “validate --shapes-product refuses this file outright; what, concretely, would change if I re-packed it?”

The refusal dimensions

Every refusal carries one of twenty-two stable kebab-case labels, printed to stderr as shacl dimension <label>. The dimension is what you branch on; the message is what you read. Matching on message text is not supported.

The dimensions are checked from the outside of the container inward, and the first one that fails is the one reported — so the label always names the outermost unmet precondition rather than a downstream symptom of it.

The twenty-two split into two groups by which verb can report them, and the split is not a taxonomy: it is the cost decision this codec is built around. A restore is the common path and must not canonicalize a shapes graph’s blank nodes; certification is the cold path that does. A dimension only one of the two can reach is not something the other quietly skips — it is a statement that verb never makes.

Admit-time dimensions

Reported by validate --shapes-product on every restore, by shacl explain for the structural rows it reaches while opening the bytes, and by shacl verify, which opens the product before it certifies anything.

DimensionWhat it meansWhat to do
magicthese bytes never were a prepared shapes producthand the loader a file written by purrdf shacl pack
format-versionthe container format is not the one this build decodesre-pack with the PurRDF build that will execute it
stage-idthe model memo was written against a model this build no longer hasrestore with rebuild, or re-pack
profileprepared under a different preparation profilere-pack with the build that will execute it
truncatedthe bytes end inside a structure they declaredre-pack — this is an interrupted write, not corruption in place
trailerthe trailer is absent or inconsistent with the bytes ahead of itdiscard and re-pack
section-digesta section no longer matches its recorded digestdiscard and re-pack — the product is corrupt in place
container-digestthe whole-container digest disagrees with the bytesdiscard and re-pack — something outside the section bodies moved
shapes-graphprepared under a different sh:shapesGraph IRI — or, under --expect-identity, carrying an input binding that is not the one you requiredexpose the shapes under the same named graph and re-pack with that IRI, or point the expectation at the product you meant
prefixesprepared against a different prefix mappack and execute under the same prefixes — the map decides which IRI a prefixed name denotes
baseprepared against a different base IRIpack and execute under the same base — relative references resolve against it
vocabularyprepared under a different box-role vocabularypass the SAME --box-role-vocab NS (or none) to both purrdf shacl pack and the executing side; PurRDF mints no vocabulary IRIs and there is no default to fall back on
function-registryprepared against a different SPARQL function registry, or against a different build of the host implementations behind itwire the same functions into the executing host, under the same implementation identity
aggregate-registryprepared against a different custom-aggregate registry, or against a different build of the host implementations behind itwire the same aggregates into the executing host, under the same implementation identity
property-function-registryprepared against a different property-function registry, or against a different build of the host implementations behind itwire the same relations into the executing host, under the same implementation identity
class-catalogthe class analysis the product carries is not the one its own identity pinsdiscard and re-pack from the shapes graph — the section and the binding over it no longer describe one analysis
parse-configurationprepared under different declared extension-function or relation namespacesdeclare the same namespaces on the executing host
included-graphsthe product’s shapes graph was assembled from a different set of named graphs (its --import documents and resolved sh:shapesGraph links) than its binding pinsre-pack with the same import table — the recorded graphs decide which data-graph links the product holds
unsupported-capabilitywell-formed bytes asking for something this build cannot honour, or a preparation this build will not write a product forsee the message — a declared function nothing in the model reaches, a decode larger than the scratch ceiling in force, or host implementations the packer was given no identity for
depth-limita structure nests past the decoder’s fixed ceilingre-pack from a shapes graph this build parses; the ceiling is a stack guard, not a semantic limit
malformedstructurally invalid in a way no other dimension namesdiscard and re-pack

Three of those are three genuinely different actions and that is the point of naming them: malformed is a corrupt cache to discard, format-version is a stale artifact to recompile, and function-registry is a configuration error in your own process that re-packing will not fix.

The certify-time dimension

DimensionWhat it meansWhat to do
dataset-identitythe shapes dataset the product carries does not canonicalize to the digest its own binding claimsdiscard and re-pack from the shapes graph — the section and the binding over it no longer describe one dataset

Only shacl verify reports this one. A restore does not re-canonicalize the shapes dataset: it takes that component from the product’s own binding, because the computation is a graph isomorphism over the shapes graph’s blank nodes and would plausibly cost more than the shapes parse a product exists to eliminate. The envelope’s per-section SHA-256 and whole-container digest still cover those bytes on every path, so a swapped section is refused as section-digest or container-digest; what only certification establishes is that the bytes canonicalize to what the binding claims.

So a product whose stored canonical digest has been tampered with will open, will admit, and will fail shacl verify. That is a documented split pinned by test, not a hole: if you need the statement, run verify — from a build step, a release check or a conformance harness, never before every validation.

One failure carries no dimension: a shapes or data document that does not parse never reached the admission boundary, so nothing was inspected and naming a dimension for it would claim otherwise. On the CLI no shacl dimension line is printed; in Python the exception’s .dimension is None; in JavaScript dimension is undefined.

The command-line verbs

CommandDoesExit 0Exit 1Exit 2
purrdf shacl pack --shapes FILE --out OUT [--base IRI] [--shapes-graph IRI] [--import IRI=FILE] [--box-role-vocab NS]parse, prepare, write the productproduct writtenthe shapes did not parse, the graph declares something a product cannot carry, or an owl:imports is unresolvedbad flags, --shapes - without --base, or a --shapes-graph/--import the shapes graph cannot resolve
purrdf shacl verify [IN]corroborate the carried dataset against the claimed identityprints the identity digestrefused, with shacl dimension <label> on stderrbad flags
purrdf shacl explain [IN]print what the product says it was compiled fromprints the key value renderingthe bytes are not a well-formed productbad flags
purrdf shacl diff A Bcompare two products’ declared identities, without admitting eitherthe two identities are identicalthe two identities differ (the diff lines are still printed, on stdout)bad flags, or both A and B naming standard input
purrdf validate --shapes-product FILE [--rebuild] [--expect-identity HEX] [IN]restore and validate — --rebuild re-derives from the carried dataset instead of admitting the memovalidation ranproduct refusedbad flags, a parse flag passed against a product, --rebuild or --expect-identity without a product, or an --expect-identity that is not 64 hexadecimal digits

IN defaults to - (standard input) for verify and explain. For validate, --shapes-product - collides with the data graph’s own default of - and is refused by name: there is only one standard input. diff’s two products, A and B, are both required positional paths with no default, for the identical reason: naming standard input for both would give each product part of one byte stream.

validate --shapes FILE [--box-role-vocab NS] accepts the identical flag on the parse lane — see Producing a product above — and refuses it by name against --shapes-product, in the four-flag table above.

validate additionally exits 3 when a governor budget trips, and always writes shacl shapes-provenance <token> before the run and shacl conforms true or shacl conforms false plus shacl results <N> after it, all on stderr — including when the shapes came from a product.

From Python

from purrdf import shapes

prepared = shapes.Shapes(shapes_ttl, base="https://example.org/shapes").prepare()
product = prepared.to_product()           # bytes, byte-deterministic

view = shapes.ShapesProduct.open(product) # framing and integrity only
print(view.format_version())             # 1
print(view.stage_known())                # True
print(view.identity_digest())            # 64 lowercase hex characters
for label, value in view.identity_components():
    print(label, value)

restored = view.admit() if view.stage_known() else view.rebuild()
report = restored.validate_nt(data_nt)
print(report.conforms)

# Which artifact did that verdict come out of? Total — always an answer, never
# "unknown": "parsed", "restored-admitted <digest>" or "restored-rebuilt <digest>".
print(restored.provenance())             # restored-admitted 9f2c…1b

# …or say which product you meant, and fail closed when it is not that one.
restored = view.admit_expecting("9f2c…64 lowercase hex…1b")

# The same binding, over the rebuild seam: the expectation is checked FIRST,
# ahead of the re-derivation, so choosing to rebuild never bypasses it.
restored = view.rebuild_expecting("9f2c…64 lowercase hex…1b")

PreparedShapes.provenance() is the Python spelling of the CLI’s shacl shapes-provenance receipt, and renders the identical token — the digest in it is the value identity_digest() reports, so it can be handed straight back to admit_expecting(). A preparation built by Shapes(...).prepare() answers parsed, because it was never restored from an artifact and none may be invented for it.

ShapesProduct.certify() is the cold path; call it from a build step or a test. Refusals raise shapes.ShapesProductError, whose .dimension carries the label string (or None when no product was ever inspected).

The submodule is purrdf.shapes. purrdf.shacl is a back-compatible alias for the same object and keeps working, but new code should spell the canonical name: the alias predates the surface this page describes, and the two names resolving to one module is the kind of thing that reads as two APIs to someone learning it.

admit_expecting(expected_identity) is admit() bound to the product you meant: it takes the 64 hexadecimal digits identity_digest() reports, and raises ShapesProductError with .dimension == "shapes-graph" when the product carries a different binding. A selector that is not a digest raises a plain ValueError instead — no product was opened, so nothing may be blamed on one.

rebuild() re-derives from the SAME carried dataset admit() restores a memo of, so calling it on a CURRENT product (stage_known() == True) reaches the byte-identical report — it is a second door onto one product, never a second, divergent answer. rebuild_expecting(expected_identity) is its bound twin, taking the identical expected_identity admit_expecting does: the expectation is checked FIRST, ahead of the re-derivation, exactly as it is on admit_expecting, so a caller who reaches for the forward-compatibility rescue never loses the binding.

From JavaScript / WebAssembly

import {
  ready,
  shaclPackProduct,
  shaclProductExplain,
  shaclProductCertify,
  shaclProductValidateToSarif,
  shaclProductValidateToSarifRebuild,
  shaclProductValidateToSarifExpecting,
  shaclProductValidateToSarifRebuildExpecting,
} from "@blackcatinformatics/purrdf";

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

const product = shaclPackProduct(shapesTtl, "https://example.org/shapes");
console.log(shaclProductExplain(product));

try {
  const sarif = shaclProductValidateToSarif(product, dataNt);

  // …or, for a stage id this guest does not know, re-derive from the carried
  // dataset instead of admitting the memo — the remedy the `stage-id` refusal
  // above would name.
  const rebuilt = shaclProductValidateToSarifRebuild(product, dataNt);

  // …or say which product you meant, and fail closed when it is not that one.
  const bound = shaclProductValidateToSarifExpecting(product, dataNt, wantHex);

  // …or both: rebuild AND fail closed when it is not the product you meant.
  // The expectation is checked FIRST, ahead of the re-derivation, so the
  // rescue never bypasses the binding.
  const boundRebuilt = shaclProductValidateToSarifRebuildExpecting(
    product,
    dataNt,
    wantHex,
  );
} catch (refusal) {
  console.error(refusal.dimension, refusal.message);
  refusal.free();
}

A wasm guest has no retrieval IRI to derive a base from, so the host supplies shapesBase explicitly; it is recorded in the product, and a restore resolves the same relative references without the document.

shaclProductValidateToSarifRebuild re-derives from the SAME carried dataset shaclProductValidateToSarif restores a memo of, so calling it on a CURRENT product reaches the byte-identical report — it is a second door onto one product, never a second, divergent answer. shaclProductValidateToSarifRebuildExpecting is its bound twin, taking the identical expectIdentity shaclProductValidateToSarifExpecting does: the expectation is checked FIRST, ahead of the re-derivation, exactly as it is on shaclProductValidateToSarifExpecting, so a host that reaches for the forward-compatibility rescue never loses the binding.

shaclProductValidateToSarifExpecting takes the 64 hexadecimal digits shaclProductExplain prints on its identity-digest line. A product carrying a different binding rejects with dimension === "shapes-graph"; a selector that is not a digest rejects with dimension === undefined, because no product was inspected. shaclProductValidateToSarifRebuildExpecting carries the identical selector and the identical refusal shape.

From C

The C ABI exposes purrdf_shapes_product_encode, purrdf_shapes_product_open, purrdf_shapes_product_admit, purrdf_shapes_product_admit_expecting, purrdf_shapes_product_rebuild, purrdf_shapes_product_rebuild_expecting and purrdf_shapes_product_certify, with purrdf_shapes_product_error_dimension returning the refusal’s stable label (or NULL when the shapes document simply did not parse).

purrdf_shapes_product_rebuild is the forward-compatibility twin of purrdf_shapes_product_admit: it re-derives the preparation from the shapes dataset the product carries instead of admitting its memo, which is the remedy purrdf_shapes_product_admit’s stage-id refusal names. purrdf_shapes_product_rebuild_expecting is its bound twin, taking the identical expect_identity purrdf_shapes_product_admit_expecting does: the expectation is checked FIRST, ahead of the re-derivation, exactly as it is on purrdf_shapes_product_admit_expecting, so a C host that reaches for the forward-compatibility rescue never loses the binding.

purrdf_shapes_product_admit_expecting is purrdf_shapes_product_admit bound to the product you meant: it takes the identity-digest value purrdf_shapes_product_open renders, as a NUL-terminated C string, and returns PURRDF_STATUS_SHAPES_PRODUCT_ERROR with the dimension shapes-graph when the product carries a different binding. A selector that is not 64 hexadecimal digits returns PURRDF_STATUS_INVALID_ARGUMENT and no dimension. purrdf_shapes_product_rebuild_expecting carries the identical selector and the identical refusal shape. See crates/rdf-capi/include/purrdf.h and Getting Started: C.

  • SHACL — the validator itself, its coverage, and the report model.
  • docs/design/purrdf-prepared-products.md — the design record: why admission rather than parsing, why three seams, the derived stage identity, the census closure, and the bounded guarantee.
  • Codecs & Determinism — the byte-determinism rules every PurRDF writer follows, including this one.
  • Base IRIs & Relative References — what --base records and why a product refuses to guess it.
  • Performance — the report-only benches that decompose the pack and restore phases.

ShEx

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

Schemas: ShExC and ShExJ

  • ShExC (the compact syntax, spec §6) — a hand-rolled lexer and recursive-descent parser covering the full grammar: directives, start, the AND/OR/NOT shape algebra, node constraints and the facet table, value sets with stems/ranges/exclusions, triple expressions with all cardinality forms, $/& labels and inclusions, ^ inverse, annotations and %…{ … %} semantic actions, with relative-IRI resolution against BASE via purrdf-iri.
  • ShExJ (the JSON wire format, spec Appendix A) — a strict, round-tripping reader and writer matching the shexTest ground truth.
  • Structural checks (spec §5.7) — dangling references, label collisions, reference-only cycles, and the negation-stratification requirement.
#![allow(unused)]
fn main() {
use purrdf::shex::{check_structure, parse_shexc, parse_shexj, 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,
).expect("well-formed");
check_structure(&schema).expect("well-formed");
let json = to_shexj(&schema);
let restored = parse_shexj(&json, None).expect("well-formed");
check_structure(&restored).expect("well-formed");
assert_eq!(restored, schema);
}

Schema parsing reports malformed syntax through a typed ShexError; check_structure reports a list of StructureError violations. There is no lenient mode that accepts a malformed schema.

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"])

Dated XPath regex laws

String-facet PATTERNs run on the compatibility regular expressions unless the caller selects a dated native XPath law by name: xpath-2.0-2010-12-14 (XPath and XQuery Functions and Operators 2.0, Second Edition) or xpath-3.1-2017-03-21 (Functions and Operators 3.1). Both admit backreferences; only 3.1 admits non-capturing groups and the q flag.

HostSpelling
Rustvalidate_with_xpath and validate_shape_map_with_xpath, with Profile and Limits from purrdf_core::xsd_regex::xpath; an XPathValidator::new(&schema) serves repeated validations of a fixed map
CLI--xpath-regex <NAME> on shex
Pythonxpath_regex= on shex.validate
results = shex.validate(my_schema_shexc, my_data_ttl,
                        [("https://example.org/alice", "https://example.org/PersonShape")],
                        xpath_regex="xpath-3.1-2017-03-21")

Names match exactly, so xpath-3.1 is refused with a list of the accepted names. With no selection, validate, validate_with and validate_shape_map keep the compatibility behaviour exactly. ShExJ hands the pattern and flag text over as written. ShExC keeps its own concrete-syntax escape grammar, which has no \1, so a backreference is written in ShExJ.

A pattern or flag the selected law does not define fails its facet, as any invalid pattern does. A selected law also runs under finite resource bounds, 64 KiB of pattern source among them. Exceeding one aborts the whole shape map with the resource’s xpath-* code. Negation, alternatives and previously decided entries cannot turn that refusal into a nonconformant entry or a partial result map.

Conformance

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

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

SHACL or ShEx?

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

Entailment

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

Surface map

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

let ds = purrdf::parse_dataset(
    b"<https://example.org/alice> <https://example.org/name> \"Alice\" .",
    "application/n-triples",
    None,
)?;
// 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);
assert!(closed.quad_count() > ds.quad_count());
Ok::<(), Box<dyn std::error::Error>>(())
}

The same engine in five hosts

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

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

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

One host-specific note:

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

Asking a question instead: the conclusion-directed services

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

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

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

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

  • Five regimes, not seven. owl-direct is directed by a query’s class expressions and rif entails under the caller’s rule document, and “premise, conclusion, regime” carries neither. Both are refused by name on every host — never answered under a weaker regime and labelled with the one that was asked for — and both still materialize.
  • The import table is a parameter, on every host. An ontology’s imports closure is the ontology, so a premise carrying an owl:imports the call was not handed is a different premise. PurRDF fetches nothing, so the closure arrives as caller-supplied configuration: an ordered list of (ontology IRI, document) pairs, spelled --import IRI=FILE on the command line. An import needs no pair when it names the premise document itself — the CLI’s file:// retrieval IRI or --base, and on the Python, WebAssembly and C hosts the premise_iris argument (premiseIris in JavaScript), the IRIs the caller read the premise from, [] for bare text — or a graph the premise already holds (<X> a owl:Ontology, <X> a sh:ShapesGraph, or an owl:versionIRI naming it). Any other unresolved import is a refusal naming the document, never a silently truncated premise, and a table entry the premise’s closure never reaches is refused too — it would be read and never used — on every host, as SHACL validation refuses an unused shapes-graph entry. Every other entailment service takes the same table and applies the same rule: materialization (CLI reason, convert --entailment; Python materialize/materialize_nt; WebAssembly entailMaterialize; C purrdf_entail_materialize_to_nquads; Rust materialize_with_imports), the entailment-regime query (CLI query --entailment … --import IRI=FILE; Python Store.query_entailment_governed(..., imports=…, premise_iris=…); WebAssembly queryEntailmentGoverned’s importIris/importDocuments/premiseIris options; C purrdf_query_entailment_governed’s import_iris/import_documents/ import_count; Rust query_with_entailment_closure_governed), and the OWL-Direct consistency question (CLI consistency --import IRI=FILE; Python consistency(data, imports, premise_iris); WebAssembly entailConsistency(doc, iris, docs, premiseIris, …); C purrdf_entail_consistency). Each closes or decides the merged premise, states ontology-import-resolved in its report or certificate, and refuses an unresolved import or an unreached entry exactly as reason does. The Rust entry points that take no table (query_with_entailment, query_with_entailment_governed) resolve against the empty one, so a premise that imports a document it does not hold is refused there too, never closed without it. The owl:imports read are the same ones SHACL validation reads: on the premise’s own IRI, on an owl:Ontology header, on a sh:ShapesGraph (sh:RulesGraph and subclasses included), or on a node naming one of those as its owl:versionIRI. An owl:imports on any other node, a node that is only a sh:DataGraph included, is an ordinary premise triple.

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, which the theorem behind the rule table’s completeness does not cover. That theorem is Theorem PR1 of OWL 2 Profiles §4.3, and its hypothesis has two halves: the premise lies inside the OWL 2 RL syntax, and the conclusion is assertional — class assertions over class names, property assertions, owl:sameAs and owl:differentFrom over named individuals. Only when both hold is the rule table complete, so a schema conclusion is one the table claims no completeness for, whether or not scm-* derives it. The predicate variable also ranges over the constructs the mechanisms beyond the table decide, for which the closure the rows are drawn from holds nothing.

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

Rule coverage

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

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

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

Where the numbers stop:

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

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

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

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

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

The RDF, RDFS, OWL-RL and D lanes run under purrdf-datalog’s evaluation limits: the facts each graph’s store may hold and the candidate solutions its rules may enumerate. The defaults are 4,194,304 facts and 1,048,576 join steps natively, and 131,072 and 1,048,576 in WebAssembly. A caller that needs other limits states them — materialize_with(ds, plan, &options, stop) in Rust, --max-stored-facts and --max-join-steps on purrdf reason and purrdf convert --entailment, max_stored_facts= and max_join_steps= on Python’s materialize and materialize_nt, maxStoredFacts and maxJoinSteps on entailMaterialize, and max_stored_facts and max_join_steps on purrdf_entail_materialize_to_nquads. A run past a limit is refused, naming the limit, the numbers and that host’s knob; a run inside the limits returns the same closure any larger limits would, and its report’s contract hash names the calculus under the limits in force.

Every other service that evaluates a regime’s rule table takes the same two limits, and names the knob of the entry point the caller called — never a Rust type to a caller who cannot reach one. The conclusion-directed services hold the premise’s closure and every re-chase of the refutation and freeze mechanisms to them: entails_with and certain_answers_with in Rust (a MaterializeLimits on the string boundary), --max-stored-facts and --max-join-steps on purrdf entails, keyword-only max_stored_facts= and max_join_steps= on Python’s certain_answers, graph_entails and verify_entailment, trailing maxStoredFacts and maxJoinSteps on entailCertainAnswers, entailGraphEntails and entailVerifyEntailment, and max_stored_facts and max_join_steps after premise_iri_count on the three C entry points. The entailment-regime query holds its closure to them: EntailmentClosure::with_limits in Rust, --max-stored-facts and --max-join-steps beside purrdf query --entailment, max_stored_facts= and max_join_steps= on Store.query_entailment_governed, the maxStoredFacts and maxJoinSteps options of queryEntailmentGoverned, and the two parameters of purrdf_query_entailment_governed; the query governors still price only the evaluation over the closure. The OWL-Direct consistency question runs no rule table: its evaluator is the hypertableau, bounded by step_cap and work_cap on every host, so it takes no stored-fact or join-step knob that would govern nothing.

Every run says what it did

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

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

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

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

OWL-Direct: the tableau

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

RIF

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

D (datatype) entailment

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

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

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

There is no unsupported-regime error. materialize takes a Materialization, which carries each regime’s own input — a basic graph pattern for OWL-Direct, a RuleSet for RIF — so each of the seven alternatives dispatches to its engine. Invalid input, inconsistency and exhausted resource limits return typed errors. Regime stays as the reporting and identity type that ReasoningReport::regime(), rules(), implemented() and Regime::from_iri speak in.

Invariants

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

Conformance

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

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

  • W3C OWL 2 test suite — 262 of 262 cases agree, 0 ledgered, zero unledgered. This corpus is consistency-shaped: all 262 vendored cases are otest:ConsistencyTest (226) or otest:InconsistencyTest (36). It therefore grades the DL/tableau lane’s satisfiability verdicts and says nothing about the OWL 2 RL rule table. The typed ledger contains 0 divergences. Every disagreement and every stale ledger entry remains a hard failure.

    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 “262 of 262” is a number over a corpus rather than over what W3C published.

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

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

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

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

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

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

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

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

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

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

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

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

The live scoreboard is docs/CONFORMANCE.md.

Entailment Rule Inventory

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

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

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

78 / 78 and 50 / 50 are two different measurements

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

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

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

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

Coverage by regime

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

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

Extensions

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

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

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

RDF — 3 of 3 rules implemented

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

RDFS — 18 of 18 rules implemented

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

OWL-RL — 78 of 78 rules implemented

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

D — 5 of 5 rules implemented

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

Datalog: the Fixpoint Engine

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

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

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

One rule IR: the DL-clause

Every rule has the shape

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

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

The semi-naive evaluator runs atomic and unquantified conjunctive heads. Several heads share one body evaluation; each recorded derivation names its authored clause index and head ordinal. Recursion, factor projection and stratified negation use the same evaluator. Predicate completion follows every producer, even when a shared rule emits a higher-stratum head early. Independent proofs check the selected head ordinal; their version 2 encoding carries it explicitly.

Existential, disjunctive and false heads are refused 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

Use Guard::not_equal("?left", "?right") for native term inequality, without a callback. It compares exact bound term identity without numeric coercion. A caller-defined Guard::filter("!=", ...) remains a callback: operation kind, not its name, selects native behavior and participates in the clause digest. The independent proof checker re-evaluates native top-level filters from the checked premises.

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

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

Limits refuse; they never truncate

Three limits bound what every run holds and enumerates. All are caller-selected through EvalOptions, with explicit defaults:

LimitBoundsSet withDefault on wasm32Default elsewhere
join stepscandidate solutions enumeratedEvalOptions::with_max_join_steps1,048,5761,048,576
stored factsfacts seeded or derivedEvalOptions::with_max_stored_facts131,0724,194,304
term arena bytesinterned term surface bytesEvalOptions::with_max_term_arena_bytes16,777,21616,777,216

EvalOptions::without_term_arena_limit removes the logical surface-byte ceiling. Actual storage admission still applies. The effective ceiling is available in the budget report and participates in the contract hash.

Stored-fact admission checks each unique insertion before changing the store. On refusal, BudgetReport::stored_facts reports the admitted count and observed_stored_facts reports the first refused insertion, exactly one higher. Duplicate candidates do not consume another stored-fact allowance.

The stored-fact defaults differ because the memory the store lives in does: a wasm32 evaluation shares one linear memory with the rest of its page, and its default is the value every target used while the limit was fixed. A native process can hold a far larger least model, and a ceiling sized for a browser refused ordinary terminating rule sets there — a single rule that copies each of 70,000 ex:p triples to ex:q. The default is chosen at compile time from the target architecture, never by a Cargo feature.

The join-step default is the same on every target. Evaluation reserves a candidate’s credit before expanding it, using one round-wide pool; an exhausted allowance stops further candidate expansion. This is a work ceiling, separate from retained storage: raising it can permit larger intermediate buffers, without raising the stored-fact or term-arena ceilings. A caller whose program needs more candidate work must state that allowance explicitly (--max-join-steps on the command line).

The join-step limit is not what stops a divergent rule. A SHACL shape rule minting a new focus node every round is re-executed only for the focus nodes whose inputs changed, not for every earlier one. Term generation has its own guards: the term-generating round limit when new terms have bounded length, and the term-arena ceiling when retained surfaces grow.

A limit can only refuse. A run inside its limits returns the program’s least model, the same model under every limit that admits it. Complete-only calls return a refusal past a limit. A retained EvaluationSession exposes certified facts through a separate PartialModel. Every caller passing the same options gets the same answer or the same refusal, and the effective limits are folded into the result’s contract hash, so a closure computed under one set of limits, or on one target under its defaults, never claims the identity of another. A join-step count also depends on the evaluator’s plan, so a limit sized tightly against one release can refuse under the next; a limit with headroom cannot change a completed answer.

The Datalog evaluator reads no clock. A host can adapt a wall-clock deadline or cancellation flag to the answer-blind StopSignal interface. The signal is polled at session entry and round boundaries. A stopped session can resume with a fresh signal; neither signal changes the completed model.

Model output, proofs and measured work

Ordinary evaluation defaults to DerivationMode::ModelOnly. Set EvalOptions::with_derivation_mode(DerivationMode::Record) when exported derivations are needed. Evaluation::derivations returns None when recording was disabled and Some([]) when recording was enabled but derived nothing. Proof construction refuses a model-only result with ProofError::DerivationsNotRecorded. Recording does not change the least model, internal canonical witness preference or candidate accounting.

Evaluation::work separates unique input facts and committed fact insertions from physical candidate admissions. For an ordinary completed stratified run, the fact counts are independent of the join plan. Candidate counts can change with the planner; the report names its version, and callers can compare candidates per committed fact. Scheduled withdrawals can cause a later commit of the same fact and therefore another commit event.

EvalOptions::with_round_execution(RoundExecution::Sequential) selects the direct execution path for one call, without changing a global thread pool. Both scheduling policies use the same evaluator, program-order merge and sorted commit, and preserve the same model and recorded derivations.

Complete-only entry points such as evaluate_guarded return no model on refusal. The error names the limit, the numbers observed and permitted, and the knob that raises it; entailment reports these through EntailError::Evaluate or EntailError::Chase.

Certified partial models and continuations

EvaluationSession::new consumes the input store and borrows the immutable program and guard evaluator. Its SessionContext requires the caller’s canonical snapshot, guard-semantics and evidence-regime identities. The session adds the canonical authored-program identity. Retain its binding() value and present that binding unchanged to resume, together with cumulative limits and an optional stop signal. Scheduling may change; derivation recording remains fixed. Changing any binding coordinate refuses before execution. The original dictionary checks its pinned source at every public read and resume boundary.

ResumeProgress::Paused carries the original resource or stop refusal. partial_model() borrows the seeded and proved published facts, actual successful work, optional derivations and each stratum’s Complete, SoundLowerBound or Withheld status. Execution never enters a stratum before all preceding strata finish, so negation cannot observe an incomplete lower extension. Later dependent conclusions are withheld. The partial identity includes the original canonical bindings, governors and consumption in a separate hash domain. Runtime term and row handles never participate in it.

Raise a ceiling or supply a fresh stop signal to resume the retained joins, guards, rule/round work, sorted publication and current delta. Successful work is counted once; retrying the same refused allowance adds no successful work or guard calls. EvaluationProofs::of_partial builds proofs for recorded published derivations, checked by the same independent checker used for complete models. Source and guard failures stay hard failures and prevent further resumption.

Only ResumeProgress::Complete enables take_complete() to return an Evaluation. An incomplete request leaves the session intact. The completed model, derivations and successful join count match uninterrupted execution under the final options; its calculus identity uses the ordinary contract_hash_with recipe. The continuation is an in-process owner and has no serialized token form.

GTS Graph Transport

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

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

The container model, at a high level

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

Properties that fall out of this design:

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

Reading and writing from Rust

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

#![allow(unused)]
fn main() {
use purrdf::gts::reader;
use purrdf::gts::{model::{Graph, Term}, writer::Writer};

let mut source = Graph::default();
source.terms.extend([
    Term::iri("https://example.org/alice"),
    Term::iri("https://example.org/knows"),
    Term::iri("https://example.org/bob"),
]);
source.quads.push((0, 1, 2, None));
let bytes = Writer::deterministic(&source, "example-profile")?.into_bytes();

// 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());
assert_eq!(graph.quads.len(), 1);
assert!(graph.blobs.is_empty());
assert!(graph.diagnostics.is_empty());
assert_eq!(bytes, Writer::deterministic(&graph, "example-profile")?.into_bytes());
Ok::<(), Box<dyn std::error::Error>>(())
}

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

Native unit tests cover file media-type mapping and tar/PAX serialization and parsing. Frozen vectors and compaction/certification fixtures exercise additional paths. These checks cover their stated cases; they do not establish complete coverage of the files profile, nested GTS-in-blob reader, trust policies or ULID behavior. The upstream gmeow-gts specification and frozen vectors remain the format authority.

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.

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.

#![allow(unused)]
fn main() {
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 each manifest.ttl below the caller's slices directory.
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. SssomSetComment models set-level ordinary comments and provenance as a lossless document envelope, independently of YAML-like header metadata. Newly appended provenance uses the interoperable metadata-to-table position; parsed after-table comments are retained as an explicit extension. Raw Unicode comment lines and their order are preserved, while physical line endings serialize deterministically as LF.
  • FnO — a Function Ontology function-catalog codec (FnoCatalog, fno_to_quads, fno_to_ntriples), used by the slice emitters to describe function catalogs as RDF.

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

rdflib Compatibility

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

Tier 1: the explicit compat layer

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

from purrdf.compat.rdflib import Graph

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

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

Graph.query, the SPARQL processor, Dataset and ConjunctiveGraph use contextual initial-binding semantics compiled into the native evaluator. Query assignments can replace an initial value while the initial mapping remains available to enclosing operations. Named graph views and default_union select the query’s graph scope. The direct purrdf.Store.query and Store.prepare interfaces retain native pre-binding rules.

Tier 2: the purrdf[rdflib] shadow distribution

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

pip install "purrdf[rdflib]"

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

How compatibility is kept honest

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

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

Both use strict expected-failure ledgers: every known divergence is listed with a per-test reason, an unexpected failure breaks the build, and a silently fixed divergence also breaks the build until the ledger shrinks. The ledgers state the current divergences and distinguish unsupported configured execution from deliberate term and query admission differences.

Performance

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

RDF/JS in JavaScript

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

The data factory

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

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:

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

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, trix, hextuples, jsonld, yamlld, or their media types. Because these are the native codecs, output is byte-deterministic and identical to what the Rust, Python, and C surfaces emit (Codecs & Determinism).

SPARQL

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

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

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

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

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

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

Streams and sinks

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

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

Scope notes

  • The package’s Dataset holds data in memory; these SPARQL methods query that resident dataset. The synchronous methods install no SERVICE or LOAD source, so there a remote SERVICE or LOAD fails explicitly unless written SILENT; the asynchronous twins reach remote endpoints only through the handlers the host passes them (see Asynchronous queries and federation).
  • A quoted-triple term as a quad object round-trips through Turtle, N-Triples, N-Quads, TriG, RDF/XML and JSON-LD / YAML-LD; TriX and HexTuples have no triple-term surface, and serializing one to them throws (see Scope and current limitations).

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.

Explicit application vocabularies

PurRDF is not an ontology. Structural Markdown and ordered JSON offer named standard profiles and vocabularies under their shipped specifications; callers select a profile deliberately or supply a vocabulary. There is no implicit namespace fallback. Other application vocabularies — slice manifests, statement-metadata downcast, box roles, SPARQL extension-function namespaces, JSON-Schema namespaces — are 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 FixedHasher 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 PyO3, and a hygiene gate asserts the dependency tree. The root purrdf-hash keeps zero runtime dependencies, and the foundation leaves above it (purrdf-lex, purrdf-iri, purrdf-xsd, purrdf-events) depend only on the first-party crates their layers.toml rows allow and on no third-party crate. 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, malformed ShEx syntax is a ShexError, a passed evaluation limit 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 bench, not an adjective (Performance).
  • One version, lockstep releases — crates.io, PyPI, and npm ship one workspace version (Versioning & Releases).
  • Zero nightly features, stable MSRV — there are no #![feature(...)] attributes anywhere in the workspace and the MSRV floor (currently 1.98, on the stable channel) is enforced by a dedicated CI job that builds on exactly that compiler. Contributors and the CI gates run a floating nightly named in rust-toolchain.toml for its sharper clippy and rustdoc lints and its stronger default borrow checker; release artifacts are built on stable.
  • Brand — the project is PurRDF in prose and purrdf in identifiers (docs/BRAND.md).

Conformance & Testing

PurRDF checks its engines against standards suites and first-party corpora. The suites are vendored and byte-frozen in-repo — never hand-edited, SHA-256-verified on every make check so a silent content edit fails the build. The full, live scoreboard is docs/CONFORMANCE.md; this chapter explains how the machine works.

The single conformance matrix

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

make conformance    # aggregates every suite into one table

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

What is gated

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

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

Ledger discipline

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

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

Running the suites locally

make conformance                                     # the single matrix
cargo test -p purrdf-shex                            # all four ShEx suites
cargo test -p purrdf-shapes --test w3c_conformance   # W3C SHACL scoreboard
cargo test -p purrdf-shapes --test w3c12_conformance # W3C SHACL 1.2 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 resident RdfDataset IR (The Interned Dataset IR) interns lexical text in a string arena and stores triple terms as component IDs. Its copyable TermId uses NonZeroU32; the separate global address space uses NonZeroU64 in GlobalTermId. Hot maps use the fixed-key FixedHasher. Frozen quad rows have lazy ordinal permutation indexes (four bytes per quad for each populated permutation). The explicit graph-free TripleDataset has three-component rows and separate reifier and annotation tables. Evaluation works over interned IDs.

Layout comparisons live in crates/rdf-core/benches/ir_layout.rs. The suite compares the resident quad-row store with structure-of-arrays and predicate-adjacency alternatives over the same frozen quads, reporting allocation counts, high-water memory and end-to-end latency. It also reports the graph-free carrier’s physical row tables and populated ordinal indexes. Each result applies to its measured source, workload and host.

The two benchmark layers

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

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

The discipline for changes

Any change claiming a performance win must extend the 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 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).

Rust, LLVM and hardware paths

The Rust and SIMD optimization guide explains the source formulations, mathematical contracts, assembly checks and measurement discipline. Its hashing and compression sections cover constant indices and rotations, bounded round expansion, SIMD chunk and parent batching, streaming state, direct output sinks and allocation floors. It distinguishes instruction coverage from throughput measured on the target hardware.

Reproducing locally

make bench                              # the default bench 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 and purrdf-rdflib distributions, and the npm @blackcatinformatics/purrdf package — from one workspace version, in lockstep. The full process is docs/RELEASE.md.

Semver policy from 1.0.0

From 1.0.0 the suite follows semantic versioning in full:

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

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

The one exception is the C ABI. libpurrdf’s purrdf.h carries its own PURRDF_ABI_MAJOR.PURRDF_ABI_MINOR, declared in that generated header and 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.98 — 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.98. 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 names a floating nightly for local work and the CI gates to use its current clippy and rustdoc analysis. By policy the source uses zero nightly (unstable) features (no #![feature(...)] attributes, which the MSRV job proves on every change), and the release lanes build every published artifact on stable.

Tag-driven trusted publishing

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

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

Every functional version of every crate in the 33-crate release set is published by that lane. Functional publication requires the crate records to be locked with Require trusted publishing (trustpub_only). That setting prevents an API token from publishing a new version: crates.io answers with 403 Forbidden: New versions of this crate can only be published using Trusted Publishing.

Set up new crate records before tagging. A token creates each missing record by publishing an isolated, empty, dependency-free 0.0.0 package; the real workspace crate keeps its functional release version. Configure its Trusted Publisher entry and enable Require trusted publishing, then reconcile PURRDF_UNBOOTSTRAPPED_CRATES in scripts/release-crates.sh. All 33 records must exist, all publisher entries must be configured, every record must be locked, and the ledger must be empty before the functional release begins. scripts/check-crates-io-records.sh --require-all verifies the public records and locks; publisher entries require separate configuration confirmation.

The tagged trusted-publishing run then publishes the functional versions in dependency order. After those versions are verified, yank the empty 0.0.0 versions using a token with the separate yank permission, retaining the records and publisher settings. The complete setup and historical bootstrap receipts are in the release process document linked above.

Twelve 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-conformance-kit (the manifest, report and outcome grading the community corpus runner uses), purrdf-hash-conformance (the frozen-vector suites of purrdf-hash), purrdf-cli (the purrdf binary), purrdf-envelope-probe (the micro-hardware envelope capture tool), purrdf-bench (benchmark tooling), purrdf-alloc-probe (the shared counting allocator the allocation tests and benches measure with), purrdf-testkit (the shared test support: goldens, temporary paths, frozen vectors and the harness = false runner), wasm-link (the wasm package’s post-link step), helper-census (the structural census behind the shared-helpers gate), and purrdf-python (the extension crate, which ships to PyPI via maturin instead).

purrdf-alloc-probe and purrdf-testkit are the only two of the twelve that published crates depend on, and they reach them solely through [dev-dependencies]. Their root [workspace.dependencies] entries are therefore path-only, with no version key, which is what makes cargo drop them from the packaged manifest: an unpublished crate cannot be a versioned dependency of a published one, and cargo publish’s verification step resolves dev-dependencies too.

Cutting a release

Prepare the release bump on an isolated branch, using the make helpers to keep the three lanes aligned. Merge the reviewed release changes before cutting tags from a clean, synchronized main:

Rename the changelog’s Unreleased section to the release version and date, and write the reviewed short summary at docs/releases/<version>.md. Use make changelog only for history-generated notes.

# 1. Bump the release versions 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. Complete the changelog and reviewed summary, preserving migration guidance.

# 4. Stage only the reviewed bump, header, changelog and summary, then commit.
git commit -m "chore(release): 0.2.2"

# 5. After merging the release PR, use up-to-date main for the gated tag push.
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 and crates.io record/lock checks pass, VERSION matches the tree, the full changelog section and validated reviewed summary exist, and none of the three tags already exists locally or remotely. It then runs these gates in order:

  1. make check: the Rust and wasm workspace gate and repository hygiene.
  2. make capi-check: the generated C-ABI/header check and linked C smoke.
  3. make pytest: the native Python binding suite.
  4. make python-release-check: build and audit both Python wheel/sdist pairs, check them with the pinned publisher tools, and install them together.
  5. make wasm-pkg-test: the optimized size-gated npm/wasm package tests.
  6. make capi-bundle: build and audit the native C distribution, including recipient notices and relocated linked smoke.

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 using the committed reviewed summary in docs/releases/<version>.md, linked to the complete changelog. The shared checker validates the exact version and 64 KiB UTF-8 size ceiling before tags and publication; no notes are truncated.

Citing PurRDF

Releases carry a DOI; if you use PurRDF in research, please cite it — see CITATION.cff in the repository.

Diagnostic Code Reference

Failures reported as RdfDiagnostic values carry a stable, machine-readable code alongside severity, message, detail and 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.

RdfDiagnostic::presentation() optionally provides a stable message identity, named typed parameters and structured detail. Select a presentation template by message_id(): one diagnostic code can cover several templates. Read parameter values directly instead of extracting them from English text. to_json() emits the versioned machine record, with logical 64-bit anchors and typed integers as exact decimal strings. SARIF retains it in properties.diagnosticRecord; C callers use the borrowed purrdf_error_presentation_json accessor.

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 base-related codes distinguish an invalid base, a missing base and a grammar that forbids relative references; their remedies differ.

CodeMeaningRemedy
iri-emptyThe string is empty where a non-empty IRI/URI was required.Supply a non-empty IRI.
iri-missing-schemeThe string has no scheme, so it cannot be an absolute IRI.Write the IRI in absolute form, with a scheme.
iri-bad-schemeThe scheme is malformed (it must start with a letter and contain only letters, digits, +, - and .).Correct the scheme.
iri-bad-percent-encodingA % at the reported byte offset is not followed by two hexadecimal digits.Percent-encode the % itself, or complete the escape.
iri-disallowed-charA character the IRI grammar does not admit occurs at the reported byte offset.Percent-encode or remove the character.
iri-bad-authorityThe authority component (//host:port) is malformed.Correct the host or port.
iri-non-absolute-baseThe base IRI supplied for resolution is not itself absolute.Supply a base IRI that has a scheme, e.g. http://example.org/dir/.
iri-relative-no-baseA relative IRI reference was met with no base in scope: no in-document directive, no caller-supplied base, no retrieval IRI.Add a base to the document (@base/BASE in Turtle-family syntaxes, xml:base in RDF/XML, @context.@base in JSON-LD) or pass a base IRI to the API.
iri-not-absolute-by-grammarThe reference is not absolute and the syntax admits no relative reference at all (N-Triples, N-Quads), so no base could ever apply.Write the IRI in absolute form; supplying a base will not help.

native-codec-* — the text and XML codecs (purrdf-rdf)

CodeMeaningRemedy
native-codec-parseThe codec (Turtle family, RDF/XML, TriX, HexTuples) could not parse the input; the location names the line and column. Term nesting past the parser’s depth limit is reported under this code too.Fix the document at the reported position.
native-codec-limitA cumulative source byte/line counter or explicitly bounded local column cannot represent the next position.Split the source into separately addressed documents, or shorten an oversized line.
native-codec-utf8The input bytes are not valid UTF-8.Re-encode the document as UTF-8.
native-codec-panicThe codec panicked while parsing and the panic guard caught it. This is a defect in PurRDF, never in the input.Report it with the input that triggered it.
native-codec-readReading the RDF source through the streaming reader failed with an IO error.Check the source stream or file.
native-codec-writeWriting the serialized output failed with an IO error.Check the destination.
native-codec-serializeThe target format cannot represent the dataset — for example a named graph in a single-graph format — or the writer failed.Choose a format that carries the construct, or serialize through the loss-ledger lane.
native-codec-replayReplaying a parsed dataset into an event sink failed because the sink returned an error.The sink’s own error is carried in the message.
native-codec-unsupported-formatThe media type or format identifier names no codec.Use one of the supported media types or format ids.
native-codec-datatype-not-iriA literal’s datatype term is not an IRI.Give the literal an IRI datatype.
native-codec-direction-without-languageA base direction was given on a literal that has no language tag.Add a language tag, or drop the direction.
native-codec-invalid-directionA literal base direction other than ltr or rtl was given.Use ltr or rtl.
native-codec-iri-missing-valueAn IRI term event carried an empty value.Supply the IRI.
native-codec-missing-reifier-bindingA triple term refers to a reifier that has no binding.Bind the reifier before referring to it.
native-codec-predicate-not-iriA term in an IRI-only position (the predicate) is not an IRI.Use an IRI predicate.
native-codec-reifier-not-tripleA reifier binds something other than a triple term.Bind the reifier to a triple term.
native-codec-term-out-of-rangeA term id in the event stream is outside the range the stream introduced.The producing stream is inconsistent; regenerate it.
native-codec-unbound-triple-termA triple term names neither its components nor a reifier.Give the triple term its components or a reifier.

The last nine arise when the codec lane consumes term events rather than text — a GTS graph being resolved through the codec surface — and mirror the gts-* and rdf-ir-* codes below.

native-jsonld-* and jsonld-* — JSON-LD and YAML-LD

CodeMeaningRemedy
native-jsonld-decodeThe JSON-LD or YAML-LD input is malformed at the surface: invalid JSON/YAML, an encoding error, or a materialized-carrier byte budget exceeded.Fix the surface syntax or reduce the document.
native-jsonld-parseThe input is well-formed JSON-LD that does not map to RDF.Correct the JSON-LD structure.
jsonld-json-inputThe strict JSON reader rejected the input, for example a duplicate object member.Remove the duplicate or correct the JSON.
jsonld-context-invalidA context document, or the strict versioned options document, is invalid.Correct the context or options document.
jsonld-context-limitA context-processing ceiling was exceeded: loaded bytes, work count, definition complexity, or the offline context registry size.Reduce the context, or raise the limit the options document declares.
jsonld-derived-invalidThe deterministic dataset-IRI derived mode could not derive a prefix (an invalid IRI, or a null mapping).Correct the IRI the prefix would be derived from.
jsonld-derived-limitA derived-context work or byte ceiling was exceeded.Reduce the dataset’s IRI vocabulary or raise the declared limit.
jsonld-options-unusedJSON-LD serialization options were supplied for a format that is not JSON-LD or YAML-LD.Drop the options, or serialize to JSON-LD/YAML-LD.

cdt-* — SEP-0009 composite literals

A cdt:List or cdt:Map lexical form denotes blank nodes in the enclosing document’s scope, so a form that does not parse leaves that scope undefined and the whole document is refused rather than the literal being kept opaque.

CodeMeaningRemedy
cdt-literal-malformedA composite literal’s lexical form does not parse.Correct the literal.
cdt-literal-scan-disagreementThe bounded lexical scanner and the full parser disagree about the literal.This is a defect in PurRDF; report it with the literal.

rdf-ir-* — dataset structure (purrdf-core freeze and GTS import)

RdfDatasetBuilder::freeze() validates the structure of the dataset it is about to freeze; the GTS import sink reports the same family for a container whose terms do not form a well-formed dataset.

CodeMeaningRemedy
rdf-ir-term-out-of-rangeA quad references a TermId the builder never interned.Intern the term before pushing the quad.
rdf-ir-predicate-not-iriA quad’s predicate is not an IRI.Use an IRI predicate.
rdf-ir-literal-subjectA literal occupies subject position.RDF admits no literal subject; restructure the statement.
rdf-ir-triple-subjectA triple term occupies subject position.RDF 1.2 admits triple terms in object position only; use a reifier.
rdf-ir-graph-name-invalidA graph name is a literal or a triple term.A graph name must be an IRI or a blank node.
rdf-ir-graph-already-existsCreating a graph finds an existing slot in remembered-empty mode.Choose a fresh graph name or suppress this semantic error with SILENT.
rdf-ir-reifier-not-tripleA reifier binding points at something other than a triple term.Bind the reifier to a triple term.
rdf-ir-triple-cycleA triple term contains itself, directly or through nesting.Remove the cycle.
rdf-ir-triple-nesting-limitTriple-term nesting exceeds the builder’s depth limit.Flatten the nesting.
rdf-ir-dangling-term-refA GTS role references a term id that no term event introduced.The container is inconsistent; regenerate it.
rdf-ir-gts-fold-diagnosticThe GTS fold reported a diagnostic, surfaced through the import.The fold diagnostic’s own code and detail are in the message.
rdf-ir-iri-missing-valueAn imported IRI term has an empty value.Supply the IRI.
rdf-ir-literal-datatype-not-iriAn imported literal’s datatype resolves to a non-IRI.Give the literal an IRI datatype.
rdf-ir-missing-reifier-bindingAn imported triple term references a reifier with no recorded binding.Bind the reifier in the container.
rdf-ir-term-nesting-limitImported triple-term nesting exceeds the depth limit.Flatten the nesting.
rdf-ir-unbound-triple-termAn imported triple term names neither its components nor a reifier.Give the triple term its components or a reifier.

gts-* and rdf-* — GTS graph resolution, verification and writing

CodeMeaningRemedy
gts-term-out-of-rangeA GTS term id is out of range for the graph.The container is inconsistent; regenerate it.
gts-iri-missing-valueA GTS IRI term has an empty value.Supply the IRI.
gts-predicate-not-iriA GTS predicate term is not an IRI.Use an IRI predicate.
gts-literal-datatype-not-iriA GTS literal datatype does not resolve to an IRI.Give the literal an IRI datatype.
gts-direction-without-languageA GTS literal carries a base direction but no language tag.Add a language tag, or drop the direction.
gts-invalid-directionA GTS literal base direction is neither ltr nor rtl.Use ltr or rtl.
gts-missing-reifier-bindingA GTS triple term references a reifier the graph does not bind.Bind the reifier in the container.
gts-unbound-triple-termA GTS triple term names neither its own components nor a reifier.Give the triple term its components or a reifier.
gts-self-reaching-termA GTS term resolves through itself, so no walk of its components can terminate.Remove the cycle.
gts-term-nesting-limitGTS term nesting exceeds the depth limit.Flatten the nesting.
gts-fold-diagnosticThe GTS fold reported one or more diagnostics.Inspect the fold diagnostics listed in the detail.
gts-verify-digest-inclusionContent-addressed terms are not included in the verified chain.The container’s chain does not cover its content; do not trust it.
gts-verify-signatureCOSE signature verification failed.Check the signing key and the container’s integrity.
gts-writer-codecThe GTS writer’s codec reported an error while writing.The codec’s own error is in the message.
rdf-graph-name-not-nodeWhile building a GTS graph, a named-graph name is not an IRI or blank node.Use an IRI or blank node graph name.
rdf-reifier-not-nodeWhile building a GTS graph, an RDF 1.2 reifier is not an IRI or blank node.Use an IRI or blank node reifier.
rdf-term-nesting-limitRDF term nesting exceeded the depth limit while building a GTS graph.Flatten the nesting.

native-sparql-* — the SPARQL engine boundary (purrdf-sparql-eval)

CodeMeaningRemedy
native-sparql-query-parseThe query text does not parse under the SPARQL 1.1/1.2 grammar (including a malformed VERSION declaration; a well-formed but unrecognized version is native-sparql-unsupported).Fix the query at the reported position.
native-sparql-update-parseThe update request does not parse.Fix the update at the reported position.
native-sparql-query-explainEvaluation under --explain failed; the evaluator’s error is in the message.Address the underlying evaluation error.
native-sparql-query-evalQuery evaluation failed with an error no more specific code classifies; the evaluator’s error is in the message.Address the underlying evaluation error.
native-sparql-internalAn evaluator invariant was violated. The execution cannot produce a valid answer.Report the failure with its query and input; it requires an engine repair.
native-sparql-composite-boundConstructing a composite datatype value exceeded its element or lexical-byte resource bound.Reduce the value being constructed.
native-sparql-float-environmentThe calling thread’s floating-point environment cannot preserve the distance arithmetic’s specified IEEE-754 results.Evaluate on a thread with the supported floating-point environment.
native-sparql-function-operationalA host function or relation failed during execution: an opaque returned error, a caught panic, a resource ceiling, or an invalid protocol response.Repair the callee or adjust the resource limit; this cannot establish a validation verdict.
native-sparql-source-readOperational storage refused a forward/reverse lookup or complete row drain. The error is fatal, including inside SERVICE SILENT; no partial answer is published.Repair the source or adjust its explicit resource limits before retrying.
native-sparql-workspace-unpricedAn execution operation has no admitted working-memory owner, or a raw evaluator entry was called without the required reservation.Use an engine entry that carries the view’s owned workspace admission; supply any missing host/provider workspace certificate.
native-sparql-workspace-bound-overflowCertified workspace arithmetic exceeds u64.Reduce the query’s intermediate cardinality or pattern count.
native-sparql-update-evalUpdate evaluation failed with an error no more specific code classifies; the evaluator’s error is in the message.Address the underlying evaluation error.
native-sparql-algebraThe query’s algebra was refused before evaluation: Query::validate refused it, or a prepared plan needs replanning for the registries this evaluation supplies.Fix the query as the message says, or prepare the changed algebra before executing it.
native-sparql-constructTyped graph building was handed a query that is not a CONSTRUCT.Build graphs from a CONSTRUCT query.
native-sparql-execution-parameterA prepared execution was given a parameter name or slot it does not declare.Bind only the parameters the execution declares; the message lists them.
native-sparql-relation-incompleteA property-function relation answered from less than all of its data on an entry that has no evidence slot to label the short answer (the ungoverned query lane, and UPDATE). The message names the relation and its own reason.Run the query on a governed entry, whose receipt carries the relation’s witness, or wait for the relation to be complete.
native-sparql-property-functionThe property-function seam refused the query: a predicate under a declared namespace has no registration, a call site’s arity does not match the relation, no total order can serve a chain, or a prepared plan is being evaluated under a different registry than it was prepared with.Register the relation, correct the arity, or prepare and evaluate under the same registry.
native-sparql-aggregate-functionThe custom-aggregate seam refused the query: an AGG(<iri>, …) names no registered aggregate, or a prepared plan is being evaluated under a different aggregate registry.Register the aggregate, or prepare and evaluate under the same registry.
native-sparql-custom-functionA function or aggregate IRI resolved to no registered custom function, native function, or XSD constructor.Register the function under that IRI, or use a native one.
native-sparql-quoted-triple-term-variableA variable occupies a component of a quoted-triple term in a basic graph pattern or property path; structural triple-term matching is out of scope.Bind the triple term as a whole, or match its components through the reifier surface.
native-sparql-heldin-unconfiguredheldIn was called with no caller-supplied standpoint-predicate configuration.Configure the standpoint predicates before using heldIn.
native-sparql-evaluation-stack-exhaustedThe request nests deeper than the stack of the thread evaluating it can hold: before its recursive passes run, the evaluator measures the whole height of the query plan against the stack it has left (and a correlated substitution checks at every level of the copy it makes), and refuses before it runs out, rather than aborting the process or trapping the wasm instance. The parser admitted the request, and the same request answers with more stack.Evaluate on a native thread spawned with more stack, or nest the request less deeply. In wasm both lanes run on stacks of the same size — an asynchronous job’s region is exactly as large as the module’s own shadow stack — so nesting less deeply is the remedy there, and the message names no other.
native-sparql-unsupportedThe request is well-formed but uses something this engine refuses to evaluate as written — for example a SERVICE ?e for which no solution names an endpoint, or (without SILENT) ?e bound to a literal, or an unrecognized VERSION. The message names what and, where there is one, the rewrite. The Cloudflare adapter answers it with a 400.Rewrite the request as the message says.
native-sparql-host-stack-exhaustedwasm only: the request nests deeper than the JavaScript engine’s own call stack holds. PurRDF keeps a fixed 640 KiB budget of that stack for evaluating a request — graph patterns nested at most 284 levels, a tree at most 2 048 levels plus eight per nested group, expressions and paths at most 2 304 levels — and V8 gives the synchronous lane and an asynchronous job the same stack, so a plan past it is refused before it runs, the same on both lanes. Parsing handles nesting on heap stacks; these limits govern evaluator admission. The Cloudflare adapter answers it with a 400.Nest the request less deeply.
native-sparql-bnode-mint-prefixThe blank-node mint prefix supplied in the options is invalid.Supply a valid prefix.
native-sparql-service-deniedAn installed ServiceCatalog withheld a capability from a SERVICE endpoint the request names — the endpoint has no profile, or its profile does not grant a required query, network or credentials capability — so no endpoint was contacted. Under SERVICE SILENT the clause is the join identity instead, recorded as a silenced denied invocation. The Cloudflare adapter answers it with a 403.Grant the endpoint the capability in the catalog, or remove the SERVICE.
native-sparql-service-host-deniedThe host’s own service resolver refused a SERVICE request by its own policy, naming no catalog capability — for example a JavaScript resolveService that answered { kind: "denied" } — so no endpoint was contacted. Under SERVICE SILENT the clause is the join identity instead, recorded as a silenced host-denied invocation. The Cloudflare adapter answers it with a 403.Allow the request in the host’s policy, or remove the SERVICE.
native-sparql-service-failedA SERVICE endpoint was contacted and gave no usable answer: a transport failure, an HTTP error status, a redirect, a timeout of the resolver’s own, or a body that does not decode. Under SERVICE SILENT the clause is the join identity instead, recorded as a silenced invocation. The Cloudflare adapter answers it with a 502.Make the endpoint reachable, or write SERVICE SILENT.
native-sparql-service-unconfiguredNo remote query source reaches a SERVICE endpoint the request names: none was configured, or the configured one answers that nothing it holds reaches the endpoint. Under SERVICE SILENT the clause is the join identity instead, recorded as a silenced unconfigured invocation. The Cloudflare adapter answers it with a 500.Configure a source that reaches the endpoint, or remove the SERVICE.
native-sparql-load-no-resolverLOAD <iri> was requested but no GraphResolver host seam was provided. LOAD SILENT succeeds instead with nothing loaded, recorded as a silenced unconfigured invocation.Inject a resolver, or remove the LOAD.
native-sparql-load-deniedAn installed ServiceCatalog withheld a capability from the LOAD source (LoadError::Denied) — the source, or a location a redirect named, has no profile, or its profile does not grant a required network or credentials capability — so nothing was fetched. LOAD SILENT succeeds instead with nothing loaded, recorded as a silenced denied invocation. The Cloudflare adapter answers it with a 403.Grant the source the missing capability in the catalog, or remove the LOAD.
native-sparql-load-host-deniedThe host’s own LOAD resolver refused the source by its own policy, naming no catalog capability (LoadError::HostDenied) — for example a JavaScript resolveLoad that answered { kind: "denied" }. LOAD SILENT succeeds instead with nothing loaded, recorded as a silenced host-denied invocation. The Cloudflare adapter answers it with a 403.Allow the source in the host’s policy, or remove the LOAD.
native-sparql-load-failedA GraphResolver could not fetch the document (LoadError::Transport) — for example the wasm package’s asynchronous resolveLoad answered { kind: "transport" }, the fetch outlived its catalog profile’s timeout, or it followed more than five redirects. LOAD SILENT succeeds instead with nothing loaded, recorded as a silenced invocation. The Cloudflare adapter answers it with a 502.Make the source reachable, or write LOAD SILENT.
native-sparql-load-decodeA GraphResolver fetched the document and could not parse it (LoadError::Decode) — for example the wasm package’s asynchronous resolveLoad delivered a document in a media type no parser reads, or bytes that are not the syntax named. LOAD SILENT succeeds instead with nothing loaded, recorded as a silenced invocation. The Cloudflare adapter answers it with a 502.Serve a document in a syntax the resolver parses, or write LOAD SILENT.
native-sparql-load-stoppedThe request’s own stop signal (a cancellation or the deadline) fired while a LOAD was awaited (LoadError::Governed). A governed request reports the governor trip, not this code, SILENT or not.Raise the deadline, or do not cancel the job.
native-sparql-load-faultA GraphResolver’s handler faulted (LoadError::Fault) — for example the wasm package’s asynchronous resolveLoad threw, rejected, answered with nothing, or named a failure kind the protocol does not define. The message carries the handler’s words. LOAD SILENT succeeds instead with nothing loaded, recorded as a silenced fault invocation. The Cloudflare adapter answers it with a sanitized 500 whose real words go to its log under a correlation id.Return a document with its media type, a Response, a Dataset, a redirect or a typed failure — never throw.
native-sparql-cancelledwasm asynchronous lane: an ungoverned operation was cancelled (its AbortSignal fired). A governed operation reports the trip on its outcome instead. The Cloudflare adapter answers it with a 503.Do not cancel the operation.
native-sparql-deadlinewasm asynchronous lane: an ungoverned operation’s deadline passed. The Cloudflare adapter answers it with a 503.Raise the deadline.
native-sparql-not-acceptablewasm asynchronous lane: a negotiated query’s result — a graph carrying named graphs — can be carried by no format the Accept header allows. The Cloudflare adapter answers it with a 406 naming the formats that can.Accept TriG, N-Quads or JSON-LD.
native-sparql-update-in-flightwasm asynchronous lane: an asynchronous update of the same dataset is already in flight, or the dataset was mutated while the update ran, so the update is refused rather than evaluated against (or committed over) a state it never saw. The Cloudflare adapter answers it with a 409.Await the first update, then send the next.
native-sparql-host-faultThe host, not the request and not an endpoint, failed: a SERVICE source’s handler faulted (EvalError::ServiceHostFault) — for example a wasm resolveService that threw, rejected, answered with something that is not an answer, or named a failure kind the protocol does not define — in a clause without SILENT (under SERVICE SILENT the fault is the join identity, recorded on the evidence as a silenced fault); or, on the wasm asynchronous lane, a job whose delivery protocol broke or whose frames overwrote its stack region’s canary. The Cloudflare adapter answers it with a sanitized 500 whose real words go to its log under a correlation id.Fix the host handler.
native-sparql-governor-ceilingProtocol boundary: a deterministic governor ceiling stopped the operation — it was reached, or the planner’s estimate already exceeded it. The operation’s outcome carries the trip; this is the code of the problem a host answers it with (the Cloudflare adapter’s 422).Raise the ceiling, or narrow the request.
native-sparql-evaluationProtocol boundary: the operation’s evaluation failed with no more specific code; the problem’s code a host answers it with.Address the underlying evaluation error.
native-sparql-update-bad-destinationAn ADD/MOVE/COPY/LOAD destination is NAMED or ALL; it must be DEFAULT or a single named GRAPH.Name a single destination graph.
native-sparql-update-graph-missingAn UPDATE under the default RememberEmpty graph lifetime names an input graph that does not exist. CLEAR, DROP, ADD, COPY and MOVE refuse this input before changing the destination; SILENT leaves the operation unchanged. Self COPY and MOVE are unconditional no-ops.Create or load the input graph, or use SILENT.
native-sparql-subst-iriA substitution value is not a valid IRI.Supply a valid IRI.
native-sparql-subst-triple-predicateA substituted quoted triple has a predicate that is not an IRI.Use an IRI predicate.
native-sparql-subst-literal-datatypeA substituted literal’s datatype is not an IRI.Use an IRI datatype.
native-sparql-subst-langtagA pre-bound literal’s language tag is not one the lexing profile accepts.Supply a well-formed language tag.
native-sparql-xpath-operationalA selected dated XPath law failed operationally in a way that names no single xpath-* resource. The query is aborted and publishes no answer.Report the failure with its query and input.

xpath-* — dated native XPath regular expressions (purrdf-core)

These codes arise only when a caller selects a dated XPath law (xpath-2.0-2010-12-14 or xpath-3.1-2017-03-21) for SPARQL REGEX and REPLACE, SHACL sh:pattern or ShEx pattern facets. Without a selection, the compatibility regular expressions run and none of these codes appears. purrdf_core::xsd_regex::xpath::Resource::code is their single owner. Each one names the Resource that a Limits bound withheld (Error::Resource, with the exact requirement and the bound), or whose storage the host refused to allocate (Error::Allocation). The production bound Limits::new() sets is given in each row, and zero withholds a resource completely.

Every xpath-* code is operational. It is never a syntax verdict and never a non-match: invalid flags (FORX0001), an invalid pattern (FORX0002), an empty-matching replacement pattern (FORX0003) or invalid replacement text (FORX0004) is the host’s ordinary invalid-pattern outcome instead, which is a SPARQL expression error, a SHACL finding or a failed ShEx facet. A refusal fails the whole operation. A query publishes no answer, an update changes nothing, and a validation writes no report; the refusal never becomes an unbound value, a false filter or a conforming graph. The C ABI reports it as PURRDF_STATUS_REGEX_RESOURCE_ERROR (13).

The bounds apply to every pattern. Matching charges the work actually performed: character comparisons, choices, state copies, search advances and replacement output. A matcher step has no universal nanosecond cost, and neither one step per byte nor a one-to-three-step range describes every search.

Under a dated law, a finite maximum that the input cannot reach no longer creates distinct count states. Compatibility nullable bodies keep their finite bounds and original capture priority. Greedy single-character runs, including choices and capture chains, scan once and retain their priority-ordered stops. Large nonnullable counts can reuse relative states while their bound comparisons stay unchanged, with exact counts restored near a bound.

Production-default tests cover four mebibytes of ^[\s\S]{0,10000000}$, ^[^\n]{0,10000000}$, ^[^<>]{1,5000000}$, ^[\p{L} ]{1,5000000}$ and ^(\w+\s?){1,5000000}$, and four mebibytes of the exact-count choices ^(a|b){4194304}$ and (a|b){4000000}. Decision and capture searches have integer work bounds of four steps per byte for these classes/grouped words and six for these choices. Nonbinding maxima ten times larger spend the same work; these fixture bounds are not universal admission or timing guarantees. REPLACE over two mebibytes of grouped words retains the final iteration’s capture and also charges template and output work. The native_xpath_large benchmark group measures these forms and their count/input-size ladders beside the original literal, grouped and adversarial neighbours.

Nested counted repetitions whose bodies can repeat empty still distinguish count combinations. On aaab, ((a?){3000}){3000}b answers while ((a?){5000}){5000}b reaches an operational bound. There is no universal minimum-count threshold: the pattern and input determine which states coexist. A backreference keeps the backtracking matcher, whose exponential exploration (^(a|aa)*c\1$ over forty a) can reach the step bound. Such refusal is a hard operational error, not false, an absent capture or a partial replacement.

CodeMeaningRemedy
xpath-pattern-bytesResource::PatternBytes: the pattern source has more UTF-8 bytes than the bound (64 KiB). The source is refused before parsing, whether or not its syntax is valid.Shorten the pattern.
xpath-compile-stepsResource::CompileSteps: compiling the pattern needs more compiler operations, scanned characters and set operations included, than the bound (4,000,000).Simplify the pattern, for example its character-class arithmetic.
xpath-program-nodesResource::ProgramNodes: the compiled program needs more nodes than the bound (262,144).Simplify the pattern or reduce its counted repetitions.
xpath-compile-slotsResource::CompileSlots: building the program needs more construction cells than the bound (2,097,152).Simplify the pattern or reduce its counted repetitions.
xpath-match-stepsResource::MatchSteps: actual matching, capture search or replacement work exceeds 250,000,000 operations. Large nonbinding maxima and eligible single-character counts do not multiply per-byte work; nested count combinations, state closures, backreferences and replacement output can still exhaust it. No answer or partial replacement is published.Simplify the costly pattern structure, remove a backreference where its semantics are unnecessary, or process shorter input.
xpath-match-statesResource::MatchStates: matching needs more simultaneously pending alternative states than the bound (65,536).Reduce the pattern’s alternation and repetition.
xpath-match-slotsResource::MatchSlots: the live states, captures and continuations need more cells than the bound (1,048,576). Nested empty-capable repetitions can reach the bound depending on their combination of counts.Reduce the pattern’s captures and repetition, or match shorter input.
xpath-output-bytesResource::OutputBytes: REPLACE output exceeds the bound (64 MiB).Replace over shorter input or with shorter replacement text.

reasoning-* — SPARQL under an entailment regime (purrdf)

CodeMeaningRemedy
reasoning-closure-relation-witnessProperty-function relations derived from the closure cannot be combined with an OWL Direct-Semantics run whose restricted chase minted existential witnesses: a relation walking the closure could return a minted blank node the regime’s scoping graph does not contain.Query under a regime that mints no witnesses (rdf, rdfs, owl-rl, d, rif, simple), or drop the dataset-derived relations from the call.
reasoning-closure-relation-rebuildRebuilding the property-function relations over the closure failed.The relation builder’s own error is in the message.

statements-* — statement-metadata ingestion (purrdf-rdf)

These arise when a document’s rdf:reifies and owl:Axiom statements are read into the statement layer.

CodeMeaningRemedy
statements-turtle-parseThe statement-metadata Turtle failed to parse.Fix the Turtle.
statements-non-iriA term that must be an IRI in this context is not one.Use an IRI.
statements-reifies-non-tripleThe object of rdf:reifies is not a triple term.Reify a triple term.
statements-malformed-axiomAn owl:Axiom lacks its source, property or target.Complete the axiom.
statements-conflicting-structuralOne subject carries two different values for a structural field.Keep one value.

Other single-code families

CodeMeaningRemedy
sssom-tsv-parseAn SSSOM TSV document is malformed: a missing or unreadable header row, a malformed curie_map entry or set comment, a malformed row, or a non-numeric confidence; the location names the line.Fix the TSV at the reported line.
content-id-schemeA content-id scheme prefix is invalid: empty, non-ASCII, or ending in a hexadecimal digit (which would make it ambiguous with the 64-hex-character tail).Choose a prefix that is non-empty ASCII and does not end in 0-9, a-f or A-F.

Where the code reaches you

  • Rust — RdfDiagnostic::code on the returned error; its Display form is <severity> <code>: <message>.
  • Python — the ValueError message is that Display form (for example error native-codec-parse: …); an IRI failure is rendered as <code>: <message> (for example iri-relative-no-base: …).
  • JavaScript — the thrown Error message carries the same text, and its code property carries the code itself. The wasm package’s own refusals carry a purrdf-wasm-* code: purrdf-wasm-options (an option or argument an operation refused), purrdf-wasm-usage (a job asked for something out of turn), purrdf-wasm-serialize (a result that cannot be written in the format asked for), purrdf-wasm-entailment (an entailment regime or its closure failed), purrdf-wasm-extension-environment (the aggregate registry could not be built), purrdf-wasm-shacl and purrdf-wasm-shacl-product-refusal (a SHACL surface failed, or refused a prepared product).
  • C — the error string returned through the C ABI carries the same text.
  • SARIF — the ruleId of each result is the code, with a reportingDescriptor in the run’s rule table (purrdf-validate).