From 00f524957459960b784a6db7bccdd2bf5c3efe9a Mon Sep 17 00:00:00 2001 From: He-Pin Date: Sun, 9 Aug 2026 15:50:42 +0800 Subject: [PATCH] fix: round formatted doubles ties-to-even Motivation: std.format mixed shortest-decimal and binary64 intermediate rounding, producing mathematically incorrect results such as 1.005 -> 1.01 and inconsistent behavior across precisions and conversions. Modification: round the exact binary64 value once with HALF_EVEN for fixed, scientific, and generic formats, update exponent selection, and expand regression coverage. Result: %f, %e, and %g now consistently follow Python-style round-to-nearest ties-to-even semantics across tested precisions and platforms. --- sjsonnet/src/sjsonnet/DecimalFormat.scala | 138 +++++++----------- sjsonnet/src/sjsonnet/Format.scala | 16 +- .../format_exact_binary64_rounding.jsonnet | 50 +++++++ ...at_exact_binary64_rounding.jsonnet.golden} | 0 ...ormat_rounding_half_away_from_zero.jsonnet | 28 ---- .../format_scientific_notation.jsonnet | 4 +- 6 files changed, 112 insertions(+), 124 deletions(-) create mode 100644 sjsonnet/test/resources/new_test_suite/format_exact_binary64_rounding.jsonnet rename sjsonnet/test/resources/new_test_suite/{format_rounding_half_away_from_zero.jsonnet.golden => format_exact_binary64_rounding.jsonnet.golden} (100%) delete mode 100644 sjsonnet/test/resources/new_test_suite/format_rounding_half_away_from_zero.jsonnet diff --git a/sjsonnet/src/sjsonnet/DecimalFormat.scala b/sjsonnet/src/sjsonnet/DecimalFormat.scala index f8902e0ee..3f28ed51f 100644 --- a/sjsonnet/src/sjsonnet/DecimalFormat.scala +++ b/sjsonnet/src/sjsonnet/DecimalFormat.scala @@ -51,111 +51,73 @@ object DecimalFormat { hashes: Int, alternate: Boolean, expLengthOpt: Option[Int], - number: Double): String = - format(zeroes, hashes, alternate, expLengthOpt, number, useExactDecimal = false) - - private[sjsonnet] def format( - zeroes: Int, - hashes: Int, - alternate: Boolean, - expLengthOpt: Option[Int], - number: Double, - useExactDecimal: Boolean): String = { + number: Double): String = { expLengthOpt match { case Some(expLength) => var expNum = if (number == 0.0) 0L else Math.floor(Math.log10(math.abs(number))).toLong val precision = zeroes + hashes - if (precision > 15) { - // A Double cannot represent every scaled mantissa once scientific formatting asks for - // more than 16 significant digits. Math.round first loses low digits and then saturates - // at Long.MaxValue. Start from the exact binary64 value and keep the scaled integer - // outside Long. - val tenPowPrec = BigInt(10).pow(precision) - val exactMagnitude = BigDecimal.exact(number).abs - val exactJava = exactMagnitude.bigDecimal - if (exactJava.signum() != 0) { - while ( - exactJava.compareTo( - java.math.BigDecimal.ONE.scaleByPowerOfTen(expNum.toInt + 1) - ) >= 0 - ) expNum += 1 - while ( - exactJava.compareTo(java.math.BigDecimal.ONE.scaleByPowerOfTen(expNum.toInt)) < 0 - ) expNum -= 1 - } - val scaleExponent = precision - expNum.toInt - val scaled = - if (scaleExponent >= 0) exactMagnitude * BigDecimal(10).pow(scaleExponent) - else exactMagnitude / BigDecimal(10).pow(-scaleExponent) - var roundedMagnitude = - scaled.setScale(0, BigDecimal.RoundingMode.HALF_EVEN).toBigInt - if (roundedMagnitude >= tenPowPrec * 10) { - roundedMagnitude /= 10 - expNum += 1 - } - val rounded = if (number < 0) -roundedMagnitude else roundedMagnitude - val fracStr = (rounded % tenPowPrec).abs.toString - val fracDigits = Platform.repeatString("0", precision - fracStr.length) + fracStr - formatExponentParts( - precision, - hashes, - alternate, - expLength, - expNum, - (rounded / tenPowPrec).toString, - fracDigits - ) - } else { - // Scale so mantissa * 10^precision becomes a roundable integer - val divided = number / Math.pow(10, (expNum - precision).toDouble) - var rounded = Math.round(divided) - val tenPowPrec = Math.pow(10, precision).toLong - if (rounded.toDouble >= Math.pow(10, precision + 1)) { - rounded = Math.round(rounded / 10.0) - expNum += 1 - } - val fracNum = math.abs(rounded % tenPowPrec) - formatExponentParts( - precision, - hashes, - alternate, - expLength, - expNum, - (rounded / tenPowPrec).toString, - leftPad(fracNum, precision) - ) + // Start from the exact binary64 value, shift the decimal point without an intermediate + // rounding, then perform the one requested round using ties-to-even. + val tenPowPrec = BigInt(10).pow(precision) + val exactJava = BigDecimal.exact(number).abs.bigDecimal + if (exactJava.signum() != 0) { + while ( + exactJava.compareTo( + java.math.BigDecimal.ONE.scaleByPowerOfTen(expNum.toInt + 1) + ) >= 0 + ) expNum += 1 + while (exactJava.compareTo(java.math.BigDecimal.ONE.scaleByPowerOfTen(expNum.toInt)) < 0) + expNum -= 1 + } + var roundedMagnitude = BigInt( + exactJava + .scaleByPowerOfTen(precision - expNum.toInt) + .setScale(0, java.math.RoundingMode.HALF_EVEN) + .toBigInteger + ) + if (roundedMagnitude >= tenPowPrec * 10) { + roundedMagnitude /= 10 + expNum += 1 } + val rounded = if (number < 0) -roundedMagnitude else roundedMagnitude + val fracStr = (rounded % tenPowPrec).abs.toString + val fracDigits = Platform.repeatString("0", precision - fracStr.length) + fracStr + formatExponentParts( + precision, + hashes, + alternate, + expLength, + expNum, + (rounded / tenPowPrec).toString, + fracDigits + ) case None => val precision = zeroes + hashes if (precision == 0) { - // Round half away from zero (matching go-jsonnet/jrsonnet behavior) - val rounded = - if (number != number || math.abs(number) >= 4503599627370496.0) number - else if (number >= 0) math.floor(number + 0.5) - else math.ceil(number - 0.5) + val rounded = math.rint(number) val prefix = if (number != number) rounded.toLong.toString else RenderUtils.truncatedDoubleToString(rounded) if (alternate) prefix + "." else prefix } else { - val denominator = BigDecimal(10).pow(precision) - val exactDecimal = useExactDecimal || precision > 15 - val bd = - if (exactDecimal) BigDecimal.exact(number).abs - else BigDecimal(number).abs - val scaled = - if (exactDecimal) - (bd * denominator).setScale(0, BigDecimal.RoundingMode.HALF_EVEN) - else - (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 denominator = BigInt(10).pow(precision) + val scaled = BigInt( + BigDecimal + .exact(number) + .abs + .bigDecimal + .scaleByPowerOfTen(precision) + .setScale(0, java.math.RoundingMode.HALF_EVEN) + .toBigInteger + ) + val whole = scaled / denominator + val fracMagnitude = scaled % denominator val sign = if (number < 0) "-" else "" - val prefix = sign + wholeBD.toBigInt.toString - val fracStr = fracBD.toBigInt.toString + val prefix = sign + whole.toString + val fracStr = fracMagnitude.toString val frac = if (fracStr == "0" && zeroes == 0) "" diff --git a/sjsonnet/src/sjsonnet/Format.scala b/sjsonnet/src/sjsonnet/Format.scala index 1219a1950..c464fda80 100644 --- a/sjsonnet/src/sjsonnet/Format.scala +++ b/sjsonnet/src/sjsonnet/Format.scala @@ -1182,10 +1182,15 @@ object Format { else { val abs = math.abs(s) val rawExponent = decimalExponent(abs) - val scale = math.pow(10, rawExponent - precision + 1) - val roundedDigits = Math.round(abs / scale) - if (roundedDigits == 0) 0 - else if (roundedDigits.toDouble >= math.pow(10, precision)) rawExponent + 1 + val roundedDigits = BigInt( + BigDecimal + .exact(abs) + .bigDecimal + .scaleByPowerOfTen(precision - 1 - rawExponent) + .setScale(0, java.math.RoundingMode.HALF_EVEN) + .toBigInteger + ) + if (roundedDigits >= BigInt(10).pow(precision)) rawExponent + 1 else rawExponent } } @@ -1222,8 +1227,7 @@ object Format { if (formatted.alternate) 0 else fractionalPrecision, formatted.alternate, None, - math.abs(s), - useExactDecimal = precision > 15 + math.abs(s) ), numeric = true, signedConversion = !isNegative(s) diff --git a/sjsonnet/test/resources/new_test_suite/format_exact_binary64_rounding.jsonnet b/sjsonnet/test/resources/new_test_suite/format_exact_binary64_rounding.jsonnet new file mode 100644 index 000000000..83e9e792e --- /dev/null +++ b/sjsonnet/test/resources/new_test_suite/format_exact_binary64_rounding.jsonnet @@ -0,0 +1,50 @@ +// Jsonnet documents std.format as Python %-formatting. Round the exact binary64 input once, +// using round-to-nearest with ties-to-even. + +// Exact halfway values at integer precision +std.assertEqual(std.format("%.0f", [0.5]), "0") && +std.assertEqual(std.format("%.0f", [1.5]), "2") && +std.assertEqual(std.format("%.0f", [2.5]), "2") && +std.assertEqual(std.format("%.0f", [3.5]), "4") && +std.assertEqual(std.format("%.0f", [4.5]), "4") && +std.assertEqual(std.format("%.0f", [-0.5]), "-0") && +std.assertEqual(std.format("%.0f", [-1.5]), "-2") && +std.assertEqual(std.format("%.0f", [-2.5]), "-2") && + +// Exact halfway values and decimal-looking values at fractional precision +std.assertEqual(std.format("%.1f", [0.25]), "0.2") && +std.assertEqual(std.format("%.1f", [0.35]), "0.3") && +std.assertEqual(std.format("%.1f", [-0.25]), "-0.2") && +std.assertEqual(std.format("%.2f", [0.125]), "0.12") && +std.assertEqual(std.format("%.2f", [0.375]), "0.38") && +std.assertEqual(std.format("%.2f", [0.005]), "0.01") && +std.assertEqual(std.format("%.2f", [0.015]), "0.01") && +std.assertEqual(std.format("%.2f", [-0.005]), "-0.01") && +std.assertEqual(std.format("%.2f", [1.005]), "1.00") && +std.assertEqual(std.format("%.2f", [1.015]), "1.01") && +std.assertEqual(std.format("%.2f", [1.025]), "1.02") && +std.assertEqual(std.format("%.2f", [0.145]), "0.14") && +std.assertEqual(std.format("%.2f", [2.675]), "2.67") && +std.assertEqual(std.format("%.2f", [-2.675]), "-2.67") && + +// Scientific and generic conversions use the same exact-value rounding rule +std.assertEqual(std.format("%.0e", [2.5]), "2e+00") && +std.assertEqual(std.format("%.0e", [-2.5]), "-2e+00") && +std.assertEqual(std.format("%.2e", [1.005]), "1.00e+00") && +std.assertEqual(std.format("%.1g", [0.25]), "0.2") && +std.assertEqual(std.format("%.1g", [2.5]), "2") && +std.assertEqual(std.format("%.1g", [25]), "2e+01") && +std.assertEqual(std.format("%.1g", [-25]), "-2e+01") && +std.assertEqual(std.format("%.3g", [1.005]), "1") && +std.assertEqual(std.format("%.3g", [2.675]), "2.67") && + +// 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") && +// Large-integer regression: |x| >= 2^52, ULP >= 1.0, must be identity +std.assertEqual(std.format("%.0f", [9007199254740991]), "9007199254740991") && +std.assertEqual(std.format("%.0f", [-9007199254740991]), "-9007199254740991") && +std.assertEqual(std.format("%.0f", [4503599627370497]), "4503599627370497") && +std.assertEqual(std.format("%.0f", [1e20]), "100000000000000000000") && +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_exact_binary64_rounding.jsonnet.golden similarity index 100% rename from sjsonnet/test/resources/new_test_suite/format_rounding_half_away_from_zero.jsonnet.golden rename to sjsonnet/test/resources/new_test_suite/format_exact_binary64_rounding.jsonnet.golden 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 deleted file mode 100644 index 9b46f98e8..000000000 --- a/sjsonnet/test/resources/new_test_suite/format_rounding_half_away_from_zero.jsonnet +++ /dev/null @@ -1,28 +0,0 @@ -// 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") && -// Large-integer regression: |x| >= 2^52, ULP >= 1.0, must be identity -std.assertEqual(std.format("%.0f", [9007199254740991]), "9007199254740991") && -std.assertEqual(std.format("%.0f", [-9007199254740991]), "-9007199254740991") && -std.assertEqual(std.format("%.0f", [4503599627370497]), "4503599627370497") && -std.assertEqual(std.format("%.0f", [1e20]), "100000000000000000000") && -true diff --git a/sjsonnet/test/resources/new_test_suite/format_scientific_notation.jsonnet b/sjsonnet/test/resources/new_test_suite/format_scientific_notation.jsonnet index a97cbc76d..a6295ffe6 100644 --- a/sjsonnet/test/resources/new_test_suite/format_scientific_notation.jsonnet +++ b/sjsonnet/test/resources/new_test_suite/format_scientific_notation.jsonnet @@ -1,5 +1,5 @@ // Directional tests for std.format scientific notation (%e). -// Verifies correct rounding and high-precision formatting against go-jsonnet. +// Verifies Python-style ties-to-even rounding and high-precision formatting. // Basic scientific notation local b1 = std.assertEqual(std.format("%e", 0.001), "1.000000e-03"); @@ -11,7 +11,7 @@ local b6 = std.assertEqual(std.format("%e", 0), "0.000000e+00"); // Rounding cases (previously truncated instead of rounding) local r1 = std.assertEqual("%.0e" % 1.5e10, "2e+10"); -local r2 = std.assertEqual("%.0e" % 2.5e10, "3e+10"); +local r2 = std.assertEqual("%.0e" % 2.5e10, "2e+10"); local r3 = std.assertEqual("%.0e" % 9.5e10, "1e+11"); local r4 = std.assertEqual("%.1e" % 1.55e10, "1.6e+10"); local r5 = std.assertEqual("%.0e" % 1.4e10, "1e+10");