Table of Contents

Rule sets, priority and else

A rule set is several rules in one document, evaluated against the same fact in one pass. Priority orders them, an else branch gives a rule something to do when it doesn't match, and a stop option turns a set into a ladder where the first match decides.

A rule set

{
  "name": "Checkout policy",
  "rules": [
    {
      "id": "free-shipping",
      "priority": 30,
      "condition": { "field": "Order.Total", "operator": "GreaterThanOrEqual", "value": 50 },
      "actions": [ { "type": "setOutput", "target": "Shipping", "value": 0 } ],
      "else": [ { "type": "setOutput", "target": "Shipping", "value": 4.95 } ]
    },
    {
      "id": "vip-badge",
      "priority": 20,
      "condition": { "field": "Customer.IsVip", "operator": "Equals", "value": true },
      "actions": [ { "type": "setOutput", "target": "Badge", "value": "vip" } ]
    },
    {
      "id": "loyal-badge",
      "priority": 10,
      "condition": { "field": "Customer.LoyaltyYears", "operator": "GreaterThanOrEqual", "value": 5 },
      "actions": [
        { "type": "setOutput", "target": "Badge", "value": "loyal" }
      ]
    },
    {
      "id": "legacy-coupon",
      "enabled": false,
      "condition": { "field": "Order.Coupon", "operator": "IsNotNull" },
      "actions": [ { "type": "setOutput", "target": "Legacy", "value": true } ]
    }
  ]
}
Key Meaning
name, description Optional, for people and tools. The engine ignores them.
rules The rules. Each needs an id unique in the set.
stopAfterFirstMatch Optional, false by default. See below.

And on each rule:

Key Meaning
priority An integer, 0 by default. Higher runs first. Equal priorities keep document order.
else Actions to run when the condition does not hold.
enabled false retires the rule without deleting it. It is skipped, and never compiled.
failureMessage The rule's explanation for saying no. See below.
description, layout Free text, and position data for a visual editor. The engine ignores both.

Evaluating it

using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;

LoadedRuleSet rules = engine.LoadRuleSet(File.ReadAllText("checkout-ladder.json"));

void Show(string label, RuleEvaluationResult result)
{
    Console.WriteLine(label);
    foreach (FiredRule fired in result.FiredRules)
    {
        Console.WriteLine($"  {fired.RuleId,-14} {fired.Branch,-5} {string.Join(", ", fired.Outputs.Select(o => $"{o.Key}={o.Value}"))}");
    }
    Console.WriteLine($"  => Shipping={result.Outputs["Shipping"]}, Badge={(result.Outputs.TryGetValue("Badge", out object? badge) ? badge : "none")}");
}

Show("Aroha, collecting (the default):", engine.Evaluate(rules, Facts.Vip()));
Show("Ben, collecting:", engine.Evaluate(rules, Facts.Newcomer()));
Show("Aroha, StopOnFirstMatch:", engine.Evaluate(rules, Facts.Vip(), new EvaluationOptions { StopOnFirstMatch = true }));

Output

Aroha, collecting (the default):
  free-shipping  Then  Shipping=0
  vip-badge      Then  Badge=vip
  loyal-badge    Then  Badge=loyal
  => Shipping=0, Badge=loyal
Ben, collecting:
  free-shipping  Else  Shipping=4.95
  => Shipping=4.95, Badge=none
Aroha, StopOnFirstMatch:
  free-shipping  Then  Shipping=0
  => Shipping=0, Badge=none
  • Aroha matched both badge rules. vip-badge ran first (priority 20) and loyal-badge ran last (priority 10), so the merged Badge is loyal: the last writer wins.
  • Ben didn't match free-shipping, so its else branch ran and FiredRule.Branch is Else. An else firing is not a match: it appears in FiredRules, but it never triggers a stop.
  • legacy-coupon is disabled, so it never appears.

Stop after the first match

Two ways to make evaluation stop at the first rule whose condition holds:

  • The caller decides, for one evaluation: new EvaluationOptions { StopOnFirstMatch = true }, as in the third run above.
  • The document decides, always: "stopAfterFirstMatch": true on the set. This is how you write a fallthrough ladder, where the highest-priority match is the answer and nothing below it runs.
{
  "name": "Shipping fee ladder",
  "stopAfterFirstMatch": true,
  "rules": [
    {
      "id": "free-over-100",
      "priority": 30,
      "condition": { "field": "Order.Total", "operator": "GreaterThanOrEqual", "value": 100 },
      "actions": [
        { "type": "setOutput", "target": "ShippingFee", "value": 0 }
      ]
    },
    {
      "id": "heavy-parcel",
      "priority": 20,
      "condition": { "field": "Order.Weight", "operator": "GreaterThan", "value": 2 },
      "actions": [
        { "type": "setOutput", "target": "ShippingFee", "value": 12 }
      ]
    },
    {
      "id": "standard",
      "priority": 10,
      "condition": { "field": "Order.Total", "operator": "GreaterThan", "value": 0 },
      "actions": [
        { "type": "setOutput", "target": "ShippingFee", "value": 6 }
      ]
    }
  ]
}
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;

