PurRDF for Python
PurRDF is a from-scratch, dependency-light RDF 1.2
engine — parsers and serializers, SPARQL, SHACL, ShEx, RDFC-1.0 canonicalization, and the
GTS graph-transport container — written in Rust and carried verbatim into Python, JavaScript,
and C (the GTS container reaches Python and C; the JavaScript package does not expose
it). The purrdf package is the Python surface of that one engine: the same
byte-identical semantics in every language, including triple terms, reifiers, and
base-direction literals that most incumbent libraries do not carry.
Install
pip install purrdf
Requires Python 3.13+. Wheels bundle the native extension; no Rust toolchain needed.
Parse RDF
import purrdf
quads = purrdf.parse(
'<https://example.org/alice> <http://xmlns.com/foaf/0.1/name> "Alice" .',
purrdf.RdfFormat.TURTLE,
)
purrdf.parse accepts Turtle, TriG, N-Triples, N-Quads, TriX, HexTuples, and
RDF/XML (purrdf.RdfFormat); JSON-LD and RDF/XML also have the dedicated
purrdf.from_json_ld / purrdf.to_json_ld and purrdf.from_rdf_xml /
purrdf.to_rdf_xml converters. All codecs are first-party with
byte-deterministic output.
Configured JSON-LD and YAML-LD use one strict versioned options document. Compile a reusable context when serializing several datasets:
import json
import purrdf
options = json.dumps({
"version": 1,
"mode": "context",
"prefixes": {"ex": "https://example.org/", "schema": "https://schema.org/"},
})
context = purrdf.CompiledJsonLdContext(options)
jsonld = purrdf.serialize_jsonld(
nquads,
format=purrdf.RdfFormat.N_QUADS,
output_format="jsonld",
context=context,
)
expanded, context, and deterministic dataset-IRI derived modes are
explicit. PurRDF never infers a caller vocabulary or fetches a remote context.
Project graph, tabular, and research-object carriers
purrdf.project and purrdf.lift are thin calls into the same Rust projection
engine used by every other surface. Configuration is mandatory, strict JSON:
PurRDF supplies no vocabulary, identity IRI, or resource-limit default.
import json
import purrdf
config = json.dumps({
"profile": "lpg-csv",
"config": {
"rdf_type": "https://example.org/type",
"scope": {"mode": "all"},
"limits": {
"max_artifacts": 16,
"max_artifact_bytes": 1_000_000,
"max_total_bytes": 4_000_000,
"max_archive_bytes": 5_000_000,
"max_term_depth": 16,
},
"execution_limits": {
"max_input_records": 1_000,
"max_model_records": 1_000,
"max_nodes": 1_000,
"max_edges": 1_000,
},
},
})
package = purrdf.project(
"@prefix ex: <https://example.org/> . ex:alice ex:knows ex:bob .",
format=purrdf.RdfFormat.TURTLE,
profile="lpg-csv",
config=config,
)
lifted = purrdf.lift(package.archive, profile="lpg-csv", config=config)
assert lifted.dataset.quad_count() == 1
print([(loss.code, loss.location) for loss in package.losses])
Project 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. Curated CSVW/OKF terms, OBO Graphs, SKOS, native
DCAT RDF, and VoID are deliberately write-only, ledgered views. Returned
archives are canonical deterministic USTAR bytes and every result carries its always-computed
structured loss records. Research-object contexts, vocabularies, identities,
and profiles are all mandatory caller configuration.
Native RDF dataset descriptions use the same call. The complete JSON names the output syntax and either a mapped/CONSTRUCT DCAT source or the VoID source graphs, role vocabulary, dataset-prefix registries, and resource bounds:
from pathlib import Path
import purrdf
source = Path("void-source.trig").read_text()
void_config = Path("void.json").read_text()
description = purrdf.project(
source,
format=purrdf.RdfFormat.TRIG,
profile="void",
config=void_config,
)
Path("void.tar").write_bytes(description.archive)
Portable void-source.trig, void.json, and dcat-rdf.json examples are in
crates/rdf/tests/fixtures/dataset-description/.
Attached RO-Crate packaging uses the same call with assets= set to a canonical
payload-only USTAR archive and configuration packaging: "attached". The result
contains the exact payloads, deterministic metadata, and self-contained preview;
missing, unowned, reserved, or size-inconsistent members raise ValueError.
See the runnable
projection_roundtrip.py
file-producing example.
For large LPG carriers, purrdf.project_artifacts(...) invokes a transactional
artifact callback with package/artifact begin, bounded chunk, artifact finish,
commit, and abort events. An optional progress callback receives immutable
ProjectionProgress snapshots; callback exceptions abort the package and are
re-raised unchanged. This path retains the selected canonical LPG model but not
complete artifact bodies or USTAR bytes. See the runnable atomic-directory
projection_stream.py
example.
Validate with SHACL
The SHACL engine lives at purrdf.shapes (mirroring the Rust crate; purrdf.shacl
is a back-compat alias):
from purrdf import shapes
report = shapes.validate(shapes_ttl=my_shapes, data_nt=my_data)
print(report["conforms"])
SHACL 1.2 Core, SPARQL Extensions and Node Expressions, and SHACL rules
entailment via shapes.entail(...), which returns {"ntriples", "diagnostics"}.
A shape whose sh:in or sh:xone list is empty is a mandatory diagnostic every
run reports, beside the verdict and never among the results: shapes.validate's
dict, ValidationReport.diagnostics, and the apply_rules and entail dicts
carry a diagnostics list of {"rule", "shape"} dicts, and to_sarif() carries
each as a note-level toolExecutionNotifications entry. Reusable parsed shapes are available as
shapes.Shapes(shapes_ttl).validate_nt(data_nt). Each result dict carries
messages: every sh:resultMessage as a dict with text and, when present,
language, direction and datatype. shapes.validate(..., conformance_disallows=[...]) sets the severity IRIs that make a report
non-conforming (by default sh:Violation, sh:Warning and sh:Info), and
the dict's conformance_disallows names the set the report was judged
against. shapes_graph=IRI on shapes.validate, shapes.Shapes,
shapes.pack_product and shapes.lint_shapes names the IRI SHACL-SPARQL sees
the shapes graph under, as purrdf validate --shapes-graph does: $shapesGraph
is pre-bound to it and GRAPH $shapesGraph { ... } reads the shapes graph
(SHACL 1.0's pre-binding, which SHACL 1.2 removed). Omitted, $shapesGraph is
an ordinary variable. A Shapes carries it into prepare() and its products.
shapes.apply_rules and shapes.entail take the same keyword, pre-binding a
sh:SPARQLRule's $shapesGraph; apply_rules raises ValueError when it is
named beside srl, which has no shapes graph. shapes.entail also takes the four
rule-evaluation limits apply_rules takes — max_term_generating_rounds,
max_generated_terms, max_stored_facts and max_join_steps, with the same
defaults — and a run past one raises ValueError naming the limit, the numbers
and the keyword that raises it (entail(max_stored_facts=...)).
subclass_of_in_shapes_graph=True on shapes.validate and shapes.Shapes is
SHACL 1.2 Core §6.3's subClassOfInShapesGraph: the shapes graph's
rdfs:subClassOf triples are read, in addition to the data graph's, wherever
SHACL type decides class membership (sh:targetClass, implicit class targets,
sh:class, sh:rootClass, shnex:instancesOf). It is off by default, the
specification's default. A shapes graph whose owl:imports closure holds two
versions of one series, or a graph another declares owl:incompatibleWith,
raises ShapesImportError with kind incompatible-import-versions.
Four tools sit beside validation, each the same library call the CLI, WebAssembly and C surfaces make:
# Run the SHACL 1.2 rules of a shapes graph, or a SPARQL 1.2 RL rule set (srl=...),
# and get the INFERENCE GRAPH: the inferred triples only, as N-Triples. With
# explain=True, "proof" carries the proof of every inferred triple.
out = shapes.apply_rules(my_data, my_shapes, explain=True)
out["inferred"], out["proof"]
# Two limits stop a rule set that keeps inferring new terms: 16384 term-generating
# rounds, and max(65536, 4 x the input's distinct terms) generated terms. A rule set
# that needs more states it; a run past either raises ValueError naming the limit.
shapes.apply_rules(my_data, srl=counting_rules, max_term_generating_rounds=50_000)
# Two more bound what a run holds and enumerates: 4194304 stored facts (the data
# graph, a rule set's data and every inferred triple) and 1048576 join steps. A
# run past either raises ValueError naming the limit and the keyword argument.
shapes.apply_rules(my_data, my_shapes, max_stored_facts=8_000_000)
# Check a SPARQL 1.2 RL rule set WITHOUT running it: the grammar, the IMPORTS
# closure (from `imports`), well-formedness and stratification -- every static
# check apply_rules(srl=...) applies first. level="syntax" or "well-formed" stops
# earlier. A refused rule set raises ValueError naming the stage.
shapes.check_rules(counting_rules)["summary"]
# Evaluate one node expression of a shapes graph against a focus node. The
# expression is an IRI or "_:label"; the scope binds shnex:var names. The result is
# {"outputs": [...], "diagnostics": [{"rule", "shape"}, ...]}: the output nodes, and
# the shapes graph's mandatory diagnostics (an empty sh:in / sh:xone list).
shapes.eval_node_expr(my_shapes, my_data, "http://example.org/Tag",
"http://example.org/a", scope={"suffix": '"!"'})["outputs"]
# Certify a shapes graph: the loader's verdict, the W3C shacl-shacl.ttl results,
# which implementation every function call binds to, and the validators a
# vocabulary declares for built-in components (superseded, never run).
lint = shapes.lint_shapes(my_shapes)
lint["clean"], lint["findings"], lint["calls"], lint["alternatives"], lint["report"]
A malformed shapes graph is a lint_shapes report with findings, not an
exception; only a document that is not Turtle raises ValueError.
Validate with ShEx
from purrdf import shex
results = shex.validate(
my_schema_shexc,
my_data_ttl,
[("https://example.org/alice", "https://example.org/PersonShape")],
)
print(all(entry["conformant"] for entry in results))
The ShEx 2.1 validator passes 1,105/1,105 attempted validation tests of the official
shexTest suite (see the repo's docs/CONFORMANCE.md).
Entailment regimes
The SPARQL entailment regimes live at purrdf.entail (mirroring the
purrdf-entail Rust crate). It closes a dataset under a regime's own
specification rule table and takes no shapes at all — not to be confused with
purrdf.shapes.entail(...), which applies the SHACL-AF sh:rules a shapes
graph declares.
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)
The fourth and fifth arguments are the dataset's owl:imports table — a list of
(ontology_iri, nquads_document) pairs — and the IRIs the dataset was read from, spelled
exactly as entail.certain_answers spells them. OWL 2 defines an ontology's imports
closure to BE the ontology, so a dataset that imports a document is closed over the merge;
an import the table does not resolve raises ValueError naming it, and so does an entry
the closure never reaches. [], [] imports nothing. entail.consistency(data, imports, premise_iris) and Store.query_entailment_governed(..., imports=…, premise_iris=…) take
the same table.
For callers holding a document rather than a parsed dataset,
entail.materialize_nt(text, regime, program, imports, premise_iris) takes
N-Triples/N-Quads and returns (canonical_nquads, report). Both accept the regime as a plain string ("simple",
"rdf", "rdfs", "owl-rl", "owl-direct", "rif", "d") or as
entail.Regime.RDFS.
All seven regimes close; none is refused. The third argument is the regime's own
rule document. Six regimes take none, so theirs is "" — and a non-empty one raises
rather than being silently discarded. "rif" is the exception: it entails under the
caller's rules, which PurRDF does not declare, so its program is a normative
RIF-in-XML document:
closure, report = entail.materialize(dataset, "rif", my_rif_xml, [], [])
"owl-direct" takes no program either, and that is a statement rather than an
omission: its extra input is a query's class expressions, and this surface closes a
dataset rather than answering a query — so what it runs is the query-independent
tableau augmentation (the classification, the realization, the entailed role
assertions and the owl:sameAs identifications the tableau decides about the
ontology's own named terms).
The report is the second return value and is never optional. It is a byte-stable rendering naming which rules fired and how often, which specification rules did not fire, which constructs the run left at a boundary, what it consumed against the evaluation limits it ran under, and the contract hash of the calculus that ran under those limits — so a cached closure minted under a different rule set can be refused rather than trusted.
The rule tables are readable directly, so coverage is something you measure rather than something you take on faith:
defined = entail.rules("owl-rl") # 78 — OWL 2 Profiles §4.3 Tables 4–9
fired = entail.implemented_rules("owl-rl") # 78
missing = [rule for rule in entail.rules("rdfs") if rule not in entail.implemented_rules("rdfs")]
# [] — RDFS fires 18 of its 18 rules; the gap is legitimately empty
added = entail.extensions("owl-rl") # ['ext-eq-diff-sym']
extensions(regime) is a third, disjoint inventory: the rules this build fires
that no specification table states. owl-rl has one — ext-eq-diff-sym,
symmetry of owl:differentFrom, sound and shaped exactly like prp-symp — and
every other regime has none. It appears in neither rules() nor
implemented_rules() for any regime, so the 78 above is unaffected by it: those
two are statements about the specification, and firing a sound rule the table
omits does not change what the table says. Asking is a question in its own right
rather than something you learn by materializing a dataset and reading the
report's extension line — though the report says the same thing, and the two
cannot drift apart.
rdfD1, rdfD1a, rdfs14 and rdfs14a are in that fired set and each concludes
about a fresh blank node. The restricted chase mints one as a frontier-addressed
Skolem witness and closes under it, so the rules genuinely run — but every
conclusion mentioning a witness is withheld when the closure is materialized back,
because a SPARQL entailment regime draws its answers from the scoping graph and a
minted blank node is not in it. The report says so with a boundary surrogate
line rather than with a missing rule, and completeness reads
exact-within-boundaries rather than exact.
78 / 78 is rule-table coverage, and rule-table coverage is not entailment
conformance. The two are measured separately and stating only the first is the
overclaim the reasoning report exists to prevent: on this vendored W3C corpus of
OWL 2 RL entailment tests this chase reaches 27 of 27 published positive entailments, and agrees
with W3C on 23 of 23 negative ones — 3 of those 23 refuted, a decided
non-entailment, and 20 admitted, the closure computed and observed not to contain
the non-conclusion. Read the negative figure as "no unsoundness found", never as
"23 non-entailments proved". Both numbers are true. The full scoreboard, the typed divergence ledger, and every
other suite are in
docs/CONFORMANCE.md.
ValueError is raised for an unknown regime spelling (the message names the
accepted set), for a program that is wrong for the regime — a non-empty one for
any regime but "rif", or one "rif" cannot parse as a normative RIF-in-XML
document — and for a passed evaluation limit. A passed limit is a refusal, never a
truncated closure handed back as a complete one. Being "owl-direct" or "rif" is
not itself a refusal: both materialize.
materialize and materialize_nt take two keyword-only evaluation limits for the
"rdf", "rdfs", "owl-rl" and "d" regimes: max_stored_facts (the facts each
graph's store may hold, default 4194304) and max_join_steps (the candidate
solutions the rules may enumerate, default 1048576). A run past either raises
ValueError naming the limit, the numbers and the keyword argument; a run inside them
returns the closure any larger limits would, and the report's contract-hash names the
calculus under the limits in force.
Description-Logic reasoning services
Materialization is the chase. The OWL 2 Direct-Semantics reasoner is a second
lane on the same module — a SHOIQ(D) hypertableau — and every one of its services is on
purrdf.entail. Each takes an N-Triples (or N-Quads) document and returns
(answer, certificate) as a tuple, so a caller unpacks the evidence rather than
being able to not ask for it:
| Service | Call | Answer |
|---|---|---|
| Consistency | entail.consistency(data, imports, premise_iris) |
consistency true / false / unknown — unknown means the tableau reached its step cap, and is never collapsed to false |
| Classification | entail.classify(data) |
equivalent, subclass (transitively closed), direct (its reduction) and unsatisfiable lines |
| Realization | entail.realize(data) |
type lines for the named individuals, then the most specific direct-type lines |
| Instance retrieval | entail.instances(data, class_) |
instance <term> lines; class_ is ONE N-Triples term, angle brackets included |
| Axiom entailment | entail.entails(data, axiom) |
entails true / false / unknown, then the axiom as it was read, so you can see which kind its predicate selected |
| Profile certification | entail.profile(data) |
certified <profile> lines, most restrictive first (EL, QL, RL, DL, Full) |
| Module extraction | entail.extract_module(data, signature, method) |
the locality module as canonical N-Quads; method is "bot", "top" or "star" |
| Justification | entail.justify(data, axiom) |
a minimal subset of the ontology that still entails axiom, as canonical N-Quads |
| Proof | entail.explain_conclusion(data, regime, conclusion) |
asserted, steps, and one rule line per rule the derivation cited |
Proof terms: opt-in to produce, and a checker to consume
Every call above records nothing and returns a two-tuple. entail.prove is the
opt-in: it records the tableau runs a service made — which costs the completion graph of
each one — and returns a THREE-tuple whose third element is a purrdf-dl-proof 1
document. The answer and the certificate are byte-identical either way, because recording
is an observation the reasoner makes of itself rather than a lever it reads.
| Service | Call | Answer |
|---|---|---|
| Prove | entail.prove(data, service, argument, step_cap, work_cap) |
(answer, certificate, proof); the proof is a purrdf-dl-proof 1 block whose header is derived from the term and whose body lines are the term's own canonical bytes as lowercase hex |
| Check a proof | entail.check_proof(data, service, argument, answer, certificate, proof) |
the purrdf-dl-proof-check 1 report — the digest and input identity it checked, the runs it replayed, and the attested/trusted/unattested counts with the producer-shared components the check rests on |
| The service set | entail.proof_services() |
the seven services a proof term can be about, so the set is measurable rather than written down here |
service is one of those seven; argument is the question's own input in that service's
grammar — "" for consistency/classify/realize (a non-empty one raises rather than
being discarded), ONE N-Triples term for class-satisfiability/instances, ONE triple for
entails, and a method <bot|top|star> line followed by one term per line for
extract-module. entail.Reasoner(data, proofs=True) is the session-level opt-in, and
session.prove(service, argument) is the same call over a document parsed once.
Nothing in check_proof trusts the producer: the ontology is parsed from data, the
question is re-derived from service and argument, the claims are read back out of
answer's own grammar, and the checking context comes from a reverse mapping the call
performs itself. A proof for a different ontology, for a different question, or of a
different answer is refused — and so is a document reading availability not-recorded,
because an answer nobody asked to record must never be presented as a verified one.
The three services below are the chase lane's, not the tableau's, and their
certificate is a purrdf-reasoning-report 4 block rather than a DL one. Note the
collision and that both names are right: entail.entails asks the tableau about one
axiom of the OWL 2 RDF mapping, while entail.graph_entails asks the regime's rule
table whether a premise entails a conclusion graph.
| Service | Call | Answer |
|---|---|---|
| Certain answers | entail.certain_answers(regime, data, pattern, imports, premise_iris) |
mechanism, one var line per projected variable, one row per certain answer, and a limit line per reason the row set may not be exhaustive |
| Graph entailment | entail.graph_entails(regime, premise, conclusion, imports, premise_iris) |
mechanism <name>, then entailment entailed / not-entailed / undecided — three verdicts, never two |
| Verified entailment | entail.verify_entailment(regime, premise, conclusion, imports, premise_iris) |
the above plus warrant present/absent and verified true/false/not-applicable |
pattern is N-Triples with ?name in any position, the predicate included; a blank
node in it is a non-distinguished variable, constrained by the match and not projected,
which is what SPARQL says a query blank node is. A variable inside an RDF 1.2 triple term
is an ordinary variable — it binds and it is a column — and one name is one variable
wherever it was written, so ?x <ex:p> <<( ?x <ex:q> <ex:r> )>> is the join it reads as.
A row is a substitution the knowledge
base entails the pattern under — true in every model, not merely present in one closure.
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 raises ValueError
naming it.
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 no rule of the table concludes and over the constructs the mechanisms beyond
the table decide, and the closure the rows are drawn from holds neither.
A pattern with no ?name in it is a conclusion graph, so certain_answers and
graph_entails are asking one question and answer it through one fold: the mechanism is
whichever one actually reached it, and the relation has no columns — a yes is one bare
row line, a no is none. With something to project the five mechanisms beyond the rule
table are not run, because a projected variable over what any of them decides is a
different question; that one of them would have been needed arrives as a limit line
naming the lane, never as an exhaustive empty answer.
mechanism says which of seven mechanisms reached the verdict. strict-table is the
regime's own rule table, run once; refutation, freeze, comprehension,
reflexivity and data-range each exist because that table decides no conclusion
of that shape, and none of them adds a rule to it. composite is two or
more of those folded over one conclusion — each discharging the triples it reads and
handing the rest on — and it is spelled that way rather than by any one constituent's
name, which would say that one mechanism sufficed.
entailment not-entailed is a proof — the procedure was complete for this premise,
so the absence of a mapping is the absence of an entailment — while undecided is what
an incomplete procedure is entitled to say instead. Reading the second as the first
would turn a limitation of this library into a false statement about your ontology.
imports — the documents your premise says it is not all of
imports is an ordered sequence of (ontology_iri, document) pairs, where document is
N-Quads (or N-Triples) text exactly like the premise. An ontology carrying an
owl:imports states that its axioms are its own plus those of the documents it names,
so answering over the premise alone would answer a different question — this is where
those documents go, and your owl:imports triple stays exactly where you wrote it.
PurRDF fetches nothing. An ontology IRI you did not supply raises ValueError naming
the document, never a network access and never a silently empty import. [] is the
ordinary imports nothing case; the argument is required rather than defaulted, in the
same position on all four hosts, so one call shape works from Python, from JavaScript,
from C and from Rust. Resolution is transitive to a fixpoint, so a supplied document's
own owl:imports is followed too.
premise_iris is the list of IRIs the premise document was read from — its URL, or the
base you parsed it under, when you know one. An owl:imports of one of those names the
premise itself and is resolved in place, with no imports entry; so is an import of an
ontology the premise already declares. Text you were handed with no location has no such
IRI, and [] is that ordinary case. Like imports, the argument is required.
from purrdf import entail
premise = (
"<https://example.org/o>"
" <http://www.w3.org/2002/07/owl#imports> <https://example.org/schema> .\n"
"<https://example.org/socrates>"
" <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://example.org/Man> .\n"
)
schema = (
"<https://example.org/Man>"
" <http://www.w3.org/2000/01/rdf-schema#subClassOf> <https://example.org/Mortal> .\n"
)
conclusion = (
"<https://example.org/socrates>"
" <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://example.org/Mortal> .\n"
)
answer, _ = entail.graph_entails(
"owl-rl", premise, conclusion, [("https://example.org/schema", schema)], []
)
assert "entailment entailed" in answer
# The same call with nothing supplied refuses BY NAME rather than reasoning over a
# premise that is missing the axioms it told you about.
try:
entail.graph_entails("owl-rl", premise, conclusion, [], [])
except ValueError as refusal:
assert "https://example.org/schema" in str(refusal)
from purrdf import entail
ontology = (
"<https://example.org/Cat>"
" <http://www.w3.org/2000/01/rdf-schema#subClassOf>"
" <https://example.org/Animal> .\n"
"<https://example.org/felix>"
" <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>"
" <https://example.org/Cat> .\n"
)
answer, certificate = entail.consistency(ontology, [], [])
assert answer.strip() == "consistency true"
assert certificate.startswith("purrdf-dl-certificate 1")
assert "completeness decided" in certificate
answer, _ = entail.instances(ontology, "<https://example.org/Animal>")
assert answer.strip() == "instance <https://example.org/felix>"
answer, _ = entail.profile(ontology)
assert answer.splitlines()[0] == "certified EL"
The certificate is the point, and there is a different one per kind of evidence.
consistency, classify, realize, instances and entails render a
purrdf-dl-certificate 1 block carrying the DL lane's own completeness —
decided, decided-within-boundaries (an axiom that never became a DL clause,
with each such construct named) or budget-exhausted. That is a different
notion from the chase report's, which subtracts two rule tables, and it is
rendered under a different banner so neither can be parsed as the other.
profile reports no search at all — it is purely syntactic — so it renders a
purrdf-owl-profile-certificate 1 block ending one-directional true: a
certification proves membership, a violation does not prove non-membership.
extract_module renders purrdf-module-extraction 1, whose conservative line
says whether the module is minimal or a sound superset. justify renders
purrdf-justification 1 and explain_conclusion renders purrdf-chase-proof 1;
both re-check their own answers rather than restating them — sufficient and
minimal are re-decided over the justification and over each one-axiom-smaller
subset, and a proof's derived-* lines are what the checker re-derived from the
proof term, not what the proof claims.
A tableau performs no derivation steps, so justify is a justification and
deliberately not called a proof; explain_conclusion is the chase lane's
genuinely derivational one. They are different kinds of thing rather than two
spellings of one, which is why there is no single explain.
Beside completeness, a purrdf-dl-certificate 1 block carries eight search-cost
counters — the numbers a caller needs to tell a decision that finished from one that
merely stopped:
| Line | What it counts |
|---|---|
steps |
rounds spent, against the per-decision round cap |
budget |
the round cap the decision ran under (the knowledge base's own derived cap, or step_cap if that narrowed it) |
work |
matcher, scan, closure and clone work spent, against the work cap |
work-budget |
the work cap the decision ran under (derived, or work_cap if that narrowed it) |
decisions |
how many sub-decisions the run made |
peak-nodes |
the largest completion graph a decision built |
disjunctions |
how many times the tableau's case-split rule fired |
peak-depth |
how deep that rule's branch stack got |
step_cap and work_cap (both 0 by default, meaning "no narrowing") appear on
every DL service above and on Reasoner's constructor; each can only tighten the
knowledge base's own derived cap, never loosen it, and a run narrowed into its cap
answers unknown rather than a false it has not actually decided.
Nothing here re-implements the reasoner: every entry point routes through the same shared boundary the WebAssembly and C hosts call, checked against one committed golden-vector artifact, so the four hosts return byte-identical results for the same input.
SPARQL property functions
A property function is a host-supplied relation invoked from predicate
position. Unlike an extension function it is a row source, so one call may emit
zero, one, or many rows. Store.query / Store.query_governed / Store.update /
Store.update_governed (and the same four on MutableDataset) take relations as
data, registered for that call only:
import purrdf
EX = "http://example.org/"
store = purrdf.Store()
rows = store.query(
f"SELECT ?person ?team WHERE {{ ?person <{EX}rel/memberOf> ?team }}",
relations={
f"{EX}rel/memberOf": (
1, # subject-side arity
1, # object-side arity
[
[purrdf.NamedNode(f"{EX}ada"), purrdf.NamedNode(f"{EX}alpha")],
[purrdf.NamedNode(f"{EX}chen"), purrdf.NamedNode(f"{EX}beta")],
],
)
},
)
A table can also be written as RDF rather than as Python — an rdf:List of
rdf:Lists in the store's own default graph, one inner list per row — and named by
its head:
store.query(query, relations_from_graph={f"{EX}rel/memberOf": (purrdf.NamedNode(f"{EX}memberTable"), 1, 1)})
A registered IRI is recognized in predicate position exactly, so reaching one
needs no namespace declaration. Passing property_fn_namespaces=[f"{EX}rel/"] asks
for the stricter reading instead: every predicate under that prefix becomes a call,
and an unregistered one is a hard error rather than a triple pattern that quietly
matches nothing. A duplicate IRI, a ragged table, a torn list, or a head naming
nothing raises ValueError where it is supplied.
The third spelling is not a table at all. path_relations registers a
path-witness traversal over the store's own edges: a call reads
?start <iri> ( ?end ?pathId ?len ?step ?node ?edge ) and emits one row per
hop, with ?edge bound to the traversed statement as an RDF 1.2 triple term, so
GROUP BY ?pathId with ORDER BY ?step reassembles a whole walk inside the
query. Every field of the specification is mandatory — PurRDF invents no
relation IRI and no traversal envelope:
store.query(
"SELECT ?end ?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")], # steps
1, 4, # min_hops, max_hops
1024, 100000, # max_paths_per_seed, max_expansions_per_invocation
"walk", # "walk" (every simple-prefix witness) | "shortest"
)
},
)
Any of the three declarations may carry one extra trailing position:
(generation, incompleteness), what the host knows about the index the rows came
from. It is the one part of a relation the rows cannot express — a table read out of
a search index mid-rebuild is the same tuple of rows as one read out of a whole
index, and no query text or dataset snapshot differs between the two runs. Both
members are recorded verbatim and None on either is silence, never a claim that
the index was current or whole:
outcome = store.query_governed(
query,
relations={
f"{EX}rel/memberOf": (
1, 1, rows,
("members-index-7", "shard 3 of 4 is still rebuilding"),
)
},
)
outcome.relation_witness[f"{EX}rel/memberOf"]["incompleteness"]
# ['shard 3 of 4 is still rebuilding']
An attested incompleteness is witnessed or fatal, decided by the entry point's
own return type rather than by any keyword. A governed outcome has a slot for the
declaration, so query_governed / query_entailment_governed answer and report it
on relation_witness — {relation_iri: {"invocations": int, "generations": [...], "incompleteness": [...]}}, keyed in IRI order, always present and possibly empty.
query and update have nowhere to put it, so they raise ValueError carrying
native-sparql-relation-incomplete rather than hand back a short answer that is
indistinguishable from a complete one. An empty witness, an empty
incompleteness list, and a None generation are all absences — none of them is a
certificate that an index was whole.
The two declaration lists are comparable between runs; invocations is not. It
counts entries into host code, which is a fact about the schedule rather than about
the index — under a FILTER EXISTS the relation is re-entered once per chunk of
driving rows and the chunk count comes from the runtime's thread count, so the same
query over the same data can report a different number while every declaration
beside it is identical. Read it as "did this relation run at all", never as a value
to compare between two receipts.
Registration is per call and carries no callable, so the whole evaluation still runs with the GIL released. The property functions that are arbitrary host closures on the Rust side — the full-text index, the GeoSPARQL relations, the embedding kNN relation — do not cross this boundary; only these three data-shaped registrations do.
Base IRIs
A document that spells a relative IRI needs a base. Every parse entry point
takes an optional base= keyword (purrdf.parse(text, format, base=...),
RdfDataset(text, format, base=...), the Store loaders, and the JSON-LD and
RDF/XML converters), and shapes.validate / shapes.entail take
shapes_base= for the shapes document. An in-document directive (@base,
BASE, xml:base, @context.@base) wins over the keyword. With neither in
scope a relative reference raises ValueError with the code
iri-relative-no-base — PurRDF has no retrieval IRI for text handed to it as a
string and will not invent one. N-Triples and N-Quads admit no relative
reference by grammar, so they need no base.
rdflib compatibility layer
The package ships an rdflib-shaped API over the native engine:
from purrdf.compat.rdflib import Graph, URIRef
g = Graph()
g.parse(data=my_ntriples, format="nt")
print(len(g), g.serialize(format="turtle"))
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.
GTS graph transport and relational rows
GTS is PurRDF's single-file, content-addressed, append-only container for RDF 1.2 graphs. Build one from quads and read it back as in-memory relational rows:
import purrdf
gts_bytes = purrdf.gts_from_quads(my_nquads_bytes, format=purrdf.RdfFormat.N_QUADS)
rows = purrdf.gts_relational_rows_from_bytes(gts_bytes)
rows["terms"], rows["quads"], rows["reifiers"], rows["annotations"], rows["blobs"]
gts_relational_rows_from_bytes returns a GtsRelationalRows dict of five row
lists. gts_to_sqlite(data, path), gts_to_duckdb(data, path) and
gts_to_parquet(data, out_dir) write those five tables out — terms, quads,
reifiers, annotations, blobs — in the projection's own row order, so
exporting the same container twice produces the same content. gts_to_parquet
writes one file per table and returns their paths in table order.
SQLite needs nothing beyond the standard library. The other two raise
ModuleNotFoundError naming the extra when it is absent:
pip install 'purrdf[duckdb]' # gts_to_duckdb
pip install 'purrdf[parquet]' # gts_to_parquet
The same entry points are grouped under purrdf.gts for discoverability.
Learn more
- Repository: https://github.com/Blackcat-Informatics/purrdf
- Project site: https://blackcatinformatics.ca/purrdf/
- GTS specification, conformance matrix, and full docs live under
docs/in the repo.
Licensed under MIT OR Apache-2.0 OR MulanPSL-2.0, at your option.
Results names, governed calls and JSON documents
The results serializer and reader accept json, srj, sparql-json,
application/sparql-results+json, xml, sparql-xml,
application/sparql-results+xml, csv, text/csv, tsv and
text/tab-separated-values, ignoring ASCII case and surrounding whitespace.
CSV/TSV serialize SELECT bindings; the reader accepts JSON/XML, including ASK.
Store and MutableDataset use the same governor decoder on query_governed,
query_entailment_governed and update_governed. Each omitted resource cap is
metered at 2**64 - 2; evidence still records its consumption. Zero is a real,
inclusive cap. no_ceiling=True selects an unbounded execution with no resource
accounting; it conflicts with any explicitly supplied resource cap, even zero.
Deadline and cancellation signals remain active. The entailment closure keeps
its own reasoning limits, independently of the query's governors.
JSON documents cross these APIs as the documented str or bytes: JSON-LD,
compiled contexts, projection configurations, loss matrices and proof/retrieval
JSON accessors retain their document representation. The shared JSON reader retains number lexemes; each codec applies its specified
conversion before emitting a document. JSON-LD numeric RDF values and @json
fractions use the nearest finite binary64 spelling, while integers within the
signed/unsigned 64-bit domain remain integers. Decode the emitted document to
Python values with
json.loads(document, parse_int=int, parse_float=decimal.Decimal) for exact
integers and decimals; Python's default json.loads instead converts fractional
and exponent lexemes to binary64 float. Neither Python decoding policy changes the emitted document or recovers digits
already rounded by a codec. RDF literal values remain lexical strings, including numeric literals. In expanded
JSON-LD output, an rdf:JSON literal has a string @value containing its JSON
lexical form; decode that string separately to obtain its Python object.
Structured report/provenance APIs documented as dictionaries return dictionaries.
The GTS fold view's python_value is a typed value conversion: integer cells are
Python int, and IEEE binary64 cells are Python float.
Metadata
Release files for purrdf 3.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| purrdf-3.0.0.tar.gz | 28.2 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| purrdf-3.0.0-cp313-abi3-manylinux_2_34_x86_64.whl | CPython 3.13 | abi3 | Linux glibc 2.34+ x86-64 | Details |
Total release size: 41.7 MB
Release files / purrdf-3.0.0.tar.gz
| Download URL | purrdf-3.0.0.tar.gz |
|---|---|
| Size | 28.2 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
50ebf590541107c5e6c14a75844b13b0fc522d44acd01cae888779e974ffb32f
|
|
BLAKE2b-256 checksum How to use checksums |
5be38b712931e235fa71f4722cca97d9960f301457e90f62303637111a219339
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.15
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency logRelease files / purrdf-3.0.0-cp313-abi3-manylinux_2_34_x86_64.whl
| Download URL | purrdf-3.0.0-cp313-abi3-manylinux_2_34_x86_64.whl |
|---|---|
| Size | 13.5 MB |
| Tags | CPython 3.13 Linux glibc 2.34+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
1ae17d0bc75f9570f086987fd636adf2bfdea447251969e6f1896599e1476946
|
|
BLAKE2b-256 checksum How to use checksums |
423fea61bb36eb0a31560d0b8ff621dce6f086ecc560cf1636b99a04d88381c3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.15
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency log