Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 50 additions & 88 deletions sjsonnet/src/sjsonnet/DecimalFormat.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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) ""
Expand Down
16 changes: 10 additions & 6 deletions sjsonnet/src/sjsonnet/Format.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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");
Expand Down
Loading