diff --git a/RulesEngineInternal/Operators/ArithmeticOperators.swift b/RulesEngineInternal/Operators/ArithmeticOperators.swift index 3b7ff73066..0dd63fcdac 100644 --- a/RulesEngineInternal/Operators/ArithmeticOperators.swift +++ b/RulesEngineInternal/Operators/ArithmeticOperators.swift @@ -33,11 +33,10 @@ import Foundation /// become `Double.nan` and propagate naturally — the final result is /// `.float(nan)`, which `isTruthy` reports as falsy. /// -/// Division and modulo by zero return `.null` instead of `infinity` / -/// `nan`. JSON Logic JS produces the IEEE values, but for rule authoring -/// `null` is friendlier: it short-circuits comparisons in a predictable -/// way and matches the engine's "missing value" convention. This is the -/// one intentional deviation from the spec in this file. +/// Division and modulo by zero produce the IEEE 754 values (`±Infinity` +/// for `n / 0` with `n ≠ 0`, `NaN` for `0 / 0` and any `n % 0`), matching +/// `json-logic-js` exactly. Result is wrapped in `.float`, which means +/// `isTruthy` correctly reports `Infinity` as truthy and `NaN` as falsy. enum ArithmeticOperators { /// `{"+": [a, b, ...]}` — variadic sum. Each operand is coerced via @@ -83,25 +82,18 @@ enum ArithmeticOperators { } /// `{"/": [a, b]}` — division. Operands are coerced via JS `Number()` - /// (`asNumber`). Division by zero returns `.null` (see type docs). + /// (`asNumber`). Division by zero follows IEEE 754: `n / 0` is + /// `±Infinity` (sign matches the dividend), `0 / 0` is `NaN`. static func opDiv(args: Value, vars: Value) throws -> Value { let (lhs, rhs) = try Operators.evalTwo(args, vars: vars, opName: "/") - let divisor = asDouble(rhs) - if divisor == 0.0 { - return .null - } - return .float(asDouble(lhs) / divisor) + return .float(asDouble(lhs) / asDouble(rhs)) } /// `{"%": [a, b]}` — modulo. Operands are coerced via JS `Number()` - /// (`asNumber`). Modulo by zero returns `.null` (see type docs). + /// (`asNumber`). `n % 0` follows IEEE 754 and is `NaN`. static func opMod(args: Value, vars: Value) throws -> Value { let (lhs, rhs) = try Operators.evalTwo(args, vars: vars, opName: "%") - let divisor = asDouble(rhs) - if divisor == 0.0 { - return .null - } - return .float(asDouble(lhs).truncatingRemainder(dividingBy: divisor)) + return .float(asDouble(lhs).truncatingRemainder(dividingBy: asDouble(rhs))) } /// `Number(value)`-style coercion for `-`, `/`, `%`. Falls back to diff --git a/Tests/RulesEngineInternalTests/ArithmeticOperatorsTests.swift b/Tests/RulesEngineInternalTests/ArithmeticOperatorsTests.swift index 6cccce58fb..2c386cf8e6 100644 --- a/Tests/RulesEngineInternalTests/ArithmeticOperatorsTests.swift +++ b/Tests/RulesEngineInternalTests/ArithmeticOperatorsTests.swift @@ -179,22 +179,26 @@ final class ArithmeticOperatorsTests: XCTestCase { ) } - func testDivByZeroReturnsNull() throws { - // Both .int(0) and .float(0.0) divisors → .null - XCTAssertEqual( - try run(ArithmeticOperators.opDiv, args: arr(.int(1), .int(0))), - .null + /// `n / 0` follows IEEE 754: positive dividend → `+Infinity`, negative + /// dividend → `-Infinity`, `0 / 0` → `NaN`. Matches `json-logic-js`, + /// which delegates to native JS `/` (no short-circuit). + func testDivByZeroFollowsIeee754() throws { + // 1 / 0 → +Infinity (covers both .int(0) and .float(0.0) divisors). + let posIntZero = unwrapFloat( + try run(ArithmeticOperators.opDiv, args: arr(.int(1), .int(0))) ) - XCTAssertEqual( - try run(ArithmeticOperators.opDiv, args: arr(.int(1), .float(0.0))), - .null + XCTAssertEqual(posIntZero, .infinity) + let posFloatZero = unwrapFloat( + try run(ArithmeticOperators.opDiv, args: arr(.int(1), .float(0.0))) ) - // Even 0/0 returns .null (not NaN) — explicit short-circuit before - // arithmetic. - XCTAssertEqual( - try run(ArithmeticOperators.opDiv, args: arr(.int(0), .int(0))), - .null + XCTAssertEqual(posFloatZero, .infinity) + // -1 / 0 → -Infinity. + let neg = unwrapFloat( + try run(ArithmeticOperators.opDiv, args: arr(.int(-1), .int(0))) ) + XCTAssertEqual(neg, -.infinity) + // 0 / 0 → NaN. + assertNaN(try run(ArithmeticOperators.opDiv, args: arr(.int(0), .int(0)))) } func testDivWrongArityIsTypeError() { @@ -217,11 +221,11 @@ final class ArithmeticOperatorsTests: XCTestCase { ) } - func testModByZeroReturnsNull() throws { - XCTAssertEqual( - try run(ArithmeticOperators.opMod, args: arr(.int(7), .int(0))), - .null - ) + /// `n % 0` is always `NaN` per IEEE 754. Matches `json-logic-js`, + /// which delegates to native JS `%` (no short-circuit). + func testModByZeroIsNan() throws { + assertNaN(try run(ArithmeticOperators.opMod, args: arr(.int(7), .int(0)))) + assertNaN(try run(ArithmeticOperators.opMod, args: arr(.int(0), .int(0)))) } func testModWrongArityIsTypeError() { @@ -297,16 +301,14 @@ final class ArithmeticOperatorsTests: XCTestCase { .float(0.0) ) - // 1 / null and 1 % null → divisor coerces to 0 → null (engine's - // intentional div/mod-by-zero short-circuit; see type docs). + // 1 / null → divisor coerces to 0 → +Infinity (IEEE 754, see + // testDivByZeroFollowsIeee754 for the broader pinning). XCTAssertEqual( - try run(ArithmeticOperators.opDiv, args: arr(.int(1), .null)), - .null - ) - XCTAssertEqual( - try run(ArithmeticOperators.opMod, args: arr(.int(1), .null)), - .null + unwrapFloat(try run(ArithmeticOperators.opDiv, args: arr(.int(1), .null))), + .infinity ) + // 1 % null → divisor coerces to 0 → NaN. + assertNaN(try run(ArithmeticOperators.opMod, args: arr(.int(1), .null))) // Bools coerce to 0 / 1 (Number(true) === 1, Number(false) === 0). XCTAssertEqual(