Table of Contents

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"] }
    ]
  }
}
  • inputs are columns of conditions: a field and an operator (Equals by default; any comparison operator works, and In cells are arrays).
  • outputs are columns of actions: a target and a type (setOutput by default; addToOutput and appendToOutput work too).
  • rows pair when cells, one per input, with then cells, one per output.
  • A null input cell is a wildcard: that column doesn't constrain the row. An all-wildcard row is a catch-all.
  • A null output 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.