decimal_gda design
This page explains the mathematics that decimal_gda implements and why the
package is built the way it is. The tutorial
shows how to use it and the API page lists every
name.
Design goal
decimal_gda implements the General Decimal Arithmetic Specification
(version 1.70) by M. F. Cowlishaw11 M. F. Cowlishaw, General Decimal Arithmetic Specification, version
1.70 (2009), https://speleotrove.com/decimal/decarith.html. The test suite
is the same author’s dectest collection, version 2.62. as pure MoonBit values. A GDA operation
is specified to produce more than a number: it produces a result, a set of
conditions, an update of the context’s sticky status, and possibly a
trap that transfers control while the defined result stays available. The
goal is to model all of that exactly and observably, so that
- each operation is a total function from (operands, context) to (result, raised conditions, next context, trap decision), with no hidden state;
- results are bit-for-bit those of the specification, checked against the pinned official test suite (see conformance);
- the package is independent of the IEEE 754 package
decimal, so changes to one contract cannot leak into the other.
Mathematical background
Numbers, cohorts and the adjusted exponent
A finite GDA number is a triple with sign , coefficient and exponent , denoting
The map is not injective: and both denote . The triples with the same value form a cohort, and GDA keeps the triple, not only the value, because the exponent carries meaning (the quantum : “2.50” was measured to the cent). Zero is signed, so is . For write for the number of decimal digits of and
for the adjusted exponent, the exponent of the leading digit: . Besides finite numbers there are and quiet and signaling NaNs carrying a sign and a payload .
Contexts and the representable set
A context fixes a precision , a rounding mode, an exponent range and a clamp bit. A finite number is representable in the context when
and, if clamping is on, also . The value is the exponent of the smallest unit: a number with and digits has , and numbers below (the subnormal range) keep that unit while losing leading digits. is the exponent of the largest -digit number with ; clamping makes the set of exponents exactly the one an IEEE interchange format can encode, which is why the decimal32/64/128 presets clamp.
The arithmetic rule: exact result, then round once
Every arithmetic operation in GDA is defined by the same two-step rule:
- compute the exact mathematical result and choose, among the triples that denote , the one whose exponent is closest to the operation’s ideal exponent ;
- if that triple is not representable, round it to the context, raising conditions that describe what happened.
Because step 1 is exact, step 2 is a single rounding, and every bound below is
a bound on one rounding. The package follows the rule literally: each
operation builds an exact coefficient and exponent from its operands and then
calls one shared finalizer (normalize_decimal_parts_ctx_repr followed by
finalize_finite_ctx), which is the only code that rounds a finite result or
raises a precision- or range-related condition.
Ideal exponents
The ideal exponent is the coarsest quantum that represents the exact result using only information present in the operands. The derivations are short:
For addition, is the largest exponent at which both operands are integers, so it is the largest exponent at which the sum is guaranteed to be an integer multiple of the quantum; the bracket is then an exact integer coefficient. For multiplication the coefficient is an integer at exponent and in general at no larger one. For division the quotient need not be an integer, so the ideal is used only when it is reachable: the exact quotient is written with the exponent nearest to that keeps an integer coefficient of at most digits, and an inexact quotient uses all digits. For the square root, is the exponent whose square is the largest even exponent not above . The table summarizes the rules used by the package:
| Operation | Ideal exponent |
|---|---|
add, subtract, fma (sum part) | |
multiply, fma (product part) | |
divide | if exact, else digits |
sqrt | if exact, else digits |
remainder, remainder_near | |
divide_integer, to_integral_* | for the integral value, 0 for a quotient |
quantize, rescale | the requested exponent |
scaleb | |
power with integer , exact | |
exp, ln, log10, non-integer power | digits (always inexact, except exp(0), ln(1), log10(10^k)) |
plus, minus, abs, apply |
A zero result has no leading digit, so its exponent is simply the ideal one,
clamped into (or ). The sign of
an exact zero sum is unless both operands are negative, or the mode is
Floor and either is; this is the decimal form of the IEEE rule that
except when rounding towards .
///|
test "ideal exponents" {
let ctx = @decimal_gda.GdaContext::decimal64()
let d = (s : String) => @decimal_gda.Decimal::from_string(s).unwrap()
inspect(@decimal_gda.add(d("1.30"), d("1.2"), ctx).value(), content="2.50")
inspect(@decimal_gda.multiply(d("1.30"), d("1.2"), ctx).value(), content="1.560")
inspect(@decimal_gda.divide(d("2.40"), d("2"), ctx).value(), content="1.20")
inspect(@decimal_gda.divide(d("1"), d("4"), ctx).value(), content="0.25")
inspect(@decimal_gda.sqrt(d("1.00"), ctx).value(), content="1.0")
inspect(@decimal_gda.subtract(d("1.0"), d("1.00"), ctx).value(), content="0.00")
}
Rounding to precision
Let the exact result be with , .
Divide off the low digits, with . Every
rounding mode returns with an increment
, and the eight modes differ only in
(should_increment_decimal_repr):
ZeroFiveUp reads “round towards zero, unless the retained last digit is 0 or
5”; its purpose is that a later rounding to fewer digits by any mode is not
affected by the first one. The comparison versus is done exactly on
the decimal limbs (gda_coeff_div_pow10_round_info_repr returns the quotient,
whether , and the sign of ). If the result
is renormalized to . Before rounding, the finalizer
first tries to drop trailing zeros of ; when the exact coefficient is too
long only because of zeros, the result is exact and only Rounded is raised.
Let be the unit in the last place of the result . Since in the half modes and otherwise,
and since gives ,
This is the decimal unit roundoff of the
standard model ,
.22 N. J. Higham, Accuracy and Stability of Numerical Algorithms,
2nd ed., SIAM 2002, §2.2. Decimal wobble is larger than binary: the
relative spacing varies by a factor of 10 across a decade, against 2 in
binary (Goldberg 1991, §1.2). Rounding raises Rounded whenever
digits are removed and Inexact whenever a removed digit was
nonzero ().
The full proofs of this bound and of the other rounding facts on this page are in the attachment:
Subnormal results and
If the exact result has it is tiny. A tiny result may still need the unit at most, so the rounding position is not “keep digits” but “keep the digits at or above ”: with the exact exponent , the finalizer removes digits, possibly all of them. The absolute error is then bounded by the subnormal unit,
but the relative error is not bounded by any more: the precision
falls gradually from digits to 1 as drops from to
. The conditions record this: Subnormal is raised for
every tiny result (GDA, and this package’s GDA functions, detect tininess
before rounding, from the exact ), Underflow when a tiny result is
also inexact, and Clamped when it rounds to zero (the zero then takes the
exponent ). The status-free layer additionally offers
after-rounding detection (DecimalTininessDetection::AfterRounding) for
IEEE-style use; it changes only which results count as tiny, never the value.
Clamping
With clamp, a result with but
is representable as a value but not as a triple. Since
means , padding the coefficient with
zeros gives
so the padded triple has at most digits and the same value. The package
does exactly this and raises Clamped; the value is unchanged, only the
cohort member differs. Zeros are clamped the same way, by moving their
exponent into .
Overflow
If the rounded result has the operation overflows:
Overflow, Inexact and Rounded are raised and the result is either
or the largest finite number of the same sign,
. Which one follows from
treating as the representable number beyond and applying
the mode’s own direction: the half modes and Up move away from zero, so they
reach ; Down and ZeroFiveUp move towards zero (ZeroFiveUp would
only round away from zero from a last digit 0 or 5, and the last digit of
is 9), so they stop at ; Ceiling gives for a
positive and for a negative result, and Floor the mirror image
(overflow_to_infinity):
| Mode | positive overflow | negative overflow |
|---|---|---|
HalfEven, HalfUp, HalfDown, Up | ||
Down, ZeroFiveUp | ||
Ceiling | ||
Floor |
Conditions, signals and traps
GDA distinguishes conditions (what happened: division by zero, a result was
rounded, a conversion was malformed, …), signals (the named events a
condition triggers) and traps (signals the user has asked to interrupt the
computation). The package keeps one flag per condition and maps conditions to
the GDA signals by one rule: the four detailed invalid conditions
ConversionSyntax, DivisionImpossible, DivisionUndefined and
InvalidContext all signal InvalidOperation. Formally, let be
the thirteen flags and let be the five
invalid-family flags. For a flag set and a signal ,
which is GdaFlags::contains. An operation is then the state machine
where is the result and raised flags computed from the operands and the policy (precision, rounding, exponent range, clamp, extended) alone,
and is the first signal in the precedence list
InvalidOperation, DivisionByZero, DivisionUndefined, DivisionImpossible,
InvalidContext, ConversionSyntax, Overflow, Underflow, Subnormal, Inexact,
Rounded, Clamped, LostDigits with and
, or if there is none (complete_gda and
trapped_signal).
Three properties follow directly from the definition and are what users rely on:
- The value does not depend on status or traps. and are
functions of ; status and traps only enter and
. Enabling a trap therefore never changes a result, and
Trappedcan carry the defined result. - Status is monotone and idempotent. , and since is associative, commutative and idempotent, the status after a threaded sequence is the union of all raised sets, independent of how the sequence is grouped.
- The trap choice is deterministic. The precedence list is a total order on signals, so one operation selects at most one trap, whatever the order in which its conditions were detected.
///|
test "status is the union of the raised sets" {
let ctx = @decimal_gda.context(precision=3)
let d = (s : String) => @decimal_gda.Decimal::from_string(s).unwrap()
let a = @decimal_gda.divide(d("1"), d("3"), ctx) // Inexact, Rounded
let b = @decimal_gda.divide(d("1"), d("0"), a.next_context()) // DivisionByZero
let expected = a.raised().combine(b.raised())
inspect(b.next_context().status() == expected, content="true")
// Trapping changes the variant, never the value.
let trapping = ctx.trap(Inexact)
let t = @decimal_gda.divide(d("1"), d("3"), trapping)
inspect(t.value() == a.value(), content="true")
inspect(t is @decimal_gda.GdaOutcome::Trapped(Inexact, _, _, _), content="true")
}
Design decisions
Thread status through GdaOutcome, not global state
Problem. The GDA specification describes the context as a mutable object
whose status flags operations set. Most implementations (decNumber, Python’s
decimal) keep a current context per thread.
Options. (a) A mutable context passed by reference; (b) a thread-local or global current context; (c) an immutable context returned with every result.
Choice: (c). Every GDA function returns GdaOutcome, and the caller passes
next_context() to the next operation. The reasons are the properties above.
Because reads only the policy, the state machine factors into a pure
numerical part and a pure bookkeeping part, and both are referentially
transparent: an expression can be re-evaluated, memoized or run on another
thread without changing its result or its flags. A test runner can snapshot a
context and run the same operation under it many times; the .decTest runner
in frontend/gda_expr does exactly that. MoonBit
also has no thread-local storage, so (b) would mean process-global state,
which Luna-Flow excludes. The cost is explicit threading; the
decimal_gda_checked package removes it for linear
pipelines. When nothing is raised, the input context is returned unchanged,
so exact operations do not allocate a new context.
Keep a trap’s defined result
In GDA a trap transfers control, but the specification still defines the
result the operation would have delivered. Representing a trap as an error
(Result::Err, raise) would discard that value. Trapped keeps the value,
the next context and the raised set, so the caller can inspect, log or resume
from it; property 1 guarantees it is the same value as without the trap.
Keep the detailed invalid conditions
The specification reports ConversionSyntax, DivisionImpossible,
DivisionUndefined and InvalidContext through InvalidOperation. The
package keeps them as separate flags and makes contains(InvalidOperation)
true for each, and sets invalid_operation in the status whenever one is
raised. A program that only knows the eight GDA signals sees exactly the GDA
behaviour, while a test harness or a diagnostic can still tell a malformed
literal from . Placing InvalidOperation first in the precedence list
makes an InvalidOperation trap catch all four, as the specification
requires.
One finalizer, special values first
Each operation first decides the special cases (NaN propagation, invalid operations, infinities, exact zeros), then builds an exact finite coefficient and exponent, and only then calls the finalizer. No coefficient algorithm sets a flag. This keeps the conditions a function of the exact result and the policy, independent of which multiplication or division kernel was chosen, and lets kernels be tuned without touching the standard-facing behaviour.
A GDA package independent of decimal
IEEE 754-2008 decimal arithmetic grew out of GDA, so the two agree on most finite results, but their contracts differ:
| Aspect | decimal_gda (GDA 1.70) | decimal (IEEE 754-2019) |
|---|---|---|
| Precision | any per context | the format’s (or a chosen one) |
| Rounding modes | eight, including HalfUp, HalfDown, ZeroFiveUp | the IEEE attributes |
| Conditions | sticky status in the context, traps with defined results | per-operation flags returned with the value |
Detailed invalid conditions, LostDigits, subset arithmetic | yes | no |
| Tininess | before rounding | selectable |
| Elementary functions | sqrt, exp, ln, log10, power | the IEEE recommended set |
| Interchange | DPD | DPD and BID |
A shared core would have to carry the union of both state models, and a change
made for one standard could silently change the other’s results. The package
therefore owns its value type, coefficient kernels, contexts, finalizer and
DPD codec, and production dependency scans check that it never imports
decimal. The coefficient thresholds currently equal those of decimal;
that is a measured coincidence, not a shared dependency.
The status-free layer (DecimalContext, DecimalFlags, the *_ctx methods)
is the package’s own engine made public. It returns flags per operation like
IEEE, which is convenient for adapters (the Luna-Flow/arithmetic trait
implementations use it), but it implements the GDA arithmetic; the IEEE 754
contract lives in decimal.
Fast paths that are proven equivalent
Two kinds of shortcut avoid the general machinery without changing any observable result.
Small exact integers. parse, add, subtract, multiply and fma first
check whether all operands are integers with exponent 0 and coefficients below
, the exact result is a nonzero integer below with at most
digits, its adjusted exponent lies in , exponent 0
is allowed by clamping, and the context is extended. Under these predicates
the general path performs no rounding, raises no condition and returns
exponent 0 (the ideal exponent of every one of these operations), so the
shortcut returns Completed(v, C, none) with the same . A zero result is
excluded because its sign depends on the rounding mode.
Absorbed addends. When adding and a much smaller with , the exact sum need not be formed. Every digit of lies below the last retained digit of , so influences the rounding only through its sign and its comparison with half a unit:
which is the rounding table above with replaced by a sticky
representative of the same comparison class. exact_base_small_addend_result
evaluates exactly that, with compare_magnitude_to_half_ulp comparing
with exactly. The special case where is a power of ten and
has the opposite sign (the unit below is ten times smaller) is excluded
and takes the general path.
Division
divide handles , and exact divisions by powers of ten and by
small exact divisors separately. Otherwise it scales the dividend by
with , so that has at least integer digits, rounds to an integer
with the context mode, and then rounds to digits. Whether the quotient
terminates is decided exactly (has_finite_decimal_expansion_repr:
terminates iff has no prime factors other than 2 and 5),
which selects the ideal-exponent cohort for exact quotients and forces
Inexact with a -digit coefficient for the others.
The two successive roundings are equivalent to one for the directed modes, because truncations compose: . For the half modes they are equivalent except when the first rounding manufactures a tie: the discarded digits of are exactly while .
Square root
sqrt first tries an exact root: it removes trailing zeros, makes the
exponent even, takes the integer square root with
by Newton’s iteration , and accepts , then pads towards the ideal exponent
within digits. Otherwise it rounds directly at the
final position. With root exponent it computes
and decides the increment by comparing the radicand with the square of the
midpoint, which is exact in integers:
Rounding once at (not first at digits and then again at ) avoids double rounding of tiny roots. Because exact roots were handled first, the comparison only ever decides an irrational root, which cannot equal a midpoint. The GDA function always rounds half to even.
Integer powers
For an integer exponent the result is computed as the GDA specification prescribes: if the exact power fits in digits it is returned exactly with exponent ; otherwise binary powering runs at working precision (one digit fewer in subset contexts) with half-even rounding after every product, a negative starting from , and the final product is rounded to the context. Every one of the factors passes through at most roundings, so the working result is with22 N. J. Higham, Accuracy and Stability of Numerical Algorithms, 2nd ed., SIAM 2002, §2.2. Decimal wobble is larger than binary: the relative spacing varies by a factor of 10 across a decade, against 2 in binary (Goldberg 1991, §1.2).
which is at most units in the last place of the result. Adding the final rounding, an inexact integer power is within units in the last place in the half modes. That is close to, but not, correct rounding, and the specification does not require more for integer powers. Exponents so large that the result must overflow or underflow are detected beforehand from the bounds and on the result’s adjusted exponent.
Correctly rounded exp, ln, log10 and non-integer power
These functions are evaluated by certified interval arithmetic and a Ziv-style refinement loop.33 A. Ziv, “Fast evaluation of elementary mathematical functions with correctly rounded last bit”, ACM TOMS 17(3), 1991. On ball arithmetic see J. van der Hoeven, “Ball arithmetic”, 2009, and F. Johansson, “Arb: efficient arbitrary-precision midpoint-radius interval arithmetic”, IEEE Trans. Computers 66(8), 2017. For :
- Exact cases first. , , ,
special operands, domain errors, and the cases
powercan decide exactly ( viasqrt, powers of ten, , guaranteed overflow or underflow) never reach the loop. - Enclose the input. is converted to a binary ball at
bits with directed rounding (
to_bin_floattowards and ), so exactly. - Enclose the output.
ball_floatevaluates on the ball and returns (forlog10, divided by a cached enclosure of ; forpower, through enclosures of ). - Certify a candidate. A decimal approximation (the ball midpoint, or a decimal series evaluation) is rounded to . Let be the open interval of reals around that round to : the two midpoints with the neighbours for the half modes, or for the directed ones. Its endpoints are exact decimals; each is enclosed in binary, and the result is accepted when which proves . A second test rounds both exact dyadic endpoints and to the context; since every rounding mode is monotone, also proves .
- Refine. Otherwise the working precision grows, , at most twelve times.
The starting precision is bits with : four bits per decimal digit exceed , so the input is carried without loss of decimal information and 64 guard bits remain. For arguments with in a wide, unclamped context with , a cheaper first attempt uses about bits and falls back to if it cannot certify.
The loop terminates whenever is not itself a representable number or a
midpoint, because the enclosure shrinks to a point. For exp, ln and
log10 this always holds after step 1: by the Lindemann–Weierstrass theorem
is transcendental for rational , hence is irrational
for rational , and is rational only for integral
powers of ten; representable numbers and midpoints are rational. If the
budget is still exhausted (for example a power whose exact value is a
midpoint and is not caught in step 1), the try_* methods return a
certification failure and the GDA functions return NaN with
InvalidOperation, never an unproven result.
The GDA functions exp, ln, log10 and sqrt pass a copy of the context
with HalfEven rounding, because the specification defines these functions as
correctly rounded with round-half-even regardless of the context mode; power
uses the context’s mode, as the specification and its test vectors require,
and reports non-integer powers as Inexact with digits even when the
power happens to be exact (decimal_power_noninteger_gda_result). Two more
GDA rules are implemented literally: these functions are defined only for
contexts with , and at most 999,999
(math_context_is_restricted, raising InvalidContext otherwise), and in
subset arithmetic ln reproduces the result of the classic reference
algorithm, which can be one unit in the last place above the correctly rounded
one (decimal_ln_subset_result), because the legacy subset test vectors
encode that result.
Coefficient representation and kernels
A coefficient is either Small(UInt64) for values below or an array
of base- limbs with a cached digit count, both persistent: operations
never mutate operand limbs. Decimal limbs make digit counts, trailing-zero
removal, the half-unit comparison and the ZeroFiveUp last-digit test
constant-time or linear without binary-to-decimal conversion. Multiplication
and division choose among schoolbook, Karatsuba, Toom-3, a dual-modulus NTT,
Knuth’s algorithm D, Burnikel–Ziegler and Newton reciprocal division by limb
count, with thresholds per target:
| Target | Karatsuba mul / square | Toom-3 | first NTT mul / square | Burnikel–Ziegler | Newton division |
|---|---|---|---|---|---|
| native | 96 / 48 | 1,152 | 1,728 / 640 | from 2,816 | off |
| LLVM | 96 / 96 | 2,048 | 4,096 / 2,048 | 2,048 | 4,096 |
| Wasm, Wasm-GC, JS | 96 / 96 | 4,096 | 8,192 / 4,096 | 2,048 | 4,096 |
The thresholds are measured performance policy, not semantics: every kernel returns the exact product or quotient, so the choice cannot change a result or a flag. See performance for the measurements.
Correctness / invariants
- Single rounding. Every finite result other than integer
powerand the division case above is the exact result rounded once, so in the half modes and otherwise, and outside the subnormal range. - Ideal cohort. An exact result is returned at the representable exponent
nearest to its ideal exponent, so operations on exact data keep their
quantum (
2.50 × 3 = 7.50). - Independence of status. depends on only through its policy: results never depend on the sticky status or the traps.
- Status algebra. Status only grows, and the status after a threaded sequence is the union of the raised sets.
- Trap determinism. At most one trap fires per operation, selected by a
fixed total order;
Trappedcarries the same value asCompletedwould. - Certified elementary functions. A finite inexact result of
exp,ln,log10or non-integerpoweris returned only together with a proof (Lemma 3 or 4 of the attachment) that it is the correctly rounded value; a failure to prove it yields NaN andInvalidOperation. - Total order.
compare_totalis a total order on triples (sign, then class, then value, then exponent, then payload), andDecimal::compareis a total preorder in which NaNs form one class above all numbers. - Evidence. The pinned
officialtest suite passes 64,986/64,986 legal executable scalar rows and the legacyofficial0suite 16,124/16,124 (conformance). These are finite claims: the division defect above and the to-integral difference noted on the API page are not covered by any pinned row.
Alternatives rejected
- A mutable or global current context. Rejected because it makes results and flags depend on evaluation order and hidden state (see the first design decision).
- Traps as errors. Rejected because the GDA-defined result would be lost.
- Sharing the engine with
decimal. Rejected because one standard’s change could alter the other’s results; a measured duplication is cheaper than an accidental semantic coupling. - Binary floating point for elementary functions with a fixed number of guard digits. Rejected because no fixed number of guard digits guarantees correct rounding (the table-maker’s dilemma); certification with refinement does, and fails visibly when it cannot.
- Normalizing every result. Rejected because the quantum is part of a
GDA number; normalization is available explicitly as
reduce.
Boundaries
- The package implements the GDA operations and nothing else: no trigonometric, hyperbolic or other functions outside the specification, and no IEEE 754 operations other than the few extras of the status-free layer.
- It does not parse
.decTestfiles, run test suites or read files; that isfrontend/gda_exprand the repository tools. - It does not provide BID interchange; only DPD.
- It does not give correct rounding for integer powers beyond the GDA requirement, and on the current branch not for the half-mode division case described above.
- It does not provide a mutable or global context, and contexts carry no identity: two contexts with the same fields are interchangeable.
Footnotes
-
M. F. Cowlishaw, General Decimal Arithmetic Specification, version 1.70 (2009), https://speleotrove.com/decimal/decarith.html. The test suite is the same author’s
dectestcollection, version 2.62. ↩ -
N. J. Higham, Accuracy and Stability of Numerical Algorithms, 2nd ed., SIAM 2002, §2.2. Decimal wobble is larger than binary: the relative spacing varies by a factor of 10 across a decade, against 2 in binary (Goldberg 1991, §1.2). ↩ ↩2
-
A. Ziv, “Fast evaluation of elementary mathematical functions with correctly rounded last bit”, ACM TOMS 17(3), 1991. On ball arithmetic see J. van der Hoeven, “Ball arithmetic”, 2009, and F. Johansson, “Arb: efficient arbitrary-precision midpoint-radius interval arithmetic”, IEEE Trans. Computers 66(8), 2017. ↩