Decision tables
Some policies read naturally as a grid: tier down one side, order size across the top, discount in the cells. A decision table writes them that way. Each input column turns a cell into a condition, and each output column turns a cell into an action.
A discount table
{
"decisionTable": {
"id": "discount",
"hitPolicy": "first",
"inputs": [
{ "field": "Customer.Tier", "operator": "Equals" },
{ "field": "Order.Total", "operator": "GreaterThanOrEqual" }
],
"outputs": [ { "target": "Discount" }, { "target": "Label" } ],
"rows": [
{ "when": ["gold", 200], "then": [20, "gold, big order"] },
{ "when": ["gold", null], "then": [10, "gold"] },
{ "when": [null, 200], "then": [5, "big order"] },
{ "when": [null, null], "then": [0, "standard"] }
]
}
}
inputsare columns of conditions: afieldand anoperator(Equalsby default; any comparison operator works, andIncells are arrays).outputsare columns of actions: atargetand atype(setOutputby default;addToOutputandappendToOutputwork too).rowspairwhencells, one per input, withthencells, one per output.- A
nullinput cell is a wildcard: that column doesn't constrain the row. An all-wildcard row is a catch-all. - A
nulloutput cell means the row doesn't write that output.
Evaluating it
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
LoadedRuleSet table = engine.LoadRuleSet(File.ReadAllText("discount-table.json"));
// A table is expanded into ordinary rules when it loads: one per row.
Console.WriteLine($"Rules: {string.Join(", ", table.RuleSet.Rules.Select(r => $"{r.Id} (priority {r.Priority})"))}");
Console.WriteLine($"StopAfterFirstMatch: {table.RuleSet.StopAfterFirstMatch}");
Console.WriteLine();
var cases = new[]
{
new Checkout { Customer = new Customer { Tier = "gold" }, Order = new Order { Total = 250m } },
new Checkout { Customer = new Customer { Tier = "gold" }, Order = new Order { Total = 40m } },
new Checkout { Customer = new Customer { Tier = "silver" }, Order = new Order { Total = 250m } },
new Checkout { Customer = new Customer { Tier = "silver" }, Order = new Order { Total = 40m } },
};
foreach (Checkout checkout in cases)
{
RuleEvaluationResult result = engine.Evaluate(table, checkout);
Console.WriteLine($"{checkout.Customer.Tier,-7}{checkout.Order.Total,5} -> {result.FiredRules.Single().RuleId}: "
+ $"Discount={result.Outputs["Discount"]}, Label={result.Outputs["Label"]}");
}
Output
Rules: discount-0 (priority 4), discount-1 (priority 3), discount-2 (priority 2), discount-3 (priority 1)
StopAfterFirstMatch: True
gold 250 -> discount-0: Discount=20, Label=gold, big order
gold 40 -> discount-1: Discount=10, Label=gold
silver 250 -> discount-2: Discount=5, Label=big order
silver 40 -> discount-3: Discount=0, Label=standard
A table is rules
When a table loads, it expands into ordinary rules, one per row, with ids <table id>-0, -1 and so on, and descending priorities in row order. Nothing downstream knows it was a table, so tracing, hashing, validation and both execution paths work exactly as they do for rules you write by hand.
Hit policies
hitPolicy |
Behaviour | Use it for |
|---|---|---|
collect (default) |
Every matching row applies, in row order. | Scoring: pair it with addToOutput and appendToOutput. |
first |
Only the first matching row applies. The set gets StopAfterFirstMatch. |
Lookups: the most specific row first, a catch-all last. |
Each row's condition is exactly what its cells say. first doesn't add "and no earlier row matched" to later rows. It stops evaluation instead, so a table of any size stays linear.
Scoring with collect
{
"decisionTable": {
"id": "risk",
"hitPolicy": "collect",
"inputs": [
{ "field": "Customer.LoyaltyYears", "operator": "LessThan" },
{ "field": "Order.Total", "operator": "GreaterThan" },
{ "field": "Customer.Country", "operator": "In" }
],
"outputs": [
{ "target": "RiskScore", "type": "addToOutput" },
{ "target": "RiskReasons", "type": "appendToOutput" }
],
"rows": [
{ "when": [1, null, null], "then": [30, "new customer"] },
{ "when": [null, 200, null], "then": [20, "high value"] },
{
"when": [ null, null, ["XX", "YY"] ],
"then": [50, "high-risk region"]
},
{
"when": [null, null, null],
"then": [
{
"op": "multiply",
"operands": [ { "field": "Order.ItemCount" }, 2 ]
},
null
]
}
]
}
}
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
LoadedRuleSet scoring = engine.LoadRuleSet(File.ReadAllText("scoring-table.json"));
foreach (Checkout checkout in new[] { Facts.Vip(), Facts.Newcomer() })
{
RuleEvaluationResult result = engine.Evaluate(scoring, checkout);
string reasons = result.Outputs.TryGetValue("RiskReasons", out object? value)
? string.Join(", ", (List<object?>)value!)
: "none";
Console.WriteLine($"{checkout.Customer.Name,-6} rows {string.Join("+", result.FiredRules.Select(r => r.RuleId)),-15} "
+ $"score {result.Outputs["RiskScore"],3} reasons: {reasons}");
}
Output
Aroha rows risk-1+risk-3 score 28 reasons: high value
Ben rows risk-0+risk-3 score 32 reasons: new customer
The last row is a catch-all whose Score cell is an expression: two points per item. Its RiskReasons cell is null, so it adds to the score without adding a reason.
Cells
| Cell | Can be |
|---|---|
when |
null (wildcard), a scalar, or an array for In / NotIn columns |
then |
null (skip), a scalar, or any value expression |
Validation checks each row has one cell per column, that operators and action types are known, and that In cells are non-empty arrays. Errors point at the cell: /decisionTable/rows/2/when/1.
Next
Custom functions: tests the built-in operators don't cover.