Table of Contents

How RuleWright works

A rule document is parsed and checked once, prepared once, compiled once per fact type, and then executed as a direct delegate call as often as you like. Each stage lives in its own package, and the packages depend on each other in one direction only.

The packages

 RuleWright.Json.SystemText ──┐
 RuleWright.Json.NewtonsoftJson ┤   adapters: JSON text → a small neutral JSON tree
                                ▼
                 RuleWright.Serialization ─────────▶ RuleWright.Core
                 parse, validate, expand tables,     the domain model, results,
                 content hash, schema catalog        IRuleFunction. No dependencies.
                                ▲
                                │
                 RuleWright.Execution
                 compiler, interpreter, delegate cache,
                 RuleWrightBuilder / RuleWrightEngine
  • Core is the domain model: immutable, no I/O, no JSON, no dependencies.
  • Serialization turns a neutral JSON tree into the model and checks it. It defines IRuleJsonReader, but it doesn't reference any JSON library.
  • The adapters do one thing: translate their library's parsed JSON into the neutral tree. That's why you can choose System.Text.Json or Newtonsoft.Json and get identical behaviour.
  • Execution owns everything that runs.
  • Extensions.Functions adds the built-in functions on top of Execution.

The pipeline

  1. Parse and validate, once per document. LoadRuleSet(json) reads the text through the adapter, validates it (every error, with a JSON pointer), expands any decision table into rules, and builds an immutable RuleSet.
  2. Prepare, once per document. Each rule gets its content hash, its place in priority order, its node layout for tracing and, when all its actions are constants, its outputs built up front. Custom function names are bound here, so an unknown one fails now.
  3. Compile, once per rule and fact type. The first time Evaluate<TFact> reaches a rule, it compiles the rule to delegates for TFact and caches them on the engine.
  4. Execute, per evaluation. Delegate calls, in priority order, applying each fired rule's actions to one shared set of outputs.

Compiled: typed facts

The compiler builds an expression tree per rule, specialised to the fact type:

  • Field paths become chained member access, each segment evaluated once, with null checks that fall into the operator's null rule instead of throwing.
  • Constants are converted at compile time to the member's exact type: JSON 18 becomes an int for an int property, an ISO string becomes a DateTime, a string becomes an enum member. Value-type comparisons then run without boxing. A constant that can't convert is a RuleCompilationException naming the rule and the field.
  • Mixed numeric comparisons widen once, to decimal, or double if binary floating point is involved. An equality that can never hold, such as an int field against 10.5, folds to a constant.
  • Strings use ordinal comparison throughout, and In builds an ordinal HashSet once.
  • MatchesRegex embeds a compiled Regex with the engine's match timeout.
  • Quantifiers keep the collection's static type, read the element type from its IEnumerable<T>, and compile the element condition against that type, so member access inside the loop is as fast as anywhere else.
  • Custom functions are embedded as constants: the delegate calls IRuleFunction.Evaluate directly.

Interpreted: dictionary facts

A dictionary has no shape to compile against, so the interpreter walks the condition tree for each evaluation, resolving paths through nested dictionaries (and, for an object stored in a dictionary, cached reflection). It shares its operator semantics with the compiler: computed values on both paths go through one routine, and both paths run the same parity tests. So they agree by construction. CompilationMode.Interpreted on the result tells you the slower path ran, as does a fact whose static type is object.

Decision tables

A decisionTable document is expanded into an ordinary RuleSet when it is parsed: one rule per row, in descending priority. The engine, compiler and interpreter don't know tables exist. A first table sets StopAfterFirstMatch on the set, instead of rewriting each row as "this row, and not any earlier row", which would grow quadratically with the table.

Tracing without cost

Every rule compiles to two delegates from the same expression builder: a plain predicate, and a traced one that also writes each condition node's result into an array. EnableTrace chooses which to call. An untraced evaluation never checks a flag, so turning tracing off costs nothing. Slots left empty are the nodes that were short-circuited, reported as Passed == null.

The compiled-delegate cache

Compiled delegates are cached on the engine in a ConcurrentDictionary, keyed by fact type and rule content hash.

The content hash is a SHA-256 of a canonical form of the rule's condition, actions and else actions, with sorted keys and invariant number formatting. It leaves out everything that doesn't change the compiled code: id, description, priority, enabled, layout, whitespace and key order.

using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Serialization;

string original = """
    { "id": "vip", "priority": 10,
      "condition": { "field": "Customer.IsVip", "operator": "Equals", "value": true },
      "actions": [ { "type": "setOutput", "target": "Discount", "value": 10 } ] }
    """;
string cosmetic = """
    {
      "id": "vip-renamed",
      "description": "Same logic, new id, new priority, moved on the canvas.",
      "priority": 99,
      "layout": { "x": 420, "y": 96 },
      "actions": [ { "value": 10, "target": "Discount", "type": "setOutput" } ],
      "condition": { "value": true, "operator": "Equals", "field": "Customer.IsVip" }
    }
    """;
string semantic = original.Replace("\"value\": 10", "\"value\": 12");

string Hash(string json) => RuleHasher.ComputeHash(engine.LoadRuleSet(json).RuleSet.Rules[0]);

Console.WriteLine($"original  {Hash(original)[..16]}…");
Console.WriteLine($"cosmetic  {Hash(cosmetic)[..16]}…   same compiled delegate");
Console.WriteLine($"semantic  {Hash(semantic)[..16]}…   recompiled on next use");
Console.WriteLine();
Console.WriteLine(RuleHasher.GetCanonicalForm(engine.LoadRuleSet(original).RuleSet.Rules[0]));

Output

original  e5801ab2cc04f0f9…
cosmetic  e5801ab2cc04f0f9…   same compiled delegate
semantic  54ef37092149997e…   recompiled on next use

{"actions":[{"target":"Discount","type":"setOutput","value":10}],"condition":{"field":"Customer.IsVip","operator":"Equals","value":true}}

So a visual editor that moves nodes around, or a reviewer who renames a rule, never causes a recompile, while any change to the logic always does. Two rules with the same logic share one delegate. Numbers are hashed by behaviour as well as by text: a double 1.0 and a long 1 hash differently, because arithmetic runs in double for one and decimal for the other.

Next

Values, numbers and text.