Skip to content

fix: std.format %f uses round-half-away-from-zero instead of HALF_EVEN#1001

Open
He-Pin wants to merge 1 commit into
databricks:masterfrom
He-Pin:worktree-fix-format-rounding-mode
Open

fix: std.format %f uses round-half-away-from-zero instead of HALF_EVEN#1001
He-Pin wants to merge 1 commit into
databricks:masterfrom
He-Pin:worktree-fix-format-rounding-mode

Conversation

@He-Pin

@He-Pin He-Pin commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

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 Jsonnet reference
standard library (std.jsonnet) implements %f rounding as
std.abs(n_) * denominator + 0.5 followed by std.floor, which is
half-away-from-zero. sjsonnet's use of math.rint (HALF_EVEN)
diverged from this spec for every %.0f half-value.

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 and reconstruct the sign explicitly. This is a
    defensive cleanup — all four callers in Format.scala already pass
    math.abs(s), so the old code never saw negative inputs. The change
    makes DecimalFormat self-consistent and safe against future direct
    callers.
  • Add directional test format_rounding_half_away_from_zero.jsonnet
    with 16 assertions covering %.0f, %.1f, %.2f for positive and
    negative half values, the carry case, and negative-to-zero rounding.

Result

Cross-implementation verification (validated locally against go-jsonnet
v0.22.0 and jrsonnet v0.5.0-pre99): all 16 test cases and 5 additional
edge cases produce identical output across all three implementations.

Only the %.0f half-value cases were actually broken. The %.1f /
%.2f rows already matched because DecimalFormat.format is always
called with math.abs(s) from Format.scala, so the old precision>0
formula happened to give the right answer.

All existing tests continue to pass on JVM / JS / Wasm / Native / Graal.

References

  • google/jsonnet std.jsonnet: numerator = std.abs(n_) * denominator + 0.5
    (half-away-from-zero algorithm in the reference standard library)
  • go-jsonnet: https://github.com/google/go-jsonnet

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.
@He-Pin He-Pin force-pushed the worktree-fix-format-rounding-mode branch from 5831f20 to b522792 Compare June 19, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant