Reasoner Reference
Complete reference for the Quipu reasoner: rule syntax, CLI, Rust API, error catalogue, and current limitations.
Placement (quipu-0b6, 2026-08-27). Derivations are written to the premise graph’s companion inferred graph (
<graph>#inferred; ROOT’s isurn:quipu:graph:root#inferred), never beside their premises. Premises are read from the graph plus its companion, so closure feeds further derivation. Compose reads withFROM <urn:quipu:graph:root> FROM <urn:quipu:graph:root#inferred>; migrate a pre-regime store withquipu db migrate-inferred.
Reactive graph scope
Each committed transaction carries its authoritative graph in the observer
Delta. Reactive rules read that graph and its companion inferred graph;
derivations and withdrawals stay in that companion. A multi-graph batch
notifies each graph separately. A change in one project cannot update the
same rule’s derivations in another project or in ROOT.
This applies only when the reactive reasoner is enabled and its rules are
loaded. Explicit evaluate_in_graph remains available for a reconciliation
or backfill; it is not a substitute for current reactive evaluation.
Rule Syntax
Rules are written in standard Turtle files using the rule: vocabulary. The
reasoner reads any resource typed rule:Rule and ignores everything else, so
rules can live alongside SHACL shapes in the same file.
Namespace
@prefix rule: <http://quipu.local/rule#> .
| Property | Type | Required | Description |
|---|---|---|---|
a rule:Rule | type | yes | Marks this resource as a rule |
rule:id | string | yes | Stable identifier used in provenance (source = "reasoner:<id>") |
rule:head | string | yes | Head atom: predicate(?var1, ?var2) |
rule:body | string | yes | Body atoms: p(?x, ?y), q(?y, ?z) |
rule:prefix | string | no | Per-rule IRI prefix for bare predicate names |
RuleSet Container
An optional rule:RuleSet resource sets defaults for all rules in the file:
ex:my_rules a rule:RuleSet ;
rule:defaultPrefix "http://aegis.gastown.local/ontology/" .
| Property | Type | Description |
|---|---|---|
a rule:RuleSet | type | Marks this resource as a ruleset |
rule:defaultPrefix | string | Default IRI prefix for all rules in this file |
Prefix Resolution Order
When the reasoner encounters a bare predicate name like dependsOn inside a
head or body string, it resolves the full IRI using this precedence:
- Per-rule
rule:prefixproperty (highest priority) - Ruleset
rule:defaultPrefixproperty - Fallback
http://quipu.local/default/(lowest priority)
Atoms and Terms
An atom is predicate(arg1, arg2). Arguments can be:
| Term | Syntax | Example | Notes |
|---|---|---|---|
| Variable | ?name | ?svc | Bound by body atoms, projected into head |
| Bare name | name | dependsOn | Expanded with prefix resolution |
| Full IRI | <http://...> | <http://ex.org/p> | Used as-is, no expansion |
| String | "value" | "active" | Allowed in head only (constants) |
Body Syntax
Body atoms are comma-separated. Whitespace is flexible:
dependsOn(?a, ?b), dependsOn(?b, ?c)
Negation uses the not keyword:
reachable(?x, ?y), not blocked(?y)
Negation is stratified negation-as-failure (since 2026-08-27, quipu-923): a negated atom filters out rows matching the predicate’s tuples from lower strata. Every variable in a negated atom must be bound by a positive atom (unsafe negation is rejected), and NAF is evaluated over the graph’s materialized state — an open-world caveat to keep in mind when a predicate is only partially recorded.
Supported Rule Shapes
Since 2026-08-27 (quipu-923) rules compile to a general left-deep join pipeline, so the old 1-atom/2-atom shape caps are gone:
-
Any number of body atoms — each further positive atom joins into the accumulated bindings on the tuple of its shared variables.
rule:head "path3(?a, ?d)" ; rule:body "edge(?a, ?b), edge(?b, ?c), edge(?c, ?d)" . -
Any number of shared variables between atoms — including zero (a cross join) and both columns (an intersection).
-
Repeated variables within an atom —
p(?x, ?x)is an equality selection over reflexive tuples. -
Constants in body atoms — a selection; an un-interned constant makes the rule unsatisfiable (derives nothing) rather than erroring.
-
Stratified negation —
not q(?x, ?y)antijoins against the negated predicate’s lower-stratum tuples.
Constraints that remain:
- Head and body atoms must have exactly 2 arguments (binary predicates)
- At least one positive body atom
- Every head variable and every negated-atom variable must be bound by a positive atom
- No string literals in atoms (facts are reference triples)
Complete Example
@prefix rule: <http://quipu.local/rule#> .
@prefix ex: <http://aegis.gastown.local/rules/> .
@prefix aegis: <http://aegis.gastown.local/ontology/> .
ex:aegis a rule:RuleSet ;
rule:defaultPrefix "http://aegis.gastown.local/ontology/" .
# Transitive dependency closure (positive recursion)
ex:depends_on_transitive a rule:Rule ;
rule:id "depends_on_transitive" ;
rule:head "depends_on(?a, ?c)" ;
rule:body "depends_on(?a, ?b), depends_on(?b, ?c)" .
# Host-level runs_on closure
ex:runs_on_transitive a rule:Rule ;
rule:id "runs_on_transitive" ;
rule:head "runs_on(?svc, ?host)" ;
rule:body "runs_on(?svc, ?container), runs_on(?container, ?host)" .
Safety Checks
The parser enforces these safety properties at load time:
- Range restriction: every variable in the head must appear in at least one positive body atom. A head variable that only appears under negation is rejected.
- Non-empty body: rules with empty bodies are rejected.
- Valid syntax: malformed head or body strings produce errors that name the specific rule and the parse location.
CLI: quipu reason
Run the reasoner against a store.
quipu reason [--rules <file.ttl>] [--reactive] [--db <path>]
Flags
| Flag | Default | Description |
|---|---|---|
--rules <file> | shapes/aegis-rules.ttl | Path to Turtle file containing rules |
--reactive | off | Register a ReactiveReasoner observer after evaluation (requires reactive-reasoner feature) |
--db <path> | config default | Store database path |
Output
reasoner: 2 rules across 1 strata — asserted 5, retracted 0
per-rule contributions:
depends_on_transitive 3
runs_on_transitive 2
The report shows:
- Total rules and strata evaluated
- Aggregate asserted/retracted counts
- Per-rule breakdown of new assertions
Examples
Run with the default aegis rules:
quipu reason --db homelab.db
Run with custom rules:
quipu reason --rules my-rules.ttl --db homelab.db
Run and keep derived facts fresh going forward:
quipu reason --reactive --db homelab.db
Rust API
Parsing
#![allow(unused)]
fn main() {
use quipu::reasoner::{parse_rules, RuleSet};
let turtle = std::fs::read_to_string("rules.ttl")?;
let ruleset: RuleSet = parse_rules(&turtle, None)?;
// Or with a fallback prefix:
let ruleset = parse_rules(&turtle, Some("http://my.org/"))?;
}
RuleSet contains:
rules: Vec<Rule>— rules in source orderdefault_prefix: String— resolved default prefix
Evaluation
#![allow(unused)]
fn main() {
use quipu::reasoner::{evaluate, EvalReport};
use quipu::store::Store;
let mut store = Store::open("homelab.db")?;
let report: EvalReport = evaluate(&mut store, &ruleset, "2026-04-04T12:00:00Z")?;
println!("asserted: {}, retracted: {}", report.asserted, report.retracted);
for (rule_id, count) in &report.per_rule {
println!(" {}: {}", rule_id, count);
}
}
EvalReport fields:
| Field | Type | Description |
|---|---|---|
asserted | usize | Total new derived facts |
retracted | usize | Total retracted derived facts |
strata_run | usize | Number of non-empty strata executed |
per_rule | Vec<(String, usize)> | Per-rule assertion counts |
Historical edition evaluation (native library)
The native library exposes a separate historical path. The timestamp argument
of evaluate stamps a current evaluation; it does not select historical
premises or a historical rule edition. The file-based CLI above keeps its
existing behavior. No historical CLI flags are provided by this library API.
Use the following sequence for an independently retained raw SQLite artifact:
- Verify a detached
SignedHistoryAttestationwithhistory_attestation::verify_history_attestation. The caller supplies a trustedHistoryCoverageGrant, including the external policy revision, reviewed evidence bytes and digest, issuer key, exact artifact digest, source lineage, catalogue and premise graphs, and both coverage intervals. A key or completeness assertion supplied by the manifest itself is not authority. Missing, expired, ambiguous or mismatched evidence refuses. - Call
raw_history::admit_raw_historywith those verified terms, the exact retained bytes, aRawHistoryRequest { valid_at, as_of_tx }, configuration, the caller’s policy store, and the actual admission time. It verifies the digest, SQLite integrity, lineage, real transaction boundaries and graph registration, and refuses attachments and active frozen providers. The reader owns a private immutable copy; it does not snapshot a live service. ROOT is explicitlyurn:quipu:graph:root, reserved graph zero; an unknown named graph never falls back to ROOT. - Call
governed::evaluate_governed_history(&source, ruleset_iri). Catalogue membership is read at the transaction cut, then the edition is selected independently by its valid-time applicability. Premises are read from the admitted premise graph at both pins. Current inferred companions and current shape sets do not participate. Evaluation occurs in isolation without writing derivations into the retained source.
GovernedHistoricalAnswer returns the selected edition identity, executable
Turtle digest and applicability interval, the exact signed source terms,
transaction cut, original admission time and sorted derived IRI tuples. It is
an illustrative model answer, not a legal judgment or a captured derivation
proof. Rule parsing alone does not establish that a vocabulary is loaded or
that primary text supports the model.
The selector refuses absent, overlapping, malformed or digest-mismatched editions. Its bounded profile permits at most 64 editions, 4,096 catalogue triples and 1 MiB of catalogue text; execution permits at most 64 uniquely identified rules. Premise evaluation permits at most 10,000 tuples and 64 predicates. A smaller positive configured SPARQL output ceiling remains in force, with a reserved sentinel row: reaching the ceiling is a capacity refusal, not a truncated successful answer. Zero means no configured output ceiling, while these profile bounds still apply. Query deadlines and join budgets remain enforced for premise extraction. The same absolute deadline also covers derivation. Historical derivation streams one binding at a time, refuses before inserting a sentinel-sized derived relation, and shares one candidate-work budget across rules, strata and fixed-point rounds (at most 1,000,000 candidate steps, reduced by a smaller positive caller row cap or configured join cap). It admits at most 64 body atoms per rule. Capacity or deadline exhaustion refuses the whole answer; it never returns a successful partial closure. This bounded historical evaluator does not change the legacy file evaluator.
A matching artifact digest, store ID or known transaction cannot prove history completeness. That authority must come from the independently reviewed coverage grant, including pre-window facts needed by the catalogue and premises. This API supplies no producer certificate, issuer registration, live schema activation or automatic trust-policy loader. Preserve UNKNOWN when those prerequisites are unavailable. Retaining an old receipt does not extend its admission validity.
Bounded standards mapping
This profile makes a conceptual mapping to LegalRuleML 1.0, sections 4.3.1, 4.3.5 and 4.4. LegalRuleML separates legal entry, efficacy and applicability and supports source associations and temporal parameters for parts of rules. Here, one immutable executable edition has one applicability interval and separately linked primary sources. That interval is not its assertion time, execution time, event time, legal entry or efficacy. This is not LegalRuleML XML interchange or a conformance claim.
The catalogue reuses DCMI isVersionOf and source, PROV value, SPDX checksum
terms and OWL-Time interval/instant
relationships. Half-open applicability intervals, whole-second UTC and exact
executable-byte SHA256 are application profile choices; OWL-Time does not
mandate these choices.
Unsupported constructs include temporal associations below edition granularity, legal authority and jurisdiction reasoning, deontic operators, defeasible priorities, penalties and reparations, legal completeness, and civil calendar arithmetic. Synthetic transition facts are precomputed inputs, not calculated legal deadlines. A reviewed teaching-case oracle is required before presenting any jurisdiction-specific example as an accepted model result.
Reactive Evaluation
Requires the reactive-reasoner feature.
#![allow(unused)]
fn main() {
use quipu::reasoner::reactive::ReactiveReasoner;
use std::sync::Arc;
let observer = Arc::new(ReactiveReasoner::new(ruleset));
store.add_observer(observer.clone());
// Now any transact() call triggers automatic re-derivation.
store.transact(&new_facts, timestamp, Some("agent"), Some("discovery"))?;
// Derived facts are already updated.
// Check stats:
let stats = observer.stats();
println!("triggers: {}, asserted: {}", stats.triggers, stats.total_asserted);
}
ReactiveStats fields:
| Field | Type | Description |
|---|---|---|
triggers | usize | Number of times the observer fired |
total_asserted | usize | Cumulative assertions across all triggers |
total_retracted | usize | Cumulative retractions across all triggers |
The reactive reasoner:
- Skips transactions with
sourcestarting with"reasoner:"(prevents loops) - Computes the transitive closure of affected rules via dependency analysis
- Re-evaluates only the affected strata, not the entire ruleset
Speculate
#![allow(unused)]
fn main() {
let result = store.speculate(&hypothetical_datums, timestamp, |store| {
evaluate(store, &ruleset, timestamp)
})?;
// result is the EvalReport from inside the closure
// store is unchanged — the hypothetical was rolled back
}
The closure receives a &Store with the hypothetical facts applied. When
the closure returns, all changes are rolled back via ROLLBACK TO SAVEPOINT.
Core Types
#![allow(unused)]
fn main() {
// A term in an atom's argument list
pub enum Term {
Var(String), // Variable (without leading ?)
Iri(String), // Full IRI
Str(String), // String literal
}
// A predicate application: pred(arg1, arg2)
pub struct Atom {
pub predicate: String, // Full IRI after expansion
pub args: Vec<Term>,
}
// A body literal
pub enum BodyAtom {
Positive(Atom),
Negative(Atom), // Parsed but not yet evaluated
}
// A Horn clause rule
pub struct Rule {
pub id: String, // Provenance identifier
pub head: Atom,
pub body: Vec<BodyAtom>,
}
}
Error Reference
All errors are variants of ReasonerError.
Turtle
rule Turtle parse error: <details>
The Turtle file itself failed to parse as valid RDF. Check for unclosed strings, missing prefixes, or invalid IRIs.
MissingProperty
rule "R1" is missing required property head
A resource typed rule:Rule lacks a required property. Every rule needs
rule:id, rule:head, and rule:body.
BadSyntax
rule "R1" head: expected 'predicate(args)' but got 'foo bar'
A head or body string couldn’t be parsed as atoms. Check for missing parentheses, unbalanced commas, or invalid variable syntax.
UnboundHeadVariable
rule "R1" head variable ?z is not bound in the body
The head references a variable that doesn’t appear in any positive body atom. Every head variable must be range-restricted — it must appear in at least one positive body literal so the reasoner knows what values to bind.
UnstratifiableCycle
rule set is not stratifiable: negation cycle through ["p", "q"]
The ruleset contains a cycle through negation: rule A negates predicate P
which rule B produces, and rule B negates predicate Q which rule A produces
(or a self-negation like p :- not p). Break the cycle by restructuring
your rules so negation only flows “downward” between strata.
Unsupported
rule "R1" uses unsupported feature: non-binary body atom
The rule parsed and stratified successfully, but uses a shape the evaluator doesn’t handle. Current unsupported features (the 3-atom, negation, constant, and repeated-variable rejections were lifted 2026-08-27, quipu-923):
| Feature | Message |
|---|---|
| Non-binary atoms | non-binary head/body atom |
| Purely negative body | body needs at least one positive atom |
| Unsafe negation | unsafe negation: variable not bound by a positive atom |
| Unbound head variable | head variable not bound by a positive body atom |
| Unknown head IRI | head references an IRI that has never been interned |
| String literals | string constant in head atom / string constant in body atom |
Store
store error: <sqlite error details>
A store operation failed during evaluation (reading facts, writing derivations). This typically indicates a database problem, not a rule problem.
Provenance Tags
Every derived fact is written with structured provenance:
| Field | Value |
|---|---|
source | reasoner:<rule-id> (e.g., reasoner:depends_on_transitive) |
actor | reasoner |
You can query or filter by these tags. To find all facts derived by a specific rule:
quipu read "SELECT ?e ?a ?v WHERE {
?e ?a ?v .
}" --db homelab.db | grep "reasoner:depends_on_transitive"
Or from Rust, filter the source field on returned Fact structs.
Limitations
Binary predicates only. Head and body atoms must have exactly 2
arguments. This covers the vast majority of RDF-style relations
(subject predicate object) but can’t express ternary or higher-arity
relations directly.
Full re-derivation. Each evaluation pass re-derives all facts for every rule in the affected strata, then diffs against the old state. This is correct and fast at the target scale (~50K facts), but would need incremental truth maintenance for much larger workloads.
No aggregation. There’s no COUNT, SUM, MIN/MAX in rules. Use SPARQL for aggregation over derived facts.