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.
34equals34.0, and anintcompares with34.5by widening both todecimal. - 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.2is exactly0.3, and7 / 2is3.5, not3.
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, andIn/NotInsets. - For case-insensitive equality, use the built-in
EqualsIgnoreCasefunction, which is ordinal too. - A string is not a collection.
countof a string isnull, and the quantifiers don't iterate characters.
Dates and times
- A
DateTime,DateTimeOffset,TimeSpanorGuidfield compares with a string constant that parses as one. IsInPastandIsInFuturecompare instants: aDateTimeOffsetorLocalDateTimeis converted to UTC, and anUnspecifiedDateTimeis read as UTC, so the answer doesn't depend on the server's time zone.IsWeekendandIsWeekdaystay 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.