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 (
TermIdspace, string arena, copy-on-write mutation), with triple terms in object position, reifier/annotation side-tables, and base-direction literals. See The Interned Dataset IR. - Native codecs — first-party parsers/serializers for Turtle, TriG, N-Triples, N-Quads, RDF/XML, TriX, HexTuples, JSON-LD (star), and YAML-LD, with byte-deterministic output. See Codecs & Determinism. Every syntax resolves relative IRI references through one RFC 3986 layer, and a relative reference with no base in scope is a hard error. See Base IRIs & Relative References.
- Canonicalization — W3C RDFC-1.0 on the RDF 1.1 subset; over RDF 1.2
constructs, either the flat form (RDFC-1.0 over
rdf:reifiestriples) or the first-partypurrdf-rdfc12profile, named apart — plus dataset diff and isomorphism. See Canonicalization & Diff. - Projections & carriers — deterministic LPG, CSVW, OBO Graphs, dataset-description, and research-object projections, each with a located loss ledger. See Graph, Tabular & Research-Object Projections.
- SPARQL 1.1/1.2 — native parser → algebra → multiset evaluator, with full
Update, SEP-0002 temporal arithmetic,
LATERAL(SEP-0006), the SEP-0008 SHA-3 hash builtins (SHA3-224/256/384/512), quad templates thatCONSTRUCTinto named graphs (a first-party extension, not a SPARQL 1.2 feature), the SEP-0009 composite datatypes (cdt:List/cdt:Map,FOLD,UNFOLD— with one stated divergence: PurRDF admits RDF 1.2 triple terms and directional language-tagged literals as composite elements, a lexical superset a conformant SEP-0009 reader will call ill-formed), caller-registered aggregates and property functions (including path witnesses that bind a traversal hop by hop), governed execution with per-node explain receipts, and aSERVICEseam — a host-injected resolver carrying per-service context; no HTTP client and no resolver ship, soSERVICEandLOADfail by name on every shipped surface unless writtenSILENT— gated by the W3C conformance suites. See SPARQL. - SPARQL extensions outside
purrdf-core— deterministic full-text search with exact fixed-point BM25 (Full-Text Search), 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 needed | Usually from | Now inside PurRDF | Where it stops |
|---|---|---|---|
| Ranked full-text search | PostgreSQL tsvector/tsquery | Full-Text Search: purrdf-text, an inverted index over RDF 1.2 literals with BM25 ranking in exact i128 fixed point and no floating point in the crate. | Exact BM25F, explicit stemming/dictionaries and independent Han retrieval; no stop-word lists or query dialect; an in-memory index over frozen datasets. |
| Spatial predicates | PostGIS | GeoSPARQL: 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 similarity | pgvector | Embedding Nearest Neighbours: exact top-k over a PURREMB embedding space, binary64 in a pinned accumulation order. | Exact scan bounded by a caller-supplied KnnGuard, three metrics, no approximate index; PurRDF computes no embeddings — the vectors come from a PURREMB artifact the caller fills, which PurRDF itself writes (EmbeddingBuilder, EmbeddingStreamWriter; Rust only) and opens fail-closed. |
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
- New users: start with Getting Started in your language, then read the Concepts chapters.
- Engine users: jump to SPARQL, Validation, or Entailment.
- Integrators: see Interop and GTS Graph Transport.
- Contributors: read the Project chapters, then AGENTS.md in the repository.
API reference documentation lives on docs.rs/purrdf; the repository is github.com/Blackcat-Informatics/purrdf.
Getting Started: Rust
The single dependency a Rust downstream needs is the umbrella
purrdf crate. It re-exports the RDF 1.2
implementation surface at its root and carries every other published crate
under a stable module (purrdf::sparql, purrdf::shapes, purrdf::shex,
purrdf::gts, purrdf::entail, purrdf::validate, purrdf::slice,
purrdf::iri, purrdf::xsd, purrdf::events) — anything a consumer
legitimately imports is reachable from purrdf alone, never by reaching into
a sub-crate.
cargo add purrdf
The MSRV is Rust 1.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
- The Interned Dataset IR — how the IR works and why it is fast.
- Graph, Tabular & Research-Object Projections — deterministic LPG, CSVW, OBO Graphs, SKOS, and research-object carriers through the umbrella crate.
- SPARQL: Querying — running queries over a frozen dataset.
- docs.rs/purrdf — the full API reference.
Getting Started: Python
The Python package wraps the same native Rust engine — not a reimplementation — so parsing, serialization, SPARQL, and validation behave identically to the Rust, JavaScript, and C surfaces.
pip install purrdf
Parsing
import purrdf
quads = purrdf.parse(
'<https://example.org/alice> <http://xmlns.com/foaf/0.1/name> "Alice" .',
purrdf.RdfFormat.TURTLE,
)
Validation: SHACL and ShEx
The native validation engines are exposed as top-level submodules mirroring the
Rust purrdf umbrella crate — never through the internal purrdf_native
extension module directly:
from purrdf import shapes, shex
report = shapes.validate(shapes_ttl=my_shapes, data_nt=my_data)
print(report["conforms"])
results = shex.validate(my_schema_shexc, my_data_ttl,
[("https://example.org/alice", "https://example.org/PersonShape")])
print(results[0]["conformant"])
SHACL result dicts keep the stable keys focus, path, value, severity,
component, source_shape, and 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
- rdflib Compatibility — the drop-in story in depth.
- Validation — SHACL and ShEx from Python.
- Entailment — the regimes, the report, and what each fires.
- GTS Graph Transport — the container format the exports read.
- Graph, Tabular & Research-Object Projections — LPG, CSVW, OBO, SKOS, and five research-object carriers.
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
| Type | Use and ownership |
|---|---|
Store | Mutable quad store with query, update, load, prepare and checkpoint operations. Its add and remove return None. |
MutableDataset | The same shared query/update interface, with boolean add/remove, contains, quads_for_pattern and explicit compact. |
Dataset | A mutable quad collection with iteration, length and in-place blank-node canonicalization. It is not the frozen RdfDataset. |
RdfDataset | An immutable parsed RDF 1.2 dataset with counts and canonical/text/GTS/columnar export. |
QuadIter | The 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.
| Service | Question |
|---|---|
consistency | Whether the knowledge base has a model; false and unknown are distinct. |
classify | Named-class equivalence, transitive subsumption, direct edges and unsatisfiable classes. |
realize | Entailed individual types and most-specific direct types. |
instances | Named instances of one class, supplied as an N-Triples term. |
entails | Whether one OWL RDF-mapping axiom is entailed; different from graph_entails. |
profile | Syntactic OWL profile certification and blockers, without decision search. |
extract_module | A locality module for a seed signature under bot, top or star. |
justify | A sufficient minimal axiom subset, with sufficiency/minimality re-decided. |
explain_conclusion | A 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.
| Kind | Tuple |
|---|---|
| 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 compiledWebAssembly.Module.DataFactory—namedNode,blankNode,literal(value, languageOrDatatype?),typedLiteral,directionalLiteral,variable,defaultGraph,quad,quotedTriple,fromTerm,fromQuad.Dataset(RDF/JSDatasetCore) —Dataset.parse(input, format, base?),serialize(format),add/delete/has/match/quads/size, and iteration (for (const quad of dataset)). Formats:turtle,ntriples,nquads,trig,rdfxml(or their media types);serializeadditionally acceptsjsonld.- Graph identity —
Dataset.canonicalize()returns the RDFC-1.0 canonical, flat N-Quads for the graph;Dataset.isomorphic(other)decides RDF graph equality under blank-node relabeling (an exact oracle backed by full RDFC-1.0 canonicalization). - Graph/tabular/research-object carriers —
Dataset.project(profile, configJson)returns canonical USTAR bytes and loss-ledger JSON;Dataset.projectWithAssets("ro-crate-1.3", configJson, payloadArchive)adds bounded attached RO-Crate payloads;liftProjection(...)reconstructs RDF for the bidirectional profiles. See Graph, Tabular & Research-Object Projections. - SPARQL —
QueryEnginekeeps the native plan cache alive across calls and exposes typedselect/ask/construct/describe, atomicupdate, andqueryRawserialization.Dataset.query(...)remains the compatibility raw-string helper. Each evaluating method has a Promise-returning twin that takes host handlers forSERVICEandLOAD— 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-AFsh:ruleinferences and returns aShaclEntailment:ntriplesholds the result as N-Triples, anddiagnosticsholds the shapes graph’s mandatory diagnostics. Callfree()on it. Sink— a streaming consumer (push(quad)/finish() → Dataset);datasetToStream/streamToDatasetare the async RDF/JS Stream/Sink helpers.
More on the RDF/JS mapping in RDF/JS in JavaScript.
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
yieldEveryPollsgovernor polls (65 536 by default;0yields at every poll). Every turn is onesetTimeout(…, 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, andevidence.asyncreports what each phase cost. signal: AbortSignalcancels a job at its next yield or host effect. On the governed twins,deadlineMsincludes 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.stackHighWaterBytesreports 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
SERVICEorLOADsource, so there a remoteSERVICEorLOADfails explicitly unless writtenSILENT; the asynchronous twins reach remote endpoints only through the handlers the host passes them. - Triple terms per format.
serializeis the writer-native lane: an object-position quoted-triple term and the RDF 1.2 statement layer survive Turtle, N-Triples, N-Quads and TriG (as<<( … )>>), RDF/XML (asrdf:parseType="Triple"), and JSON-LD / YAML-LD (as@triple). TriX and HexTuples have no triple-term surface, so serializing a dataset that carries one to either of them throws rather than dropping the layer silently. Single-graph targets (Turtle, N-Triples, RDF/XML) emit the default graph alone.
Building from source
The Rust cdylib lives in
crates/rdf-wasm;
the published ESM package is generated from it:
make wasm-pkg # release wasm + wasm-bindgen ESM bindings → js/pkg/
make wasm-pkg-test # the above + TypeScript, Node, and packed-tarball gates
This requires the wasm32-unknown-unknown Rust target and a
wasm-bindgen-cli pinned to the crate’s wasm-bindgen version.
Getting Started: C
libpurrdf is a stable, SemVer-disciplined extern "C" surface over the
native PurRDF stack: parse, serialize, pattern iteration, copy-on-write
mutation, SPARQL, SHACL validation/entailment, and GTS container round-trips.
The committed, reproducible header
include/purrdf.h
is the ABI contract — CI fails if it drifts from the crate.
It is one shared library: libpurrdf statically reuses the purrdf-gts Rust
crate, so a language shim links libpurrdf alone and still reads/writes
.gts containers.
Building
The library, header, and pkg-config file are produced by
cargo-c:
make capi-build # cargo capi build: libpurrdf.{so,a} + purrdf.h + purrdf.pc
make capi-install PREFIX=/usr # cargo capi install into a prefix
make capi-check # verify the committed header is current + run the C smoke
A first program
Adapted from the repository’s C smoke test
(crates/rdf-capi/tests/smoke.c):
#include "purrdf.h"
#include <stdio.h>
#include <string.h>
int main(void) {
const char *doc = "<http://a> <http://b> <http://c> .";
PurrdfDataset *dataset = NULL;
PurrdfError *error = NULL;
int rc = purrdf_parse((const uint8_t *)doc, strlen(doc), "text/turtle",
NULL, NULL, &dataset, &error);
if (rc != PURRDF_STATUS_OK) return 1;
size_t quad_count = 0;
purrdf_dataset_quad_count(dataset, &quad_count);
printf("%zu quad(s)\n", quad_count);
/* Iterate every quad through a pattern cursor. */
PurrdfGraphMatch any;
memset(&any, 0, sizeof(any));
any.kind = PURRDF_GRAPH_MATCH_KIND_ANY;
PurrdfCursor *cursor = NULL;
purrdf_quads_for_pattern(dataset, NULL, NULL, NULL, &any, &cursor, &error);
PurrdfTermView s, p, o, g;
uint8_t has_graph = 0;
while (purrdf_cursor_next(cursor, &s, &p, &o, &g, &has_graph) == PURRDF_STATUS_OK) {
printf("subject=%.*s\n", (int)s.lexical.len, (const char *)s.lexical.ptr);
}
purrdf_cursor_free(cursor);
purrdf_dataset_free(dataset);
return 0;
}
purrdf_project and purrdf_lift add deterministic graph/tabular/research-object carrier
archives to this same handle model. Project returns independent archive and
loss-ledger buffers; lift returns a dataset plus a ledger buffer. The compiled
purrdf_project_with_assets entry point accepts a bounded payload-only USTAR and
emits an attached RO-Crate with deterministic metadata and preview. The compiled
crates/rdf-capi/examples/projection_roundtrip.c example demonstrates the full
ownership/free order. Profiles and configuration are described in
Graph, Tabular & Research-Object Projections.
The ABI contract
- No unwinding across the boundary. Fallible entry points use
catch_unwind; a caught unwinding panic becomesPURRDF_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_tstatus + out-params. Fallible functions return aPurrdfStatusvalue and write results through out-pointers.PURRDF_STATUS_CURSOR_EXHAUSTEDis the (non-error) end-of-rows signal.- SemVer-frozen ABI. The status enum is append-only; new fields and
functions are additive.
purrdf_abi_versionreports the current ABI version at runtime; thePURRDF_ABI_MAJOR/_MINOR/_PATCHmacros give the same triple for the header you compiled against, and comparing the two is how you check a library you did not build. The minor number tracks the exported signatures: any change to a parameter list, a return contract, or the documented behaviour of an exported symbol bumps it, additive parameters included, because an additive parameter is still a recompile for every C consumer.
Ownership and lifetimes
- Every handle/buffer/error/cursor has exactly one matching
*_free(purrdf_dataset_free,purrdf_graph_free,purrdf_cursor_free,purrdf_rowcursor_free,purrdf_buffer_free,purrdf_error_free). FreeingNULLis a no-op. - The C side never
free()s aPurrdfStr.ptr— it borrows library-owned memory; copy the bytes out if they must outlive the borrow. Term views frompurrdf_cursor_nextare valid until the nextpurrdf_cursor_nexton that cursor orpurrdf_cursor_free. - Pattern cursors pin the dataset and pull rows lazily from the selected core index; opening a cursor does not allocate a matching-row snapshot.
PurrdfDatasetis frozen andSend + Sync— readable concurrently from many threads.PurrdfGraph(the copy-on-write mutable delta) and cursors are single-threaded.
GTS star-layer round-trip
The GTS star layer round-trip (purrdf_to_gts → purrdf_from_gts of a
dataset containing quoted triples / reifier bindings) succeeds with
PURRDF_STATUS_OK, the same as a star-free round-trip. The C ABI calls the
canonical kernel path (to_gts → read_graph → import_gts_graph); a
characterization test,
gts_star_roundtrip_preserves_the_statement_layer in
crates/rdf-capi/tests/abi.rs, pins the restored dataset’s quoted triple and
reifier binding so a regression in either layer fails there.
Full contract details — status codes, term crossing representations,
thread-safety per handle — are in the
purrdf-capi README.
The Interned Dataset IR
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 ispurrdf-validate). - Canonicalization (RDFC-1.0 on the RDF 1.1 subset; the
purrdf-rdfc12profile over reifiers and annotations), dataset diff, and isomorphism — see Canonicalization & Diff. - Store and engine seams — the narrow parser-ingress, serializer-egress,
and
SparqlEnginetraits that adapters implement in sibling crates. - Provenance and the loss ledger — a generic provenance sidecar and the machine-readable RDF↔GTS loss matrix, plus native FnO and SSSOM codecs (see Slices, Mappings & Provenance).
Text codecs are not in the kernel — parsing and serialization live one layer
up in purrdf-rdf. The split keeps the kernel
small and its invariants enforceable at the crate boundary: no 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-algebraand evaluated natively — the W3C SPARQL 1.2 triple-term surface passes in the conformance harness (Conformance & Testing). - In JavaScript,
DataFactory.quotedTriple(...)produces the same term (RDF/JS in JavaScript).
Reifiers and annotations
RDF 1.2 replaces old-style reification with reifiers: terms that name an
occurrence of a triple (rdf:reifies), so you can attach metadata to a
statement without asserting anything odd. In PurRDF, reifier bindings and
annotations live in dedicated side-tables on the dataset rather than being
smeared into the quad table.
Reifier bindings and annotations survive every star-capable codec round-trip;
projections into star-incapable formats drop them loudly, with the realized
count handed to the loss ledger (see
Slices, Mappings & Provenance). SHACL support for validating
reified statements — the 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
| Feature | IR | Codecs | SPARQL | SHACL | RDF/JS | GTS |
|---|---|---|---|---|---|---|
| Triple terms (object position) | interned term | star-capable formats | <<( s p o )>> | via paths/values | quotedTriple | mapped per spec |
| Reifiers / annotations | side-tables | star-capable formats | reifier surface | sh:reifierShape, sh:reificationRequired | — | rdf:reifies mapping |
| Base-direction literals | literal kind | round-trips | matched/produced | value nodes | directionalLiteral | carried |
The GTS mapping of triple terms and rdf:reifies is formalized in the
GTS specification,
which pins its RDF 1.2 substrate to the 07 April 2026 W3C Candidate
Recommendation Snapshot.
RDF 1.2 Visualization
PurRDF projects RDF into a renderer-neutral statement model before producing a layout or SVG. The projection keeps structural triple terms, assertion occurrences, reifier identity, annotations, graph context, nesting, and RDF dialect diagnostics distinct. The SVG carries that model as embedded JSON metadata, so it is both a visual document and a lossless machine-readable export.
The examples below are generated directly by the Rust visualization surface. Line colour varies subtly within each semantic relation class to make dense routes easier to follow; line width, dash pattern, labels, and arrow grammar continue to carry the RDF meaning without relying on colour.
Ordinary shared resources
The compact view preserves the familiar RDF resource graph while separating routes that share nodes.
Asserted, reified, and annotated statements
Solid arrows remain assertions. Addressable statement anchors connect those assertions to reifier resources, whose ordinary RDF properties carry provenance, confidence, timestamps, and directional language literals.
Quoted-only and nested triple terms
A quoted-only triple is a bounded statement glyph, never a solid assertion arrow. Reifier resources stay distinct from the structural statement they reify.
Nested triple terms retain recursive statement identity. Dialect badges make symmetric or generalized RDF positions explicit rather than silently rendering them as ordinary RDF 1.2.
Dense connected data
The exact view uses separated vertical channels and rounded orthogonal turns so individual subject, predicate, object, reification, and annotation routes remain traceable through a dense connected dataset.
Regenerating the samples
The committed SVGs are projections of the Rust fixtures, not hand-edited book artwork:
make book-samples
make book
make check regenerates the same artifacts in a temporary directory and rejects
any drift between the renderer and the book.
Graph, Tabular, Dataset-Description & Research-Object Projections
PurRDF projects an RDF 1.2 dataset into graph, tabular, dataset-description, and research-object formats without making any of those formats the semantic authority. One Rust engine implements the mapping, packages its artifacts as canonical USTAR, and is exposed unchanged through Rust, the CLI, Python, WebAssembly, and C.
Every operation has four non-negotiable properties:
- configuration supplies every vocabulary role, identity IRI, processing policy, and resource limit; the library fabricates none;
- output bytes are deterministic for the same dataset and configuration;
- loss is always computed as a closed, located ledger, even when a host chooses not to display it;
- malformed, ambiguous, non-canonical, or out-of-bounds input is a hard error.
Profiles
| Profile | Native artifacts | Direction | RDF fidelity |
|---|---|---|---|
lpg-csv | generic nodes/edges CSV | RDF ↔ carrier | exact RDF sideband; property-graph lowering ledgered |
neo4j-csv | Neo4j Admin Import CSV | RDF ↔ carrier | same canonical LPG authority and ledger |
open-cypher | deterministic CREATE program | RDF ↔ carrier | strict reader accepts exactly the emitted grammar |
graphml | GraphML 1.0 XML | RDF ↔ carrier | exact RDF sideband; strict namespace/key validation |
csvw-exact | CSVW metadata plus RDF 1.2 tables | RDF ↔ carrier | lossless |
csvw-terms | CSVW metadata plus caller-declared entity tables | RDF → view | located, closed loss ledger |
okf-terms | OKF v0.1 concept documents and indexes | RDF → view | located, closed loss ledger |
obo-graphs | OBO Graphs 0.3.2 JSON | RDF → view | located, closed loss ledger |
skos | SKOS Turtle | RDF → view | located, closed loss ledger |
croissant-1.1 | croissant.json | RDF ↔ carrier | shared model; profile loss is located |
ro-crate-1.3 | ro-crate-metadata.json | RDF ↔ carrier | shared model; profile loss is located |
datacite-4.6 | datacite.xml | RDF ↔ carrier | shared model; profile loss is located |
dcat-3 | dcat.jsonld | RDF ↔ carrier | shared model; profile loss is located |
dcat-rdf | dcat.<native extension> | RDF → view | mapped or caller-CONSTRUCTed; blank-free RDF |
void | void.<native extension> | RDF → view | selected-graph statistics, partitions, and linksets |
frictionless-data-package-1 | datapackage.json | RDF ↔ carrier | shared model; profile loss is located |
The type distinction between ProjectionProfile and LiftProfile matters:
curated CSVW/OKF terms, OBO Graphs, SKOS, native DCAT RDF, and VoID cannot even
be named as lift profiles. They are useful views, not pretend interchange
formats.
One canonical LPG model
The four labeled-property-graph syntaxes are adapters over one typed LPG model, not independent RDF mappings. Nodes, edges, labels, typed property atoms, graph context, reifiers, annotations, and exact RDF statements are ordered and validated once. This gives all four carriers the same identity and reverse mapping.
Property graphs do not define RDF semantics. PurRDF therefore records each semantic lowering in the RDF-to-LPG ledger even though the canonical package also retains exact RDF sideband for reconstruction. A carrier consumer may use the native LPG view; the sideband remains the authority for an RDF lift.
Readers accept the complete form PurRDF emits and reject drift: wrong headers, duplicate rows or keys, dangling endpoints, token-map inconsistencies, unknown Cypher statements, unsafe XML, unexpected package members, and non-canonical encodings all fail.
LPG scope, limits, progress, and memory
Every LPG configuration contains a mandatory scope. {"mode":"all"} is the
only way to request every graph and predicate; omission never means “all.” A
selective scope independently controls the default graph, exact named-graph
terms, predicate allow/deny sets, node RDF types, and native edge predicates.
Named blank graphs retain their scope ordinal, and every selector IRI must be
absolute.
Selection is one closed RDF 1.2 operation. Node types are indexed from
graph-selected rdf_type statements even when that predicate is omitted from
the output. A retained edge retains its endpoints. Reifiers and annotations are
retained only when their source statement survives, and annotation predicates
obey the same predicate selector. These rules prevent a selected property graph
from containing dangling sideband.
LpgExecutionLimits independently bounds input records scanned, model records,
nodes, and edges. ProjectionLimits bounds artifact count, one-artifact bytes,
total artifact-body bytes, canonical USTAR bytes, and recursive RDF-term depth.
Each bound is consumed before the corresponding model mutation or sink write;
the first excess is a typed hard error. The engine does not paginate. Splitting
one canonically ordered, exactly reversible carrier would make page identity and
cross-page endpoints order-sensitive; callers instead choose a narrower scope
or a larger explicit bound.
The direct sink path retains the selected canonical LPG model because stable backend-independent ordering, type selection, endpoint closure, and exact RDF sideband require that bounded sort/index. It then emits each artifact in lexical path order in chunks no larger than 16 KiB, retaining neither complete artifact bodies nor a USTAR buffer. The archive convenience path additionally retains materialized artifacts and the final archive. Thus the sink path is bounded by the selected model plus encoder scratch and one chunk; it is not a constant-memory RDF-to-LPG mapper.
Progress observers receive monotonic scanning, building, writing,
complete, or aborted snapshots with input/model/node/edge, finished-artifact,
body-byte, and active-path counters. A sink or observer failure aborts the active
transaction. A sink must stage partial state, publish only on commit_package,
and discard it on abort_package.
CSVW
The lower-level CSVW API models annotated table groups, schemas, dialects, columns, rows, inherited properties, datatypes and formats, language and text direction, null/default/separator handling, virtual or suppressed columns, primary and foreign keys, row titles, annotations, and URI templates. It supports the standard CSVW CSV-to-RDF processing modes over a complete in-memory package; filesystem and network discovery remain caller responsibilities.
The csvw-exact archive profile uses that machinery to carry RDF 1.2 without
loss. Its canonical tables preserve terms, quads, named graph placement,
recursive triple terms, reifier bindings, annotations, datatypes, language,
direction, and blank-node scope. A valid exact round trip has an empty ledger.
The separate csvw-terms profile is a deliberately curated wide-table view.
It is for compact catalogs such as classes, properties, individuals, business
entities, or release inventories. Those names have no built-in meaning: every
table, selector, predicate, datatype, and identity rule is supplied by the
caller. The profile never imports an ontology model or assumes RDF, RDFS, OWL,
or any application vocabulary.
A CsvwTermsConfig contains only mandatory policy:
csvwis the complete normative CSVW context, vocabulary, processing mode, record bound, and package limits;metadata_pathis the safe package-relative metadata member;graph_selectionis either explicitallor an exact default-graph flag and set of named-graph IRIs;- each ordered table declaration supplies a stable name, absolute table URL, artifact path, row selector, visible subject-IRI column, and one or more ordered predicate columns;
- a selector may constrain any/all/none RDF types through an explicitly named type predicate and may constrain subject IRI prefixes; the type predicate is present exactly when a type set is non-empty, while an empty prefix set means no subject-namespace constraint;
- a predicate column accepts either exact IRI objects or literals with exact datatype, language, and RDF 1.2 direction facets, plus requiredness and an explicit one-or-many cardinality;
execution_limitsbounds total output rows, represented values, and values in one cell independently of the package and input-record bounds.
Table overlap is intentional. A subject may appear in several views when it
matches several selectors. Duplicate table identities, paths, column names, or
mapped predicates are rejected. One fails on a second matching value.
Many sorts RDF terms canonically and joins them with the caller’s separator;
an actual value containing that separator fails instead of creating an
ambiguous cell. RDF direct-value statements do not carry a source sequence, so
the profile does not fabricate CSVW ordered-list semantics.
Rows are ordered by subject IRI, values by canonical RDF term order, columns and tables by their declaration order, and archive members by lexical path. The same dataset and configuration therefore produce the same CSV, metadata, and USTAR bytes regardless of source interning or statement insertion order.
Every source row is accounted for. Unselected graphs or subjects, blank or
triple-term subjects, unmapped predicates, facet-mismatched objects, selected
named-graph placement, empty named graphs, reifier bindings, and annotations
produce stable source-located ledger entries. The generated tables themselves
can be read with the normative read_csvw API, but they cannot reconstruct
omitted source RDF. RDF 1.2 direction remains exact in the annotated CSVW value
and textDirection metadata; the W3C CSVW-to-RDF algorithm itself targets RDF
1.1 and therefore returns the language literal without direction. csvw-terms
consequently has no LiftProfile variant and contains no hidden exact sideband;
use csvw-exact whenever archival fidelity or an RDF round trip is required.
Research-object carriers
Croissant 1.1, RO-Crate 1.3, DataCite Metadata Schema 4.6, DCAT 3, and
Frictionless Data Package v1 are adapters over one typed ResearchObjectModel.
The model covers dataset identity and description, identifiers and dates,
agents, licenses, resources and checksums, activities, record sets, and fields.
It is the N-to-N semantic pivot: a document can be lifted to caller-vocabulary
RDF and projected into any other profile without a format-pair implementation.
The three JSON-LD profiles are completely offline. Croissant, RO-Crate, and
DCAT configuration supplies both the exact accepted/emitted @context JSON and
the complete term-to-absolute-IRI definition map. PurRDF never dereferences a
context URL, follows @import, or supplies a vocabulary. Expanded graphs are
validated through the same native RDF 1.2 JSON-LD engine used elsewhere.
RO-Crate also requires an explicit packaging value. metadata-only is the
single-descriptor form. attached accepts a bounded RoCrateAssets payload
carrier and produces ro-crate-metadata.json, a self-contained deterministic
ro-crate-preview.html, and the referenced payload members. The engine owns no
filesystem access: Rust receives RoCrateAssets, the CLI receives a canonical
payload-only USTAR via --assets, and the language bindings receive the same
archive bytes. Local File identities and payload paths must match one-to-one;
missing/extra assets, reserved paths, duplicate ownership, declared-size drift,
or a nonstandard attached crate root are integrity errors. The reader validates
the same contract and exact preview rendering. Preview files are not added to
hasPart; only payload File entities are root data entities.
DataCite configuration supplies the namespace, schema location, XML-Schema-instance IRI, controlled values, and common RDF roles. Its reader is namespace-aware and rejects DTD/entity input. A separate identifier is used when present; otherwise the mandatory caller/document dataset identity is the primary identifier, so no DOI is synthesized. Frictionless configuration supplies the exact package profile and package name. A resource without a separate locator uses its caller-bounded relative entity identity as the safe Data Package path; no new IRI is created.
Each native reader accepts the complete profile form emitted by PurRDF and rejects duplicate members, dangling references, unsafe relative paths, incorrect context/profile identity, ambiguous cardinality, and resource-limit excesses. Format-specific constructs outside the shared model are represented by stable, location-bearing ledger entries. The committed adversarial fixtures exercise a non-empty reverse ledger for every profile; a 5×5 metamorphic matrix proves the shared semantic intersection stabilizes through every source/target pair.
Native RDF dataset descriptions
dcat-3 remains the bidirectional JSON-LD research-object carrier described
above. The separate dcat-rdf profile emits a blank-free default graph in any
registered native RDF syntax. Its tagged source is mandatory and has two
explicit modes:
mappedreuses the caller-configured research-object interpretation, then emits direct RDF IR with the caller’s complete DCAT context/role map plus explicitrdf:typeand XSD-string IRIs. The semantic lowering ledger from the shared model is preserved.constructtreats one caller-supplied SPARQL CONSTRUCT as the complete view. Query bytes, input records, output records, term depth, artifacts, bodies, and archive bytes are all bounded. The query is parsed at configuration time, runs over the complete dataset including RDF 1.2 statement layers, and must produce a graph.
Both modes use the same deterministic native serializer. The selected syntax
controls the single dcat.<extension> member; configuration never infers a
vocabulary, query, syntax, or base IRI. Because the result is a description
view rather than an exact carrier, dcat-rdf has no lift profile.
The void profile generates a deterministic, blank-free dataset description
directly in caller-vocabulary RDF. Its configuration names the described
dataset, the base for stable generated resource IRIs, the source header subject
and predicates, three distinct header/alignment/metadata graph selectors, a
non-empty exact set of data graphs, all 22 target roles, local and external
dataset-prefix registries, optional metadata-link mappings and static dataset
statements, and every package/execution limit.
Statistics are computed only from the selected data graphs. triples counts
selected statements, entities and distinctSubjects count distinct subjects,
distinctObjects counts distinct objects, classes counts IRI objects of the
configured type predicate, and properties counts predicates. A class
partition includes every selected statement whose subject has that class; a
property partition includes statements with exactly that predicate. Partition
and linkset IRIs use full deterministic identifiers beneath the caller’s base.
Alignment rows must have IRI endpoints. Longest-prefix matching classifies each endpoint, equal-length ambiguity or no match is an error, and every alignment must have at least one local endpoint. Linksets retain source/object orientation and are grouped by subject dataset, object dataset, and predicate. The generator never guesses ownership, swaps direction, fetches metadata, or supplies a VoID/RDF/XSD namespace.
Complete portable example.org configurations and an executable TriG source
are under crates/rdf/tests/fixtures/dataset-description/. They run unchanged
through every host, for example:
purrdf project --profile dcat-rdf \
--config crates/rdf/tests/fixtures/dataset-description/dcat-rdf.json \
--from trig crates/rdf/tests/fixtures/dataset-description/void-source.trig dcat.tar
purrdf project --profile void \
--config crates/rdf/tests/fixtures/dataset-description/void.json \
--from trig crates/rdf/tests/fixtures/dataset-description/void-source.trig void.tar
OBO Graphs and SKOS views
The OBO Graphs writer emits version 0.3.2 nodes, edges and metadata plus directly representable equivalent-node sets, logical definitions, restrictions, domain/range axioms, and property chains. The caller supplies the graph identity and every RDF/RDFS/OWL/OBO role. Output is checked against the pinned official 0.3.2 JSON Schema.
The SKOS writer maps a caller-selected RDF graph into a caller-identified concept scheme. It supports concepts, labels, notation, documentation, hierarchy, mapping relations, membership, and top concepts, while enforcing the relevant SKOS integrity conditions. Target SKOS role IRIs are supplied just like source roles; PurRDF is a carrier and does not mint even standard vocabulary defaults.
Both views record every omitted or widened source construct, named-graph placement, and RDF 1.2 statement-layer row with a stable source location.
Configuration
The production archive API accepts strict tagged JSON of the form
{"profile":"…","config":{…}}. Unknown fields and a profile/config mismatch
fail. There is no default configuration. A minimal generic LPG example is:
{
"profile": "lpg-csv",
"config": {
"rdf_type": "https://example.org/type",
"scope": {"mode": "all"},
"limits": {
"max_artifacts": 16,
"max_artifact_bytes": 1000000,
"max_total_bytes": 4000000,
"max_archive_bytes": 5000000,
"max_term_depth": 16
},
"execution_limits": {
"max_input_records": 1000,
"max_model_records": 1000,
"max_nodes": 1000,
"max_edges": 1000
}
}
}
To retain one named graph while admitting every predicate and type, replace the scope object with:
{
"mode": "select",
"include_default_graph": false,
"named_graphs": {
"mode": "only",
"include": [{"kind": "iri", "value": "https://example.org/business"}],
"exclude": []
},
"predicates": {"mode": "all", "deny": []},
"node_types": {"mode": "all", "deny": []},
"edge_types": {"mode": "all", "deny": []}
}
The package and canonical-model bounds apply on write and read as relevant; the input-record bound governs RDF projection scans. Together they cover member count, one-member bytes, total body bytes, encoded archive bytes, input/model records, nodes, edges, and recursive RDF term depth. They are trust-boundary policy and must be chosen by the application.
Research-object configurations add a mandatory common object containing the
complete roles, identity, and bounded policy maps. Profile-specific
vocabulary roles, context data, schema identity, controlled values, and native
profile identity are also mandatory. Complete runnable example.org
configurations for all five profiles are under
crates/rdf/tests/fixtures/research-objects/carrier/; they are examples, never
library defaults.
The complete strict tagged-JSON shape for curated CSVW is exercised by
crates/rdf/tests/fixtures/csvw-terms.json. Its graph selector is explicit:
{
"kind": "include",
"default_graph": true,
"named_graphs": ["https://example.org/business"]
}
Each table selector then names its own caller vocabulary and row population:
{
"type_predicate": "https://example.org/type",
"any_types": ["https://example.org/Class"],
"all_types": [],
"none_types": ["https://example.org/Retired"],
"iri_prefixes": ["https://example.org/vocab/"]
}
The runnable csvw_terms Rust example constructs the complete configuration
with three ordinary table declarations—classes, properties, and individuals—
and writes the canonical archive:
cargo run -p purrdf-rdf --example csvw_terms -- /tmp/terms.tar
The separate okf-terms profile projects caller-classified RDF resources into
OKF v0.1 concept documents and navigation indexes. Configuration is the entire
projection algebra: graph scope, category selectors, safe path identity, fixed
standard-field roles, producer extensions, body/link sections, index prose,
the in-band fidelity declaration, and resource ceilings. No vocabulary or
category has library-owned meaning. Concept frontmatter uses the OKF standard
key order followed by lexical extension keys; reserved index.md files contain
navigation Markdown without concept frontmatter.
The complete strict portable configuration is
crates/rdf/tests/fixtures/okf-terms.json. It is accepted unchanged by Rust,
the CLI, Python, WebAssembly/TypeScript, and C:
purrdf project --profile okf-terms --config okf-terms.json \
--from trig ontology.trig knowledge.tar
The profile is deliberately absent from LiftProfile. Bundle-to-RDF import
remains the responsibility of the existing caller-profiled OKF codec; the
curated generator records every source row it does not carry instead of
claiming an inverse.
Rust archive API
#![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:
| Host | Materialized project | Attached RO-Crate | Direct LPG artifacts | Lift |
|---|---|---|---|---|
| Rust | project_archive | project_archive_with_assets | project_lpg_artifacts_to_sink | lift_archive |
| CLI | purrdf project | purrdf project --assets PAYLOADS.tar | — | purrdf lift |
| Python | purrdf.project(...) | purrdf.project(..., assets=archive) | purrdf.project_artifacts(...) | purrdf.lift(...) |
| JavaScript | dataset.project(...) | dataset.projectWithAssets(...) | — | liftProjection(...) |
| C | purrdf_project(...) | purrdf_project_with_assets(...) | — | purrdf_lift(...) |
Runnable examples live at:
crates/rdf/examples/projection_archive.rscrates/rdf/examples/csvw_terms.rscrates/rdf/examples/okf_terms.rscrates/rdf/examples/research_object_roundtrip.rscrates/rdf/examples/attached_ro_crate.rscrates/cli/examples/projection-roundtrip.shcrates/cli/examples/dataset-descriptions.shbindings/python/examples/projection_roundtrip.pybindings/python/examples/projection_stream.pycrates/rdf-wasm/js/examples/projection-roundtrip.mjscrates/rdf-capi/examples/projection_roundtrip.c
Determinism and verification
Archive members use safe POSIX-relative paths in lexical order. USTAR headers, metadata, checksums, padding, and trailer are fixed. A reader validates the archive and requires its canonical re-encoding to match the input bytes, so it does not silently normalize attacker-controlled alternatives.
The pinned W3C CSVW manifests exercise 270 RDF cases and 282 validation cases.
A locked independent csvw implementation validates production output and
rejects deliberate metadata/data corruption. The OBO writer is independently
validated against the pinned official schema with corruption probes. Run the
whole projection verification slice with:
make projection-oracles
cargo bench -p purrdf-rdf --bench projections -- --quick
The benchmark is report-only. It measures RDF-to-LPG mapping, scoped versus explicit-all mapping over a 20-graph carrier, materialized package versus direct sink output for every LPG syntax, every LPG read path, exact CSVW write/read, exact-versus-curated CSVW over the same 12,000-quad carrier, one-graph versus all-graph curated scope, OBO Graphs and SKOS projection, mapped and CONSTRUCT DCAT RDF, VoID generation, the shared research-object model, and all five research-object write/read paths. It also reports allocation and artifact-body-size observations over deterministic fixtures.
Codecs & Determinism
PurRDF ships first-party parsers and serializers — no wrapped third-party codec — for nine formats:
| Format | Media type | Star-capable |
|---|---|---|
| Turtle | text/turtle | yes |
| TriG | application/trig | yes |
| N-Triples | application/n-triples | yes |
| N-Quads | application/n-quads | yes |
| RDF/XML | application/rdf+xml | no |
| TriX | application/trix | no |
| HexTuples | application/x-hextuples | no |
| JSON-LD (star) | application/ld+json | yes |
| YAML-LD | application/ld+yaml | yes |
They live in purrdf-rdf, one layer above the
kernel, and are reachable through the umbrella crate:
#![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.
Related
- Canonicalization & Diff — when you need a canonical serialization rather than just a deterministic one.
- The Interned Dataset IR — what the text codecs parse
into, and the
DatasetViewread seam the pack codec implements alongsideRdfDataset.
Base IRIs & Relative References
An RDF document may spell an IRI relatively — <other.ttl>, <#me>, <> —
and the reader must turn it into an absolute IRI before it can be a term. That
turning needs a base IRI. PurRDF resolves every relative reference, in every
syntax and on every surface, through one layer: purrdf-iri.
This is a hard failure, and it used to be silent. A relative IRI with no base in scope is now an error. Documents that previously “worked” this way did not: they interned a relative string as if it were an IRI, and emitted N-Triples that no conformant parser accepts. If a document of yours starts failing with
iri-relative-no-base, it was already producing invalid RDF — give it a base (below) rather than working around the error.
Where the base comes from
The precedence chain is RFC 3986 §5.1’s, in its order. The first step that yields a base wins:
| Source | RFC 3986 | Example | |
|---|---|---|---|
| 1 | An in-document base directive | §5.1.1 | Turtle/TriG @base or BASE, SPARQL BASE, RDF/XML xml:base, JSON-LD/YAML-LD @context.@base |
| 2 | A base the caller supplied | §5.1.2 | the base argument to parse_dataset, the CLI’s --base |
| 3 | The document’s retrieval IRI | §5.1.3 | the file:// IRI of the file the CLI read |
| 4 | (none) — hard failure | §5.1.4 | iri-relative-no-base |
Step 1 nests: a @base inside a document rebinds relative to the base already
in force, so @base <sub/> under http://example.org/dir/ yields
http://example.org/dir/sub/.
Nothing is ever invented. There is no default base, no fabricated urn:
placeholder, and no fall back to the current working directory. Step 4 is a
real, specified outcome, not a gap.
Which surfaces have a retrieval IRI
Step 3 needs a retrieval IRI, and only a surface that opened the file itself has one. Three do. The derivation lives in exactly one of them, and the other two consume it rather than re-deriving one.
| Surface | Retrieval IRI? | Consequence |
|---|---|---|
purrdf-slice (slice tree, catalog, dependency fixes) | yes — derives it; the workspace’s one implementation of §5.1.3 | a relative IRI in an on-disk slice document resolves with no flags |
purrdf-shapes shape-union loader | yes — consumes purrdf-slice’s | each shape file parses under its own file:// IRI |
purrdf CLI, per input file | yes — consumes purrdf-slice’s | a relative IRI resolves with no flags |
purrdf CLI, stdin (-) | no | a relative IRI is iri-relative-no-base; pass --base |
Rust byte APIs (parse_dataset, purrdf-rdf, purrdf-iri) | no | pass a base, or the document must carry one |
| WebAssembly | no | as above |
| C ABI | no | as above |
| Python | no | as above |
Every surface that is handed bytes rather than a path is in the second group.
Bytes have no retrieval IRI, so §5.1.3 is vacuous there and §5.1.4 — the hard
failure — is the specified answer. This is deliberate rather than an omission: a
base invented from the local filesystem would differ per machine and leak local
paths into published RDF, which would break the byte determinism the whole toolkit
rests on. It is also why purrdf-iri and purrdf-rdf never touch the filesystem
at all — that is what keeps them wasm32-clean.
purrdf-slice derives the retrieval IRI from the canonicalized path,
translating Windows paths (including UNC hosts and the extended-length \\?\
prefix) into RFC 8089 form and percent-encoding each component. Its consumers
apply it only when nothing of higher precedence was given, and a path with no
usable file:// IRI is a hard error naming --base, never a silent fall back to
“no base”.
Two grammar families
Whether a base can help at all depends on the syntax, not on the base:
| Family | Syntaxes | Relative reference |
|---|---|---|
| Admits relative references | Turtle, TriG, RDF/XML, JSON-LD, YAML-LD, SPARQL | resolved against the base in force |
| Absolute-only by grammar | N-Triples, N-Quads, TriX, HexTuples | rejected — no base is ever applied |
The second family’s grammars have no base directive and no relative-IRI production. A relative reference there makes the document invalid for its own syntax, so PurRDF reports a different code and supplying a base will not rescue it: convert the source to Turtle or N-Triples-with-absolute-IRIs instead.
Absolute references are never touched. In both families an IRI a document
spelled absolutely is taken lexically verbatim — <http://a/bb/ccc/../d;p?q>
survives intact, with or without a base in scope. Putting it through resolution
anyway would apply RFC 3986 §5.2.4 dot-segment removal, which is §6.2.2.3
syntax-based normalization, forbidden by RDF Concepts §3.2. Identical document
bytes must denote one graph with one canonical digest.
Beyond documents: the IR boundary
Everything above is about reading a document, but a document is not the only
way a term reaches the store. You can also build a dataset directly — from
Python quad objects, from a GTS container, by reopening a .pack file, with
SPARQL INSERT DATA, or through a projection — and none of those pass a parser
that could resolve a base for you.
The absoluteness rule therefore does not live in the codecs. It lives at the
interned term table every one of those paths necessarily arrives at, so a
relative IRI is not merely rejected on the way in: it is unrepresentable. A
frozen dataset carrying one cannot be constructed, and the refusal reports the
same iri-relative-no-base code a parser would.
There is no base in scope at that boundary and there cannot be: a frozen dataset
is a set of resolved identities, with no document and no @base alongside it
to resolve against later. So a relative reference there is not a term awaiting
resolution — it is a term whose identity is unknowable. Resolve it against
whatever base your application means before it becomes a term.
When you see the error
At the mutation that introduced it. Store.add, MutableDataset.add, the RDF/JS
dataset.add, and purrdf_graph_insert all refuse a relative IRI at the call,
naming the offending term:
>>> store.add(Quad(NamedNode("rel"), p, o))
ValueError: iri-relative-no-base: relative IRI reference "rel" cannot be resolved:
no base IRI is in scope; add a base to the document (`@base`/`BASE` in
Turtle-family syntaxes, `xml:base` in RDF/XML) or pass a base IRI to the API
The refused quad does not land — the store is unchanged and still usable.
The check is repeated at every point where a working set becomes a dataset:
freezing a builder or a store’s pending edits, serializing, canonicalizing or
digesting the result, and reopening a .pack file (whose bytes may have been
written by another engine, an older version, or corrupted on disk). That
repetition is deliberate. The freeze-time check is the invariant — it is what
makes a relative IRI unrepresentable in the IR from any ingress, including ones
that do not go through a mutation call at all. The insert-time check is the
diagnosis: it exists so the error can name the line that caused it. Neither
subsumes the other.
One exception you may rely on
Blank node labels and literal lexical forms are arbitrary strings and are not touched by any of this. Only IRIs are IRIs.
The diagnostic codes
These codes are stable and machine-readable. Every codec, the CLI, the C ABI, Python and wasm report the same code for the same condition.
| Code | Condition | What to do |
|---|---|---|
iri-relative-no-base | a relative reference in a syntax that admits one, with no base in scope | add @base/BASE/xml:base/@context.@base to the document, or pass a base to the API (--base on the CLI) |
iri-not-absolute-by-grammar | a relative reference — including the empty reference <> — in N-Triples, N-Quads, TriX or HexTuples; or an RDF/XML element or attribute QName whose xmlns: namespace is itself relative | write the IRI in absolute form; a base cannot help — this position admits no relative reference |
iri-non-absolute-base | the base itself has no scheme (RFC 3986 §5.1 requires an absolute base) | supply a base with a scheme, e.g. http://example.org/dir/. A filesystem path is a relative reference, not a base IRI — the CLI rejects one at the argument boundary and suggests the file:// IRI you meant |
RDF/XML appears in both grammar families for a reason, and the row above is the
narrow half. Its rdf:about / rdf:resource / rdf:ID values are references
and do resolve against xml:base, which is why the table further up lists it as
admitting relative references. But an element or attribute name is composed from
an xmlns: declaration plus a local name; it is not a reference, so nothing
resolves it, and a relative xmlns:ex="rel/" composes to a relative IRI no base
may rescue:
$ purrdf convert data.rdf --from rdfxml --to ntriples
purrdf: error iri-not-absolute-by-grammar: invalid IRI from an XML qualified name:
relative IRI reference "rel/p" is not permitted by this syntax (the caller-supplied
base, <file:///tmp/data.rdf>, is in scope but is never applied here); write the IRI
in absolute form; this syntax admits no relative IRI reference, so supplying a base
will not help
Note that the message names the base that is in scope and says it is deliberately not applied there, so a caller who passed one is not sent hunting for a dropped parameter.
The message rendered for each already carries its remedy, so a consumer that prints the error alone still tells its user what to do.
Writing a base out
Resolution has a mirror on the serialize leg. A syntax that can express a document base emits one when a base is supplied, and relativizes its IRIs against it:
| Syntax | Reads a base | Writes a base |
|---|---|---|
| Turtle, TriG | @base / BASE | @base |
| RDF/XML | xml:base | xml:base |
| JSON-LD, YAML-LD | @context.@base | @context.@base |
| N-Triples, N-Quads, TriX, HexTuples | no | no — absolute IRIs only |
A format in the last row reaches its writer with no base and emits absolute
IRIs. That is not a silent drop: the format simply has no base surface, so
there is nothing to write, and the output stays valid for its grammar. On the
CLI there is one more step — if a --base was given and neither leg of the run
can spend it, the command is refused outright rather than accepting an inert flag.
See below.
--base on the command line
purrdf convert, query, update and the other RDF-producing subcommands take
--base <IRI>, and it acts on both legs:
-
Parsing — it is the caller-supplied base (§5.1.2), so it outranks the input’s retrieval IRI but not an in-document directive. A parse leg can spend the base only if the source syntax admits a relative reference.
-
Serializing — if the target syntax can write a base, it is emitted as the output document’s base and the IRIs are relativized against it. A serialize leg can spend the base only if the target syntax emits a base directive.
-
Neither leg can spend it — a usage error, exit 2. A base spent by any one leg is honoured, so
--from turtle --to ntriples --base …is fine (the parse leg spends it) and so is--from ntriples --to turtle --base …(the serialize leg does). But--from ntriples --to ntriples --base …has nowhere to put it. Rather than exit 0 having silently ignored the flag, the CLI names both legs and refuses:$ purrdf convert data.nt --from ntriples --to ntriples --base http://example.org/dir/ purrdf: --base has no effect on this run: on the source `data.nt`, ntriples's grammar admits no relative IRI reference, so nothing in the document resolves against a base; and on the --to target, ntriples can express no base directive, so nothing is written under one or relativized against it. Drop --base, or name a syntax that carries one (turtle, trig, rdfxml, jsonld, yamlld) $ echo $? 2The verdict is read off the format registry’s
admits_relative_iriandemits_basecolumns, so a newly registered syntax is classified by its own row rather than by a hand list. It applies toconvert,validate,reason,entails,consistencyandproject.query,update,shex’s shape map anddescribe --iriare deliberately exempt: each has a command-line-text IRI surface with no document of its own, so--baseis never inert there whatever the format rows say.
# A file input needs no flag: its own file:// retrieval IRI is the base.
purrdf convert data.ttl --to ntriples
# stdin has no retrieval IRI, so a relative IRI in it needs an explicit base.
cat data.ttl | purrdf convert - --from turtle --to ntriples \
--base http://example.org/dir/
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:
expandedpreserves the byte-frozen empty-@contextrepresentation;contextcompiles a caller prefix map or local JSON-LD 1.1 context once and applies normative compaction;deriveddeterministically assigns neutralns0,ns1, … aliases solely from absolute IRIs in the dataset. It never invents a vocabulary or infers@vocab.
An RDF dataset does not retain prefix declarations from Turtle, JSON-LD, or another source syntax. Supply them explicitly when those declarations are application policy. All configured surfaces consume the same closed version-1 JSON document and reject duplicate members, unknown fields, invalid contexts, network-only context references, cycles, and resource-limit excesses before emitting output.
{
"version": 1,
"mode": "context",
"prefixes": {
"ex": "https://example.org/",
"schema": "https://schema.org/"
},
"yaml_schema_url": "https://example.org/purrdf.schema.json"
}
Rust
#![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_nquadsinpurrdf-rdfrewrites the statement layer to plainrdf:reifies/ annotation triples first and canonicalizes that triple set under conformant RDFC-1.0. This is what the CLI’sconvert --canonical, the wasmDataset.canonicalize()and the W3C conformance gate run. - The
purrdf-rdfc12v2 profile —canonicalizeinpurrdf-corekeeps the statement layer and lowers it into a reservedurn:purrdf:rdfc:namespace instead (any input already carrying that namespace is refused, unless it is written in one of the two shapes the profile itself emits — those fold back into the statement layer, which is what makes canonicalization idempotent over its own output). It agrees with RDFC-1.0 byte for byte only on the RDF 1.1 subset, and a digest taken over its output must not be labelled RDFC-1.0;CANON_PROFILE_ID/CANON_PROFILE_VERSIONname the profile at runtime. The normative text isdocs/RDF12-CANON-PROFILE.md.
Isomorphism
datasets_isomorphic(a, b) decides whether two frozen datasets are
RDF-structurally isomorphic: the same quads, reifier bindings, and annotations
under a single blank-node bijection.
Canonicalization gives the equivalent verdict — two datasets are isomorphic
iff their canonicalizations are equal — but the direct check is the
convenient form for tests and harnesses. PurRDF’s own conformance harnesses
use RDFC-1.0 isomorphism to compare, for example, SHACL Rules output graphs
against expected inferred graphs.
Diff
dataset_diff(a, b) produces a structural diff between two frozen datasets,
including an isomorphic verdict. For a human-facing review flow,
purrdf-rdf additionally provides per-subject Symmetric-CBD extraction
(“describe”) and a review-friendly Turtle normalizer, so a graph change reads
like a code change.
Choosing the right tool
| Need | Use |
|---|---|
| Same dataset → same bytes | any native serializer (always true) |
| Same graph (up to blank nodes) → same bytes, RDF 1.1 subset | RDFC-1.0 (canonicalize or canonical_flat_nquads, identical here) |
| Same graph with reifiers/annotations → same bytes | canonical_flat_nquads (RDFC-1.0 over the flattened layer) or canonicalize (purrdf-rdfc12 profile) — not interchangeable |
| “Are these two datasets the same graph?” | datasets_isomorphic |
| “What changed between these datasets?” | dataset_diff + describe/normalize |
| Content-addressed transport of a graph | GTS (BLAKE3 content ids) |
API details are on
docs.rs/purrdf-core (the canonicalize,
datasets_isomorphic, and dataset_diff items) and
docs.rs/purrdf-rdf (describe and normalization).
SPARQL: Querying
PurRDF’s SPARQL stack is native and three-layered, gated by the W3C SPARQL 1.1 and 1.2 conformance suites:
purrdf-sparql-algebra— parses query and update text into a PurRDF-owned, RDF 1.2-native query algebra (Query/GraphPattern,Update/GraphUpdateOperation). Parse and algebra only.purrdf-sparql-eval— the multiset evaluator over the frozen IR’sDatasetView, entirely in internedTermIdspace.purrdf-sparql-results— the results boundary (next chapter).
All three are re-exported under purrdf::sparql.
A first query
#![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 theirFOLDaggregate andUNFOLDgraph pattern (see below). - Update —
INSERT DATA/DELETE DATA, theDELETE/INSERT … WHEREfamily (WITH/USING,DELETE WHERE),LOAD, andCLEAR/DROP/CREATE/ADD/MOVE/COPY. - Beyond the grammar — everything else arrives through the caller-keyed
extension seams described further down this page, never as new syntax:
path witnesses,
full-text search, GeoSPARQL and
embedding nearest neighbours are each a property function
(or, for the
geof:family, a scalar function) registered under an IRI the caller supplies.
Anything outside this surface — and every malformed query — is a typed
ParseError, never a silently degraded parse.
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
TermIdonce; solution comparison is an integer compare; computed FILTER/BIND values that already exist in the dataset are promoted to the interned id at mint time. - Property paths in-engine — the full path algebra
(
* + ? / | ^ !()) evaluated over the same indexed surface, wasm-safe. - Cost-based BGP planning — join order is chosen by a cost model;
NativeSparqlEngine::explain_queryexposes the chosen order as an ordered list of triple-pattern strings so you can audit planner decisions without running the query. EXISTS/NOT EXISTS, defensibly — one substitution-based definition (SEP-0007), answered either by a memoized existence probe or by the per-row definition itself, chosen per site by a prepare-time admissibility proof — see below.- The SERVICE seam —
SERVICEfederation is evaluated through a host-injectableServiceResolver: the engine itself performs no I/O, so federation stays wasm-portable and the host decides how (and whether) remote endpoints are reached — down to per-service headers, credentials and capabilities (see below). Where it stops: PurRDF ships no HTTP client (the exchange is anHttpTransporttrait the Rust host implements). The CLI, Python and C surfaces install no resolver, and neither do the wasm package’s synchronous methods, so aSERVICEorLOADthere fails by name unless writtenSILENT. 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 W3Cservicefederation cases pass through this seam. The forwarded body is re-emitted through the deterministic serializer — the federation wire format — whose parse → serialize → re-parse fidelity is swept over the 823-item corpus (every vendored W3C and first-party query and update text, plus this book’s own examples) with an empty exception ledger. - Governed twins and explain receipts — every query/update entry point has
a governed counterpart running under caller-set ceilings (fuel, answer rows,
intermediate cells, scratch bytes, remote requests, deadline) that trips
with certified rows rather than a wrong answer, and
explain_queryreturns aQueryExplanationwhose ledger decomposes the fuel spent per algebra node and per charge point, beside the cost planner’s estimate for each basic graph pattern. The normative charge schedule and the frozen 54-case governor corpus are documented indocs/SPARQL-GOVERNOR-PROFILE.md. - Hard-fail — an out-of-scope algebra node or unimplemented builtin is a
typed
EvalError::Unsupported, never a partial or wrong answer.
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:
| Datatype | Values |
|---|---|
xsd:integer, xsd:nonNegativeInteger, xsd:positiveInteger, xsd:nonPositiveInteger, xsd:negativeInteger | every integer of the type’s sign, of any length |
xsd:long, xsd:int, xsd:short, xsd:byte and the unsigned types | the type’s own range |
xsd:decimal | every 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 / 1048576is0.00000095367431640625. A repeating quotient is rounded to 18 fractional digits, half to even:2 / 3is0.666666666666666667. The caller chooses another precision withQueryOptions::with_divisionin Rust,--divisionin the command line, or the division setting of the C, WebAssembly and Python bindings. The setting is writtenexact,N(N digits, truncated toward zero),N:ROUNDING(for example5:half-even) orexact-or-N[:ROUNDING]. Underexactevery terminating quotient is exact. A non-terminating quotient, such as1 / 3, is an expression error (err:FOAR0002) and leaves the result unbound.AVGdivides under the same setting. - To
xsd:floatorxsd:double. The value is rounded once, to the nearest value, ties to even, from its exact value at any size. - From
xsd:floatorxsd:doubletoxsd:decimal. Every binary value is a finite decimal, so the cast is its exact value:xsd:decimal("0.1"^^xsd:double)is0.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
?pis already in scope, aBIND(… 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-SELECTthat does not project?pand anEXISTSbody included: no scope has a?pof its own. With?pbound to:a, an assigned:zleaves 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, andOPTIONAL { BIND(:z AS ?p) }keeps each left row unextended. Assigning:aitself keeps the rows, and an assignment whose expression errors leaves?pthe bound value. MINUSsees the bound value on both sides, whether or not a side mentions?p, so every right row shares?pwith every left row and subtracts it:?x :p ?o MINUS { ?s :q ?w }answers no row once:qhas 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 ash:SPARQLFunctionbody and ash:SPARQLTargetTypequery, refuse only aVALUESthat mentions a pre-bound name, as Appendix A words it:VALUES ?v { true }loads. - A
sh:sparqlconstraint, like a SPARQL-based component’s validators, ash:SPARQLTarget’ssh:askand a SPARQL rule, refuses everyVALUES. The vendored W3C SHACL caseunsupported-sparql-002requires the refusal ofVALUES ?any { true }, where?anyis 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.
| From | To | Result |
|---|---|---|
| float | double | the same value, exactly |
| double | float | the nearest float, ties to even (see below) |
| integer, decimal | float, double | the nearest value, rounded once |
| float, double | decimal | the exact value; NaN and INF are errors |
| float, double, decimal | integer types | the fractional part discarded, at any size; NaN, INF and values outside the target type are errors |
| boolean | numeric | 1 or 0 |
| numeric | boolean | false 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)is1.0E-40here, not zero.
Two values show where the rules differ:
| Expression | PurRDF (round to nearest) | F&O 3.1 text (truncation) |
|---|---|---|
xsd:float("1.0000000894069671630859375"^^xsd:double) | 1.0000001E0 | 1.0E0 |
xsd:float("3.4028235677973366e38"^^xsd:double) | INF | 3.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 / 2is42.0,CEIL(3.2)is4.0,SUMover4.2and37.8is42.0, andAVGof2and4is3.0. A decimal with a fraction is unchanged (2.5). - Casts and constructors,
xsd:integerresults andxsd:float/xsd:doubleresults keep the XSD 1.1 canonical text:xsd:decimal(3)is3,CEIL(3)over an integer is3. - Unary plus is the identity and returns its operand as written, so it keeps
the operand’s spelling: a stored
"4.0"^^xsd:decimalstays4.0, and+(1.5 + 2.5)stays4.0, because the computed operand was4.0. Unary minus computes a new number, so minus a decimal4is-4.0. STRreturns a literal’s lexical form unchanged, soSTR(1.5 + 2.5)is"4.0".xsd:string(...)and the other casts to string use the XPathfn:stringtext of the value, soxsd:string(1.5 + 2.5)is"4". UseSTRwhen the spelling of the stored or computed literal matters.SECONDSreturns the seconds field of its argument’s lexical form exactly as written, as the SPARQL text defines it:00for"2023-12-25T10:30:00"^^xsd:dateTime,05.500for"2023-12-25T10:30:05.500"^^xsd:dateTime,00.250for"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, andSECONDSreads that. The SPARQL 1.1seconds-01test expects"1","2","0","3"for the fields01,02,00,03and is graded by value; no spelling satisfies it term for term and also the SHACL node-expressionseconds-example, which expects"00".- The rule above covers the arithmetic in
purrdf-xsdand the SPARQL evaluator. Other computed decimals have their own spelling: the scores of the text-search and retrieval extensions (purrdf-text,purrdf-retrieval) arexsd:decimalliterals 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:
| Operands | Result |
|---|---|
date - date | dayTimeDuration |
date + yearMonthDuration | date |
date - yearMonthDuration | date |
date + dayTimeDuration | date |
date - dayTimeDuration | date |
time - time | dayTimeDuration |
time + dayTimeDuration | time |
time - dayTimeDuration | time |
dateTime - dateTime | dayTimeDuration |
dateTime + yearMonthDuration | dateTime |
dateTime - yearMonthDuration | dateTime |
dateTime + dayTimeDuration | dateTime |
dateTime - dayTimeDuration | dateTime |
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?diff WHERE {
BIND("2002-01-02T10:00:00"^^xsd:dateTime - "2001-01-01T10:00:00"^^xsd:dateTime AS ?diff)
}
Beyond this table, purrdf also accepts the general xsd:duration on every
row above (SEP-0002 lists xsd:duration among the operand types the operator
table covers, and the general type’s own value space subsumes both subtypes),
duration ±
duration, duration ×/÷ an exact number, and Gregorian ± duration for all
five Gregorian types where the result does not require fabricating an absent
field (see Divergence below):
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?next WHERE {
BIND("2012-10"^^xsd:gYearMonth + "P1Y1M"^^xsd:yearMonthDuration AS ?next)
}
Result datatype
A +/- between two durations 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/AVGover durations. SPARQL 1.1 §18.5.1.3 definesSUMby repeatedop:numeric-add, whose domain is the numeric tower only;purrdfextends the same fold to the duration group, which is exact and associative under componentwise addition.PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> SELECT (SUM(?d) AS ?total) WHERE { VALUES ?d { "P1M"^^xsd:yearMonthDuration "P2M"^^xsd:yearMonthDuration } }?totalis"P3M"^^xsd:yearMonthDuration. A group mixing numeric and duration values stays unbound — the extension does not widenSUM’s existing numeric-only acceptance, it only adds a second, disjoint one. -
Unary minus on durations. F&O’s unary minus (§4.2.8) is numeric-only and defines no duration form.
purrdfnegates a duration’s two components together, so-(?duration)never produces the mixed-sign value the type cannot represent. Unary plus deliberately stays numeric-only, so+(?duration)is a type error while-(?duration)is not:PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> SELECT ?neg WHERE { BIND(-("P1Y2M"^^xsd:yearMonthDuration) AS ?neg) }?negis"-P1Y2M"^^xsd:yearMonthDuration. -
duration ÷ durationby value commensurability. F&O defines only the two same-subtype forms (op:divide-yearMonthDuration-by-yearMonthDuration,op:divide-dayTimeDuration-by-dayTimeDuration).purrdfalso accepts the generalxsd:duration, dispatching on whether the two operands’ values are commensurable (both purely months, or both purely seconds) rather than on their declared tags, so adayTimeDurationand a generalxsd:durationthat happens to be purely day-shaped still divide:PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> SELECT ?ratio WHERE { BIND("P30D"^^xsd:duration / "P1D"^^xsd:dayTimeDuration AS ?ratio) }?ratiois30, typedxsd:decimal. Two operands whose values are not commensurable (a purely-months value against a purely-seconds one, even under matching declared tags) are a typed error, not an arbitrary answer.
ADJUST
ADJUST(value, timezone) shifts an xsd:dateTime/xsd:date/xsd:time value to a
given timezone offset, or attaches one to an untimezoned value. The SPARQL 1.2
Query specification’s own text carries no ADJUST section; the function’s one
documented definition is SEP-0002’s two-argument signature, which maps onto
XPath and XQuery Functions and Operators §9.6’s fn:adjust-*-to-timezone
family (the same table purrdf-xsd implements for every other SPARQL 1.2
temporal builtin).
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?adjusted WHERE {
BIND(ADJUST("2011-01-10T14:45:13Z"^^xsd:dateTime,
"-PT05H"^^xsd:dayTimeDuration) AS ?adjusted)
}
timezone is an xsd:dayTimeDuration in [-PT14H, PT14H] (whole minutes only),
or the empty simple literal "" — SPARQL’s stand-in for XPath’s empty-sequence
“remove the timezone” argument, since SPARQL itself has no empty sequence; this is
the same resolution every other known SEP-0002 implementation (e.g. Apache Jena’s
E_AdjustToTimezone) reaches. Arity is fixed at two and enforced at parse time —
ADJUST(?x) and ADJUST(?x, ?tz, ?extra) are both refused before evaluation.
Every domain or type violation (a non-whole-minute offset, an out-of-range offset,
a non-temporal first argument) is a SPARQL type error, which — inside BIND,
FILTER, or an aggregate argument — poisons to unbound rather than aborting the
query, per the engine’s ordinary type-error discipline.
LATERAL (SEP-0006)
The SPARQL 1.2 Query specification’s own text carries no LATERAL production;
the one documented definition is
SEP-0006’s,
implemented in Apache Jena 4.7.0, which this section follows. LATERAL adds one
production to GroupGraphPatternSub, positioned alongside OPTIONAL/MINUS/
GRAPH and left-associative the same way:
GroupGraphPatternSub ::= ... | 'LATERAL' GroupGraphPattern
Unlike an ordinary join, LATERAL’s right-hand side is evaluated ONCE PER
SOLUTION of its left-hand side, with that solution’s bindings visible inside —
the same relationship a SQL LATERAL/CROSS APPLY subquery has to its outer
query. This makes a per-group “top N” query expressible in plain SPARQL: each
subject on the left picks its own smallest label on the right, rather than one
globally-smallest label being computed once and joined against every row.
PREFIX : <https://example.org/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT * WHERE {
?s :p ?o
LATERAL {
SELECT * WHERE { ?s rdfs:label ?label }
ORDER BY ?label LIMIT 1
}
}
Correlation carries a left-hand binding of ANY RDF 1.2 term kind — including a
blank node or a quoted triple — into the right-hand side, even when the only
occurrence of that variable there is inside an expression (a FILTER, a
BIND, a BOUND(?s) call) rather than as a triple-pattern leaf: BOUND(?s)
answers correctly for a left-bound blank node or quoted triple the same as it
does for an IRI or a literal, and such a row is not silently dropped for
lacking a leaf occurrence to carry it.
The scope restriction
LATERAL’s right-hand side may freely REUSE a variable already bound on the
left (that is the whole point — it is how correlation happens), but it may not
INTRODUCE a fresh binding for one: no variable target of a BIND, of a
sub-SELECT’s (expr AS ?v) projection, of a GROUP BY aggregate’s output,
of an expression-valued GROUP BY (expr AS ?v) grouping condition, or a
VALUES column, at the right-hand side’s own scope level, may collide with a
variable already visible on the left. A bare GROUP BY ?v grouping key that
just names an already-bound variable is a USE, not an introduction, and never
collides. The one construct that opens a fresh scope level is a sub-SELECT’s
own projection — OPTIONAL/UNION/GRAPH/a nested group/a nested LATERAL
are all transparent to it. The SEP’s own legal and illegal pair:
PREFIX : <https://example.org/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
# Legal: the sub-SELECT projects only ?label, so its own (unprojected) reuse
# of ?s is correlation, not an introduction, and cannot collide.
SELECT * WHERE {
?s a :T
LATERAL { SELECT ?label WHERE { ?s rdfs:label ?label } LIMIT 1 }
}
# Illegal — refused with a typed ParseError naming ?o: ?o is already bound by
# the left-hand triple pattern, and BIND tries to give it a NEW value at the
# LATERAL right-hand side's own scope level.
SELECT * WHERE {
?s ?p ?o
LATERAL { BIND(123 AS ?o) }
}
Points of disagreement with Jena
Two corners of the restriction disagree with Jena’s own SyntaxVarScope
check, each pinned by a named test rather than left to drift:
- Laxer. A
BIND/VALUES/aggregate introduction confined to aMINUSright operand is ACCEPTED here at any depth — directly under theLATERALkeyword or nested under aSELECT *sub-select — while Jena rejects it. SPARQL §18.2.1 putsMINUS-right variables out of scope, and §18.5’s evaluation explains why: the right operand is used only to build the compatibility test against the left operand’s rows, then its bindings are discarded, so nothing built on top of aMINUS— aSELECT *projection or anything else — can ever observe a value that operand introduced. There is nothing observable for the rule to reject. (SELECT *over such a right-hand side is one route to this same shape, not a separate one.) - Stricter. A
SERVICE ?g { ... }endpoint variable counts as left-hand scope here; Jena omits it.SERVICE ?g { ... }with a variable endpoint requires?gto already be bound in the incoming solution — the endpoint IRI is resolved from that binding before the remote call is made — so by the time aLATERALright-hand side runs,?galready holds an observable, per-row value from the left, the same as any other left-bound variable. ABIND/VALUESon the right giving it a NEW value is therefore exactly the kind of observable rebinding this rule exists to reject.
In UPDATE
INSERT/DELETE ... WHERE and WITH ... WHERE route through the same
group-graph-pattern grammar a SELECT’s WHERE clause uses, so LATERAL —
scope-checked exactly the same way — is legal there too. A DELETE WHERE quad
template is a different grammar production (TriplesTemplate, not a
GroupGraphPattern) with no group-pattern operators of any kind, so LATERAL
there is refused by name rather than misparsed as a subject term.
SERVICE forwarding
A pattern containing a written LATERAL clause — anywhere in the forwarded
body, including nested inside another SERVICE — is 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:
| Capability | Grants |
|---|---|
Query | resolving this service at all |
Network | performing network I/O to resolve it |
Credentials | attaching the profile’s credential to the request |
A catalog denies by default: a service with no entry and no explicitly configured fallback profile is refused. Gating is opt-in — a resolver with no catalog behaves exactly as it did before catalogs existed, contacting whatever endpoint it is handed and adding no headers — and configuring a catalog changes no byte of a request whose profile adds nothing.
Withholding Network is what makes an in-process façade provable rather than
promised: InProcessServiceResolver owns a dataset map and nothing else — no
transport, no socket, no injected callback — so there is no code path through it
that performs I/O. A host can therefore offer SERVICE-shaped composition
without SERVICE-shaped risk. ServiceRouter composes the two, sending some
services in process and the rest to the network, with the routing table rather
than the query text deciding which is which. The catalog is consulted on
every resolution, nested ones included, so a SERVICE buried inside a
forwarded body cannot reach an endpoint the catalog refuses at the top level.
The policy is applied before the transport is called, which is the whole difference between a gate and an audit log: a denied service never has its socket opened and then discarded.
A service IRI is an IRI like any other, so it is resolved by the workspace’s
single RFC 3986 base layer (see Base IRIs) and not by
a rule this seam invented. SERVICE <sparql> with no BASE in the prologue and
no base passed to the API is the shared iri-relative-no-base hard error, raised
while the query is parsed — before any resolver is consulted, and not softened by
SILENT, which is a promise about an endpoint that does not answer rather than
about an endpoint IRI that cannot be formed. With a base in scope the resolver is
handed the absolute IRI, which is the form a ServiceCatalog is keyed on.
The SILENT contract
SPARQL 1.1 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:
| Outcome | SERVICE | SERVICE 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 IRI | query error | join identity, recorded on the evidence |
| This engine’s own governor tripped, or its stack ran out | truncation, or the stack refusal | truncation, 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.
substituteis stated over variable POSITIONS in a pattern, with no defined action on aBgp/Pathleaf term or aGRAPH ?gname treated as anything other than an expression — so a correlated variable occurring only in a triple or graph-name position has no substitution to apply. - The
MINUSdomain flip. Rewriting aMINUS’s shared variables away as constants erases the very columns its domain-join compares the two operands on, turning a correlatedMINUSinto an unconditionally disjoint one — the opposite of what substituting the row in was supposed to preserve. - Blank nodes as variables. No SPARQL surface syntax can spell a blank node or a quoted triple as a rewritten constant, so a correlated blank-node or quoted-triple binding has no legal substituted form under literal term-rewriting.
- Disconnected variables. A correlated variable reachable ONLY through an
expression position inside a nested
EXISTS— never through that inner’s own triple-pattern leaves — has nothing insubstitute’s pattern-rewriting reading to carry it there. - The assignment restriction. §18.6’s own text states no rule at all
about an
EXISTSbody rebinding a variable already in scope on the row it filters.
SEP-0007 — a SPARQL-dev proposal, not yet folded into the SPARQL 1.1/1.2 REC
text — repairs the first four by restating substitute as Replace, a JOIN
rather than a rewrite, and adds the fifth as Part 3, a new restriction.
purrdf implements SEP-0007’s repairs in full, including Part 3.
The one definition
exists(X, μ) ⟺ eval(D(G), Replace(PrjMap(X), μ)) is non-empty
Replace is Values Insertion: the current row μ joins into X as a
one-row VALUES table at each Bgp/Path/Graph(Var, ·) site, rather than
being spliced in as syntactic constants — total over every RDF 1.2 term kind,
including blank nodes and quoted triples, because it reaches the dataset
through the same evaluation path real VALUES data uses, and it needs no
special case for a variable reachable only through an expression, because the
join reaches every leaf regardless of which position introduced the
correlation. PrjMap is Project-boundary narrowing: μ is restricted
to a Project node’s own variable list before it is joined in below that
node — the one scope boundary the surface language has, and the reason a
sub-SELECT’s own projection can legitimately shadow an outer variable.
SEP-0007 states Replace in terms of toMultiSet(μ) — converting the row to
the multiset unit the join needs. purrdf’s algebra has no pattern/query
type split for that conversion to bridge: a solution row already IS that
multiset unit, so Replace lands directly below whatever solution modifiers
wrap the leaf it targets, structurally, with nothing extra to construct or
adjudicate.
The HAVING position is outside SEP-0007’s stated coverage — SEP-0007
only specifies Replace for Bgp/Path/Graph Values-Insertion sites and
ordinary expression positions, not for a sub-SELECT’s HAVING clause.
purrdf follows the literal PrjMap reading: a sub-SELECT’s HAVING
filters that sub-SELECT’s own scope only, exactly like every other
expression inside it — no special correlation channel is invented for it.
Existential Normal Form
Exactly one bit per row is observed under EXISTS: whether the inner
pattern’s evaluation is empty. 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.OPTIONALemits at least one row per left row unconditionally — a padded left row, or one row per compatible match — and never removes one, soLeftJoin(A, B, c)is empty iffAis, regardless ofBor its join condition.OrderBy(P) → P. Sorting is a permutation, never a filter: the sort keys are never even evaluated once emptiness is the only question asked.Distinct(P) → P,Reduced(P) → P. De-duplication only ever removes rows, and only when an earlier row already carried the same value, soPnon-empty always leaves the first row’s value present.Slice(0, len ≥ 1)(P) → P. An offset-zeroLIMITwith room for at least one row drops rows only from the end ofP’s bag, soPnon-empty always leaves row zero inside the window.Slice(_, Some(0))(P) → false. A zero-lengthLIMITis empty for everyPand every row — the wholeEXISTSfolds to constantfalse(NOT EXISTStotrue) without touching the dataset at all.
Every law above is proved emptiness-equivalent over row SETS, never over side
effects, so each fires only when the portion it erases (B and the join
condition for LeftJoin, the sort keys for OrderBy, the whole inner for
the zero-length Slice fold) is effect-free — no SERVICE call, property
function, or custom/heldIn/rdf:List function reachable within it — so a
hard error or a remote effect that would have propagated outside the EXISTS
still does, rather than vanishing merely for having been written inside one.
Two strategies, one answer
purrdf carries exactly two implementations of the one definition above:
- The definition itself — per-row substitution via
Replace, wrapped in aSlice{0, Some(1)}first-witness stop (sound because “does this bag have a first row” is exactly whatEXISTSasks) and backed by a restriction-keyed memo: the memo key is the outer row restricted to the inner’s own correlated-variable columns, sokdistinct restrictions acrossNouter rows evaluate the inner exactlyktimes, neverN. Always correct, for any inner pattern. - The memoized probe — evaluate the inner exactly once, unconstrained,
index it on the columns it shares with the outer schema, and
existence-probe each row’s
μagainst that index. Correct only where a prepare-time admissibility proof shows it equivalent to the definition for every row the site can see.
A prepare-time analysis decides, once per site per evaluation, which strategy
answers each EXISTS/NOT EXISTS. --explain’s per-algebra-node charge
ledger reports the decision through three evidence counters:
exists-probe-answered (one memoized-probe evaluation),
exists-definition-answered (one per-row-definition evaluation), and
exists-inner-solutions-consumed (one row the definition path’s inner
actually materialized — bounded at 1 per evaluation by the first-witness
stop). An EXISTS/NOT EXISTS inner’s own plan nodes — a MINUS, a Bgp, a
nested EXISTS (however deeply nested), whatever the body contains — carry
their own ledger lines too, decomposed exactly like an ordinary node’s rather
than folded into the enclosing FILTER/BIND, under EITHER strategy: the
memoized probe’s one once-per-site evaluation attributes exactly like the
per-row definition’s, just charged once rather than once per distinct
restriction. A node that legitimately reads fuel=0 rows=0 is one
Existential Normal Form erased from the evaluated tree entirely (an
OPTIONAL/ORDER BY/DISTINCT/LIMIT wrapper the emptiness-preserving
laws proved transparent — see “Existential Normal Form” above) and therefore
never ran, not a charge that went missing.
The one named exception: an EXISTS/NOT EXISTS inner whose OWN
ENF-normalized top-level shape is a Project (a sub-SELECT) or a Union
(a top-level { A } UNION { B }, not one nested further down) does NOT
attribute — every node inside it, however deep, folds into the enclosing
FILTER/BIND as fuel=0 rows=0, under either strategy, regardless of what
the body itself contains. Project/Union synthesize their output from more
than one child rather than being a 1:1 structural clone of anything in the
original tree, and reconstructing the same original-address correspondence
for them would need the same wrapper/wrapped counts_rows arbitration the
LATERAL Values-Insertion machinery already does — deliberately not
attempted, so the correspondence is simply absent for that one site rather
than guessed at. This is not a regression: it is exactly the behavior every
EXISTS/NOT EXISTS inner had before per-node attribution existed at all,
now confined to this one shape instead of the whole feature.
The performance characteristic, stated plainly: an uncorrelated inner, or
a correlated one built only from Bgp/Path/Values/Graph/Join/
Union/OrderBy/Project/Distinct/Reduced and Filter/Extend
expressions that read only certainly-bound columns of the inner, is served by
the probe — one evaluation total, however many outer rows filter through it.
A shape the probe cannot serve — MINUS, a restricting Slice (any offset
or limit, not only one past the first row), GROUP BY, LATERAL, a
property-function call, a SERVICE call, or a FILTER/BIND expression
that reads a correlated variable the inner does not certainly bind (for
example, one visible only down an OPTIONAL branch) — evaluates per row
through the definition, with the restriction-keyed memo and first-witness
stop above bounding the cost.
The Part 3 assignment restriction
Neither a BIND/a sub-SELECT’s (expr AS ?v) projection target/a
GROUP BY (expr AS ?v) grouping target, nor a VALUES column, inside an
EXISTS/NOT EXISTS body may rebind a variable already in scope on the row
being filtered. NOT EXISTS shares the exact same grammar production as
EXISTS — there is no separate “NOT EXISTS” wording — so the restriction
applies identically to both, and to a nested EXISTS’s own body against
whatever its immediately enclosing EXISTS already has in scope. A rebinding
confined to a MINUS right operand inside the body is exempt at any depth,
the same reasoning LATERAL’s own scope restriction applies (§18.2.1: a
MINUS-right introduction never escapes it, so it can never observably
rebind the row).
PREFIX : <https://example.org/>
# Legal: ?fresh is not in scope on the row FILTER EXISTS is testing, so BIND
# giving it a value is an ordinary fresh binding, not a rebinding.
SELECT ?s WHERE {
?s :p ?o .
FILTER EXISTS { BIND(1 AS ?fresh) }
}
# Illegal — refused with a typed ParseError naming ?o: ?o is already bound by
# the row FILTER EXISTS is testing, and BIND tries to give it a NEW value at
# the EXISTS body's own scope level.
SELECT ?s WHERE {
?s :p ?o .
FILTER EXISTS { BIND(1 AS ?o) }
}
The restriction is SEP-0007’s own addition, adopted here because it is what
makes EXISTS’s substitution semantics defensible at every site — SPARQL
1.1/1.2’s own §18.6 text requires no such rule.
SHA-3 hashing (SEP-0008)
SPARQL 1.1 §17.4.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:
| Call | Digest | Hex characters |
|---|---|---|
SHA3-224(string) | SHA3-224 | 56 |
SHA3-256(string) | SHA3-256 | 64 |
SHA3-384(string) | SHA3-384 | 96 |
SHA3-512(string) | SHA3-512 | 128 |
SELECT ?s (SHA3-256(?label) AS ?fingerprint)
WHERE { ?s <http://example.org/label> ?label }
The argument contract
Each function takes one argument and hashes the UTF-8 bytes of its
lexical form, returning the digest as a lowercase hex xsd:string — the
same contract SHA256 has, so a query can swap one for the other without
changing anything else about the row.
The accepted arguments are a simple literal 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:
| Text | Reading |
|---|---|
SHA3-256(?o) | the built-in call — one token, hyphen included |
SHA3 - 256 | a parse error: SHA3 alone is no function or keyword |
STRLEN(SHA3-256(?o)) - 4 | subtraction — the - follows ), not a word character |
The lexer’s PN_PREFIX scan admits - as a name character, so SHA3-256
arrives at the parser as a single word. Whitespace around the hyphen makes it
the subtraction operator again, and because SHA3 is not itself a function,
the spaced form fails loudly rather than meaning something else. Names are
case-insensitive like every other built-in (sha3-256 is the same call).
SEP-0008’s own text spells the four functions with an underscore
(sha3_256), so SHA3_256(?o) is accepted as an alias for SHA3-256(?o):
a query copied out of the proposal parses. The alias is an input spelling
only — the algebra has one function per digest size, so a serialized query
always carries the canonical hyphenated name and stays byte-deterministic
whichever spelling was typed.
Taking a SHA-3 query to another engine
Expect a parse error. SHA3-256 is a built-in name here, but the SPARQL
1.1/1.2 grammar offers exactly two ways to name a function — a keyword from its
own built-in list, or a FunctionCall ::= iri ArgList whose iri is an
IRIREF or a prefixed name. On an engine that has not adopted SEP-0008,
SHA3-256 is not in the built-in list, and a bare word with no colon is not an
iri either, so SHA3-256(?o) has no parse at all. The underscored
SHA3_256(?o) spelling fails for the same reason. The failure is therefore
loud and immediate rather than a quietly unbound column.
The portable substitute is one of the SPARQL 1.1 §17.4.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.
| Call | Arity | Result |
|---|---|---|
cdt:List(…) | any | a cdt:List of the argument terms |
cdt:Map(…) | even | a cdt:Map from alternating key/value arguments |
cdt:concat(…) | any | the argument lists joined end to end |
cdt:contains(list, term) | 2 | xsd:boolean — does the list hold an equal term |
cdt:get(coll, n_or_key) | 2 | one element by 1-based position, or one map value by key |
cdt:head(list) | 1 | the first element |
cdt:tail(list) | 1 | everything but the first element |
cdt:reverse(list) | 1 | the elements in opposite order |
cdt:size(coll) | 1 | xsd:integer element or entry count — the one function that takes either datatype |
cdt:subseq(list, start[, len]) | 2–3 | a contiguous run, start 1-based |
cdt:containsKey(map, key) | 2 | xsd:boolean — is this a key of the map |
cdt:keys(map) | 1 | the map’s keys, as a cdt:List |
cdt:merge(…) | ≥ 2 | the union of the argument maps; a key held by more than one is resolved by the first map that carries it |
cdt:put(map, key[, value]) | 2–3 | the map with one entry set; an omitted or erroring value stores the null entry |
cdt:remove(map, key) | 2 | the map without that entry; an absent key leaves it unchanged |
Handing a list function a map (or the reverse) raises — it is a type error, not an unbound column. The indices are 1-based throughout, per the SEP.
PREFIX cdt: <http://w3id.org/awslabs/neptune/SPARQL-CDTs/>
SELECT ?s ?first
WHERE {
?s <http://example.org/tags> ?tags
FILTER(cdt:size(?tags) > 1)
BIND(cdt:head(?tags) AS ?first)
}
FOLD: a group becomes one term
FOLD is an aggregate, so it sits wherever SUM or GROUP_CONCAT sits — in
the SELECT list, in HAVING, beside other aggregates, under GROUP BY.
Aggregate ::= … | 'FOLD' '(' 'DISTINCT'? Expression ( ',' Expression )?
( 'ORDER' 'BY' OrderCondition+ )? ')'
One expression builds a cdt:List; two build a cdt:Map, the first being the
key. The optional ORDER BY belongs to the aggregate itself and follows the
last expression with no comma — this is the seam that makes FOLD the
first order-dependent aggregate in the engine, and it is why FOLD carries an
ORDER BY where every other aggregate rejects one.
Accepted spellings, as a catalogue:
FOLD(?v) FOLD(DISTINCT ?v)
FOLD(?v ORDER BY DESC(?v)) FOLD(DISTINCT ?v ORDER BY ASC(?a) ASC(?b))
FOLD(?k, ?v) FOLD(?k, ?v ORDER BY ?ord)
and one whole query:
PREFIX cdt: <http://w3id.org/awslabs/neptune/SPARQL-CDTs/>
SELECT ?s (FOLD(?tag ORDER BY ?tag) AS ?tags)
WHERE { ?s <http://example.org/tag> ?tag }
GROUP BY ?s
Four behaviours are worth knowing before you rely on one:
- An empty group folds to a bound empty composite —
"[]"^^cdt:Listor"{}"^^cdt:Map— never to an unbound column.SUMover nothing is0andGROUP_CONCATover nothing is"";FOLDfollows that shape rather thanMIN’s. - A row whose element expression is unbound or erroring contributes a
nullelement, which is the opposite of every other aggregate, all of which skip such a row. The SEP wants the arity of the list to match the arity of the group. A map key that is unbound or erroring drops the entry entirely, because there is nothing to file the value under. - A repeated map key resolves to the last binding.
DISTINCTis RDF-term identity, not value equality, so"1"^^xsd:integerand"01"^^xsd:integersurvive as two elements.
There is no ; SEPARATOR= on FOLD — that scalar parameter is
GROUP_CONCAT’s alone — and FOLD(*) is not grammar.
UNFOLD: one term becomes rows
UNFOLD is a graph pattern, not a function. It is its own
GraphPatternNotTriples alternative — structurally where LATERAL sits — and
it stacks above the pattern parsed so far:
Unfold ::= 'UNFOLD' '(' Expression 'AS' Var ( ',' Var )? ')'
| Operand | first variable | second variable |
|---|---|---|
cdt:List | each element, in list order, duplicates preserved | the 1-based xsd:integer index |
cdt:Map | each entry’s key | that entry’s value |
SELECT ?elem ?i
WHERE {
?s <http://example.org/tags> ?tags
UNFOLD(?tags AS ?elem, ?i)
FILTER(?i < 4)
}
Both targets obey BIND’s §19.6 scope rule: a variable already in scope in the
group graph pattern is a syntax error, not a join, and naming the same
variable twice is likewise refused.
The two edges that decide whether an OPTIONAL around an UNFOLD is doing
what you think:
- A well-formed but empty composite (
"[]"^^cdt:List,cdt:Map()) yields zero rows. The row is gone. - An operand that is not a composite at all — unbound, erroring, an
xsd:integer, a plain string, or acdt:-typed literal whose lexical form does not parse — passes the row through unchanged with both targets unbound. This is SEP-0009 §12.3 verbatim, and it is deliberately not a no-rows outcome, soUNFOLDnever silently deletes a row it merely failed to understand.
FOLD and UNFOLD compose, which is how a query re-orders a list without
leaving the query language:
PREFIX cdt: <http://w3id.org/awslabs/neptune/SPARQL-CDTs/>
SELECT (FOLD(?e ORDER BY ?e) AS ?sorted)
WHERE { BIND("[3,1,2]"^^cdt:List AS ?l) UNFOLD(?l AS ?e) }
Ordering, MIN and MAX
ORDER BY, MIN, MAX and FOLD’s own ORDER BY all order composite
literals by the value they denote, not by their lexical form, through one
shared projection of SPARQL §15.1. Composites rank between plain literals and
RDF 1.2 triple terms: unbound < blank < IRI < literal < composite < triple term. SEP-0009 does not pin that placement; it is purrdf’s documented
choice.
The order purrdf exports for composites is syntactic, and that is not an
oversight. SEP-0009’s value relations are partial and raise on incomparable
operands, and the obvious repair — order by value, break ties syntactically —
is intransitive on SPARQL’s own type lattice
("9"^^xsd:double < "P1D"^^xsd:duration < "8"^^xsd:float <
"9"^^xsd:double). Rust’s sorts may panic on a comparator like that, so a
total order is a correctness requirement here, not a nicety.
MEDIAN and PERCENTILE are numeric folds and are not composite-aware: a
composite is outside their domain and the aggregate comes back unbound.
Blank nodes inside a composite
A _:b written inside a composite lexical form binds through the same
ingress rule as a bare _:b token, on every codec — Turtle, TriG, N-Triples,
N-Quads, RDF/XML, TriX, HexTuples, JSON-LD, and both GTS import paths. So _:b
written as a subject and _:b written inside a cdt:List in the same
document are one node, while the same label in two different documents stays
two nodes, exactly as RDF 1.1 §4.1 requires. Composite-embedded blank nodes
also participate in canonicalization and in skolemization.
Query text gets its own blank scope for the same reason, so a _:b you write
inside a composite literal in a query is never the _:b in the data.
Limits, and what a composite refuses
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:
| Bound | Value |
|---|---|
| Total elements, all levels | 2²⁰ (1 048 576) |
| Lexical bytes | 64 MiB |
Crossing one at evaluation is a hard query failure naming the bound crossed, never a quietly unbound answer.
An ill-formed composite literal in a document refuses the whole document
(cdt-literal-malformed), where an ill-formed "abc"^^xsd:integer does not.
That asymmetry is deliberate and is written down in
full:
the embedded-blank scanner is lexical, so admitting an unparseable form opaquely
would leave half a blank-node scope bound and half of it raw. In query text
the same literal parses — ill-typedness is an evaluation-time question there, a
function over it is unbound, and a comparison with it raises.
Taking a composite query to another engine
Expect a parse error for FOLD and UNFOLD, which are keywords no
unextended grammar admits, and quietly different answers for the function
calls, which are ordinary iri ArgList calls that an engine without SEP-0009
will report as unknown functions. There is nothing to configure to reach any of
it from a host: like the SHA-3 built-ins, composites are unconditional, so
purrdf query, Python’s Store.query / MutableDataset.query, WebAssembly’s
Dataset.query / QueryEngine.select, and the C ABI’s purrdf_query /
purrdf_query_json all have them.
One divergence runs the other way, and it is stated rather than argued.
purrdf’s reader admits two element forms the published SEP-0009 lexical space
does not — an RDF 1.2 triple term <<( s p o )>>, and a directional
language-tagged literal ("x"@en--ltr) — as list elements and map values,
because refusing a term type the RDF 1.2 data model defines, inside a container
the data model also defines, is not an admissible outcome for this toolkit.
They are emitted only for values the published grammar cannot express at all.
A conformant SEP-0009 reader handed one of those literals will call it
ill-formed. See
docs/CONFORMANCE.md
for the divergence and the scan that bounds its blast radius.
Quad templates: CONSTRUCT into named graphs
A SPARQL 1.1 CONSTRUCT template is a set of triples, and the result is one
graph. purrdf also accepts a quad template, so a template may name the
graph each statement lands in, and a single result may span several named
graphs.
Provenance: a purrdf extension, not a SPARQL 1.2 feature
SPARQL 1.2 does not define the quad template. Neither the 1.1 nor the 1.2
grammar admits a GRAPH block inside a CONSTRUCT template
(ConstructTemplate ::= '{' ConstructTriples? '}', and ConstructTriples is
triples only), and neither defines the CONSTRUCT GRAPH … shorthand. Both
spellings documented below are first-party extensions this engine ships.
Producing quads from a CONSTRUCT is a long-running request in the SPARQL
community’s proposal process, and other engines — Jena and Stardog among them —
already ship a form of it, but no standardized spelling exists, so the one
described here is purrdf’s.
Declaring VERSION "1.2" does not subtract the extension: a version
declaration selects semantics, not a feature whitelist. See
The VERSION declaration.
Taking one of these queries to another engine
Expect a parse error, not a different answer. An engine without the
extension rejects the GRAPH keyword as soon as it meets it inside a
CONSTRUCT template, because its grammar has no production that admits one
there — the query fails before evaluation, so there is no risk of silently
getting the wrong graphs. An engine that ships its own form of the feature may
accept only one of the two spellings, since neither is standardized.
Two portable rewrites:
-
If the result is going into a store, use SPARQL 1.1 Update rather than
CONSTRUCT. Update’s template has always been a quad template, soINSERT { GRAPH … { … } } WHERE { … }is standard, universally implemented, and gives the same per-solution graph targeting — including a graph name bound per row:PREFIX ex: <http://example.org/> INSERT { GRAPH ?g { ?s ex:friend ?o } } WHERE { GRAPH ?g { ?s ex:knows ?o } } -
If the result must come back as a document, issue one ordinary triple-producing
CONSTRUCTper target graph and assemble the dataset on the client. This costs a round trip per graph and cannot express a graph name computed per solution row, which is the gap the quad template closes.
Queries that stay inside the triple form are unaffected in either direction: a
template with no GRAPH slot is an ordinary SPARQL 1.1 CONSTRUCT here and
emits byte-identically, so only the templates that actually name a graph are
the ones that will not travel.
GRAPH blocks inside the template
PREFIX ex: <http://example.org/>
CONSTRUCT { GRAPH ex:derived { ?s ex:friend ?o } }
WHERE { ?s ex:knows ?o }
A variable graph name
The graph slot takes a variable as well as an IRI, so the graph a statement lands in can be decided per solution row:
PREFIX ex: <http://example.org/>
CONSTRUCT { GRAPH ?g { ?s ex:friend ?o } }
WHERE { GRAPH ?g { ?s ex:knows ?o } }
Several graphs, and mixed default-graph triples
One template may write into more than one graph, and may mix graph-scoped quads with unscoped triples that land in the default graph:
PREFIX ex: <http://example.org/>
CONSTRUCT {
?s ex:seen true .
GRAPH ex:people { ?s ex:friend ?o }
GRAPH ex:reverse { ?o ex:friend ?s }
}
WHERE { ?s ex:knows ?o }
The whole-template shorthand
CONSTRUCT GRAPH <iri> { … } scopes the entire template to one graph without
a GRAPH block around it. It also works with the short form
(CONSTRUCT GRAPH <iri> WHERE { … }), and it takes a variable
(CONSTRUCT GRAPH ?g { … }), a prefixed name, or a BASE-relative IRI:
PREFIX ex: <http://example.org/>
CONSTRUCT GRAPH ex:derived { ?s ex:friend ?o }
WHERE { ?s ex:knows ?o }
The shorthand is a default, not an override: it supplies the graph for
every template slot that did not name one itself, so an inner GRAPH block
still wins over it. CONSTRUCT GRAPH { … } with no name is a syntax error
rather than a silently unscoped template.
Skip semantics
SPARQL §16.2 already skips a template statement whose variables are unbound or whose instantiation is ill-formed. Ill-formed means “not a legal RDF 1.2 statement”, position by position:
- the subject is an IRI or a blank node — a literal is illegal there, and so
is a triple term (a quoted triple is a value; an asserted statement is made
about a reifier, not about the quoted triple itself). Both are reachable from
ordinary data: over RDF 1.2 input,
CONSTRUCT { ?o ?p ?s } WHERE { ?s ?p ?o }binds?oto a triple term as readily as to a literal; - the predicate is an IRI, and nothing else;
- the object may be any term, but when it is a triple term that triple term’s own components carry the same rules recursively (its subject must not be a literal, its predicate must be an IRI).
The graph slot follows the same rule, and this is worth being explicit about:
An unresolvable graph name skips its statement. It is not an error, and it is not a fallback to the default graph. The graph slot is resolved first, so a statement whose graph name is an unbound variable, or is bound to anything that is not an IRI (a literal, a blank node, a triple term), is not instantiated at all — it mints no blank-node labels either, so the rest of the result is exactly what it would be if that quad were absent from the template.
The skip is per statement, not per row: a sibling template quad whose own graph slot resolves is still emitted for the same solution.
Which output formats can carry the result
A named graph needs a syntax with somewhere to put a graph name. Six of the nine RDF syntaxes have one:
| Carries named graphs | Does not |
|---|---|
| TriG, N-Quads, TriX, HexTuples, JSON-LD, YAML-LD | Turtle, N-Triples, RDF/XML |
The single-graph serializers drop graph-scoped statements — they do not fold them into the default graph — so writing a graph-carrying result to one of them would produce a well-formed document silently missing exactly what the query asked for. No host lets that pass unsignalled. The three hosts whose egress is a document refuse outright, each naming the graphs it would have dropped, the format it was asked for, and the quad-capable alternatives; the C ABI, whose egress is the dataset itself, reports the loss as a count instead:
-
CLI —
purrdf query … --results-format turtleexits 2 (a usage refusal, distinct from an evaluation error) and prints the refusal on stderr, pointing at--results-format trig/nquads/trix/hextuples/jsonld/yamlld. -
Python — the result of a graph-carrying
CONSTRUCTis aQueryQuads(whose members areQuads with a livegraph_name) rather than aQueryTriples.QueryQuads.serialize(RdfFormat.TURTLE)raisesValueError, namingRdfFormat.N_QUADS/TRIG/TRIX/HEXTUPLES/JSON_LD/YAML_LD. -
WebAssembly — an explicit
serialize("turtle")/queryRaw(…, {format: "turtle"})throws, with the same sentence and the same alternatives. When the caller names NO format the default widens fromturtletotriginstead of throwing, because there was no request to contradict and an empty document would be the wrong answer; a result with no named graph still getsturtle, byte for byte. -
C ABI — the shape is different, and deliberately so.
purrdf_queryhands back aPurrdfDatasethandle, which is the frozen IR itself and has somewhere to put a graph name, so the graphs are never lost at the query boundary. Serializing that handle is a separate call, andpurrdf_serializeto a single-graph media type succeeds while reporting what it discarded through theout_named_graph_rows_droppedout-parameter — a count rather than an exception, because that is the signal C has. The parameter is independently nullable; a caller that passes null for it has asked not to be told, so read it — or serialize to a quad-capable media type — whenever the result may carry graphs.The convenience path,
purrdf_query_json, needs neither: it renders aCONSTRUCT/DESCRIBEresult as N-Quads inside its{"graph": "..."}envelope, so the graph names, the base quads and the RDF 1.2 statement layer all survive and there is no loss to report. That member ispurrdf’s own envelope rather than a caller-selected RDF syntax, which is why it widens the way the WebAssembly no-format default does instead of refusing the way an explicitserialize("turtle")does. A default-graph-only result is byte-identical to the N-Triples the member used to hold — an N-Quads line with no graph term is the N-Triples line.
A result carrying only default-graph statements is untouched everywhere: every
SPARQL 1.1 CONSTRUCT and every DESCRIBE serializes to Turtle exactly as
before. 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 sameinteger ⊂ decimal ⊂ float ⊂ doublepromotion tower the built-inSUM/AVGfold uses; a non-numeric input poisons the fold to unbound rather than raising a hard error, matchingSUM’s own discipline. OnlySTDDEV/STDDEV_POP’s final square root leaves the exact decimal tower;VARIANCE/VAR_POPnever do. STDDEV/VARIANCEare the sample statistics (n - 1denominator, unbound under two values);STDDEV_POP/VAR_POPare the population statistics (ndenominator, defined at one value) — SQL’s own naming convention.MEDIANisPERCENTILEatp = 0.5under linear interpolation between the two closest ranks — for an even-sized group this is exactly the mean of the two middle values.PERCENTILEtakes a named scalarval,AGG(<{NS}PERCENTILE>, ?x; P=0.95):Pis resolved once for the whole aggregation (never per row — see “Named scalar-value parameters” above); aPoutside[0, 1]poisons to unbound, the same “poison, don’t abort” discipline every numeric member uses. A missing or non-numericPis refused at prepare time.MODEworks over any term kind, counted by RDF term identity (not value equality —"5"^^xsd:integerand"05"^^xsd:integercount as two different terms). A tie is broken toward the smallest term under the same total orderMIN/ORDER BYuse.FIRST/LASTwork over any term kind, in input row order.TOPKtakes a named scalarval,AGG(<{NS}TOPK>, ?x; K=3):Kmust be a positivexsd:integer, resolved once for the whole aggregation (aK ≤ 0poisons to unbound; a missing or non-integerKis refused at prepare time). Since every SPARQL aggregate yields exactly one RDF term,TOPKanswers the same wayGROUP_CONCATanswers a multi-valued question — the topkvalues, in descending order, with their lexical forms joined by a single fixed space separator.
All ten declare Volatility::Stable and merge partial folds through real,
type-recovered structural state (AggregateAccumulator::into_any) rather than
a lossy re-derivation from a finished answer — so a large single group folds
in parallel chunks (see crate::modifier::eval_custom_aggregate) with a
result byte-identical to the sequential fold.
Path witnesses: binding the derivation, not the endpoints
The core grammar’s property paths answer a reachability question. ?s ex:p+ ?o says that some sequence of ex:p edges leads from ?s to ?o, and the
answer is the endpoint pair; which edges is not expressible, so a query that
needs to explain, weight, filter, or re-join the route has nowhere to look.
A path-witness property function answers the derivation question instead. A call reads
?start <caller-iri> ( ?end ?pathId ?len ?step ?node ?edge )
and emits one row per hop. For a walk of k hops, row i binds ?len = k,
?step = i, ?node to the node that hop arrived at, and ?edge to the
statement it traversed — a first-class RDF 1.2 triple term in asserted
orientation, so it joins straight back into the dataset by an ordinary basic
graph pattern. ?step and ?len are xsd:integer literals precisely so
ORDER BY ?step orders numerically rather than putting "10" before "2".
There is no default relation IRI and no default traversal envelope. PurRDF mints
no vocabulary IRIs, so the name a query spells in predicate position is
caller-supplied; and a zero-hop path has no witness while an unbounded 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): theaggregate_namespacekeyword onquery/query_governed/update/update_governed.store.query( "PREFIX ex: <https://ex.example/> " "SELECT (AGG(<https://ex.example/agg#MEDIAN>, ?v) AS ?m) " "WHERE { ?s ex:value ?v }", aggregate_namespace="https://ex.example/agg#", ) -
CLI (
purrdf query/purrdf update): the--aggregate-namespace IRIflag.purrdf query --data data.ttl --aggregate-namespace 'https://ex.example/agg#' \ 'SELECT (AGG(<https://ex.example/agg#MEDIAN>, ?v) AS ?m) WHERE { ?s ?p ?v }' -
WebAssembly (
QueryEngine.queryGoverned/updateGoverned): theaggregateNamespaceoption, alongside the other governed-call options.const outcome = engine.queryGoverned(dataset, sparql, { aggregateNamespace: "https://ex.example/agg#", }); -
C ABI (
purrdf_query_governed/purrdf_update_governed): a nullableconst char *aggregate_namespaceparameter, the same optional-C-string convention every other nullable string on this ABI uses.purrdf_query_governed(dataset, query, /* base_iri */ NULL, "https://ex.example/agg#", &governors, /* … */);
namespace stays caller-supplied configuration with no fabricated default on
every one of these surfaces: omitting it (None / not passing the flag /
undefined / a null pointer) leaves every one of the ten names an ordinary
unregistered custom-aggregate IRI, refused exactly as before this parameter
existed.
The entailment-aware query lane (query_with_entailment/
query_with_entailment_governed, and every Python/CLI/WebAssembly/C wrapper
around the governed entry point) combines with aggregate_namespace exactly
as the raw-view lane does: both take the engine’s QueryOptions, threaded
through the closure query’s parse and its evaluation, so AGG(<{NS}MEDIAN>, …)
resolves over an entailed closure the same way it resolves over a raw view.
purrdf query --data ent.ttl --entailment rdfs \
--aggregate-namespace 'https://ex.example/agg#' \
'SELECT (AGG(<https://ex.example/agg#MEDIAN>, ?w) AS ?m)
WHERE { ?x <http://example.org/measure> ?w }'
purrdf.Store.query_entailment_governed(..., aggregate_namespace=NS) answers
the same query the same way, and the WebAssembly and C-ABI governed
entailment entry points take the same parameter.
One structural limit applies everywhere the statistical set is reachable:
SPARQL UPDATE’s grammar admits an aggregate only inside a nested
SELECT … GROUP BY in a WHERE clause (an ordinary DELETE/INSERT WHERE
basic graph pattern has no GROUP BY of its own) — every host’s update
entry reaches the statistical set through exactly that nested-subquery
shape.
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:
| Name | Law |
|---|---|
xpath-2.0-2010-12-14 | XPath and XQuery Functions and Operators 2.0 (Second Edition), 14 December 2010 |
xpath-3.1-2017-03-21 | XPath 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:
| Host | Spelling |
|---|---|
| Rust | NativeSparqlEngine::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 |
| Python | xpath_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 |
| WebAssembly | an xpathRegex option on every evaluating QueryEngine method and its asynchronous twin, and a trailing xpathRegex on Dataset.query, queryRawConfigured and queryRawWithContext |
| C | regex_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_droppedis set; the drop is never silent. The same flag is set for JSON/XML when a non-emptyResultProvenanceis supplied with noProvenanceNamespaceto anchor it under — see below. - RDF 1.2 base direction uses the spec’s own key — a directional literal’s
base direction serializes as
its:dir(JSON: an additive"its:dir"member beside"value"/"xml:lang"; XML:<literal its:dir="…">), the same spelling the SPARQL 1.2 Query Results specification’s own example uses and the RDF/XML codec already emits. The XML/JSON readers also accept two legacy spellings this crate’s own writer previously produced (a baredirattribute, and — XML only — apurrdf:dir-namespaced one) for backward compatibility, butits:dirwins when more than one is present on the same literal, and onlyits:diris ever written.
| Format | SELECT | ASK | CONSTRUCT | Provenance extension |
|---|---|---|---|---|
| JSON (SRJ) | yes | yes | yes | yes, with a namespace |
| XML | yes | yes | rejected | yes, with a namespace |
| CSV | yes | rejected | rejected | dropped, flagged |
| TSV | yes | rejected | rejected | dropped, flagged |
The provenance extension
The provenance extension is additive and caller-named: a standard
SPARQL results consumer can read the JSON/XML documents unchanged, while a
provenance-aware consumer can recover per-result data carried alongside the
bindings, anchored under an identifier the caller supplies — never a
purrdf-minted one (PurRDF mints no vocabulary IRIs of its own; see
AGENTS.md’s
“NOT an ontology” contract). Supply a ProvenanceNamespace — a prefix (the
bare top-level JSON member key, and the XML namespace prefix) plus the XML
namespace iri — as serialize’s fourth argument (to_json/to_xml take it as
their third: those two have no format parameter to pick).
ProvenanceNamespace::new validates prefix as an XML Namespaces NCName
(rejecting, among other things, :, whitespace, and the reserved xml/
xmlns names) and iri as an absolute IRI, since prefix is spliced
directly into XML element/attribute names and cannot be escaped the way text
content can:
#![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.
Related
- SPARQL: Querying — producing the
SparqlResultin the first place. - docs.rs/purrdf-sparql-results — the full API reference.
Full-Text Search
What it replaces, and where it stops. This is the surface that lets an RDF
project drop the PostgreSQL tsvector/tsquery it kept beside its triple
store for ranked text search: the question becomes a property-function call in
the SPARQL query that already holds the graph, in-process, over the same
dataset, and the answer is byte-identical natively and on wasm32. It is BM25
ranking, not a Lucene — Unicode case folding and word-boundary segmentation,
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.?scoreis anxsd:decimalcarrying the exact BM25 value,?rankis the document’s 1-based position within its corpus partition ((graph, language)for the defaultGraphLanguageprofile),?langis the partition’s language tag, and?matchedcounts how many distinct needle terms the document holds. - Term occurrence (
TermOccurrenceRelation) emits one row per occurrence of a single term, with its token?position.
Phrase and proximity search are delivered by composition in SPARQL rather than by an embedded query dialect — an embedded syntax would have minted one more incompatible vendor language, which is exactly what a carrier must not do:
# "quick" immediately followed by "brown"
SELECT ?doc WHERE {
?doc <https://example.org/pf/occurs> ( "quick" ?l ?p1 ) .
?doc <https://example.org/pf/occurs> ( "brown" ?l ?p2 ) .
FILTER(?p2 = ?p1 + 1)
}
FILTER(ABS(?p2 - ?p1) <= 3) is proximity; FILTER(?matched = 2) on the
ranked relation is conjunctive retrieval.
Wiring it from Rust
The index takes the predicates whose literals it should read and a graph
selector — GraphSelector::Any, Default, or Named(iri) — and PurRDF
supplies no default for either. A configuration naming no predicate is a
typed TextError::Config, not a guess.
use std::sync::Arc;
use purrdf::sparql::{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 kis top-k. Emission order is rank order, so the evaluator’s row-ceiling licence applies and the relation stops afterkrows.ORDER BY DESC(?score) LIMIT kis not pushed down —ORDER BYplusLIMIThas no certified lower bound the governor can license — and theORDER BYis redundant anyway. ORDER BY ?rankis the reproducing idiom. A score reaches the consumer as a decimal of fixed width, so two rows can report the same?scorewhile carrying different?rank;ORDER BY DESC(?score)can therefore disagree with the exact internal order, and?rankcannot.
RDF 1.2 first class
RdfDataset::quads() returns only the asserted triple table; annotations live
in a separate side table. The index reads both layers, so text carried only by
:s :p :o {| :note "..." |} is searchable, with the reifier as the document
subject. An index reading only the asserted layer would index zero annotation
literals and report nothing — the crate’s tests guard against exactly that.
Stated limits
- Document ids are a function of content except through blank-node labels, which are a parsing artifact: two isomorphic datasets with different labels produce different index fingerprints.
- The index is in-memory and built over a frozen dataset.
verify_bindingchecks that the index a query runs against was built over the dataset in front of it, closing the silent channel where a stale index emits documents that join back to zero rows.
The scoring design record — including the fixed-point logarithm and the
Unicode table versions folded into the index fingerprint — is
docs/design/purrdf-text-scoring.md.
GeoSPARQL
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:
| position | name | role |
|---|---|---|
| 0 | ?neighbour | out: the RDF term whose vector was retrieved |
| 1 | ?query | in: the term whose vector seeds the search |
| 2 | k | in: how many neighbours to retrieve |
| 3 | ?distance | out: the distance, as xsd:double |
The 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, withsh:detailresults), list-valuedsh:class,sh:datatypeandsh:nodeKind(includingsh:TripleTerm),sh:singleLine,sh:rootClass,sh:someValue, path-valued property pairs andsh:subsetOf,sh:uniqueValuesFor,sh:closed sh:ByTypes,sh:reifierShapeandsh:reificationRequired, andsh:uniqueLangover language tag and base direction. A property shape’ssh:valuesandsh:defaultValuecompute value nodes. The targets include implicit class targets (sh:ShapeClasstoo),sh:shapein the data graph,sh:targetWhereand node-expressionsh:targetNode. A reifier of a constraint triple can carrysh:deactivated,sh:severityorsh:messagefor that one constraint. The severities includesh:Debugandsh:Trace, and conformance follows the request’ssh:conformanceDisallowsset. Everysh:messageis reported with its language tag and direction. - Exact numeric ranges —
sh:minInclusive,sh:maxInclusive,sh:minExclusive,sh:maxExclusive,sh:lessThanandsh:lessThanOrEqualscompare numbers with the SPARQL operators, as SHACL defines them. Anxsd:integerorxsd:decimalliteral of any size is compared exactly, never through a double, so9007199254740992is below a bound of9007199254740993. 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:SPARQLFunctioncalls,sh:SPARQLTargetType,sh:sparqlExpr, andsh:prefixesresolved as that document specifies (see below), all on the native SPARQL engine. A SPARQL-based constraint’ssh:deactivatedis a node expression under the default law andshacl-ed-2026-10-16; the working draft and the 2017 Recommendation allowtrueorfalsealone 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-AFsh: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:iftakessh:thenonly when its condition is the list( true ). Theshnex-sparql.ttlfunction library is native.sh:nodeByExpressionandsh:ExpressionConstraintComponentare validated. - SHACL 1.2 Inference Rules —
sh:TripleRuleandsh:SPARQLRule, withsh:condition,sh:layer,sh:order,sh:runOnce,sh:deactivated, rule sets andsh:RulesGraph, SPARQL rule templates, temporary triples,sh:expectedPredicateandsh: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 ascendingsh: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 unresolvablesh:conditionis 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:JSConstraintand the rest), which are not part of SHACL 1.2; this engine has no JavaScript engine to evaluate them. The refusal isShapesError::ShaclJs, and it applies where a shape reaches SHACL-JS:sh:jsor a SHACL-JS type on the shape, ash:JSTargetor an instance of ash:JSTargetTypeas its target, ash:JSRuleamong its rules, and a call to ash:JSFunctionfrom a node expression or from SPARQL the shape runs. A library that only declaressh:JSLibrarys andsh:JSFunctions nothing calls loads, and those declarations are inert. Ash:JSValidatorattached to a constraint component is not inert: it is neither an ASK- nor a SELECT-based validator, so as a value ofsh:validator(validator-class),sh:nodeValidator(nodeValidator-class) orsh:propertyValidator(propertyValidator-class) it makes the shapes graph ill-formed, and the load fails withShapesError::IllFormedwhether or not a shape uses the component; sh:describeandsh:updateon a shape, a node expression, a SPARQL-based constraint, a validator or a rule. The SHACL 1.2 vocabulary declares them as the queries ofsh:SPARQLDescribeExecutableandsh: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:resultAnnotationon a SPARQL-based constraint or on a validator of a SPARQL-based constraint component adds itssh:annotationPropertyto every result the query produces. The value is the solution’s binding ofsh:annotationVarName, or of the property’s local name when no name is given. When that variable is unbound, thesh:annotationValuedefaults are used. An ASK validator’s annotations readthis,valueand the component’s parameters. The annotations appear onValidationResult::annotations, in the report graph, in the SARIF propertyshaclResultAnnotations, 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 assh:focusNodeused 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. Itssh:nodesis 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 --baseor 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 ash:RulesGraphor a node of a class the document declaresrdfs: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:
| Host | Import table | Refusal |
|---|---|---|
| Rust | a 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) pairs | ShapesError::Imports(ShapesImportError::Unresolved | Unreached | InvalidEntry | UnresolvedLink | UnheldLink | InvalidLink | IncompatibleVersions) |
| CLI | --import IRI=FILE, repeatable | exit 1 for an unresolved import; exit 2 for a pair nothing imports or a malformed pair |
| Python | imports=[(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 |
| WebAssembly | trailing importIris, importDocuments arrays on every shacl* function that takes a shapes graph | ShaclImportError with kind, iris and message |
| C ABI | import_iris, import_documents, import_count before the out-parameters | PURRDF_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:prefixesuses 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 andshacl-ed-2026-10-16read the editor’s draft pathsh:prefixes/(^owl:versionIRI)?/(owl:imports/(^owl:versionIRI)?)*/sh:declare.shacl-wd-2026-09-18reads the working draft pathsh: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-20readssh:prefixes/owl:imports*/sh:declare, with noowl:versionIRIstep. - A query without
sh:prefixesuses everysh:declareof every SHACL instance ofowl:Ontology,sh:DataGraph,sh:ShapesGraphorsh:RulesGraphin 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 issh:DataGraphsupplies 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:stringsh:prefixand exactly onesh: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:
| Host | Spelling |
|---|---|
| CLI | --shapes-graph IRI on validate, shapes lint, shacl pack and rules |
| Python | shapes_graph= on validate, Shapes(...), pack_product, lint_shapes, apply_rules and entail |
| WebAssembly | a trailing shapesGraph on shaclValidateToSarif, shaclValidateChangesToSarif, shaclPackProduct, shaclLintShapes, shaclApplyRules and shaclEntail |
| C | shapes_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 |
| Rust | engine::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.
| Host | Spelling |
|---|---|
| CLI | --subclass-of-in-shapes-graph on validate (document and product routes) |
| Python | subclass_of_in_shapes_graph=True on validate and Shapes(...) |
| WebAssembly | a trailing subClassOfInShapesGraph on shaclValidateToSarif |
| C | bool subclass_of_in_shapes_graph after the import table on purrdf_shacl_validate_to_sarif |
| Rust | ValidationOptions::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:askorsh:selectof 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-JSsh:JSValidatorunder 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 aMINUSin its pre-bound query does not fail the load:purrdf shapes lintlists it underunexecuted; - any other
sh:statement on a built-in’s declaration fails the load, exceptsh:message,sh:labelTemplateand the non-validating characteristics (sh:name,sh:description, …):sh:severityonsh: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:ffails the load, and so does a bodilesssh: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
@idis the full IRI, or_:labelfor a blank node. It is never a compact IRI, so a lexical constraint judgesstr()of an IRI directly. Keys and@typevalues stay compacted. - A well-formed RDF list is the JSON-LD list object
{"@list": [...]}, and its cells are not@graphnodes.rdf:nilis{"@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 typedrdf:Listincluded) keeps the node-graph form. A list that is a member of another list carries its head cell’s label as@index.@indexis 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:integerwithin 64 bits, andxsd:booleantrueorfalse. Every other numeric literal keeps its{"@value", "@type"}object. - An
rdf:dirLangStringliteral 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
@graphnodes 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’srdf:first. - A node shape’s
sh:uniqueMembersdoes 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
1are no finite union of the intervals and residue classesminimum,maximumandmultipleOfstate. - A range bound over
xsd:doubleorxsd:floatlexical 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:
| Boundary | Located loss families |
|---|---|
| object fields and names | additional properties, pattern properties, property names/counts |
| requiredness and recursion | nullable-presence widening, one deterministic recursive-input nullability relaxation |
| lists | singleton coercion, cardinality, contains, uniqueness, tuples, unevaluated items |
| scalar assertions | integer domain delegation, numeric predicates, string predicates |
| applicators | conditionals, dependencies, intersections, overlapping and type-array unions, oneOf, negation |
| runtime boundary | custom-scalar and unknown-keyword validation delegation |
The caller-named fallback scalar is declared but PurRDF does not invent its
parseValue, parseLiteral, or serialization semantics. Every delegated use
is therefore ledgered. Loss entries carry stable codes and source JSON Pointer
locations; an exact package has an empty ledger.
Emission fails before returning bytes for invalid caller configuration,
malformed schema keywords, $id rebasing, external/indirect/dangling $ref,
$dynamicRef/$recursiveRef, alias cycles, unsatisfiable closed required
fields, and generated-name collisions. The 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.
| Tool | Rust | CLI | Python (purrdf.shapes) | WebAssembly | C ABI |
|---|---|---|---|---|---|
| Run rules, write the inference graph | purrdf_shapes::infer, srl::infer | purrdf rules | apply_rules | shaclApplyRules | purrdf_shacl_apply_rules |
| Check a SPARQL 1.2 RL rule set, run nothing | srl::check | purrdf rules --srl FILE --check | check_rules | shaclCheckRules | purrdf_shacl_check_rules |
| Evaluate one node expression | free_expression::evaluate | purrdf node-expr | eval_node_expr | shaclEvalNodeExpr | purrdf_shacl_eval_node_expr |
| Certify a shapes graph | lint::lint | purrdf shapes lint | lint_shapes | shaclLintShapes | purrdf_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), whereNis 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:ptriples toex: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
_:labelfor 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:Labelthensh:valuesis that property shape’s computed-values expression. A W3Csht:EvalNodeExprtest entry’s expression is the entry, thenmf:action, thensht:nodeExpr. All 143 of the suite’s node-expression tests run throughpurrdf node-exprthis 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.
| Host | Node | Walk | Inline Turtle |
|---|---|---|---|
| CLI | --expr | --expr-at NODE --expr-via PREDICATE… | --expr-turtle, --expr-turtle-file |
| Python | expr | expr_at=, expr_via=[…] | expr_turtle= |
| WebAssembly | expr | exprAt, exprVia | exprTurtle |
| C ABI | expr | expr_at, expr_via, expr_via_count | expr_turtle |
Rust (purrdf_validate) | ExprSelector::Node | ExprSelector::At | ExprSelector::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:
load: the loader’s verdict, accepted or the refusal it raised.shacl-shacl: every result of validating the shapes graph against the vendoredshacl-shacl.ttl. That file predates some SHACL 1.2 Core relaxations (a list-valuedsh:nodeKind, a path-valuedsh:equals, a node-expressionsh:targetNode). A result SHACL 1.2 Core makes well-formed is markedsupersededand is not a finding, but only when the loader accepted the graph.functions: which implementation every node-expression function call binds to —native,custom,sparql-registeredorhost-extension. For a shapes graph that merges the W3C SHACL 1.2 vocabularies, ash:sparqlExprcall bindsnativeeven though the graph declaressh:SPARQLExprExpression.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.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, ash:SPARQLFunctionnothing calls, or ash:SPARQLTargetTypeno 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.diagnostics: every mandatory diagnostic, onediagnostic RULE SHAPEline per shape whosesh:inorsh:xonelist is empty.RULEis the syntax rule id,in-minListLengthorxone-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.ttlwarns about the same empty list; that result is listed markeddiagnosed RULEand is not counted a second time.unanchored-imports: everyowl:importstriple of the closure that is not an import, because its subject is no anchor of its document (seeowl:importsin a shapes graph). Each is data, so no document was looked for. These lines are never findings. The case the W3C flags is reported byshacl-shaclinstead: itsshsh:DataGraphImportsShapereports, at severitysh:Info, ash:DataGraphthat usesowl:importswithout the typeowl: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.
| Host | Spelling |
|---|---|
| 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 |
| Python | shacl_profile= on shapes.validate and the Shapes constructor |
| WebAssembly | a trailing shaclProfile on shaclValidateToSarif; its asynchronous twin takes it in its options object |
| C | shacl_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.
| Host | Spelling |
|---|---|
| Rust | PreparedShapes::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 |
| Python | xpath_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 |
| WebAssembly | a trailing xpathRegex on shaclValidateToSarif, shaclValidateChangesToSarif, the four shaclProductValidateToSarif… entries, shaclEntail, shaclApplyRules and shaclEvalNodeExpr; their asynchronous twins take it in their options object |
| C | regex_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:
| Section | Carries |
|---|---|
| identity | the preparation stage id, the profile id, and the parse inputs (base, prefix map, sh:shapesGraph IRI, box-role vocabulary) |
| dataset | the shapes dataset itself, in PurRDF’s succinct pack container |
| model | the 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:sparqlquery 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:
rebuildrestores 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:
| Flag | Why it is refused | Where it belongs |
|---|---|---|
--shapes-from | names the syntax a shapes document is read as, and a product is not a document | not applicable |
--shapes-graph | the product records its own sh:shapesGraph IRI | pass --shapes-graph IRI to purrdf shacl pack and re-pack |
--import | the owl:imports closure is folded at pack time | pass --import IRI=FILE to purrdf shacl pack |
--box-role-vocab | the 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.
| Dimension | What it means | What to do |
|---|---|---|
magic | these bytes never were a prepared shapes product | hand the loader a file written by purrdf shacl pack |
format-version | the container format is not the one this build decodes | re-pack with the PurRDF build that will execute it |
stage-id | the model memo was written against a model this build no longer has | restore with rebuild, or re-pack |
profile | prepared under a different preparation profile | re-pack with the build that will execute it |
truncated | the bytes end inside a structure they declared | re-pack — this is an interrupted write, not corruption in place |
trailer | the trailer is absent or inconsistent with the bytes ahead of it | discard and re-pack |
section-digest | a section no longer matches its recorded digest | discard and re-pack — the product is corrupt in place |
container-digest | the whole-container digest disagrees with the bytes | discard and re-pack — something outside the section bodies moved |
shapes-graph | prepared under a different sh:shapesGraph IRI — or, under --expect-identity, carrying an input binding that is not the one you required | expose the shapes under the same named graph and re-pack with that IRI, or point the expectation at the product you meant |
prefixes | prepared against a different prefix map | pack and execute under the same prefixes — the map decides which IRI a prefixed name denotes |
base | prepared against a different base IRI | pack and execute under the same base — relative references resolve against it |
vocabulary | prepared under a different box-role vocabulary | pass 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-registry | prepared against a different SPARQL function registry, or against a different build of the host implementations behind it | wire the same functions into the executing host, under the same implementation identity |
aggregate-registry | prepared against a different custom-aggregate registry, or against a different build of the host implementations behind it | wire the same aggregates into the executing host, under the same implementation identity |
property-function-registry | prepared against a different property-function registry, or against a different build of the host implementations behind it | wire the same relations into the executing host, under the same implementation identity |
class-catalog | the class analysis the product carries is not the one its own identity pins | discard and re-pack from the shapes graph — the section and the binding over it no longer describe one analysis |
parse-configuration | prepared under different declared extension-function or relation namespaces | declare the same namespaces on the executing host |
included-graphs | the product’s shapes graph was assembled from a different set of named graphs (its --import documents and resolved sh:shapesGraph links) than its binding pins | re-pack with the same import table — the recorded graphs decide which data-graph links the product holds |
unsupported-capability | well-formed bytes asking for something this build cannot honour, or a preparation this build will not write a product for | see 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-limit | a structure nests past the decoder’s fixed ceiling | re-pack from a shapes graph this build parses; the ceiling is a stack guard, not a semantic limit |
malformed | structurally invalid in a way no other dimension names | discard 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
| Dimension | What it means | What to do |
|---|---|---|
dataset-identity | the shapes dataset the product carries does not canonicalize to the digest its own binding claims | discard 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
| Command | Does | Exit 0 | Exit 1 | Exit 2 |
|---|---|---|---|---|
purrdf shacl pack --shapes FILE --out OUT [--base IRI] [--shapes-graph IRI] [--import IRI=FILE] [--box-role-vocab NS] | parse, prepare, write the product | product written | the shapes did not parse, the graph declares something a product cannot carry, or an owl:imports is unresolved | bad 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 identity | prints the identity digest | refused, with shacl dimension <label> on stderr | bad flags |
purrdf shacl explain [IN] | print what the product says it was compiled from | prints the key value rendering | the bytes are not a well-formed product | bad flags |
purrdf shacl diff A B | compare two products’ declared identities, without admitting either | the two identities are identical | the 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 memo | validation ran | product refused | bad 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.
Related
- 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
--baserecords 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, theAND/OR/NOTshape algebra, node constraints and the facet table, value sets with stems/ranges/exclusions, triple expressions with all cardinality forms,$/&labels and inclusions,^inverse, annotations and%…{ … %}semantic actions, with relative-IRI resolution againstBASEviapurrdf-iri. - ShExJ (the JSON wire format, spec Appendix A) — 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.
| Host | Spelling |
|---|---|
| Rust | validate_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 |
| Python | xpath_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 point | Regime(s) | Engine |
|---|---|---|
materialize(ds, regime) | Simple, RDF, RDFS, OWL-RL, D | Forward materialization (“chase”) of the regime’s declared clause program via a native semi-naive fixpoint. Returns (closure, ReasoningReport); the report is not optional. |
materialize_dl_reported(...), or materialize(ds, Materialization::OwlDirect(bgp)) | OWL-Direct | Open-world OWL DL over a SHOIQ(D) tableau, directed by the query’s basic graph pattern bgp; materialize delegates to it for this regime rather than restating it. |
materialize_rif(...) | RIF | RIF-Core rule entailment over a parsed RuleSet. |
parse_rif_xml(...) / resolve_rif_imports(...) | RIF | RIF-XML parsing with caller-owned, I/O-free import resolution. |
rules(regime) / implemented(regime) | — | The rule table a regime is defined by, and the subset this workspace fires. Their difference is the measurable gap. |
calculus_program(regime) | — | The regime’s calculus as DL-clause data — the very program materialize evaluates, so a consumer can recompute its contract hash. |
Regime::from_iri(iri) | — | Parse a sparql:entailmentRegime IRI to its enum. |
#![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.
| Host | Materialize | Defined rule table | Implemented rules |
|---|---|---|---|
| Rust | materialize(&ds, Materialization::Rdfs) | rules(Regime::Rdfs) | implemented(Regime::Rdfs) |
| CLI | purrdf reason --regime rdfs, purrdf convert --entailment rdfs, purrdf query --entailment rdfs (and purrdf entails asks the conclusion-directed question) | — | — |
| Python | purrdf.entail.materialize(dataset, "rdfs", "", imports, premise_iris), purrdf.entail.materialize_nt(text, "rdfs", "", imports, premise_iris) | purrdf.entail.rules("rdfs") | purrdf.entail.implemented_rules("rdfs") |
| JavaScript / WebAssembly | entailMaterialize(doc, "rdfs", "", iris, docs, premiseIris) | entailRules("rdfs") | entailImplementedRules("rdfs") |
| C | purrdf_entail_materialize_to_nquads(...) | purrdf_entail_rules(...) | purrdf_entail_implemented_rules(...) |
Every host materializes every regime; none refuses one. What two regimes need is an
INPUT, and each host has a parameter for it: --rules <FILE> on the CLI, a
program string on the Python, WebAssembly and C surfaces, and the
Materialization value itself in Rust. rif takes a normative RIF-in-XML rule
document there; every other regime takes none, and supplying one is an error rather
than a discarded argument. owl-direct’s extra input is a query’s class
expressions, so a document-in/document-out call runs the query-independent
augmentation and a query surface (purrdf::query_with_entailment,
purrdf query --entailment owl-direct) is where the query-directed lane lives.
One host-specific note:
- The WebAssembly module also exports
entailCheckGoldenVectors(), which replays the committed tri-host vector artifact inside the module a consumer actually loaded — so agreement with the reference implementation can be checked without trusting this repository’s CI.
Asking a question instead: the conclusion-directed services
Materializing answers “what does this premise entail?”. The three services below answer “does this premise entail that?”, which is a different question and not the membership test in a closure it looks like: a conclusion’s blank nodes are existentials that have to be mapped, an inconsistent premise entails everything, a failure to find a mapping means nothing unless the rule set is complete for the premise it ran on — and a conclusion can be entailed while appearing nowhere in the closure at all, which is what the five mechanisms beyond the rule table exist for (see Conformance).
They run over the same shared boundary, on all five host shapes, and the
purrdf command line is one of them. scripts/check-entailment-surface.py is the
gate: it derives the service set from purrdf-entail’s own public entry points and
fails until every one of them is reachable from every host with the boundary’s
whole parameter list, so a service or a parameter cannot land on four hosts and go
dark on the fifth. It also mutation-tests itself on every run — one edit per check,
applied in memory over the committed tree, each of which must make the gate fail —
because a check that cannot withhold a green light is not a check.
| Host | Does P entail C? | Re-decide the warrant | Certain answers of a pattern |
|---|---|---|---|
| Rust | entails(&p, &c, Regime::OwlRl, &imports) | verify(warrant, &p, &c) | certain_answers(&p, &bgp, Regime::OwlRl, &imports) |
| CLI | purrdf entails --regime owl-rl --premise P --conclusion C | … --conclusion C --verify | … --pattern BGP |
| Python | purrdf.entail.graph_entails("owl-rl", p, c, imports, premise_iris) | purrdf.entail.verify_entailment(...) | purrdf.entail.certain_answers("owl-rl", p, bgp, imports, premise_iris) |
| JavaScript / WebAssembly | entailGraphEntails("owl-rl", p, c, iris, docs, premiseIris) | entailVerifyEntailment(...) | entailCertainAnswers(...) |
| C | purrdf_entail_graph_entails(...) | purrdf_entail_verify_entailment(...) | purrdf_entail_certain_answers(...) |
Two things differ from the materializing table above, and both are consequences of the question rather than of any host:
- Five regimes, not seven.
owl-directis directed by a query’s class expressions andrifentails under the caller’s rule document, and “premise, conclusion, regime” carries neither. Both are refused by name on every host — never answered under a weaker regime and labelled with the one that was asked for — and both still materialize. - The import table is a parameter, on every host. An ontology’s imports closure
is the ontology, so a premise carrying an
owl:importsthe call was not handed is a different premise. PurRDF fetches nothing, so the closure arrives as caller-supplied configuration: an ordered list of(ontology IRI, document)pairs, spelled--import IRI=FILEon the command line. An import needs no pair when it names the premise document itself — the CLI’sfile://retrieval IRI or--base, and on the Python, WebAssembly and C hosts thepremise_irisargument (premiseIrisin 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 anowl:versionIRInaming 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 (CLIreason,convert --entailment; Pythonmaterialize/materialize_nt; WebAssemblyentailMaterialize; Cpurrdf_entail_materialize_to_nquads; Rustmaterialize_with_imports), the entailment-regime query (CLIquery --entailment … --import IRI=FILE; PythonStore.query_entailment_governed(..., imports=…, premise_iris=…); WebAssemblyqueryEntailmentGoverned’simportIris/importDocuments/premiseIrisoptions; Cpurrdf_query_entailment_governed’simport_iris/import_documents/import_count; Rustquery_with_entailment_closure_governed), and the OWL-Direct consistency question (CLIconsistency --import IRI=FILE; Pythonconsistency(data, imports, premise_iris); WebAssemblyentailConsistency(doc, iris, docs, premiseIris, …); Cpurrdf_entail_consistency). Each closes or decides the merged premise, statesontology-import-resolvedin its report or certificate, and refuses an unresolved import or an unreached entry exactly asreasondoes. 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. Theowl:importsread are the same ones SHACL validation reads: on the premise’s own IRI, on anowl:Ontologyheader, on ash:ShapesGraph(sh:RulesGraphand subclasses included), or on a node naming one of those as itsowl:versionIRI. Anowl:importson any other node, a node that is only ash:DataGraphincluded, 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:
| Regime | Rule table | Defined | Implemented |
|---|---|---|---|
Simple | — (identity closure) | 0 | 0 |
RDF | RDF 1.2 Semantics §8.1.1 | 3 | 3 |
RDFS | RDF 1.2 Semantics §8.1.1 + §9.2.1 | 18 | 18 |
OWL-RL | OWL 2 Profiles §4.3 Tables 4–9 | 78 | 78 |
D | OWL 2 Profiles §4.3 Table 8 | 5 | 5 |
OWL-Direct | — (SHOIQ(D) tableau, not a fixed table) | 0 | 0 |
RIF | — (caller-supplied rule set) | 0 | 0 |
The per-rule breakdown — every rule id, its specification citation, and whether it is fired — is generated from that API and drift-guarded, so it cannot fall behind the code.
Where the numbers stop:
- The four existential rules fire, but their conclusions are withheld.
rdfD1,rdfD1a,rdfs14andrdfs14aeach conclude about a fresh blank node. The restricted chase mints each one as a frontier-addressed Skolem witness and closes under it, so the rules genuinely fire — but every conclusion mentioning a surrogate is dropped when the closure is materialized back, because a SPARQL entailment regime draws its answers from the scoping graph and a surrogate is not in it. The withholding is reported asConstruct::Surrogate. Nothing surrogate-free is lost: replacing a term with a fresh blank node only weakens a triple. - A complete rule table is not a complete closure.
OWL-RLfires all 78 rules, and a run that met a boundary still reportsCompleteness::ExactWithinBoundariesrather thanExact. The two claims are reported separately on purpose. Nor is a complete rule table entailment conformance: on this vendored W3C corpus of OWL 2 RL entailment testsentails()reaches 27 of 27 published positive entailments, and agrees with W3C on 23 of 23 negative ones — 3 of those 23 refuted, a decided non-entailment, and 20 admitted, the closure computed and observed not to contain the non-conclusion. Read23 of 23as “no unsoundness found”, never as “23 non-entailments proved” (see Conformance below). 78 / 78 says every rule of Tables 4–9 is implemented — and the one W3C-published entailment that is reachable only by a sound rule outside those tables is reached by an extension,ext-eq-diff-sym, whichextensions(Regime::OwlRl)names, neitherrules()norimplemented()names, and every report renders on its ownextensionline. Eight more are reached by refutation rather than by matching, and those add no rule at all: see the conformance section below. - Seventeen OWL 2 RL rules conclude
false. “Implemented” for those means decided: a body match becomesEntailError::Inconsistentcarrying a witness that names the rule and the asserted triples that satisfied it. That is the only thing a rule with no conclusion can do.
The chase (Simple / RDF / RDFS / OWL-RL / D)
materialize runs a forward-materialization chase: a fixed rule set for the
selected regime, applied by a semi-naive fixpoint until no new quads appear.
Because it runs over the frozen IR, it is deterministic — a given input and
regime always yields the same closure — and because it works in TermId
space, no term is ever re-parsed or re-serialized along the way.
Typical use: materialize first, then query with the plain SPARQL engine or validate the closure with SHACL (the SHACL validator itself performs no inference).
The rule set is not written twice. calculus_program(regime) renders it as
DL clauses and materialize evaluates exactly those clauses through
purrdf-datalog’s semi-naive evaluator, so the contract hash a
report carries identifies the clauses that actually ran.
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 fromrules(regime)minusimplemented(regime), so it improves by itself as rules are added, and it names themissingrules rather than merely counting them;- per-rule firing counts — which rules fired and how many conclusions each contributed;
Boundarys — the constructs the run met and could not close over, each with its reason;- the evaluation budget — what the run consumed 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
falsematched: the rule id, the asserted triples that satisfied its premises in premise order, and the graph they were read from.
A report cannot claim Exact while naming a boundary. ReasoningReport stores
no completeness field at all: completeness() derives the value from the
boundary list itself, so the contradictory state is unrepresentable rather than
merely checked. That is deliberate — an earlier design stored the field and
compared it against a derivation of the same inputs, which is vacuous by
construction and could never fail.
The rendering is byte-stable, so the Python, WebAssembly, and C hosts hand back the same report text as Rust for the same input.
OWL-Direct: the tableau
OWL-Direct semantics is open-world Description Logic, which a forward chase
cannot answer. materialize_dl_reported runs an SHOIQ(D) tableau instead —
answering instance and subsumption queries via classification, realization,
and query-directed materialization. Because it needs the query’s class
expressions, it takes them as its own query_bgp parameter; materialize
reaches the same tableau by delegating to it for Materialization::OwlDirect
rather than restating it.
RIF
materialize_rif evaluates RIF-Core rules over a parsed RuleSet,
covering the SPARQL RIF entailment regime.
D (datatype) entailment
D is materialized, not refused. PurRDF realizes it as Simple entailment plus
the five dt-* rules of OWL 2 Profiles §4.3 Table 8 — the fixed rule table a
forward chase can enumerate for it — decided over the XSD value space by
purrdf-xsd rather than by comparing lexical forms.
What Table 8 does not cover is the infinite value spaces themselves, and that is
reported as a Construct::DatatypeValueSpace boundary on the run rather than
claimed. So a D closure is complete within its stated boundary, and the report
is where the boundary is stated.
Every host materializes d, the command-line tool included.
There is no unsupported-regime error. materialize takes a Materialization,
which carries each regime’s own input — a basic graph pattern for OWL-Direct, a
RuleSet for RIF — so 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
vocabmodule is a standardrdf:/rdfs:/owl:IRI drawn from the entailment specs themselves — the crate fabricates none, per the toolkit-not-ontology rule. - Dependency-lean and wasm-clean. The dependencies are
purrdf-core,purrdf-datalog,purrdf-xsd,purrdf-lex,purrdf-iri,purrdf-hash, andhashbrown’s raw hash table, keyed bypurrdf-core’s fixed-key hasher — every one of themwasm32-unknown-unknown-clean, so the engines carry into Rust, Python, WebAssembly, and C unchanged, with no threads, filesystem, or RNG dependency. - Deterministic. Same input + regime → same closure, always — and the same report, byte for byte.
Conformance
Two corpora measure two different things, and the distinction matters:
-
W3C SPARQL 1.1 entailment-regime group — 70 of 70 cases pass, with zero ledgered residuals: the RDF/RDFS/OWL-RL chase, the OWL-Direct (DL) tableau, the RIF-Core rule engine, and RDF-axiomatic predicate typing, all run through the SPARQL conformance harness.
-
W3C OWL 2 test suite — 262 of 262 cases agree, 0 ledgered, zero unledgered. This corpus is consistency-shaped: all 262 vendored cases are
otest:ConsistencyTest(226) orotest:InconsistencyTest(36). It therefore grades the DL/tableau lane’s satisfiability verdicts and says nothing about the OWL 2 RL rule table. 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’sdl_probecolumn and described in that suite’sPROVENANCE.md. The harness reads the column and prints it on every run; it does NOT re-run the reasoner over the 220 excluded cases, so this row cannot detect a regression among them. Re-deriving them means re-running the probe, which is a deliberate act rather than part of the gate. -
W3C OWL 2 RL entailment tests — 50 of 50 cases agree, 0 ledgered, zero unledgered. This is the independent oracle for the rule table: W3C’s own entailment tests, answered by one call to
purrdf_entail::entails()per case underRegime::OwlRl. The two lanes prove different things and are reported separately.The negative lane is 23 of 23: no unsoundness. The chase never derived a triple W3C publishes as not entailed. That is the safety result, and it holds over all 23 negative cases — soundness is owed on every case, so none were filtered by profile.
Those 23 agreements are two different results, and the harness prints the split on a scoreboard line of its own:
OWL2-RL-NEGATIVE: total 23 = refuted 3 + admitted 20 (premise-outside-rl 5, conclusion-outside-rl 10, construct-not-read 5, refutation-budget 0, freeze-budget 0, data-range-containment 0) + unsound 0 + withheld 0Three are decided non-entailments — both halves of Theorem PR1’s hypothesis hold, so the closure’s failure to contain the non-conclusion is a proof. The other 20 admit: the closure was computed and does not contain the non-conclusion, which is the whole of the soundness observation, and nothing beyond it is claimed. Both agree, and correctly so; what they differ in is discriminating power, since a reasoner that derived nothing at all would score
negative 23 of 23withrefuted 0. Read23 of 23as “no unsoundness found”, never as “23 non-entailments proved”.The positive lane is 27 of 27 — the 27 positive entailments W3C itself places inside the RL profile under RDF-Based semantics — and the typed divergence ledger
purrdf_sparql_conformance::owl2_rl::LEDGERis EMPTY — 0schema-conclusion, 0negative-conclusion, 0construct-outside-rl, 0imports-unresolved, and 0 are actionable (0missing-rule).Every class it used to hold is closed, and the rule table did not change once to close any of them.
entails()reaches a conclusion six ways, and five of the six are not matching:- refutation. A negative fact still has no head anywhere in Tables 4–9 —
no rule concludes
owl:differentFrom, and none concludes membership in anowl:complementOfclass. What the table does have is seventeen rules whose conclusion isfalse, and those seventeen are an inconsistency calculus: assert the conclusion’s negation into the premise, re-run the same seventy-eight rules over a premise whose consistency the first run already established, and read the resulting inconsistency as the proof. Anowl:AllDifferentcollection is, by OWL 2’s own definition, the conjunction of itsn(n−1)/2pairwise inequalities, so it lowers to the same shape and is entailed exactly when every pair refutes — which is why two entries left theschema-conclusionclass with them. - freeze-and-chase.
p rdf:type owl:TransitivePropertyabbreviates a universally quantified Horn implication, and an implication is decided by generalisation on constants: freeze its body over constants the premise does not mention, re-run the table, and look for the head.chain2trans1’s arrives throughprp-spo2, one of the 78. The axiom’s other conjunct —pis an object property — is a lookup in the premise’s own closure, and it is owed: a schema axiom is a conjunction and establishing only the interesting half would claim conclusions the semantics does not license. - comprehension. A conclusion may assert that a CLASS EXISTS — an
anonymous
owl:unionOf, an anonymousowl:Restriction— which the RDF-Based semantics’ own comprehension conditions license, subject to a typing side condition on the operands. Only the scaffolds the conclusion names are minted, over blank nodes checked absent from both documents. - reflexivity.
owl:ReflexivePropertyis outside the RL syntax, so the profile states no rule for it — and a rule that did would range over every resource, widening a closure every consumer computes by default. The conclusion’s own self-loops are read off the premise’s reflexive typings instead. - datatype containment. A property’s declared
rdfs:rangedatatypes intersect, and the intersection may be contained in one the premise never mentions —xsd:byte ⊑ xsd:short, andshort ⊓ unsignedInt ⊑ unsignedShort, neither of which a join over triples can discover. Decided over the XSD value spaces, three-valued, with the negative answer gated on the counterexample range being exactly decided.
The last case needed no mechanism at all, only the document its premise names:
webont-imports-011owl:importsa support ontology the upstream manifest does not inline, so it is vendored beside the cases from W3C’s own URL and supplied toentails()as caller-owned configuration. The library still fetches nothing.Nothing about the inventory moves:
rules(Regime::OwlRl)andimplemented(Regime::OwlRl)are still exactly the same 78,extensions(Regime::OwlRl)is still the oneext-eq-diff-sym, and strictMaterialization::OwlRloutput is byte-for-byte what it was. The evidence moves instead — each mechanism arrives with its ownEntailmentWarrantarm carrying what it actually used (thefalse-concluding rule that fired and a minimal entailing premise subset; the frozen constants, body and head; the minted triples and the closure triples that license them) and its own checker that re-decides the whole thing without running a reasoner.The one case that used to be actionable is closed by an extension, and the extension is labelled rather than absorbed.
a owl:differentFrom bentailsb owl:differentFrom a, which is sound —owl:differentFromdenotes inequality and inequality is symmetric — and shaped exactly likeprp-symp, yet is not among the 78 rules, because Table 4’sowl:differentFromrules only ever concludefalse. PurRDF states it asext-eq-diff-sym, in a rule family declared to sit outside every specification table:extensions(Regime::OwlRl)returns it,rules()andimplemented()are still exactly the same 78 and return none of it,RuleId::is_extensiondecides which is which, and every rendered report carries anextension ext-eq-diff-symline beside itsmissinglines. So the closure a caller gets is Tables 4–9 plus a list it can read and reject, andOWL-RL 78 / 78remains a claim about Tables 4–9 and nothing else. - refutation. A negative fact still has no head anywhere in Tables 4–9 —
no rule concludes
The live scoreboard is
docs/CONFORMANCE.md.
Entailment Rule Inventory
This file is generated. Do not edit it by hand. It is emitted by
cargo run -p purrdf-entail --example gen_rule_inventory from
purrdf_entail::RuleId, rules(regime) and implemented(regime), and
scripts/check-generated.sh fails the build when the committed copy and a fresh
run disagree. Regenerate with make metadata.
Defined is the rule table the specification defines the regime by
(rules(regime)). Implemented is the subset this workspace’s evaluator
actually fires (implemented(regime)). Their difference is the regime’s gap,
and it is the same set a ReasoningReport names under missing.
Neither column counts an extension — a rule this workspace fires that no
specification table states. Those are listed in their own section below
(extensions(regime)), never folded into a coverage number, so a figure like
OWL-RL 78 / 78 stays a claim about OWL 2 Profiles §4.3 Tables 4–9 and about
nothing else.
78 / 78 and 50 / 50 are two different measurements
This page is the RULE INVENTORY: 78 / 78 says every rule OWL 2 Profiles §4.3
Tables 4–9 states is one the chase fires. It says nothing about how many
published entailments that reaches, and the two figures are measured against
different things and can move independently.
The second measurement is ENTAILMENT CONFORMANCE, over the vendored W3C OWL 2 RL
entailment corpus: 50 of 50 cases agree with W3C’s published verdict, 27 of 27
positive and 23 of 23 negative, with an empty divergence ledger. That figure is
crates/sparql-conformance/entailment-suite/w3c-owl2-rl/’s and is bounded by
what is vendored there — see docs/CONFORMANCE.md, which carries it beside the
corpus it was measured on.
Fifteen of those 50 are reached by a mechanism that exists because the rule
table DECIDES no conclusion of that shape:
refutation, freeze-and-chase, comprehension, reflexivity and datatype
containment, each documented on purrdf_entail::EntailmentMechanism. NONE of
them adds a rule, which is why this inventory is byte-for-byte what it was
before they existed — they change how many times the table is run and what its
false is read as, not what the table states.
The five COMPOSE. A conclusion graph is a conjunction and entailment is monotone
over one, so a conclusion stating a negative fact beside a schema axiom is
entailed when each half is: purrdf_entail::entails() threads the residual
through every lane in turn and matches only what survives, and an answer that
needed two or more of them renders as composite rather than as any single
constituent’s name. No vendored case needs it — each of the 50 is reached by one
lane or by none — so the corpus does not measure it and the crate’s own tests
do.
Coverage by regime
| Regime | --regime | Defined | Implemented |
|---|---|---|---|
| Simple | simple | 0 | 0 |
| RDF | rdf | 3 | 3 |
| RDFS | rdfs | 18 | 18 |
| OWL-RL | owl-rl | 78 | 78 |
| OWL-Direct | owl-direct | 0 | 0 |
| RIF | rif | 0 | 0 |
| D | d | 5 | 5 |
A regime with a zero-length rule table is one this crate does not enumerate
rules for: Simple is the identity closure, and OWL-Direct and RIF are
served by a tableau and by a caller-supplied rule set respectively, neither of
which is a fixed table.
Extensions
A rule this workspace’s evaluator fires that no specification table states.
An extension appears in neither column above, for any regime: rules(regime) and
implemented(regime) name only specification rules, and extensions(regime)
names only these. RuleId::is_extension decides which is which, and a
ReasoningReport renders the list under extension beside the missing list —
so a caller that must act only on normative conclusions can tell from the report
rather than from prose.
Every entry is sound under the semantics of the vocabulary it reads; that is the only standard a rule with no specification to appeal to can meet.
| Regime | --regime | Rule |
|---|---|---|
| OWL-RL | owl-rl | ext-eq-diff-sym |
RDF — 3 of 3 rules implemented
| Rule | Specification | Implemented |
|---|---|---|
rdfD1 | RDF 1.2 Semantics §8.1.1 (RDF patterns) | yes |
rdfD1a | RDF 1.2 Semantics §8.1.1 (RDF patterns) | yes |
rdfD2 | RDF 1.2 Semantics §8.1.1 (RDF patterns) | yes |
RDFS — 18 of 18 rules implemented
| Rule | Specification | Implemented |
|---|---|---|
rdfD1 | RDF 1.2 Semantics §8.1.1 (RDF patterns) | yes |
rdfD1a | RDF 1.2 Semantics §8.1.1 (RDF patterns) | yes |
rdfD2 | RDF 1.2 Semantics §8.1.1 (RDF patterns) | yes |
rdfs1 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs2 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs3 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs4 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs5 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs6 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs7 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs8 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs9 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs10 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs11 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs12 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs13 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs14 | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
rdfs14a | RDF 1.2 Semantics §9.2.1 (RDFS patterns) | yes |
OWL-RL — 78 of 78 rules implemented
| Rule | Specification | Implemented |
|---|---|---|
eq-ref | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-sym | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-trans | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-rep-s | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-rep-p | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-rep-o | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-diff1 | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-diff2 | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
eq-diff3 | OWL 2 Profiles §4.3 Table 4 (Equality) | yes |
prp-ap | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-dom | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-rng | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-fp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-ifp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-irp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-symp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-asyp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-trp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-spo1 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-spo2 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-eqp1 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-eqp2 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-pdw | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-adp | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-inv1 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-inv2 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-key | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-npa1 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
prp-npa2 | OWL 2 Profiles §4.3 Table 5 (Property Axioms) | yes |
cls-thing | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-nothing1 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-nothing2 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-int1 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-int2 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-uni | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-com | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-svf1 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-svf2 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-avf | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-hv1 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-hv2 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-maxc1 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-maxc2 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-maxqc1 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-maxqc2 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-maxqc3 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-maxqc4 | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cls-oo | OWL 2 Profiles §4.3 Table 6 (Classes) | yes |
cax-sco | OWL 2 Profiles §4.3 Table 7 (Class Axioms) | yes |
cax-eqc1 | OWL 2 Profiles §4.3 Table 7 (Class Axioms) | yes |
cax-eqc2 | OWL 2 Profiles §4.3 Table 7 (Class Axioms) | yes |
cax-dw | OWL 2 Profiles §4.3 Table 7 (Class Axioms) | yes |
cax-adc | OWL 2 Profiles §4.3 Table 7 (Class Axioms) | yes |
dt-type1 | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-type2 | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-eq | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-diff | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-not-type | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
scm-cls | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-sco | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-eqc1 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-eqc2 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-op | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-dp | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-spo | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-eqp1 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-eqp2 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-dom1 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-dom2 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-rng1 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-rng2 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-hv | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-svf1 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-svf2 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-avf1 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-avf2 | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-int | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
scm-uni | OWL 2 Profiles §4.3 Table 9 (Schema Vocabulary) | yes |
D — 5 of 5 rules implemented
| Rule | Specification | Implemented |
|---|---|---|
dt-type1 | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-type2 | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-eq | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-diff | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
dt-not-type | OWL 2 Profiles §4.3 Table 8 (Datatypes) | yes |
Datalog: the Fixpoint Engine
purrdf-datalog is deterministic, wasm32-clean
Datalog evaluation: a columnar relation store, an index-selecting planner, and a
semi-naive fixpoint, carrying no ambient I/O, no wall clock, and no RNG.
It exists so that a rule set is data rather than a hand-written loop.
purrdf-entail declares the RDF, RDFS, OWL 2 RL, and D calculi
as DL-clause programs and evaluates them here, which is what lets a reasoning
report carry a contract hash of the exact program that ran instead of a claim
about which rules were meant to run.
purrdf-datalog is re-exported by the umbrella purrdf crate as the
purrdf::datalog module — the entailment surface CARRIES its types (a
ReasoningReport hands out a datalog::cache::ContractHash
and a datalog::seminaive::BudgetReport), so a consumer that matches on them
needs no second dependency on purrdf-datalog. Depend on the crate directly
(purrdf-datalog = "…") only when you want the fixpoint alone, with no other
purrdf surface in the build.
One rule IR: the DL-clause
Every rule has the shape
U₁ ∧ … ∧ Uₙ → ∃ȳ. (C₁ ∨ … ∨ Cₘ)
where each disjunct Cᵢ is itself a conjunction of head atoms. That single shape
holds all five head forms — atomic (an ordinary Datalog rule), existential,
disjunctive, conjunctive, and empty (false) — so an axiom like A ⊑ ∃r.C,
which lowers to ∃y. (r(x, y) ∧ C(y)) with one shared witness, is one rule
rather than two unrelated ones.
The semi-naive evaluator runs 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_chunksreduced in source order, neverpar_sortorpar_bridge, and degrades to inline-sequential onwasm32-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:
| Limit | Bounds | Set with | Default on wasm32 | Default elsewhere |
|---|---|---|---|---|
| join steps | candidate solutions enumerated | EvalOptions::with_max_join_steps | 1,048,576 | 1,048,576 |
| stored facts | facts seeded or derived | EvalOptions::with_max_stored_facts | 131,072 | 4,194,304 |
| term arena bytes | interned term surface bytes | EvalOptions::with_max_term_arena_bytes | 16,777,216 | 16,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:reifiesmapping, blank-node scoping, and multi-segment value union.
Reading and writing from Rust
The container engine (purrdf-gts) owns the wire-format machinery — reader,
writer, fold, verify, COSE, trust policy:
#![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_streamablerewrites an accretive log into one delivery-ordered segment (leading stream index, trailing offset footer, in-band dictionaries), andcompact_and_certify/verify_compaction/composebind a content projection so that the pre- and post-compaction containers fold to byte-identical canonical content; the verifier recomputes from the pre bytes with its own keyring. Compaction never changes content and mints no content signatures. - Merkle mountain range proofs —
mmr::{root, prove, verify_proof, prove_file}(gts-mmr-proof-v1), andverify_content_chain, which walks COSE signature → head replay → digest inclusion. There is no transparency log and no signed checkpoint service. - OpenPGP transport keys — armored Ed25519 certificates and unencrypted
secret keys (
parse_transport_key,parse_secret_signing_key) withverify_file_with_keyringfor rotation; other algorithms, encrypted keys and v5/v6 packets are rejected, and there is no keyring or revocation store.
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.
Related
docs/GTS-SPEC.md— the normative wire format (version 0.9-draft, wire-format major 1).- Slices, Mappings & Provenance — the RDF↔GTS loss ledger.
- Getting Started: Python — reading a container back as relational rows.
Slices, Mappings & Provenance
Beyond the engines, PurRDF carries the plumbing a serious vocabulary or data-pipeline project needs: a slice catalog for organizing authored RDF, an explicit loss ledger for lossy projections, and native codecs for the SSSOM and FnO interchange formats.
The slice catalog
purrdf-slice (re-exported as
purrdf::slice) is tooling for ontology/vocabulary repositories organized as
slices — directories of authored RDF (slices/<group>/<name>/), each
described by a manifest.ttl:
- Catalog — manifest-based discovery (
SliceCatalog::discover), typed slice metadata (SliceRecord,SliceTier), and artifact roles. Slice identity comes from the manifest, not the directory name. - Ownership & dependencies — term-ownership analysis (every declared term has exactly one owning slice), dependency edges with evidence, forbidden-edge rules (extension slices depend only on core), and machine-applicable fix suggestions.
- Content addressing — deterministic artifact digests and cache keys for incremental pipelines.
- Emitters — projection/mapping emitters and lints: prefix maps, JSON-LD contexts, FnO function catalogs, claim views.
True to the rule that PurRDF mints no vocabulary IRIs, every term the slice
framework reads or emits belongs to the caller’s vocabulary: a
SliceVocab is caller-constructed (it has no Default) and threaded through
every public entry point.
#![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.SssomSetCommentmodels set-level ordinary comments and provenance as a lossless document envelope, independently of YAML-like header metadata. Newly appended provenance uses the interoperable metadata-to-table position; parsed after-table comments are retained as an explicit extension. Raw Unicode comment lines and their order are preserved, while physical line endings serialize deterministically as LF. - FnO — a Function Ontology function-catalog codec
(
FnoCatalog,fno_to_quads,fno_to_ntriples), used by the slice emitters to describe function catalogs as RDF.
As with everything else in the toolkit, these are codecs for caller data — PurRDF does not define mappings or functions of its own. SSSOM envelope comments also remain caller-neutral projection data: they do not mint RDF predicates, change mapping validation, or alter the RDF projection.
Related
- GTS Graph Transport — the other side of the RDF↔GTS ledger.
- Design Rules & Invariants — the mints-no-IRIs rule in full.
rdflib Compatibility
Python’s rdflib is the incumbent RDF library of the ecosystem, and PurRDF meets it in two tiers: an explicit compat module, and an opt-in drop-in shadow.
Tier 1: the explicit compat layer
The main purrdf wheel ships an rdflib compatibility layer backed by the
native engine:
from purrdf.compat.rdflib import Graph
g = Graph()
g.parse(data="<https://example.org/a> <https://example.org/b> <https://example.org/c> .",
format="turtle")
This is the recommended path for new code that wants an rdflib-shaped API on
the PurRDF engine: the import name is honest, and it coexists with a genuine
rdflib installation.
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.compatagainst the real rdflib 7.6.
Both use strict expected-failure ledgers: every known divergence is listed with a per-test reason, an unexpected failure breaks the build, and a silently fixed divergence also breaks the build until the ledger shrinks. The 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.
Related
- Getting Started: Python
- Conformance & Testing — how the ledger discipline works.
RDF/JS in JavaScript
In JavaScript, PurRDF does not invent its own API shape: the npm package
@blackcatinformatics/purrdf
implements the RDF/JS community specifications —
DataFactory, DatasetCore, and Stream/Sink — over the wasm-compiled
native engine. Code written against RDF/JS interfaces works with PurRDF terms
and datasets.
The data factory
DataFactory covers the standard RDF/JS term constructors — namedNode,
blankNode, literal(value, languageOrDatatype?), variable,
defaultGraph, quad, fromTerm, fromQuad — plus 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 accumulatedDataset.datasetToStream(dataset)/streamToDataset(stream)— bridge between aDatasetand RDF/JS streams, for piping into or out of other RDF/JS tooling.
Scope notes
- The package’s
Datasetholds data in memory; these SPARQL methods query that resident dataset. The synchronous methods install noSERVICEorLOADsource, so there a remoteSERVICEorLOADfails explicitly unless writtenSILENT; 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).
Related
- Getting Started: JavaScript / WebAssembly
- RDF 1.2 Features — what the extensions mean.
crates/rdf-wasm— the Rust cdylib behind the package.
Design Rules & Invariants
PurRDF must stay fast, deterministic, and boring: one engine, one behavior, carried verbatim into Rust, Python, WebAssembly, and C. That promise is kept by a small set of hard invariants, each enforced by CI rather than by convention. The canonical statement is AGENTS.md in the repository; this chapter explains the why.
No semantic Cargo features, ever
The workspace has zero semantic feature flags. Its sole feature declaration
is the empty purrdf-capi:capi = [] compatibility marker that cargo-c
unconditionally enables when building the C ABI. The marker gates no code and
must never appear in cfg(feature = ...); CI verifies its exact empty shape and
scans Rust sources for feature-gated behavior. PurRDF is a data carrier, and
optionality changes semantics per consumer — two builds of “the same version”
that parse or serialize differently would defeat the whole point. No other
[features], no optional dependencies, and no feature-gated behavior. Every
consumer gets the same byte-identical semantics.
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 inrust-toolchain.tomlfor 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
purrdfin 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
| Engine | Suite |
|---|---|
| IRI (RFC 3987) | W3C IRI + RFC 3986 §5.4 resolution vectors |
| Syntax codecs | W3C rdf-tests (Turtle/TriG/N-Triples/N-Quads/RDF-XML) |
| CSVW | W3C RDF-conversion and metadata-validation manifests plus a locked independent implementation |
| OBO Graphs | official OBO Graphs 0.3.2 JSON Schema plus corruption probes |
| Research-object carriers | five adversarial native fixtures, a 5×5 stable semantic-transcode matrix, and the vendored Frictionless Data Package v1 schema |
| RDFC-1.0 | W3C rdf-canon fixtures |
| SPARQL 1.1/1.2 | full W3C sparql11 + sparql12 + entailment suites, plus first-party CONSTRUCT and DESCRIBE corpora |
| SPARQL CDT (SEP-0009) | the vendored awslabs/SPARQL-CDTs corpus — read with the lexical-space divergence recorded in docs/CONFORMANCE.md, which no upstream vector can express |
| SPARQL execution governors | a first-party frozen corpus of ceiling band cases and seam cases |
| SHACL | W3C data-shapes + DASH SHACL-AF/rules + a first-party frozen corpus |
| SHACL 1.2 | the whole vendored W3C shacl12-test-suite: validation, node expressions, inference rules and SPARQL 1.2 RL |
| ShEx 2.1 | shexTest v2.1.0 (validation, schemas, negative syntax/structure) |
| Entailment | the W3C SPARQL entailment-regime cases (via the SPARQL harness), the vendored W3C OWL 2 suite (DL consistency, ledgered), and W3C’s own OWL 2 RL entailment tests (the independent oracle for the rule table, ledgered) |
| GTS | frozen cross-language vectors — 38 of the 39 fold byte-exactly into their committed expectation, with the 39th a ledgered divergence from an upstream expectation this reader contradicts |
| rdflib drop-in | rdflib 7.6’s own vendored tests + first-party parity |
At the time of writing every suite is green — for example 1,105/1,105
attempted shexTest validation cases, 129/129 W3C SHACL, 264/264 codec
round-trips, 70/70 W3C SPARQL entailment-regime cases, and 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:
- Exact totals — each harness asserts the number of discovered tests, so corpus drift fails loudly.
- XFAIL ledgers — every known gap is listed with a reason string, and the harness asserts it still fails. Fixing a gap without removing its ledger entry breaks the build (XPASS discipline), so the ledgers double as roadmaps.
- Trait skips (ShEx only) — whole spec features can be skipped by manifest trait tags, counted exactly. The list is currently empty.
- A monotone budget (ratchet) — a committed baseline records the exact allowed count of ledgered gaps per suite. A larger live count fails RED (regression), and a smaller one also fails RED until the budget is lowered to lock the gain in. The budget may only ever be edited downward, which makes “the skip list only shrinks” a mechanical guarantee rather than a convention.
Running the suites locally
make conformance # the single matrix
cargo test -p purrdf-shex # all four ShEx suites
cargo test -p purrdf-shapes --test w3c_conformance # W3C SHACL scoreboard
cargo test -p purrdf-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
| Layer | What it measures | How to run |
|---|---|---|
| Rust bench suites | Native engine hot paths — IR layout, copy-on-write mutation, pack index alternatives, codecs, graph/tabular/research-object projections, SPARQL lexing/evaluation/planning, SHACL validation, entailment chase, GTS authoring, IRI parsing. | make bench |
| Python compat harness | purrdf.compat.rdflib (the native-backed drop-in) vs. the real rdflib 7.x on parse, serialize, SPARQL, and triple-pattern iteration, over a deterministic example.org corpus. | make bench-python |
Both layers are report-only: they are never part of make check, and no test
gate asserts a speedup.
The discipline for changes
Any change claiming a performance win must extend the 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 aBREAKING CHANGE:footer is a major-bump trigger, and the changelog marks each such entry BREAKING; - a minor bump is additive and API-compatible;
- a patch bump is bugfix-only.
That is what the version number commits to; it is a policy statement, not a
claim of stability beyond what semver means. All three published surfaces
share one workspace version and are released together, and a
version-coherence check in CI fails the build if the version sources
(Cargo.toml, pyproject.toml, package.json, CITATION.cff) disagree.
The one exception is the C ABI. libpurrdf’s
purrdf.h
carries its own PURRDF_ABI_MAJOR.PURRDF_ABI_MINOR, 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
.cratepackage receives a GitHub build-provenance attestation; - the package set receives an SPDX SBOM and SBOM attestation;
- the release crate set is checked on
wasm32-unknown-unknownbefore publishing; - every workspace crate version must match the tag version.
Every 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:
make check: the Rust and wasm workspace gate and repository hygiene.make capi-check: the generated C-ABI/header check and linked C smoke.make pytest: the native Python binding suite.make python-release-check: build and audit both Python wheel/sdist pairs, check them with the pinned publisher tools, and install them together.make wasm-pkg-test: the optimized size-gated npm/wasm package tests.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.
| Code | Meaning | Remedy |
|---|---|---|
iri-empty | The string is empty where a non-empty IRI/URI was required. | Supply a non-empty IRI. |
iri-missing-scheme | The string has no scheme, so it cannot be an absolute IRI. | Write the IRI in absolute form, with a scheme. |
iri-bad-scheme | The scheme is malformed (it must start with a letter and contain only letters, digits, +, - and .). | Correct the scheme. |
iri-bad-percent-encoding | A % at the reported byte offset is not followed by two hexadecimal digits. | Percent-encode the % itself, or complete the escape. |
iri-disallowed-char | A character the IRI grammar does not admit occurs at the reported byte offset. | Percent-encode or remove the character. |
iri-bad-authority | The authority component (//host:port) is malformed. | Correct the host or port. |
iri-non-absolute-base | The base IRI supplied for resolution is not itself absolute. | Supply a base IRI that has a scheme, e.g. http://example.org/dir/. |
iri-relative-no-base | A relative IRI reference was met with no base in scope: no in-document directive, no caller-supplied base, no retrieval IRI. | Add a base to the document (@base/BASE in Turtle-family syntaxes, xml:base in RDF/XML, @context.@base in JSON-LD) or pass a base IRI to the API. |
iri-not-absolute-by-grammar | The reference is not absolute and the syntax admits no relative reference at all (N-Triples, N-Quads), so no base could ever apply. | Write the IRI in absolute form; supplying a base will not help. |
native-codec-* — the text and XML codecs (purrdf-rdf)
| Code | Meaning | Remedy |
|---|---|---|
native-codec-parse | The codec (Turtle family, RDF/XML, TriX, HexTuples) could not parse the input; the location names the line and column. Term nesting past the parser’s depth limit is reported under this code too. | Fix the document at the reported position. |
native-codec-limit | A 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-utf8 | The input bytes are not valid UTF-8. | Re-encode the document as UTF-8. |
native-codec-panic | The codec panicked while parsing and the panic guard caught it. This is a defect in PurRDF, never in the input. | Report it with the input that triggered it. |
native-codec-read | Reading the RDF source through the streaming reader failed with an IO error. | Check the source stream or file. |
native-codec-write | Writing the serialized output failed with an IO error. | Check the destination. |
native-codec-serialize | The target format cannot represent the dataset — for example a named graph in a single-graph format — or the writer failed. | Choose a format that carries the construct, or serialize through the loss-ledger lane. |
native-codec-replay | Replaying a parsed dataset into an event sink failed because the sink returned an error. | The sink’s own error is carried in the message. |
native-codec-unsupported-format | The media type or format identifier names no codec. | Use one of the supported media types or format ids. |
native-codec-datatype-not-iri | A literal’s datatype term is not an IRI. | Give the literal an IRI datatype. |
native-codec-direction-without-language | A base direction was given on a literal that has no language tag. | Add a language tag, or drop the direction. |
native-codec-invalid-direction | A literal base direction other than ltr or rtl was given. | Use ltr or rtl. |
native-codec-iri-missing-value | An IRI term event carried an empty value. | Supply the IRI. |
native-codec-missing-reifier-binding | A triple term refers to a reifier that has no binding. | Bind the reifier before referring to it. |
native-codec-predicate-not-iri | A term in an IRI-only position (the predicate) is not an IRI. | Use an IRI predicate. |
native-codec-reifier-not-triple | A reifier binds something other than a triple term. | Bind the reifier to a triple term. |
native-codec-term-out-of-range | A term id in the event stream is outside the range the stream introduced. | The producing stream is inconsistent; regenerate it. |
native-codec-unbound-triple-term | A triple term names neither its components nor a reifier. | Give the triple term its components or a reifier. |
The last nine arise when the codec lane consumes term events rather than
text — a GTS graph being resolved through the codec surface — and mirror the
gts-* and rdf-ir-* codes below.
native-jsonld-* and jsonld-* — JSON-LD and YAML-LD
| Code | Meaning | Remedy |
|---|---|---|
native-jsonld-decode | The JSON-LD or YAML-LD input is malformed at the surface: invalid JSON/YAML, an encoding error, or a materialized-carrier byte budget exceeded. | Fix the surface syntax or reduce the document. |
native-jsonld-parse | The input is well-formed JSON-LD that does not map to RDF. | Correct the JSON-LD structure. |
jsonld-json-input | The strict JSON reader rejected the input, for example a duplicate object member. | Remove the duplicate or correct the JSON. |
jsonld-context-invalid | A context document, or the strict versioned options document, is invalid. | Correct the context or options document. |
jsonld-context-limit | A context-processing ceiling was exceeded: loaded bytes, work count, definition complexity, or the offline context registry size. | Reduce the context, or raise the limit the options document declares. |
jsonld-derived-invalid | The deterministic dataset-IRI derived mode could not derive a prefix (an invalid IRI, or a null mapping). | Correct the IRI the prefix would be derived from. |
jsonld-derived-limit | A derived-context work or byte ceiling was exceeded. | Reduce the dataset’s IRI vocabulary or raise the declared limit. |
jsonld-options-unused | JSON-LD serialization options were supplied for a format that is not JSON-LD or YAML-LD. | Drop the options, or serialize to JSON-LD/YAML-LD. |
cdt-* — SEP-0009 composite literals
A cdt:List or cdt:Map lexical form denotes blank nodes in the enclosing
document’s scope, so a form that does not parse leaves that scope undefined and
the whole document is refused rather than the literal being kept opaque.
| Code | Meaning | Remedy |
|---|---|---|
cdt-literal-malformed | A composite literal’s lexical form does not parse. | Correct the literal. |
cdt-literal-scan-disagreement | The bounded lexical scanner and the full parser disagree about the literal. | This is a defect in PurRDF; report it with the literal. |
rdf-ir-* — dataset structure (purrdf-core freeze and GTS import)
RdfDatasetBuilder::freeze() validates the structure of the dataset it is
about to freeze; the GTS import sink reports the same family for a container
whose terms do not form a well-formed dataset.
| Code | Meaning | Remedy |
|---|---|---|
rdf-ir-term-out-of-range | A quad references a TermId the builder never interned. | Intern the term before pushing the quad. |
rdf-ir-predicate-not-iri | A quad’s predicate is not an IRI. | Use an IRI predicate. |
rdf-ir-literal-subject | A literal occupies subject position. | RDF admits no literal subject; restructure the statement. |
rdf-ir-triple-subject | A triple term occupies subject position. | RDF 1.2 admits triple terms in object position only; use a reifier. |
rdf-ir-graph-name-invalid | A graph name is a literal or a triple term. | A graph name must be an IRI or a blank node. |
rdf-ir-graph-already-exists | Creating 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-triple | A reifier binding points at something other than a triple term. | Bind the reifier to a triple term. |
rdf-ir-triple-cycle | A triple term contains itself, directly or through nesting. | Remove the cycle. |
rdf-ir-triple-nesting-limit | Triple-term nesting exceeds the builder’s depth limit. | Flatten the nesting. |
rdf-ir-dangling-term-ref | A GTS role references a term id that no term event introduced. | The container is inconsistent; regenerate it. |
rdf-ir-gts-fold-diagnostic | The GTS fold reported a diagnostic, surfaced through the import. | The fold diagnostic’s own code and detail are in the message. |
rdf-ir-iri-missing-value | An imported IRI term has an empty value. | Supply the IRI. |
rdf-ir-literal-datatype-not-iri | An imported literal’s datatype resolves to a non-IRI. | Give the literal an IRI datatype. |
rdf-ir-missing-reifier-binding | An imported triple term references a reifier with no recorded binding. | Bind the reifier in the container. |
rdf-ir-term-nesting-limit | Imported triple-term nesting exceeds the depth limit. | Flatten the nesting. |
rdf-ir-unbound-triple-term | An imported triple term names neither its components nor a reifier. | Give the triple term its components or a reifier. |
gts-* and rdf-* — GTS graph resolution, verification and writing
| Code | Meaning | Remedy |
|---|---|---|
gts-term-out-of-range | A GTS term id is out of range for the graph. | The container is inconsistent; regenerate it. |
gts-iri-missing-value | A GTS IRI term has an empty value. | Supply the IRI. |
gts-predicate-not-iri | A GTS predicate term is not an IRI. | Use an IRI predicate. |
gts-literal-datatype-not-iri | A GTS literal datatype does not resolve to an IRI. | Give the literal an IRI datatype. |
gts-direction-without-language | A GTS literal carries a base direction but no language tag. | Add a language tag, or drop the direction. |
gts-invalid-direction | A GTS literal base direction is neither ltr nor rtl. | Use ltr or rtl. |
gts-missing-reifier-binding | A GTS triple term references a reifier the graph does not bind. | Bind the reifier in the container. |
gts-unbound-triple-term | A GTS triple term names neither its own components nor a reifier. | Give the triple term its components or a reifier. |
gts-self-reaching-term | A GTS term resolves through itself, so no walk of its components can terminate. | Remove the cycle. |
gts-term-nesting-limit | GTS term nesting exceeds the depth limit. | Flatten the nesting. |
gts-fold-diagnostic | The GTS fold reported one or more diagnostics. | Inspect the fold diagnostics listed in the detail. |
gts-verify-digest-inclusion | Content-addressed terms are not included in the verified chain. | The container’s chain does not cover its content; do not trust it. |
gts-verify-signature | COSE signature verification failed. | Check the signing key and the container’s integrity. |
gts-writer-codec | The GTS writer’s codec reported an error while writing. | The codec’s own error is in the message. |
rdf-graph-name-not-node | While building a GTS graph, a named-graph name is not an IRI or blank node. | Use an IRI or blank node graph name. |
rdf-reifier-not-node | While building a GTS graph, an RDF 1.2 reifier is not an IRI or blank node. | Use an IRI or blank node reifier. |
rdf-term-nesting-limit | RDF term nesting exceeded the depth limit while building a GTS graph. | Flatten the nesting. |
native-sparql-* — the SPARQL engine boundary (purrdf-sparql-eval)
| Code | Meaning | Remedy |
|---|---|---|
native-sparql-query-parse | The query text does not parse under the SPARQL 1.1/1.2 grammar (including a malformed VERSION declaration; a well-formed but unrecognized version is native-sparql-unsupported). | Fix the query at the reported position. |
native-sparql-update-parse | The update request does not parse. | Fix the update at the reported position. |
native-sparql-query-explain | Evaluation under --explain failed; the evaluator’s error is in the message. | Address the underlying evaluation error. |
native-sparql-query-eval | Query 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-internal | An 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-bound | Constructing a composite datatype value exceeded its element or lexical-byte resource bound. | Reduce the value being constructed. |
native-sparql-float-environment | The 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-operational | A 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-read | Operational 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-unpriced | An 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-overflow | Certified workspace arithmetic exceeds u64. | Reduce the query’s intermediate cardinality or pattern count. |
native-sparql-update-eval | Update 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-algebra | The 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-construct | Typed graph building was handed a query that is not a CONSTRUCT. | Build graphs from a CONSTRUCT query. |
native-sparql-execution-parameter | A 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-incomplete | A 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-function | The property-function seam refused the query: a predicate under a declared namespace has no registration, a call site’s arity does not match the relation, no total order can serve a chain, or a prepared plan is being evaluated under a different registry than it was prepared with. | Register the relation, correct the arity, or prepare and evaluate under the same registry. |
native-sparql-aggregate-function | The custom-aggregate seam refused the query: an AGG(<iri>, …) names no registered aggregate, or a prepared plan is being evaluated under a different aggregate registry. | Register the aggregate, or prepare and evaluate under the same registry. |
native-sparql-custom-function | A function or aggregate IRI resolved to no registered custom function, native function, or XSD constructor. | Register the function under that IRI, or use a native one. |
native-sparql-quoted-triple-term-variable | A variable occupies a component of a quoted-triple term in a basic graph pattern or property path; structural triple-term matching is out of scope. | Bind the triple term as a whole, or match its components through the reifier surface. |
native-sparql-heldin-unconfigured | heldIn was called with no caller-supplied standpoint-predicate configuration. | Configure the standpoint predicates before using heldIn. |
native-sparql-evaluation-stack-exhausted | The 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-unsupported | The 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-exhausted | wasm 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-prefix | The blank-node mint prefix supplied in the options is invalid. | Supply a valid prefix. |
native-sparql-service-denied | An 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-denied | The 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-failed | A 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-unconfigured | No 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-resolver | LOAD <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-denied | An 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-denied | The 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-failed | A 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-decode | A 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-stopped | The 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-fault | A 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-cancelled | wasm 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-deadline | wasm asynchronous lane: an ungoverned operation’s deadline passed. The Cloudflare adapter answers it with a 503. | Raise the deadline. |
native-sparql-not-acceptable | wasm 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-flight | wasm 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-fault | The 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-ceiling | Protocol 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-evaluation | Protocol 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-destination | An ADD/MOVE/COPY/LOAD destination is NAMED or ALL; it must be DEFAULT or a single named GRAPH. | Name a single destination graph. |
native-sparql-update-graph-missing | An 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-iri | A substitution value is not a valid IRI. | Supply a valid IRI. |
native-sparql-subst-triple-predicate | A substituted quoted triple has a predicate that is not an IRI. | Use an IRI predicate. |
native-sparql-subst-literal-datatype | A substituted literal’s datatype is not an IRI. | Use an IRI datatype. |
native-sparql-subst-langtag | A pre-bound literal’s language tag is not one the lexing profile accepts. | Supply a well-formed language tag. |
native-sparql-xpath-operational | A 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.
| Code | Meaning | Remedy |
|---|---|---|
xpath-pattern-bytes | Resource::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-steps | Resource::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-nodes | Resource::ProgramNodes: the compiled program needs more nodes than the bound (262,144). | Simplify the pattern or reduce its counted repetitions. |
xpath-compile-slots | Resource::CompileSlots: building the program needs more construction cells than the bound (2,097,152). | Simplify the pattern or reduce its counted repetitions. |
xpath-match-steps | Resource::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-states | Resource::MatchStates: matching needs more simultaneously pending alternative states than the bound (65,536). | Reduce the pattern’s alternation and repetition. |
xpath-match-slots | Resource::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-bytes | Resource::OutputBytes: REPLACE output exceeds the bound (64 MiB). | Replace over shorter input or with shorter replacement text. |
reasoning-* — SPARQL under an entailment regime (purrdf)
| Code | Meaning | Remedy |
|---|---|---|
reasoning-closure-relation-witness | Property-function relations derived from the closure cannot be combined with an OWL Direct-Semantics run whose restricted chase minted existential witnesses: a relation walking the closure could return a minted blank node the regime’s scoping graph does not contain. | Query under a regime that mints no witnesses (rdf, rdfs, owl-rl, d, rif, simple), or drop the dataset-derived relations from the call. |
reasoning-closure-relation-rebuild | Rebuilding the property-function relations over the closure failed. | The relation builder’s own error is in the message. |
statements-* — statement-metadata ingestion (purrdf-rdf)
These arise when a document’s rdf:reifies and owl:Axiom statements are
read into the statement layer.
| Code | Meaning | Remedy |
|---|---|---|
statements-turtle-parse | The statement-metadata Turtle failed to parse. | Fix the Turtle. |
statements-non-iri | A term that must be an IRI in this context is not one. | Use an IRI. |
statements-reifies-non-triple | The object of rdf:reifies is not a triple term. | Reify a triple term. |
statements-malformed-axiom | An owl:Axiom lacks its source, property or target. | Complete the axiom. |
statements-conflicting-structural | One subject carries two different values for a structural field. | Keep one value. |
Other single-code families
| Code | Meaning | Remedy |
|---|---|---|
sssom-tsv-parse | An SSSOM TSV document is malformed: a missing or unreadable header row, a malformed curie_map entry or set comment, a malformed row, or a non-numeric confidence; the location names the line. | Fix the TSV at the reported line. |
content-id-scheme | A content-id scheme prefix is invalid: empty, non-ASCII, or ending in a hexadecimal digit (which would make it ambiguous with the 64-hex-character tail). | Choose a prefix that is non-empty ASCII and does not end in 0-9, a-f or A-F. |
Where the code reaches you
- Rust —
RdfDiagnostic::codeon the returned error; itsDisplayform is<severity> <code>: <message>. - Python — the
ValueErrormessage is thatDisplayform (for exampleerror native-codec-parse: …); an IRI failure is rendered as<code>: <message>(for exampleiri-relative-no-base: …). - JavaScript — the thrown
Errormessage carries the same text, and itscodeproperty carries the code itself. The wasm package’s own refusals carry apurrdf-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-shaclandpurrdf-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
ruleIdof each result is the code, with areportingDescriptorin the run’s rule table (purrdf-validate).