A high-performance, JSON-driven business rule engine for .NET. Rules are plain JSON documents; evaluation is compiled expression trees — parse once, compile once, execute millions of times.
{
"id": "discount-rule-01",
"description": "VIP or high-value customers get 10% off",
"priority": 10,
"condition": {
"type": "group",
"operator": "AND",
"rules": [
{ "field": "Customer.Age", "operator": "GreaterThan", "value": 18 },
{
"type": "group",
"operator": "OR",
"rules": [
{ "field": "Order.Total", "operator": "GreaterThanOrEqual", "value": 100 },
{ "field": "Customer.IsVip", "operator": "Equals", "value": true }
]
}
]
},
"actions": [
{ "type": "setOutput", "target": "Discount", "value": 10 },
{ "type": "setOutput", "target": "DiscountReason", "value": "VIP or high-value order" }
]
}var engine = new RuleWrightBuilder()
.UseJsonReader(new SystemTextJsonReader())
.RegisterFunction("IsBusinessDay", (fieldValue, value) => /* ... */ true)
.Build();
LoadedRuleSet ruleSet = engine.LoadRuleSet(json); // parse + validate once
var result = engine.Evaluate(ruleSet, customerOrder, new EvaluationOptions
{
EnableTrace = true,
StopOnFirstMatch = false,
});
foreach (FiredRule fired in result.FiredRules)
Console.WriteLine($"{fired.RuleId} -> {string.Join(", ", fired.Outputs)}");- Compiled, not interpreted. Rules compile to delegates via expression trees: parse once, compile once per fact type, then evaluations are direct delegate calls — no reflection, no string parsing, no boxing of value-type comparisons. Compiled delegates are cached by a content hash of the rule (not its id), so editing a rule's body always invalidates the cache, while reformatting or moving canvas nodes never does.
- One package, .NET Framework 4.8 through .NET 10. Every library multi-targets
net48;netstandard2.0;net8.0;net10.0, and the core has zero dependencies on all four. A .NET Framework 4.8 sample project builds and runs in CI — the compatibility claim is executed, not asserted. - Bring your own JSON library. Parsing is abstracted behind
IRuleJsonReaderwith adapter packages for System.Text.Json and Newtonsoft.Json (parity-tested against each other), so the core never forces a JSON dependency on your app. - Built for auditability. Opt-in execution traces record which rules fired and which condition nodes passed, failed, or were short-circuited — with zero overhead when disabled (separate compiled fast path, not a runtime flag check).
- Designed for a visual future. The schema keeps logic and presentation apart:
the
layoutkey is reserved for drag-and-drop canvas tools and ignored by the engine. A formal JSON Schema (docs/schema/rule-schema.json), a standalone validator with JSON-pointer errors, and a runtime discovery catalog (RuleSchemaCatalog+engine.RegisteredFunctions) are the contract a future rule-builder UI plugs into.
| RuleWright | NRules | MS RulesEngine | |
|---|---|---|---|
| Rule format | JSON documents | C# fluent DSL | JSON with lambda-expression strings |
| Evaluation | Compiled expression trees | Rete network | Parsed/compiled C# expression strings |
| Inference / chaining | No (v1 non-goal) | Yes | No |
| netstandard2.0 + .NET FX 4.8 | Yes, zero-dep core | Yes | Partial |
| Layout metadata for UI round-trip | First-class (layout) |
n/a | n/a |
| Execution trace | Opt-in, per-node | Events | Partial |
NRules is the right tool for forward-chaining inference over a working memory.
Microsoft RulesEngine embeds C# expression strings in JSON, which means arbitrary
code in rule files and runtime string compilation. RuleWright's rules are pure data:
a closed, validatable operator vocabulary that a UI can safely generate and a
reviewer can safely diff. For the stateless "evaluate this fact against these rules"
case, RuleWright is also measurably faster and leaner than both — see
docs/benchmarks.md for a like-for-like harness (with a
fairness check that all three flag the same matches).
Every RulesEngine concept has a RuleWright form — redesigned to keep rules pure data (anything that is code is registered C#, named from the document and checked at load):
| Microsoft RulesEngine | RuleWright |
|---|---|
| Lambda expression strings in JSON | Closed expression vocabulary + { "call": … } into registered value functions — deliberately no embedded code |
RuleParameter[] (named inputs input1, input2, …) |
RuleFacts.With("customer", c).And("order", o) — or a composite POCO for the compiled path |
GlobalParams / LocalParams |
params on the rule set / rule, referenced as { "param": "…" } |
ReSettings.CustomTypes (methods in lambdas) |
RegisterFunction (condition predicates) + RegisterValueFunction (computed values) |
Custom actions, OutputExpression |
RegisterAction(name, handler); computed action values are built in |
ErrorMessage per rule |
failureMessage, surfaced as result.Failures |
SuccessEvent, OnSuccess/OnFail |
result.FiredRules / outputs — the result object is the event |
WorkflowsToInject |
RuleSet.Merge(...) at load time; documents stay self-contained |
RuleResultTree |
FiredRules + opt-in per-node Trace + Failures |
EvaluateRule chaining, nested rule results |
Not imported: no forward chaining by design (see non-goals) |
dotnet add package RuleWright
That is the metapackage: the domain model, JSON parsing and schema validation, the
evaluation engine, the System.Text.Json adapter, and the built-in custom functions.
Prefer to pick pieces? Reference RuleWright.Execution plus one JSON adapter:
dotnet add package RuleWright.Execution
dotnet add package RuleWright.Json.NewtonsoftJson # or RuleWright.Json.SystemText
| Package | What it is |
|---|---|
RuleWright |
Everything below except the Newtonsoft adapter. Start here. |
RuleWright.Execution |
The compiler, interpreter, delegate cache, and engine API. |
RuleWright.Serialization |
Parsing, validation, and canonical hashing — no evaluation, no JSON library. |
RuleWright.Core |
The domain model alone. Zero dependencies. |
RuleWright.Json.SystemText |
IRuleJsonReader over System.Text.Json, plus JSON fact helpers. |
RuleWright.Json.NewtonsoftJson |
The same, over Newtonsoft.Json. |
RuleWright.Extensions.Functions |
The built-in custom-operator predicate catalog. |
All packages target .NET Framework 4.8, .NET Standard 2.0, .NET 8.0 and .NET 10.0, are
strong-named, and ship symbols (.snupkg) with Source Link.
New here? usage.md is a task-oriented guide — first rule, fact shapes, actions, computed values, decision tables, custom functions, validation, tracing, and hosting — with runnable snippets throughout. This README is the reference.
The full documentation — tutorials, guides, concepts and the API reference, with every snippet compiled and run against this source — is at https://docs.albahadly.com/rulewright/.
| Term | Meaning |
|---|---|
| Rule | id + condition tree + actions (+ optional else actions, priority, enabled, failureMessage, local params, ignored layout). |
| Rule set | rules (+ optional name, description, global params, and stopAfterFirstMatch — stop at the first rule that passes, which is what a first decision table expands into). |
| Condition | A leaf (field / operator / value) or a group (AND / OR / NOT over children). |
| Fact | The object a rule set is evaluated against: a typed POCO (compiled path), an IDictionary<string, object> (interpreted path), or several named facts via RuleFacts (interpreted). |
| Action | Changes the outputs at target. setOutput replaces, addToOutput sums, appendToOutput collects into a list, removeOutput deletes — plus any custom action type registered on the builder. Runs from actions when the condition matches, or else when it does not. |
Comparison Equals, NotEquals, GreaterThan, GreaterThanOrEqual, LessThan,
LessThanOrEqual · String (ordinal) Contains, StartsWith, EndsWith,
MatchesRegex · Set membership In, NotIn · Collection quantifiers Any, All,
None (each takes a per-element condition) · Null IsNull, IsNotNull · Logical
AND, OR, NOT · Extensible custom + name, resolved against functions
registered on the builder at compile time.
In/NotIn compare one scalar against a closed set; a quantifier reasons about a
collection field, applying a nested condition to each element:
{ "field": "Order.Lines", "operator": "Any",
"condition": { "field": "Category", "operator": "In", "value": ["alcohol", "tobacco"] } }The element condition is an ordinary condition tree — groups, nested quantifiers and
computed expressions all work inside it — and "$" names the element itself, which is
how a collection of scalars is tested. count measures a collection through the usual
computed left-hand side. See
usage.md and
examples/20-collection-operators.json.
For the custom operator, RuleWright.Extensions.Functions ships a curated catalog of
ready-made predicates and helpers to register them:
var engine = new RuleWrightBuilder()
.UseJsonReader(new SystemTextJsonReader())
.RegisterBuiltInFunctions() // IsEven, EqualsIgnoreCase, IsWeekend, …
.RegisterFunctionsFrom(typeof(Program).Assembly) // scan your own IRuleFunction classes
.Build();The catalog covers what the closed operator set deliberately doesn't: IsNullOrEmpty,
IsNullOrWhiteSpace, EqualsIgnoreCase, IsEmail, IsEven/IsOdd, IsPositive/IsNegative,
DivisibleBy, IsBetweenInclusive, IsWeekend/IsWeekday, and IsInPast/IsInFuture (with an
injectable clock). Each is total — an unexpected value type yields false, never an
exception. Write your own with new NamedRuleFunction("MyCheck", (field, value) => …).
IsInPast/IsInFuture compare instants: a DateTimeOffset and a Local DateTime are
resolved to UTC before the comparison, and a DateTimeKind.Unspecified value is read as UTC so
the same rule and fact answer the same way on every machine. IsWeekend/IsWeekday stay
wall-clock — the Saturday where it happened, not where UTC puts it.
A custom leaf's value is whatever its function expects — a scalar, a string, or an array
such as IsBetweenInclusive's [min, max]:
{ "field": "Customer.Age", "operator": "custom", "name": "IsBetweenInclusive", "value": [18, 65] }MatchesRegex patterns run against consumer-supplied fact data, so matching is time-bounded:
one second by default, raising RegexMatchTimeoutException rather than letting a pattern with
catastrophic backtracking pin a thread. Change the bound with
.UseRegexTimeout(TimeSpan.FromMilliseconds(250)).
"field": "Customer.Age" resolves a dotted path against the fact:
- Typed facts: compile-time member access (
Expression.PropertyOrFieldchains). A missing member is a load/compile-timeRuleCompilationException, not a runtime surprise. Navigation is null-safe: a null intermediate applies the operator's null semantics instead of throwing. - Dictionary facts: nested dictionaries, with cached-reflection fallback for POCOs
stored inside dictionaries. Missing keys resolve to null. Keys match through the
dictionary's own comparer — exactly, by default — so for camelCase JSON from web clients,
convert with
SystemTextJsonFacts.ToDictionary(json, StringComparer.OrdinalIgnoreCase)(or the Newtonsoft equivalent). The result reportsCompilationMode.Interpretedso the slower path is visible, never silent.
A condition leaf can compare a computed expression to a value, not only a field — use
expression in place of field, with the same value-expression vocabulary as actions:
{ "expression": { "op": "divide", "operands": [ { "field": "Order.Total" }, { "field": "Order.ItemCount" } ] },
"operator": "GreaterThan", "value": 25 }That is, average item price > 25. The compiled path evaluates the left side with the same
reflection-free field access as a plain leaf, then both paths compare through one shared
operator routine, so a field leaf and an equivalent expression leaf agree. A leaf uses
field or expression (not both); custom uses field. See
examples/15-condition-side-expression.json.
A null field value (or null anywhere along the path) makes every operator return
false, except: IsNull → true, NotEquals (non-null comparand) → true,
NotIn → true, and Equals with a null comparand → true. A null inside an
In/NotIn set contributes nothing on either path — null is a field's absence, not a
member — and the schema rejects one outright.
A setOutput action writes a value into the result's outputs. That value is a bare
scalar (a constant) or an expression computed from the fact at evaluation time — one
key, and a constant is simply the simplest expression:
"actions": [
{ "type": "setOutput", "target": "Tier", "value": "gold" },
{ "type": "setOutput", "target": "Discount",
"value": { "op": "multiply", "operands": [ { "field": "Order.Total" }, 0.1 ] } },
{ "type": "setOutput", "target": "Message",
"value": { "op": "concat", "operands": [ "Thanks, ", { "field": "Customer.Name" }, "!" ] } }
]Values are pure data — a closed operator vocabulary, never embedded code — so a UI can
generate them and a reviewer can diff them, exactly like conditions. A value is a bare scalar
(a literal), { "field": "<dotted path>" }, { "literal": <scalar> },
{ "op": "<operator>", "operands": [ … ] }, { "call": "<registered value function>", "operands": [ … ] }, or { "param": "<name>" } (a scoped-param reference):
| Category | Operators |
|---|---|
| Arithmetic | add, subtract, multiply, divide, modulo, negate |
| String | concat |
| Null | coalesce (first non-null operand) |
add/multiply/concat/coalesce take two or more operands; subtract/divide/modulo
take exactly two (order significant); negate takes one. Evaluation is total — it never
throws on data: any null operand propagates to a null result (except coalesce), a
non-numeric operand to an arithmetic operator yields null, and division or modulo by zero
yields null. Arithmetic runs in decimal unless a floating-point operand forces double, so
divide is never silently integer-truncated. Computed outputs are compiled to delegates and
cached per fact type just like conditions (constant-only rules keep their pre-materialized
outputs and allocate nothing per firing); field references in expressions are checked against
typed facts at compile time.
Action types. The action type decides how the value combines with what fired rules have
already written to that target, applied in priority order across the whole evaluation:
| Type | Effect |
|---|---|
setOutput |
Replaces the value at target. |
addToOutput |
Adds numerically — a running total across fired rules. |
appendToOutput |
Appends to a list at target — collected across fired rules. |
removeOutput |
Deletes target, undoing what an earlier rule wrote (takes no value). |
"actions": [
{ "type": "addToOutput", "target": "RiskScore",
"value": { "op": "multiply", "operands": [ { "field": "Order.Total" }, 0.1 ] } },
{ "type": "appendToOutput", "target": "Reasons", "value": "high-value order" }
]The accumulators are null-tolerant: a value that resolves to null (and, for addToOutput,
any non-numeric value) contributes nothing rather than wiping the running result — so one stray
rule can't destroy a total. Each fired rule's own Outputs snapshot reflects the result at
that rule's firing (appendToOutput copies the list, so earlier snapshots stay frozen).
Else actions. A rule may carry an else array alongside actions: it runs the actions
when the condition matches and the else actions when it does not — an if/else in one rule,
rather than a rule plus a negated twin. An else-branch firing is not a match, so it never
triggers stopOnFirstMatch. Each such rule appears in FiredRules with a Branch of Then
or Else telling you which side ran.
{
"id": "tier",
"condition": { "field": "Customer.IsVip", "operator": "Equals", "value": true },
"actions": [ { "type": "setOutput", "target": "Badge", "value": "gold" } ],
"else": [ { "type": "setOutput", "target": "Badge", "value": "standard" } ]
}params declares named subexpressions: define an expression once on the rule set (global)
or a rule (local, shadowing the set's on a name collision), then reference it anywhere an
expression is valid in that scope — action values, computed left-hand sides, decision-table
cells — as { "param": "<name>" }:
{
"params": {
"averageItem": { "op": "divide", "operands": [ { "field": "Order.Total" }, { "field": "Order.ItemCount" } ] }
},
"rules": [
{ "id": "pricey",
"condition": { "expression": { "param": "averageItem" }, "operator": "GreaterThan", "value": 25 },
"actions": [ { "type": "setOutput", "target": "AverageItemPrice", "value": { "param": "averageItem" } } ] }
]
}Params may reference sibling and outer params (never cyclically — validation rejects cycles
with a pointer), and stay pure data end to end. References are inlined at load time, so
the domain model, the content hash, and both execution paths never see them: a param-authored
document compiles to exactly the delegates its hand-inlined equivalent does, and the C# way to
"use a param" is simply to reuse one immutable ValueExpression instance in several places.
One consequence to know: a param cannot be referenced inside a quantifier's per-element
condition, whose field paths resolve against the element rather than the root fact. See
examples/22-scoped-params.json.
When the closed vocabulary genuinely doesn't cover something, the escape hatch is always the
same shape: the document names a thing, and the behaviour is C# registered on the builder,
checked at LoadRuleSet — never code in the rule file. Three registration points:
| Registration | Referenced from JSON as | Adds |
|---|---|---|
RegisterFunction(name, (field, value) => bool) |
{ "operator": "custom", "name": … } |
A condition predicate |
RegisterValueFunction(name, args => object?) |
{ "call": name, "operands": [ … ] } |
A computed value usable in any expression |
RegisterAction(name, context => { … }) |
{ "type": name, "target": …, "value": … } |
An action over the running outputs |
var engine = new RuleWrightBuilder()
.UseJsonReader(new SystemTextJsonReader())
.RegisterValueFunction("RoundTo", args =>
args is [decimal value, long digits] ? decimal.Round(value, (int)digits) : null)
.RegisterAction("setIfHigher", ctx =>
{
ctx.TryGetOutput(ctx.Target, out object? current);
if (ctx.Value is decimal bid && (current is not decimal held || bid > held))
{
ctx.SetOutput(ctx.Target, bid);
}
})
.Build();A value function is bound into compiled rules at compile time (one virtual call per
evaluation, no lookup), should be total like the built-in operators, and may declare a
required operand count via IRuleValueFunctionMetadata — enforced at load. A custom action
receives a RuleActionContext (rule id, branch, target, the already-evaluated value, the
fact, and read/write access to the running outputs); its writes land in the merged outputs
and the firing rule's own snapshot exactly as the built-in types record theirs. An
unregistered call or action type fails at LoadRuleSet, and engine.Validate folds the
registered action names into the schema contract so misspellings are still caught. See
examples/23-value-functions.json and
examples/25-custom-action.json.
failureMessage is a rule's authored explanation for saying no. When a rule is evaluated
and its condition does not pass, the message surfaces on
RuleEvaluationResult.Failures (rule id + message); skipped rules — disabled, or unreached
after a stop-on-first-match — report nothing. Like description, it never changes what the
rule computes and stays out of the content hash. See
examples/24-failure-messages.json.
RuleFacts evaluates several named facts as one, each addressed by its name as the first
path segment — the multi-input shape:
var facts = RuleFacts.With("customer", customer).And("order", order);
var result = engine.Evaluate(rules, facts); // rules read customer.Age, order.TotalA RuleFacts is a dictionary fact, so it runs the interpreter (and says so via
CompilationMode.Interpreted); when the compiled path matters, wrap the same inputs in a
composite POCO — the two spell identical field paths.
RuleSet.Merge(sets, name, stopAfterFirstMatch) composes several rule sets into one at
load time, with duplicate rule ids failing loudly. Composition is deliberately a C# concern:
rule documents stay self-contained, with no include mechanism to resolve.
For logic that reads naturally as a grid, a decision table is a compact authoring form. Each input column maps a cell to a condition; each output column maps a cell to an action:
{
"decisionTable": {
"hitPolicy": "first",
"inputs": [
{ "field": "Customer.Tier", "operator": "Equals" },
{ "field": "Order.Total", "operator": "GreaterThanOrEqual" }
],
"outputs": [ { "target": "Discount" }, { "target": "Label" } ],
"rows": [
{ "when": ["VIP", 100], "then": [20, "vip-big-order"] },
{ "when": ["VIP", null], "then": [10, "vip"] },
{ "when": [null, null], "then": [0, "standard"] }
]
}
}A table expands into ordinary rules at load time — one rule per row — so it runs through
the exact same compiled/interpreted path with no special casing, and the same schema, tracing,
and hashing apply. A null input cell is a wildcard; an all-wildcard row is a catch-all.
Input columns default to Equals and take any comparison operator (In cells are arrays);
output columns default to setOutput and may use addToOutput/appendToOutput. A null
output cell skips that output for the row. Two hit policies:
collect(default) — every matching row applies its actions in row order (pairs withaddToOutputfor scoring tables).first— only the first matching row applies. Rows already carry descending priorities, so the table setsRuleSet.StopAfterFirstMatchand evaluation stops after the first hit; each row's condition stays exactly what its cells say.
The rule schema is a closed, pure-data vocabulary — and it is introspectable at runtime, so
a rule-builder UI (or codegen, or docs) can enumerate exactly what it may author instead of
hard-coding operator lists. RuleSchemaCatalog exposes the built-in vocabulary as structured
metadata, and the engine exposes what varies per configuration — RegisteredFunctions
(custom condition functions), RegisteredValueFunctions (call targets), and
RegisteredActions (custom action types) — together, the complete set of authoring choices.
foreach (var op in RuleSchemaCatalog.ConditionOperators)
Console.WriteLine($"{op.JsonName}: value={op.ValueKind}, expressionLeft={op.AllowsExpressionLeft}");
// Equals: value=Scalar, expressionLeft=True · IsNull: value=None … · custom: value=Custom …
RuleSchemaCatalog.ExpressionOperators; // op, operand arity (min/max), category
RuleSchemaCatalog.ActionTypes; // setOutput/addToOutput/appendToOutput/removeOutput + RequiresValue
RuleSchemaCatalog.LogicalOperators; // AND/OR/NOT + child arity
engine.RegisteredFunctions; // e.g. ["IsBusinessDay", "IsWeekend"] — for the custom operator
engine.ValueFunctionCatalog; // call targets: name, description, declared operand count
engine.RegisteredActions; // custom action types accepted alongside the built-in fourThe catalog is derived from the same maps and enums the parser and validator use, so it can never drift from what the engine actually accepts.
| Project | Shows |
|---|---|
RuleWright.Sample.ConsoleApp |
The quickstart: load a rule set, evaluate a typed fact (compiled path) and a dictionary fact (interpreted path), print a trace. |
RuleWright.Sample.AspNetCore |
The engine and a loaded rule set as ASP.NET Core singletons behind a minimal API: POST /evaluate a fact, GET /rules, GET /vocabulary (the schema discovery catalog over HTTP). |
RuleWright.Sample.NewtonsoftJson |
The same quickstart with NewtonsoftJsonReader in place of SystemTextJsonReader — swapping the JSON adapter is the only change. |
RuleWright.Sample.Functions |
The three ways to register custom-operator functions together: RegisterBuiltInFunctions, an inline RegisterFunction delegate, and RegisterFunctionsFrom assembly discovery. |
RuleWright.Sample.DecisionTable |
Loading decisionTable documents end to end, contrasting hitPolicy: "first" (one row wins) against "collect" (every matching row's actions apply). |
RuleWright.Sample.NetFramework48 |
A .NET Framework 4.8 smoke test proving the netstandard2.0 packages work end to end outside .NET (Core). |
RuleWright.Sample.BlazorBuilder |
The v3 rule builder: a Blazor WebAssembly app with a freeform, drag-and-drop dark-themed node canvas (palette → drag nodes onto a canvas → wire them together) in the style of n8n/Logic Apps/Node-RED. Covers the full authoring surface — all condition operators, computed value-expressions, all four action types, else branches, custom functions, and multi-rule sets — and can load any of the examples/ rule files onto the canvas. Backed by the real engine for Validate/Test, in-browser: no server, no round trip, no simulation. |
Build rule JSON files in the hosted editor: https://albahadly.github.io/rulewright
Run any of them with dotnet run --project samples/<ProjectName> (the ASP.NET Core sample also
needs dotnet run — it listens on the URL printed at startup; try curl -X POST .../evaluate -d '{"fact":{"Customer":{"Age":30,"IsVip":true},"Order":{"Total":10}}}' -H content-type:application/json).
The benchmark suite (tests/RuleWright.Benchmarks, BenchmarkDotNet) measures
compiled vs interpreted throughput at 1 / 100 / 10,000 rules, cold-load vs
warm-cache cost, and a like-for-like comparison against NRules and Microsoft
RulesEngine. Run it with:
dotnet run -c Release --project tests/RuleWright.Benchmarks -- --filter '*'
A warm compiled evaluation of a small rule set is on the order of ~100 ns/rule
with minimal allocation; the dictionary interpreter is ~1.8× slower by design, and
tracing (a separate compiled delegate) costs ~3× only when enabled. Against the
same rule set and fact, RuleWright evaluates the stateless one-shot case faster and
far leaner than both NRules and Microsoft RulesEngine — full tables, methodology, and
a fairness check are in docs/benchmarks.md. (Numbers are
illustrative and machine-specific; re-run the suite on your hardware.)
git clone https://github.com/albahadly/rulewright.git
cd RuleWright
dotnet build RuleWright.slnx
dotnet test RuleWright.slnx # runs on net8.0, net10.0 and net48 (Windows)
dotnet run --project samples/RuleWright.Sample.ConsoleApp
Requires the .NET 10 SDK (global.json pins 10.0.100). On Windows, the test suite and the
RuleWright.Sample.NetFramework48 smoke test also exercise .NET Framework 4.8.
| Package | Contents |
|---|---|
RuleWright.Core |
Domain model: Rule, conditions, results, IRuleFunction. Zero dependencies. |
RuleWright.Serialization |
JSON ↔ domain mapping, structural validator (JSON-pointer errors), content hashing. |
RuleWright.Execution |
Expression-tree compiler, interpreter fallback, delegate cache, RuleWrightBuilder / RuleWrightEngine. |
RuleWright.Json.SystemText |
IRuleJsonReader adapter for System.Text.Json + JsonElement fact helpers. |
RuleWright.Json.NewtonsoftJson |
IRuleJsonReader adapter for Newtonsoft.Json + JToken fact helpers. |
RuleWright.Extensions.Functions |
Built-in custom-operator predicates + registration/assembly-scan discovery helpers. |
Called out explicitly so expectations are clear:
- No forward-chaining / RETE-style inference. One fact set in, one result out — stateless, single-pass evaluation. Rule outputs never feed other rules' inputs.
- No correlated collection conditions. A quantifier's element condition sees the
element, not the root fact, so "any line whose price exceeds the order average" is not
expressible. The
$path prefix is reserved for adding this later. - No persistence layer. Storing rule JSON is your application's concern; this library parses, validates, compiles, and executes.
- No UI in v1. The Blazor rule builder (
RuleWright.Sample.BlazorBuilder, v3) is a sample, not part of the library, and it treats the JSON Schema and the validator built here as its contract. Its canvas positions nodes by auto-layout rather than persisting the schema'slayoutkey — round-trippinglayoutis still a follow-up.
- v1 — core engine: compiled evaluation, interpreter fallback, tracing, schema + validator, benchmarks, net48 proof.
- v2 (current) — "rules do more." Computed action expressions (arithmetic,
concat,coalesceover fact fields), accumulating action types (addToOutput/appendToOutput), aremoveOutputretract action, first-classelseactions, decision-table authoring, a schema discovery catalog (RuleSchemaCatalog+engine.RegisteredFunctions), System.Text.Json and Newtonsoft.Json adapters, and a published NRules/RulesEngine benchmark comparison (docs/benchmarks.md) have all shipped. - v3 (current) — Blazor WebAssembly rule builder (
samples/RuleWright.Sample.BlazorBuilder) emitting/consuming this exact schema, running fully client-side. Shipped: a freeform drag-and-drop node canvas covering the whole authoring surface — every condition operator, computed value-expressions, all four action types,elsebranches, andcustomfunctions — authoring multi-rule sets (one Rule anchor node per rule; two or more export{ name, rules[] }), with import of anyexamples/document and per-rule evaluate/trace against the real engine. Not yet:decisionTabledocuments on the canvas, and persisting node positions into the schema'slayoutkey (both follow-ups). From v1 on, changes to thelayoutcontract or the JSON Schema are treated as breaking changes.
See CONTRIBUTING.md. By participating you agree to the Code of Conduct.