diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e53d9..deb060a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # CHANGELOG +## Unreleased + +### Bug fixes + +* Fix `Decimal.div/2` rounding the wrong way on inexact results. The long + division discarded its remainder instead of carrying it into rounding as + a sticky bit, so a guard digit of 5 with a nonzero tail was treated as an + exact tie (`:half_even`/`:half_down`) and a guard digit of 0 with a + nonzero tail was treated as zero (`:ceiling`/`:floor`/`:up`). Roughly 5% + of random divisions at the default precision were affected, including + `:ceiling`/`:floor` returning a result on the wrong side of the true + value. The same remainder is now also reflected in the `:inexact` flag, + which was previously suppressed when the remainder was a power of ten. + ## v3.1.1 (2026-05-27) ### Bug fixes diff --git a/lib/decimal.ex b/lib/decimal.ex index b405079..8c83ed3 100644 --- a/lib/decimal.ex +++ b/lib/decimal.ex @@ -800,9 +800,15 @@ defmodule Decimal do else prec10 = pow10(Context.get().precision) {coef1, coef2, adjust} = div_adjust(coef1, coef2, 0) - {coef, adjust, _rem, signals} = div_calc(coef1, coef2, 0, adjust, prec10) - - context(%Decimal{sign: sign, coef: coef, exp: exp1 - exp2 - adjust}, signals) + {coef, adjust, rem, signals} = div_calc(coef1, coef2, 0, adjust, prec10) + + # `rem` is the leftover of the long division below the digits we kept. + # It must be carried into rounding as the sticky bit: a nonzero `rem` + # means the true quotient lies strictly beyond the last computed digit, + # so a guard digit of 5 is not an exact tie (`:half_even`/`:half_down`) + # and a guard digit of 0 is still nonzero for `:ceiling`/`:floor`/`:up`. + # Without it, ~5% of inexact divisions round the wrong way. + context(%Decimal{sign: sign, coef: coef, exp: exp1 - exp2 - adjust}, signals, rem != 0) end end diff --git a/test/decimal/context_test.exs b/test/decimal/context_test.exs index 4e5a894..f2721ac 100644 --- a/test/decimal/context_test.exs +++ b/test/decimal/context_test.exs @@ -193,7 +193,14 @@ defmodule Decimal.ContextTest do coef = :erlang.binary_to_integer("1" <> String.duplicate("0", 106)) Decimal.div(Decimal.new(1, coef, 0), ~d"17") - assert [:rounded] = Context.get().flags + # 10^106 / 17 is non-terminating, so rounding it to 111 digits produces + # an inexact result: both :rounded and :inexact must be signalled (per + # the General Decimal Arithmetic spec; Python's decimal agrees). This + # previously asserted only [:rounded] because :inexact was erroneously + # suppressed whenever the division remainder was a power of ten. + flags = Context.get().flags + assert :rounded in flags + assert :inexact in flags end) Context.with(%Context{precision: 2}, fn -> diff --git a/test/decimal_test.exs b/test/decimal_test.exs index d3d70be..3a440c3 100644 --- a/test/decimal_test.exs +++ b/test/decimal_test.exs @@ -517,6 +517,35 @@ defmodule DecimalTest do end end + test "div/2 carries the remainder as a sticky bit when rounding" do + # The digit past the kept digits being 5 with a nonzero tail is not an + # exact tie and must round up; a guard digit of 0 with a nonzero tail is + # still nonzero for the directional modes. Expected values verified + # against the exact integer quotient and Python's decimal. + + Context.with(%Context{precision: 28, rounding: :half_even}, fn -> + # 4.7460 / -5522 -> …1405287…: guard digit 5, nonzero tail -> rounds up + assert Decimal.div(~d"4.7460", ~d"-5522") == + d(-1, 8_594_712_060_847_519_014_849_692_141, -31) + end) + + # genuine ties still round to even + Context.with(%Context{precision: 1, rounding: :half_even}, fn -> + assert Decimal.div(~d"5", ~d"2") == d(1, 2, 0) + assert Decimal.div(~d"7", ~d"2") == d(1, 4, 0) + end) + + # :ceiling / :floor must honor the remainder (their defining guarantee): + # 1.0000001 / 1 at precision 5 has a zero guard digit but a nonzero tail + Context.with(%Context{precision: 5, rounding: :ceiling}, fn -> + assert Decimal.div(~d"1.0000001", ~d"1") == d(1, 10_001, -4) + end) + + Context.with(%Context{precision: 5, rounding: :floor}, fn -> + assert Decimal.div(~d"-1.0000001", ~d"1") == d(-1, 10_001, -4) + end) + end + test "div_int/2" do assert Decimal.div_int(~d"1", ~d"0.3") == d(1, 3, 0) assert Decimal.div_int(~d"2", ~d"3") == d(1, 0, 0)