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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# CHANGELOG

## Unreleased

### Bug fixes

* Re-round the coefficient when a rounding carry lengthens it past the
context precision. Rounding an all-nines coefficient up (e.g. `9.99` to
precision 2, or `Decimal.div(95, 10)` at precision 1) produced a
coefficient with one digit more than `precision` (`d(1, 100, -1)`,
`d(1, 10, 0)`) instead of re-rounding to exactly `precision` significant
digits (`d(1, 10, 0)`, `d(1, 1, 1)`). This affects every context
operation (`add`, `sub`, `mult`, `div`) and now matches the General
Decimal Arithmetic spec and Python's decimal.

## v3.1.1 (2026-05-27)

### Bug fixes
Expand Down
10 changes: 9 additions & 1 deletion lib/decimal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2450,7 +2450,15 @@ defmodule Decimal do

signals = if any_nonzero?(remain, sticky?), do: [:inexact, :rounded], else: [:rounded]

exp = exp + (num_digits - precision)
# A rounding carry can lengthen the coefficient past `precision` (e.g.
# [?9] -> [?1, ?0] at precision 1). Drop the trailing zero the carry
# introduced and raise the exponent so the result keeps exactly
# `precision` significant digits, matching the General Decimal Arithmetic
# spec's round operation and Python's decimal.
{signif, carry} =
if length(signif) > precision, do: {:lists.droplast(signif), 1}, else: {signif, 0}

exp = exp + (num_digits - precision) + carry
coef = digits_to_integer(signif)
dec = %Decimal{sign: sign, coef: coef, exp: exp}
{dec, signals}
Expand Down
46 changes: 37 additions & 9 deletions test/decimal/context_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@ defmodule Decimal.ContextTest do

test "with_context/2: half even" do
Context.with(%Context{precision: 2, rounding: :half_even}, fn ->
assert Decimal.add(~d"0", ~d"9.99") == d(1, 100, -1)
# 9.99 rounds up to 10 at precision 2; the carry re-rounds to two
# significant digits (d(1, 10, 0)), not the three-digit d(1, 100, -1).
assert Decimal.add(~d"0", ~d"9.99") == d(1, 10, 0)
assert Decimal.add(~d"0", ~d"1.0") == d(1, 10, -1)
assert Decimal.add(~d"0", ~d"123") == d(1, 12, 1)
assert Decimal.add(~d"0", ~d"6.66") == d(1, 67, -1)
assert Decimal.add(~d"0", ~d"9.99") == d(1, 100, -1)
assert Decimal.add(~d"0", ~d"9.99") == d(1, 10, 0)
assert Decimal.add(~d"0", ~d"-6.66") == d(-1, 67, -1)
assert Decimal.add(~d"0", ~d"-9.99") == d(-1, 100, -1)
assert Decimal.add(~d"0", ~d"-9.99") == d(-1, 10, 0)
end)

Context.with(%Context{precision: 3, rounding: :half_even}, fn ->
Expand Down Expand Up @@ -84,6 +86,27 @@ defmodule Decimal.ContextTest do
end)
end

test "with_context/2: rounding carry keeps exactly precision digits" do
# When rounding overflows an all-nines coefficient (9.99 -> 10.0 at
# precision 2, 9.5 -> 10 at precision 1), the result must be re-rounded
# to `precision` significant digits rather than keeping the extra digit.
# Applies to every context operation. Expected values match the General
# Decimal Arithmetic spec and Python's decimal.
Context.with(%Context{precision: 1, rounding: :half_even}, fn ->
assert Decimal.div(~d"95", ~d"10") == d(1, 1, 1)
end)

Context.with(%Context{precision: 2, rounding: :half_even}, fn ->
assert Decimal.add(~d"0", ~d"9.99") == d(1, 10, 0)
assert Decimal.mult(~d"3.33", ~d"3") == d(1, 10, 0)
assert Decimal.sub(~d"10", ~d"0.001") == d(1, 10, 0)
end)

Context.with(%Context{precision: 3, rounding: :ceiling}, fn ->
assert Decimal.div(~d"9995", ~d"1000") == d(1, 100, -1)
end)
end

test "with_context/2: large exponent gap addition" do
num = d(1, 1, 100_000)
one = d(1, 1, 0)
Expand Down Expand Up @@ -126,14 +149,17 @@ defmodule Decimal.ContextTest do
num = d(1, 1, 100_000)
one = d(1, 1, 0)

# Modes that round up carry 9.99e99999 to 1.00e100000; the carry
# re-rounds to three significant digits, d(1, 100, 99_998), rather than
# the four-digit d(1, 1000, 99_997). :down and :floor truncate (no carry).
for {rounding, result} <- [
down: d(1, 999, 99_997),
half_up: d(1, 1000, 99_997),
half_even: d(1, 1000, 99_997),
half_down: d(1, 1000, 99_997),
up: d(1, 1000, 99_997),
half_up: d(1, 100, 99_998),
half_even: d(1, 100, 99_998),
half_down: d(1, 100, 99_998),
up: d(1, 100, 99_998),
floor: d(1, 999, 99_997),
ceiling: d(1, 1000, 99_997)
ceiling: d(1, 100, 99_998)
] do
Context.with(
%Context{precision: 3, rounding: rounding, emax: :infinity, emin: :infinity},
Expand All @@ -159,7 +185,9 @@ defmodule Decimal.ContextTest do

Context.with(%Context{precision: 3, emax: :infinity, emin: :infinity}, fn ->
assert_runs_quickly("sub/2 large exponent gap", fn ->
assert Decimal.sub(num, one) == %Decimal{sign: 1, coef: 1000, exp: @bounded_smoke_exp - 3}
# default :half_up carries 9.99e(N-1) to 1.00eN; the carry re-rounds
# to three significant digits (coef 100, exp N-2).
assert Decimal.sub(num, one) == %Decimal{sign: 1, coef: 100, exp: @bounded_smoke_exp - 2}
end)
end)
end
Expand Down