Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 is urn:quipu:graph:root#inferred), never beside their premises. Premises are read from the graph plus its companion, so closure feeds further derivation. Compose reads with FROM <urn:quipu:graph:root> FROM <urn:quipu:graph:root#inferred>; migrate a pre-regime store with quipu 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#> .
PropertyTypeRequiredDescription
a rule:RuletypeyesMarks this resource as a rule
rule:idstringyesStable identifier used in provenance (source = "reasoner:<id>")
rule:headstringyesHead atom: predicate(?var1, ?var2)
rule:bodystringyesBody atoms: p(?x, ?y), q(?y, ?z)
rule:prefixstringnoPer-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/" .
PropertyTypeDescription
a rule:RuleSettypeMarks this resource as a ruleset
rule:defaultPrefixstringDefault 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:

  1. Per-rule rule:prefix property (highest priority)
  2. Ruleset rule:defaultPrefix property
  3. Fallback http://quipu.local/default/ (lowest priority)

Atoms and Terms

An atom is predicate(arg1, arg2). Arguments can be:

TermSyntaxExampleNotes
Variable?name?svcBound by body atoms, projected into head
Bare namenamedependsOnExpanded 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

FlagDefaultDescription
--rules <file>shapes/aegis-rules.ttlPath to Turtle file containing rules
--reactiveoffRegister a ReactiveReasoner observer after evaluation (requires reactive-reasoner feature)
--db <path>config defaultStore 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 order
  • default_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:

FieldTypeDescription
assertedusizeTotal new derived facts
retractedusizeTotal retracted derived facts
strata_runusizeNumber of non-empty strata executed
per_ruleVec<(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:

  1. Verify a detached SignedHistoryAttestation with history_attestation::verify_history_attestation. The caller supplies a trusted HistoryCoverageGrant, 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.
  2. Call raw_history::admit_raw_history with those verified terms, the exact retained bytes, a RawHistoryRequest { 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 explicitly urn:quipu:graph:root, reserved graph zero; an unknown named graph never falls back to ROOT.
  3. 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:

FieldTypeDescription
triggersusizeNumber of times the observer fired
total_assertedusizeCumulative assertions across all triggers
total_retractedusizeCumulative retractions across all triggers

The reactive reasoner:

  • Skips transactions with source starting 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):

FeatureMessage
Non-binary atomsnon-binary head/body atom
Purely negative bodybody needs at least one positive atom
Unsafe negationunsafe negation: variable not bound by a positive atom
Unbound head variablehead variable not bound by a positive body atom
Unknown head IRIhead references an IRI that has never been interned
String literalsstring 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:

FieldValue
sourcereasoner:<rule-id> (e.g., reasoner:depends_on_transitive)
actorreasoner

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.