Table of Contents

Value functions

A custom function answers yes or no. A value function computes something — rounding, clamping, a lookup — and a { "call": … } expression invokes it anywhere an expression is valid. The document names the function; the behaviour stays registered C#, bound when the rules load.

Calling a function

{ "call": "RoundTo", "operands": [ { "op": "multiply", "operands": [ { "field": "Order.Total" }, 0.0725 ] }, 2 ] }

The operands are expressions themselves — fields, literals, operators, params, even other calls — evaluated in document order and handed to the function as an array. operands may be omitted for a no-argument call. A call composes with everything else, on either side of a condition:

{
  "name": "Value functions",
  "rules": [
    {
      "id": "tax-rounded",
      "condition": { "field": "Order.Total", "operator": "GreaterThan", "value": 0 },
      "actions": [
        {
          "type": "setOutput",
          "target": "Tax",
          "value": {
            "call": "RoundTo",
            "operands": [
              { "op": "multiply", "operands": [ { "field": "Order.Total" }, 0.0725 ] },
              2
            ]
          }
        }
      ]
    },
    {
      "id": "big-order",
      "condition": {
        "expression": { "call": "RoundTo", "operands": [ { "field": "Order.Total" }, 0 ] },
        "operator": "GreaterThanOrEqual",
        "value": 200
      },
      "actions": [
        { "type": "setOutput", "target": "BigOrder", "value": true }
      ]
    }
  ]
}

Registering one

As a delegate

using System.Text.Json;
using Newtonsoft.Json.Linq;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Extensions.Functions;
using RuleWright.Json.NewtonsoftJson;
using RuleWright.Json.SystemText;

RuleWrightEngine engine = new RuleWrightBuilder()
    .UseJsonReader(new SystemTextJsonReader())
    .RegisterValueFunction("RoundTo", args =>
        args is [decimal value, long digits] ? decimal.Round(value, (int)digits) : null)
    .Build();

LoadedRuleSet rules = engine.LoadRuleSet(File.ReadAllText("value-functions.json"));

foreach (Checkout checkout in new[] { Facts.Vip(), Facts.Newcomer() })
{
    RuleEvaluationResult result = engine.Evaluate(rules, checkout);
    string big = result.Outputs.TryGetValue("BigOrder", out object? b) ? $"{b}" : "-";
    Console.WriteLine($"{checkout.Customer.Name,-6} Tax = {result.Outputs["Tax"],-6} BigOrder = {big}");
}

Output

Aroha  Tax = 17.40  BigOrder = True
Ben    Tax = 2.54   BigOrder = -

As a class, with metadata

A class can declare a description for rule editors and — unlike a condition function's metadata, which is purely descriptive — a required operand count that the engine enforces when rules load:

using RuleWright.Core;

/// <summary>Rounds a number to a digit count: { "call": "RoundTo", "operands": [ number, digits ] }.</summary>
public sealed class RoundToFunction : IRuleValueFunction, IRuleValueFunctionMetadata
{
    public string Name => "RoundTo";

    public string? Description => "Rounds a number to a digit count.";

    // Declared, so a call with any other operand count fails when the rules load.
    public int? RequiredOperandCount => 2;

    // Total, like the built-in operators: an argument shape it doesn't understand gives null.
    // Rule arithmetic is decimal, and a whole JSON number arrives as long.
    public object? Invoke(object?[] arguments) =>
        arguments is [decimal value, long digits] ? decimal.Round(value, (int)digits) : null;
}
using System.Text.Json;
using Newtonsoft.Json.Linq;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Extensions.Functions;
using RuleWright.Json.NewtonsoftJson;
using RuleWright.Json.SystemText;

RuleWrightEngine engine = new RuleWrightBuilder()
    .UseJsonReader(new SystemTextJsonReader())
    .RegisterValueFunction(new RoundToFunction())
    .Build();

foreach (RuleValueFunctionDescriptor function in engine.ValueFunctionCatalog)
{
    Console.WriteLine($"{function.Name,-8} operands: {function.RequiredOperandCount?.ToString() ?? "any"}  {function.Description}");
}

A call with the wrong number of operands then fails at LoadRuleSet, naming the rule, the function and both counts:

using System.Text.Json;
using Newtonsoft.Json.Linq;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Extensions.Functions;
using RuleWright.Json.NewtonsoftJson;
using RuleWright.Json.SystemText;

try
{
    engine.LoadRuleSet("""
        { "id": "x", "condition": { "field": "Order.Total", "operator": "GreaterThan", "value": 0 },
          "actions": [ { "type": "setOutput", "target": "T", "value": { "call": "RoundTo", "operands": [ 1 ] } } ] }
        """);
}
catch (RuleCompilationException ex)
{
    Console.WriteLine(ex.Message);
}

Output of both

RoundTo  operands: 2  Rounds a number to a digit count.
Rule 'x': value function 'RoundTo' requires exactly 2 operands, but the call supplies 1.

Unknown names fail at load

Like the custom operator, a call binds to its function instance when the rules load — one virtual call per evaluation, no name lookup. An unregistered name is a RuleCompilationException at LoadRuleSet, never a surprise mid-request:

using System.Text.Json;
using Newtonsoft.Json.Linq;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Extensions.Functions;
using RuleWright.Json.NewtonsoftJson;
using RuleWright.Json.SystemText;

try
{
    engine.LoadRuleSet("""
        { "id": "x", "condition": { "field": "Order.Total", "operator": "GreaterThan", "value": 0 },
          "actions": [ { "type": "setOutput", "target": "T", "value": { "call": "Truncate", "operands": [ 1 ] } } ] }
        """);
}
catch (RuleCompilationException ex)
{
    Console.WriteLine(ex.Message);
}

Output

Rule 'x': value function 'Truncate' is not registered. Register it with RuleWrightBuilder.RegisterValueFunction before loading the rule set.

Rules for value functions

  • Be total, like the built-in operators. Return null for an argument shape you don't understand rather than throwing; a null then flows through the usual computed-value rules.
  • Expect decimal and long. Rule arithmetic runs in decimal, and a whole JSON number arrives as long. Pattern-match, as RoundTo does.
  • Be thread-safe and stateless. One instance serves every concurrent evaluation.
  • Names are case-sensitive, registering a name twice throws, and the custom and call registries are independent: the same name may exist in both, and the operator decides which answers.
  • Discoverable: engine.RegisteredValueFunctions and engine.ValueFunctionCatalog list what an engine has, for editors — see Discover the vocabulary.

Next

Custom actions: the same idea for how outputs are written.