From b522792a97a37b574d8b6add00733b1fd5d94a4a Mon Sep 17 00:00:00 2001 From: He-Pin Date: Sat, 20 Jun 2026 02:33:41 +0800 Subject: [PATCH] fix: std.format %f uses round-half-away-from-zero instead of HALF_EVEN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: std.format("%.0f", [0.5]) returned "0" (banker's rounding / HALF_EVEN) while go-jsonnet v0.22.0 and jrsonnet v0.5.0-pre99 both return "1" (round-half-away-from-zero). The same HALF_EVEN divergence affected every %.0f half-value such as 2.5 and -0.5. Modification: - DecimalFormat.scala precision==0 path: replace math.rint (HALF_EVEN) with floor(x+0.5) / ceil(x-0.5) (half-away-from-zero). - DecimalFormat.scala precision>0 path: apply BigDecimal.abs to the scaled value so the sign is reconstructed from the input sign rather than from the rounded magnitude. Existing tests did not hit a negative-number failure in this path, but the arithmetic was unsafe in principle and the change aligns the formula with the precision==0 intent. - Add directional test format_rounding_half_away_from_zero.jsonnet covering %.0f, %.1f, %.2f for positive/negative half values, the carry case, and negative-to-zero rounding. Result: - std.format("%.0f", [0.5]) -> "1" (was "0") - std.format("%.0f", [2.5]) -> "3" (was "2") - std.format("%.0f", [-0.5]) -> "-1" (was "-0") - std.format("%.0f", [-2.5]) -> "-3" (was "-2") - %.1f and %.2f cases already matched go-jsonnet/jrsonnet on master (0.25 and 0.005 have exact binary64 representations, so the previous BigDecimal arithmetic happened to give the right answer). The precision>0 change is a defensive cleanup, not a behavior fix. - All existing tests continue to pass on JVM / JS / Wasm. Cross-implementation verification (locally validated against go-jsonnet v0.22.0 and jrsonnet v0.5.0-pre99 on macOS/arm64): | Expression | go-jsonnet | jrsonnet | sjsonnet before | sjsonnet after | |------------------------------|------------|----------|-----------------|----------------| | std.format("%.0f", [0.5]) | "1" | "1" | "0" (broken) | "1" | | std.format("%.0f", [2.5]) | "3" | "3" | "2" (broken) | "3" | | std.format("%.0f", [-0.5]) | "-1" | "-1" | "-0" (broken) | "-1" | | std.format("%.0f", [-2.5]) | "-3" | "-3" | "-2" (broken) | "-3" | | std.format("%.1f", [0.25]) | "0.3" | "0.3" | "0.3" (ok) | "0.3" | | std.format("%.1f", [-0.25]) | "-0.3" | "-0.3" | "-0.3" (ok) | "-0.3" | | std.format("%.2f", [0.005]) | "0.01" | "0.01" | "0.01" (ok) | "0.01" | | std.format("%.2f", [-0.005]) | "-0.01" | "-0.01" | "-0.01" (ok) | "-0.01" | Tests: - ./mill sjsonnet.jvm[2.13.18].test (all pass) - ./mill sjsonnet.js[2.13.18].test (all pass) - ./mill sjsonnet.wasm[2.13.18].test (all pass) - scalafmt on changed files References: None — standalone bug fix. --- sjsonnet/src/sjsonnet/DecimalFormat.scala | 9 ++++---- ...ormat_rounding_half_away_from_zero.jsonnet | 23 +++++++++++++++++++ ...ounding_half_away_from_zero.jsonnet.golden | 1 + 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 sjsonnet/test/resources/new_test_suite/format_rounding_half_away_from_zero.jsonnet create mode 100644 sjsonnet/test/resources/new_test_suite/format_rounding_half_away_from_zero.jsonnet.golden diff --git a/sjsonnet/src/sjsonnet/DecimalFormat.scala b/sjsonnet/src/sjsonnet/DecimalFormat.scala index 4390c676..d8b498b2 100644 --- a/sjsonnet/src/sjsonnet/DecimalFormat.scala +++ b/sjsonnet/src/sjsonnet/DecimalFormat.scala @@ -64,8 +64,8 @@ object DecimalFormat { case None => val precision = zeroes + hashes if (precision == 0) { - // No fractional digits needed - just round and format the integer part - val rounded = math.rint(number) + // Round half away from zero (matching go-jsonnet/jrsonnet behavior) + val rounded = if (number >= 0) math.floor(number + 0.5) else math.ceil(number - 0.5) val prefix = if (rounded.isInfinite || math.abs(rounded) > Long.MaxValue) BigDecimal(rounded).toBigInt.toString @@ -73,13 +73,14 @@ object DecimalFormat { if (alternate) prefix + "." else prefix } else { val denominator = BigDecimal(10).pow(precision) - val bd = BigDecimal(number) + val bd = BigDecimal(number).abs val scaled = (bd * denominator + BigDecimal("0.5")).setScale(0, BigDecimal.RoundingMode.FLOOR) val wholeBD = (scaled / denominator).setScale(0, BigDecimal.RoundingMode.FLOOR) val fracBD = (scaled - wholeBD * denominator).abs - val prefix = wholeBD.toBigInt.toString + val sign = if (number < 0) "-" else "" + val prefix = sign + wholeBD.toBigInt.toString val fracStr = fracBD.toBigInt.toString val frac = diff --git a/sjsonnet/test/resources/new_test_suite/format_rounding_half_away_from_zero.jsonnet b/sjsonnet/test/resources/new_test_suite/format_rounding_half_away_from_zero.jsonnet new file mode 100644 index 00000000..01b01c83 --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/format_rounding_half_away_from_zero.jsonnet @@ -0,0 +1,23 @@ +// Verify std.format uses round-half-away-from-zero (matching go-jsonnet/jrsonnet). +// Previously used HALF_EVEN (banker's rounding) which gave wrong results for .5 values. + +std.assertEqual(std.format("%.0f", [0.5]), "1") && +std.assertEqual(std.format("%.0f", [1.5]), "2") && +std.assertEqual(std.format("%.0f", [2.5]), "3") && +std.assertEqual(std.format("%.0f", [3.5]), "4") && +std.assertEqual(std.format("%.0f", [4.5]), "5") && +std.assertEqual(std.format("%.0f", [-0.5]), "-1") && +std.assertEqual(std.format("%.0f", [-1.5]), "-2") && +std.assertEqual(std.format("%.0f", [-2.5]), "-3") && +// Higher precision +std.assertEqual(std.format("%.1f", [0.25]), "0.3") && +std.assertEqual(std.format("%.1f", [0.35]), "0.4") && +std.assertEqual(std.format("%.1f", [-0.25]), "-0.3") && +std.assertEqual(std.format("%.2f", [0.005]), "0.01") && +std.assertEqual(std.format("%.2f", [0.015]), "0.02") && +std.assertEqual(std.format("%.2f", [-0.005]), "-0.01") && +// Carry case: rounding causes integer part to increment +std.assertEqual(std.format("%.2f", [9.999]), "10.00") && +// Negative rounding to zero +std.assertEqual(std.format("%.2f", [-0.001]), "-0.00") && +true diff --git a/sjsonnet/test/resources/new_test_suite/format_rounding_half_away_from_zero.jsonnet.golden b/sjsonnet/test/resources/new_test_suite/format_rounding_half_away_from_zero.jsonnet.golden new file mode 100644 index 00000000..27ba77dd --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/format_rounding_half_away_from_zero.jsonnet.golden @@ -0,0 +1 @@ +true