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-badgeran first (priority 20) andloyal-badgeran last (priority 10), so the mergedBadgeisloyal: the last writer wins. - Ben didn't match
free-shipping, so itselsebranch ran andFiredRule.BranchisElse. An else firing is not a match: it appears inFiredRules, but it never triggers a stop. legacy-couponis 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": trueon 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
elsebranch and a failure message work together. Ben'sfree-shippingfired its else actions and explained the condition's failure. - Like
description, afailureMessagenever 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.