LoadedRuleSet ladder = engine.LoadRuleSet(File.ReadAllText("shipping-ladder.json"));
Console.WriteLine($"StopAfterFirstMatch: {ladder.RuleSet.StopAfterFirstMatch}");

var heavyAndBig = new Checkout { Order = new Order { Total = 240m, Weight = 2.5m } };
var heavyAndSmall = new Checkout { Order = new Order { Total = 35m, Weight = 2.5m } };

foreach (Checkout checkout in new[] { heavyAndBig, heavyAndSmall })
{
    RuleEvaluationResult result = engine.Evaluate(ladder, checkout);
    Console.WriteLine($"Total {checkout.Order.Total,4}: {result.FiredRules.Single().RuleId,-14} fee {result.Outputs["ShippingFee"]}");
}

Output

StopAfterFirstMatch: True
Total  240: free-over-100  fee 0
Total   35: heavy-parcel   fee 12

The 240 order is also heavy, but free-over-100 matched first and the ladder stopped. Without stopAfterFirstMatch, all three rules would fire and the lowest rung, standard, would win the merge: the opposite of what the ladder reads like.

The two combine with OR. A caller can stop a collecting set early, but can't make a stopping set collect. A first decision table expands to a set with stopAfterFirstMatch set.

Failure messages

A rule can carry its own explanation for not matching. When a rule with a failureMessage is evaluated and its condition doesn't hold, the message lands on result.Failures as a RuleFailure (rule id + message) — ready to show a caller why they didn't qualify, without reconstructing it from a trace:

{
  "name": "Failure messages",
  "rules": [
    {
      "id": "adult",
      "failureMessage": "Customer must be at least 18.",
      "condition": { "field": "Customer.Age", "operator": "GreaterThanOrEqual", "value": 18 },
      "actions": [
        { "type": "setOutput", "target": "Eligible", "value": true }
      ]
    },
    {
      "id": "senior-discount",
      "failureMessage": "Customer must be at least 65 for the senior discount.",
      "condition": { "field": "Customer.Age", "operator": "GreaterThanOrEqual", "value": 65 },
      "actions": [
        { "type": "setOutput", "target": "SeniorDiscount", "value": 15 }
      ]
    },
    {
      "id": "free-shipping",
      "failureMessage": "Order is under the 200 free-shipping threshold.",
      "condition": { "field": "Order.Total", "operator": "GreaterThanOrEqual", "value": 200 },
      "actions": [
        { "type": "setOutput", "target": "Shipping", "value": 0 }
      ],
      "else": [
        { "type": "setOutput", "target": "Shipping", "value": 4.95 }
      ]
    }
  ]
}
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;

LoadedRuleSet rules = engine.LoadRuleSet(File.ReadAllText("failure-messages.json"));

foreach (Checkout checkout in new[] { Facts.Vip(), Facts.Newcomer() })
{
    RuleEvaluationResult result = engine.Evaluate(rules, checkout);

    Console.WriteLine($"{checkout.Customer.Name}: fired {string.Join(", ", result.FiredRules.Select(f => $"{f.RuleId} ({f.Branch})"))}");
    foreach (RuleFailure failure in result.Failures)
    {
        Console.WriteLine($"  {failure.RuleId,-15} {failure.Message}");
    }
}

Output

Aroha: fired adult (Then), free-shipping (Then)
  senior-discount Customer must be at least 65 for the senior discount.
Ben: fired free-shipping (Else)
  adult           Customer must be at least 18.
  senior-discount Customer must be at least 65 for the senior discount.
  free-shipping   Order is under the 200 free-shipping threshold.

Three behaviours worth pinning down:

  • A message means the rule was actually asked. Disabled rules, and rules never reached after a stop, report nothing.
  • An else branch and a failure message work together. Ben's free-shipping fired its else actions and explained the condition's failure.
  • Like description, a failureMessage never changes what a rule computes, and it stays out of the rule's content hash.

EvaluationOptions

Option Default Effect
StopOnFirstMatch false Stop after the first rule whose condition holds.
EnableTrace false Record why each rule did or didn't fire. See Trace why a rule fired.

EvaluationOptions is immutable: create one with an object initializer and reuse it freely. EvaluationOptions.Default is shared and read-only.

No chaining

Evaluation is one pass over the fact. A rule's outputs are never visible to another rule's condition, so the order rules run in affects only how their outputs merge, never whether they match. If one decision depends on another, evaluate in two steps. Score orders for fraud review shows how.

Next

Decision tables: the same ideas as a grid.