Table of Contents

Values, numbers and text

A rule gives the same answer on every machine. That rests on a few strict choices: JSON numbers keep their exactness, strings compare by character code, dates compare as instants, and nothing is quietly converted from text to a number.

JSON values

JSON .NET
a whole number, such as 18 long
a number exactly representable as decimal, such as 4.95 decimal
any other finite number, such as 1e30 double
a number too large for double, such as 1e400 a RuleParseException
"text" string
true, false bool
null null
an array (in In, NotIn and function values) object?[]

The same mapping applies to rule documents and to facts converted with SystemTextJsonFacts or NewtonsoftJsonFacts, whichever adapter you use.

Comparisons, tried

Each line is a condition, and whether it passes for Aroha (age 34, tier "gold", ordered 19 September 2026 at 10:30 UTC):

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

// Each line: whether a condition passes for Aroha (age 34, tier "gold", ordered 2026-09-19 10:30 UTC).
string[] conditions =
{
    """{ "field": "Customer.Age", "operator": "Equals", "value": 34.0 }""",
    """{ "field": "Customer.Age", "operator": "LessThan", "value": 34.5 }""",
    """{ "field": "Customer.Name", "operator": "Contains", "value": "aro" }""",
    """{ "field": "Customer.Tier", "operator": "GreaterThan", "value": "Zed" }""",
    """{ "field": "Order.PlacedOn", "operator": "GreaterThan", "value": "2026-09-01" }""",
    """{ "field": "Order.PlacedOn", "operator": "LessThan", "value": "2026-09-19T12:00:00Z" }""",
    """{ "expression": { "op": "add", "operands": [0.1, 0.2] }, "operator": "Equals", "value": 0.3 }""",
    """{ "expression": { "op": "divide", "operands": [7, 2] }, "operator": "Equals", "value": 3.5 }""",
};

foreach (string condition in conditions)
{
    LoadedRuleSet rules = engine.LoadRuleSet($$"""{ "id": "probe", "condition": {{condition}} }""");
    bool passed = engine.Evaluate(rules, Facts.Vip()).FiredRules.Count == 1;
    Console.WriteLine($"{passed,-6} {condition}");
}

Output

True   { "field": "Customer.Age", "operator": "Equals", "value": 34.0 }
True   { "field": "Customer.Age", "operator": "LessThan", "value": 34.5 }
False  { "field": "Customer.Name", "operator": "Contains", "value": "aro" }
True   { "field": "Customer.Tier", "operator": "GreaterThan", "value": "Zed" }
True   { "field": "Order.PlacedOn", "operator": "GreaterThan", "value": "2026-09-01" }
True   { "field": "Order.PlacedOn", "operator": "LessThan", "value": "2026-09-19T12:00:00Z" }
True   { "expression": { "op": "add", "operands": [0.1, 0.2] }, "operator": "Equals", "value": 0.3 }
True   { "expression": { "op": "divide", "operands": [7, 2] }, "operator": "Equals", "value": 3.5 }
  • Numbers compare by value across types. 34 equals 34.0, and an int compares with 34.5 by widening both to decimal.
  • Text compares ordinally. Contains "aro" is false for "Aroha": case matters. And "gold" is greater than "Zed", because lowercase letters come after uppercase ones in character order. A culture-aware comparison would say the opposite, and culture-aware results can differ from one machine to the next, which is why RuleWright never uses one.
  • Dates compare with ISO 8601 strings, date-only or with a time and offset.
  • Arithmetic is decimal. 0.1 + 0.2 is exactly 0.3, and 7 / 2 is 3.5, not 3.

No string-to-number coercion

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

// "34" is a string. RuleWright never turns a string into a number.
LoadedRuleSet rules = engine.LoadRuleSet("""
    { "id": "age-as-text", "condition": { "field": "Customer.Age", "operator": "Equals", "value": "34" } }
    """);

// A dictionary fact has no declared type to check against: the comparison is simply false.
var dictionary = new Dictionary<string, object?> { ["Customer"] = new Dictionary<string, object?> { ["Age"] = 34 } };
Console.WriteLine($"Dictionary fact: fired {engine.Evaluate(rules, dictionary).FiredRules.Count}");

// A typed fact does: Age is an int, so the rule cannot compile against Checkout.
try
{
    engine.Evaluate(rules, Facts.Vip());
}
catch (RuleCompilationException ex)
{
    Console.WriteLine($"Typed fact:      {ex.Message}");
}

Output

Dictionary fact: fired 0
Typed fact:      Rule 'age-as-text': field 'Customer.Age': Field type Int32 is numeric but the comparison value is the string "34". Use a JSON number — RuleWright does not coerce strings to numbers.

A string is never a number, and a number is never a string. On a typed fact that's an error you'll see on the first Evaluate, because the rule can't compile against an int. On a dictionary fact there is no declared type, so the values simply aren't equal. Fix the rule: write 34, not "34". The same applies to true versus "true".

Strings

  • Every text operation is ordinal: Equals, Contains, StartsWith, EndsWith, the ordering operators, and In / NotIn sets.
  • For case-insensitive equality, use the built-in EqualsIgnoreCase function, which is ordinal too.
  • A string is not a collection. count of a string is null, and the quantifiers don't iterate characters.

Dates and times

  • A DateTime, DateTimeOffset, TimeSpan or Guid field compares with a string constant that parses as one.
  • IsInPast and IsInFuture compare instants: a DateTimeOffset or Local DateTime is converted to UTC, and an Unspecified DateTime is read as UTC, so the answer doesn't depend on the server's time zone.
  • IsWeekend and IsWeekday stay wall-clock: an order placed on Saturday afternoon in Auckland is a weekend order, whatever day it is in UTC.

Nulls

A null field, or a null anywhere along its path, fails every operator except the null-aware ones. Conditions has the full table, run on both execution paths.

Next

Performance, caching and threads.