Skip to content
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 9 additions & 3 deletions lib/decimal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion test/decimal/context_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
29 changes: 29 additions & 0 deletions test/decimal_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down