Skip to content

Rules & rule store

A rule is a safe (range-restricted) Horn clause with stratified negation-as-failure, authored as JSON. Rules are the only place you encode policy; the reasoner executes them deterministically.

{
"id": "rule:coordinated_threat",
"name": "High-confidence drone convergence is a coordinated threat",
"version": 1,
"priority": 10,
"enabled": true,
"if": [
{"pred": "formation", "args": ["?E", "CONVERGENCE"]},
{"pred": "member", "args": ["?E", "?A"]},
{"pred": "member_type", "args": ["?A", "drone"]},
{"pred": "weight", "args": ["?E", "?W"]},
{"compare": ["?W", ">=", 0.8]},
{"not": {"pred": "cleared", "args": ["?A"]}}
],
"then": {"pred": "coordinated_threat", "args": ["?E"], "confidence": 0.9}
}
FieldTypeMeaning
idstring (required)Unique rule id. Upserting the same id replaces it.
namestringHuman label (shown in listings).
versionintAuthor-managed version (default 1).
priorityintHigher fires first within a stratum (default 0).
enabledboolDisabled rules are skipped by the reasoner (default true).
iflist (required)Body literals — positive atoms, comparisons, negations.
thenobject (required)Head atom with optional confidence in [0, 1].
provenanceobjectFree-form metadata stored alongside the rule.

if/then are the canonical keys; body/head are accepted as aliases.

Variables are ?-prefixed strings (SPARQL style): ?E, ?A, ?W. Everything else — numbers, unprefixed strings — is a constant. This makes a formation label like "CONVERGENCE" unambiguous rather than being mistaken for a variable.

A variable name must match ^\?[A-Za-z_][A-Za-z0-9_]*$. Predicate names must be snake_case (^[a-z_][A-Za-z0-9_]*$).

Every retrieved hyperedge is projected into these extensional base predicates. Rules read them but may not redefine them as a head:

PredicateProduced asMeaning
hyperedge(E)hyperedge(edge_idx)The hyperedge exists.
formation(E, F)formation(edge_idx, "CONVERGENCE")Typed formation label.
weight(E, W)weight(edge_idx, 0.91)Edge weight (rounded to 6 dp).
at(E, Ts)at(edge_idx, 1700000000)Event timestamp (omitted if 0).
member(E, A)member(edge_idx, node_id)A member node of the edge.
member_type(A, T)member_type(node_id, "drone")A member’s type (lowercased).

A rule head must derive a new predicate (e.g. coordinated_threat). Heads derive intensional (IDB) predicates that can in turn feed other rules.

A body (if) is a non-empty list. Each element is one of:

Positive atom{"pred": "member", "args": ["?E", "?A"]}. Binds variables by matching facts. At least one positive atom is required (it supplies bindings).

Comparison{"compare": ["?W", ">=", 0.8]}. Operators: >=, <=, >, <, ==, !=. Both sides must already be bound (or constants). Type-mismatched comparisons evaluate to false rather than raising.

Negation (as failure){"not": {"pred": "cleared", "args": ["?A"]}}. True when no matching fact exists. Variables inside a negation must be bound by a positive atom (safe negation).

The head may carry confidence in [0, 1] (default 1.0). A derived fact’s confidence is rule_confidence × min(premise_confidence), so confidence degrades monotonically along a derivation chain and never inflates.

parse_rule rejects unsafe rules at author time. A rule is safe only if:

  1. The body has at least one positive atom.
  2. Every head variable is bound by a positive body atom.
  3. Every comparison variable is bound by a positive body atom.
  4. Every negated variable is bound by a positive body atom.

These guarantee a finite, well-defined derivation. Violations raise RuleValidationError with a precise message, e.g.:

rule:bad: head variables ['?X'] not bound by any positive body atom (unsafe rule)

The whole rule set must be stratifiable: any predicate used under not must be fully computed in a strictly lower stratum. This rules out negation through recursion and guarantees a unique, terminating result. A non-stratifiable set is rejected:

ruleset is not stratifiable: negation through recursion detected

Stratification is enforced both by the reasoner and by the RuleStore on every write, so a stored rule set can never be one the reasoner would reject. See Reasoning engine → Stratification for the algorithm.

RuleStore is a durable JSON registry at {db_dir}/rag_rules.json — the same sidecar pattern HyperMesh uses for models and ingest provenance. Every mutation re-validates the entire rule set for stratifiability, atomically (temp file + os.replace + fsync).

from hypermesh.rag.symbolic import RuleStore
store = RuleStore("data/")
# Create / replace (raises RuleValidationError on bad or non-stratifiable rules)
out = store.upsert(rule) # -> rule dict + {"stratum": N}
# Dry-run validate without writing
report = store.validate(rule) # -> {"ok": bool, "errors": [...], "stratum"?: int}
store.get("rule:coordinated_threat") # -> rule dict | None
store.list() # -> [{id, name, enabled, priority, head, body_size}, ...]
store.delete("rule:coordinated_threat") # -> bool
# Parsed views the reasoner consumes
store.all_rules() # -> list[Rule]
store.enabled_rules() # -> list[Rule] (enabled only)

RAGPipeline reads from the store automatically when you don’t pass ad-hoc rules=. Pass rules=[...] to override the store for a single query (useful for what-if analysis and tests).

MethodReturnsNotes
upsert(rule_dict)rule dict + stratumValidates + stratifies the whole set; raises RuleValidationError.
validate(rule_dict){ok, errors, stratum?}No write; safe to call from a UI on every keystroke.
get(rule_id)dict | NoneRaw stored rule.
list()list of summaries{id, name, enabled, priority, version, head, body_size}.
delete(rule_id)boolFalse if the id wasn’t present.
all_rules()list[Rule]Parsed objects.
enabled_rules()list[Rule]Parsed, enabled only.
path (property)strAbsolute path to the JSON registry.

Next: Reasoning engine.