Context Graph · Feature Guide

The Business Rules Engine

A knowledge graph that only records decisions is a filing cabinet. Add a rules engine and it becomes a control point: every decision passes through a governance gate before it's written — passed, flagged for review, or rejected outright.

DSL with priorities sim() semantic match PASS · FLAG · REJECT pre-emit gate NL → policy author fail-open, audited
The purpose

What it does

The rules engine is a per-workspace governance gate that runs before any decision is written to the graph. When a decision reaches emit_decision_trace(...), an attached gate projects the edge into a flat set of fields, evaluates the workspace's rules against it, and returns an outcome plus an audit record.

Rules are authored in a small DSL. They match structured fields (numbers, dates, channels) with exact operators, and free-text fields (like the relationship type) by meaning — via a semantic sim() predicate backed by a local embedding model. Three outcomes are possible:

PASS
Persisted unchanged
FLAG
Persisted, marked needs_review
REJECT
Aborted — nothing persisted
Built on a proven core
The engine wraps the business_rule_engine library's parser and adds what a multi-tenant decision graph needs: workspace isolation, semantic matching, fail-open evaluation, audit attribution, persistence, an HTTP API, and a natural-language author agent.
The language

The rule DSL

A rule is a named, prioritised when … then block. Conditions are Python boolean expressions over the decision's fields; actions declare the outcome with a human-readable reason.

rule "large discount needs finance review"  priority 10
when
    sim(relation_type, "APPROVAL") > 0.4
    and percent > 0.15
    and approved_via == "slack"
then
    flag("Discount >15% approved over Slack — route to Finance for review")
end

Actions

VerbOutcomeEffect
reject("…")REJECTBlock the write — the emit raises and nothing is persisted (callers map to HTTP 422).
flag("…")FLAGPersist the edge but annotate it needs_review with the audit attached.
notify("…")noteRecord a side note; does not change the gate outcome.

There is no block/deny verb — the blocking action is reject. Available operators: ==, >, <, >=, <=, and boolean and/or.

Formatting is strict
when and then must each sit alone on their own line, with conditions and actions indented beneath. The loader lints for the common mistake of putting a condition on the same line as when (which the underlying parser would silently swallow) and rejects it.
The environment

The fields you match on

Before evaluation, a decision (src, tgt, relation_type, RelationContext) is projected into a flat dict. Some fields pass through raw; a few are derived.

FieldTypeSource
src, tgt, relation_typetextHead/tail names & relationship keyword — match with sim().
approved_viatextChannel: slack|zoom|email|in_person|jira|system — match with ==.
approved_by, policy_ref, provenancetextRaw RelationContext fields.
decision_trace, quantitative_data, temporal_infotextRaw free-text (may be null).
valid_from, valid_untildateISO-8601 validity window.
confidencefloatExtraction confidence, 0–1.
amountfloat|null derivedParsed from quantitative_data"$25,000"25000.0.
percentfloat|null derivedParsed from quantitative_data"20%"0.20.
is_activebool derivedWhether the decision is inside its validity window today.
evidence_countint derivedNumber of supporting sentences.
A gotcha worth internalising
amount and percent are best-effort parses of the free-text quantitative_data field, not raw numbers. A rule with percent > 0.15 only fires when quantitative_data actually contains something like "20% discount". If it can't be parsed, the value is null and the rule is skipped fail-open (see below). This is exactly the raw-text fragility the Ontology is designed to remove by handing the engine typed values.
Meaning, not strings

Semantic matching

The relationship type is free text — an LLM might write "approved", "authorized", "gave the go-ahead". Matching those with string equality is hopeless. sim(field, "CONCEPT") matches on meaning.

A concept maps a name to a list of example phrases. sim() returns the maximum cosine similarity between the field's embedding and any of the concept's phrase embeddings — a float in [-1, 1] you compare against a threshold. A companion similar(field, "CONCEPT", threshold=0.4) returns the boolean directly.

// concept catalog — name -> phrases
{
  "APPROVAL": ["approved", "authorized", "granted approval", "go ahead"]
}
Local & deterministic. Embeddings come from minishlab/potion-retrieval-32M — a static, ~30 MB, MIT-licensed model. No network call, no API cost, repeatable scores. It loads lazily, only when a gate actually evaluates.
Calibrated threshold. The default match threshold is 0.4. Empirically, non-matches ceil around 0.24 and true matches floor around 0.53 on the approval concept — so 0.4 is a safe starting line to tune per concept.

