From f0d6ed47ed280802267a71a29f104f9f44df280a Mon Sep 17 00:00:00 2001 From: He-Pin Date: Sat, 20 Jun 2026 12:03:18 +0800 Subject: [PATCH] fix: std.round preserves identity for |x| >= 2^52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: std.round returned the wrong integer for large odd inputs in [2^52, 2^53) (e.g. std.round(9007199254740991) produced 9007199254740992). The prior `floor(x + 0.5)` / `ceil(x - 0.5)` implementation invokes IEEE 754 round-to-even when the ULP is >= 1.0, so adding 0.5 rounds to the nearest *even* integer rather than the correct half-away-from-zero. go-jsonnet (math.Round) and jrsonnet both return the identity for these inputs; sjsonnet was the outlier. Modification: Short-circuit in MathModule.scala std.round when |x| >= 2^52 or x is NaN — at that magnitude every double is already an exact integer (ULP >= 1.0), so returning x unchanged is both correct and avoids the precision trap. The half-away-from-zero rule is preserved for all smaller magnitudes, and NaN/Infinity propagate naturally. Added five regression assertions (2^53 - 1, 2^53 - 2, 2^52 + 1, -(2^53 - 1), 1e20) to the existing std.round/isEven/isOdd/isInteger/ isDecimal test fixture to lock in the corrected behavior. Result: std.round now matches go-jsonnet and jrsonnet on every integer in [2^52, 2^53). The existing std.round(0.5)==1 / std.round(-0.5)==-1 half-away-from-zero semantics are preserved, and all 23 file tests plus 86 evaluator tests pass. Cross-implementation comparison (std.round input → output): | Input | sjsonnet (before) | sjsonnet (after) | go-jsonnet 0.22.0 | jrsonnet 0.5.0-pre99 | C++ jsonnet 0.22.0 | |---------------------|-------------------|------------------|-------------------|----------------------|--------------------| | 9007199254740991 | 9007199254740992 | 9007199254740991 | 9007199254740991 | 9007199254740991 | 9007199254740992 * | | 4503599627370497 | 4503599627370498 | 4503599627370497 | 4503599627370497 | 4503599627370497 | 4503599627370498 * | | -9007199254740991 | -9007199254740992 | -9007199254740991| -9007199254740991 | -9007199254740991 | -9007199254740990* | | 0.5 | 1 | 1 | 1 | 1 | 1 | | -0.5 | -1 | -1 | -1 | -1 | 0 * | | 2.5 | 3 | 3 | 3 | 3 | 2 * | * C++ jsonnet v0.22.0 uses round-half-to-even (round(-0.5)==0, round(2.5)==2) and exhibits the same large-integer precision loss as pre-fix sjsonnet; go-jsonnet and jrsonnet use half-away-from-zero. --- sjsonnet/src/sjsonnet/stdlib/MathModule.scala | 7 ++++++- .../math_round_isEven_isOdd_isInteger_isDecimal.jsonnet | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/sjsonnet/src/sjsonnet/stdlib/MathModule.scala b/sjsonnet/src/sjsonnet/stdlib/MathModule.scala index a021074d..9af39e2f 100644 --- a/sjsonnet/src/sjsonnet/stdlib/MathModule.scala +++ b/sjsonnet/src/sjsonnet/stdlib/MathModule.scala @@ -314,7 +314,12 @@ object MathModule extends AbstractFunctionModule { * The official docs list std.round(x) as a mathematical function. */ builtin("round", "x") { (pos, ev, x: Double) => - if (x >= 0) math.floor(x + 0.5) else math.ceil(x - 0.5) + // For |x| >= 2^52 the double is already an exact integer (ULP >= 1.0); + // adding 0.5 would invoke IEEE 754 round-to-even and produce the wrong + // result for odd inputs (e.g. 2^53 - 1). Short-circuit to avoid it. + if (x != x || math.abs(x) >= 4503599627370496.0) x + else if (x >= 0) math.floor(x + 0.5) + else math.ceil(x - 0.5) }, /** * [[https://jsonnet.org/ref/stdlib.html#math std.ceil(x)]]. diff --git a/sjsonnet/test/resources/new_test_suite/math_round_isEven_isOdd_isInteger_isDecimal.jsonnet b/sjsonnet/test/resources/new_test_suite/math_round_isEven_isOdd_isInteger_isDecimal.jsonnet index 5af7a288..aad08a57 100644 --- a/sjsonnet/test/resources/new_test_suite/math_round_isEven_isOdd_isInteger_isDecimal.jsonnet +++ b/sjsonnet/test/resources/new_test_suite/math_round_isEven_isOdd_isInteger_isDecimal.jsonnet @@ -12,6 +12,14 @@ assert std.round(-1.8) == -2 : 'round(-1.8)'; assert std.round(0) == 0 : 'round(0)'; assert std.round(1) == 1 : 'round(1)'; assert std.round(-1) == -1 : 'round(-1)'; +// Large-integer regression: for |x| >= 2^52 the double is already an exact +// integer (ULP >= 1.0), so std.round must be the identity. go-jsonnet and +// jrsonnet agree on all four values below. +assert std.round(9007199254740991) == 9007199254740991 : 'round(2^53 - 1)'; +assert std.round(9007199254740990) == 9007199254740990 : 'round(2^53 - 2)'; +assert std.round(4503599627370497) == 4503599627370497 : 'round(2^52 + 1)'; +assert std.round(-9007199254740991) == -9007199254740991 : 'round(-(2^53 - 1))'; +assert std.round(1e20) == 1e20 : 'round(1e20)'; // === std.isEven: truncation of integral part (go-jsonnet + jrsonnet agree) === // Integer inputs