Rule Notation and the Krank Engine

This document defines the notation a rule set is written in, the semantics of the interpreter that runs it, and the round-trip used to check a translation.

Purpose

A human writes intent in prose. A model translates that prose into a rule set. A small deterministic interpreter evaluates the rule set against facts. A renderer translates the rule set back into prose, and the regenerated prose is compared to the original intent.

The interpreter is a production-rule engine: it holds facts, matches rules against them, and fires the actions of matching rules, forward, until no rule can fire. This is the lineage of OPS5 and CLIPS, cut down to the smallest set of concepts an LLM can emit reliably.

The notation is JSON because:

The rule set

A rule set is one JSON document:

{
        "facts": [ {"type": "task", "status": "open", "priority": "high"} ],
        "rules": [
            {
                "id": "flag-open-high-priority",
                "when": [ {"fact": {"type": "task", "status": "open", "priority": "high"}} ],
                "then": [ {"emit": "an open high-priority task exists"} ]
            }
        ]
    }

Fact

A fact is a JSON object whose values are JSON scalars (string, number, boolean, or null). The key type names the kind of fact by convention; it is not special to the engine.

Rule

A rule is an object with three required keys and one optional key:

Condition

A condition is {"fact": PATTERN} or {"fact": PATTERN, "not": true}.

A pattern is an object keyed by fact key. Each value is a matcher:

A fact matches a pattern when it has every key in the pattern and every matcher holds. Extra keys on the fact are ignored, so a pattern with no keys matches every fact. "not": true inverts the condition: it holds when no fact matches the pattern. A negated condition may reference variables already bound by earlier conditions, but it may not bind a new one.

A count condition is {"count": {"fact": PATTERN, "at_least": N}} or {"count": {"fact": PATTERN, "at_most": N}}, or both bounds together. It holds when the number of facts matching the pattern is within the bounds. The pattern may reference variables already bound by earlier conditions, but it may not bind a new one. A count condition makes a “for all” non-vacuous: pair at_least: 1 with a negated condition to mean “at least one, and none of them forbidden”.

{"count": {"fact": {"type": "checklist", "task": "$t"}, "at_least": 1}}

This holds when at least one checklist fact belongs to the task bound to $t.

Disjunction is expressed by writing two rules with the same then, not by an any operator. The notation has no any.

Action

An action is one of:

emit is the observable result; assert and retract change state.

Variable

A variable is a string of the form "$name", where name starts with a letter or underscore and continues with letters, digits, or underscores.

Variables bind left to right: a condition may use a variable an earlier condition bound, never one a later condition binds.

{
        "id": "echo-priority",
        "when": [ {"fact": {"type": "task", "priority": "$p"}} ],
        "then": [ {"assert": {"type": "alert", "priority": "$p"}} ]
    }

Operator

An operator object is a non-empty object whose keys are all operators. The fact’s value must satisfy every operator in the object, so one object can carry a range.

Operator Holds when
eq the value equals the operand
ne the value does not equal the operand
lt, lte the value is less than, or at most, the operand
gt, gte the value is greater than, or at least, the operand
in the value is one of the operand array
prefix the value is a string that starts with the operand

An operand is a scalar, an already-bound $variable, or (for in only) an array of scalars. prefix compares strings; a non-string value is a no-match, not an error. A numeric comparison holds only when both sides are numbers; a non-numeric comparison is a no-match, not an error.

{"fact": {"type": "task", "score": {"gt": 4, "lt": 10}}}

This matches a task whose score is greater than 4 and less than 10.

Semantics

The interpreter is deterministic and forward-chaining.

  1. Load the facts (a set: two facts are the same fact when their keys and values are equal).
  2. Scan the rules in agenda order: priority descending, then document order. For each rule, enumerate its bindings — every way to satisfy its conditions — in canonical order.
  3. Fire the first rule with a binding that has not fired before: apply its then in order, and record the firing.
  4. Refraction: a rule binding fires at most once. A binding is the rule id, the multiset of matched facts, and the variable assignment.
  5. Repeat from step 2 until no rule has an unfired binding (a fixpoint).
  6. If the number of firings reaches a cap (10 000 by default), stop with an error: the set did not reach a fixpoint.

Before any matching, the engine validates every rule: a string id, when and then arrays, exactly one key per action, and every variable bound before it is used. A malformed rule is refused with a message naming the rule and the problem, rather than silently matching nothing.

Two facts are the same fact when their keys and values are equal, so an assert of an equal fact is a no-op and a retract removes every equal fact.

A condition that names a literal type is matched against the facts of that type only: the engine indexes facts by type. A $variable or an operator on type falls back to scanning every fact.

Session

ruleset_run evaluates a rule set once and discards its state. A session keeps working memory across runs:

const session = ruleset_session(ruleset);
    session.assert({ type: "task", priority: "high" });
    session.run();
    session.assert({ type: "cap", priority: "high" });
    session.run();          // only bindings the new fact enables fire

ruleset_run is the one-shot form: ruleset_run(rs) is ruleset_session(rs).run().

Round trip

The three-step check is:

  1. prose → rule set (a model translates),
  2. rule set → prose (the renderer),
  3. prose (step 2) compared to prose (step 1), by a human or a model.

The renderer is deterministic, so step 2 is reproducible and the comparison in step 3 is between two pieces of prose, not between prose and a data structure.

Minimality

The notation has two kinds of condition (positive, negated), three actions (assert, retract, emit), one control structure (conjunction, with disjunction by multiple rules), and three matchers (scalar equality, variable binding, operator object). Anything more is added only when a real rule set cannot be expressed without it.

The engine has no certainty factors, no conflict-resolution strategy beyond a rule’s priority, and no Rete network. Firing order is priority descending, then document order; a rule binding fires once.