Field text is normalised (snake_case/kebab-case split, whitespace collapsed) and embeddings are L2-normalised so the dot product is the cosine. The model id is recorded in a fingerprint for audit and pinning.

Where it runs

The pre-emit gate

The gate sits inside emit_decision_trace, before any write. If the edge already exists and you're upserting, the new context is merged with the existing one first, so the gate always evaluates the final decision.

decision rules gate project + evaluate PASS → write FLAG → write+mark REJECT → abort
One decision, three exits. Rules run one at a time in priority order; the decisive rule is the highest-severity, highest-priority match.
  • REJECT raises a RuleViolation — nothing is persisted.
  • FLAG persists the edge with needs_review=true and the JSON audit stored on the edge.
  • PASS persists unchanged.
◆ Fail-open by design
Each rule runs in isolation. A rule that raises — a missing numeric, an unknown concept — is skipped and its message added to warnings, never crashing the emit. A misconfigured rule can't take down your ingestion; it just doesn't fire, visibly. The audit records outcome, model_id, the decisive rule + reason, the matched_concept/score/threshold, plus any notes and warnings.
Plain English in

The NL author agent

You describe a policy in English; the RuleAuthor writes the DSL and the concepts, then proves it works with a self-test before you ever save it.

POST /rules/generate
{
  "policy": "Any purchase over $10,000 approved via email must be flagged for CFO review.",
  "max_repairs": 1,   // 0–3 self-repair attempts
  "save": false       // persist + enable only if valid
}

The agent is given a capability manifest — the available fields and action verbs — and is instructed to use sim() for free text and exact operators for numbers and channels. Each attempt is validated (does the DSL parse with non-empty conditions? is every referenced concept defined?), and any error is fed back into the next repair prompt. On success it runs a dry-run: it builds a real gate and evaluates each fixture, checking the outcome matches the expectation.

// dry_run in the response — the policy proving itself
[ {"name":"Email approval over $10k",  "expect":"FLAG", "outcome":"FLAG", "ok":true},
  {"name":"Email approval under $10k", "expect":"PASS", "outcome":"PASS", "ok":true},
  {"name":"In-person over $10k",       "expect":"PASS", "outcome":"PASS", "ok":true} ]
The interface

The /rules API

All endpoints are workspace-scoped via LIGHTRAG-WORKSPACE and return 503 unless the server runs in Context Graph mode (USE_CONTEXT_GRAPH=true).

Method · PathPurposeReturns
GET /rulesPolicy summaryRuleSummaryResponse
POST /rulesSave DSL + conceptsSummary; attaches the gate live (400 if invalid)
POST /rules/evaluateDry-run against a sample decisionEvaluateResponse
POST /rules/toggleEnable/disable the gateSummary (404 if none)
DELETE /rulesDelete the policy{deleted, workspace}
POST /rules/generateNL → policy (LLM)Generation result + saved (503 if no LLM)

The summary reports exists, enabled, version, model_id, the concept names, and the rules with their priorities. evaluate returns the outcome, the audit, and the triggered rules with their concept matches, warnings, and notes:

curl -X POST http://localhost:9621/rules/evaluate \
  -H "LIGHTRAG-WORKSPACE: sales" -H "Content-Type: application/json" \
  -d '{
    "src":"Sarah Chen","tgt":"MegaCorp Order","relation_type":"approved a large discount",
    "relation_context":{"quantitative_data":"20% discount","approved_via":"slack"}
  }'

# -> outcome FLAG · matched_concept APPROVAL · score 0.53 > 0.4
Lifecycle

Storage & lifecycle

A workspace's policy is a rule bundle — DSL, concepts, an enabled flag, the embedding model_id, and a version — persisted as one atomic JSON file per workspace.

  • Validated on save without loading the model: the DSL must parse with non-empty conditions, and every concept referenced by a sim() must be defined. Version bumps on each save.
  • Toggling enabled/disabled updates the timestamp without re-validating or bumping the version.
  • Live wiring. A service caches one built gate per workspace, keyed by version, invalidated on save/toggle/delete. Saving a policy attaches the gate so the very next decision is governed. The embedding model loads lazily — summaries and saves stay fast and offline.
◆ The arc
Facts → decisions → policy → a typed ontology → governed actions. The rules engine is the policy layer: it turns a graph that remembers decisions into one that governs them — and the same gate authorizes every action. Pair it with the Ontology for typed values and the Web Ingester to feed it from real sources.
↑ Top