decimal-scaled 0.2.3

Const-generic base-10 fixed-point decimals (D9/D18/D38/D76/D153/D307) with integer-only transcendentals correctly rounded to within 0.5 ULP — exact at the type's last representable place. Deterministic across every platform; no_std-friendly.
Documentation

decimal-scaled

A Rust library providing const-generic base-10 fixed-point decimal types with correctly-rounded (≤ 0.5 ULP) integer-only transcendentals, deterministic across every platform, and no_std-friendly.

📚 In-depth guides — getting started, scale aliases, the width family, conversions, rounding modes, strict mode, the d38! macro, every Cargo feature, benchmarks — live in docs/README.md. API reference on docs.rs.

0.5 ULP — the strongest accuracy guarantee a finite numeric type can give — is the headline feature. Every ln / exp / sin / cos / sqrt / cbrt / powf / atan / atan2 / sinh / cosh / tanh / asinh / acosh / atanh / to_degrees / to_radians lands within half an ULP of the exact result and the bit pattern is identical on every machine. No baseline numeric crate offers this for transcendentals. The algorithms, citations and per-function implementation notes are catalogued in ALGORITHMS.md.


Installation

Add to your Cargo.toml:

[dependencies]
decimal-scaled = "0.2.3"

The default build pulls in the correctly-rounded transcendentals (the strict feature is on by default). To opt in to the faster f64-bridge ("fast") path instead — ~700× quicker on series functions, but only ≈ 16 decimal digits of platform-libm precision and not platform-deterministic — disable default features and pick what you actually want:

[dependencies]
decimal-scaled = { version = "0.2.3", default-features = false, features = ["std", "serde"] }

For no_std targets (alloc is still required):

[dependencies]
decimal-scaled = { version = "0.2.3", default-features = false, features = ["serde", "alloc", "strict"] }

Quick start

[dependencies]
decimal-scaled = { version = "0.2.3", features = ["macros"] }

There are three idiomatic ways to construct a value. Use whichever fits your call site.

use decimal_scaled::{d38, D38s12};

// 1) `d38!` macro — the ergonomic constructor. Write the literal
//    as you'd read it; scale is inferred from the fractional
//    digits, or pinned explicitly with `, scale N`. (Requires the
//    `macros` feature.) One macro per width: `d9!`, `d18!`,
//    `d38!`, plus `d76!` / `d153!` / `d307!` under the wide
//    features. Pre-baked per-scale wrappers (`d38s12!`, `d18s6!`,
//    …) skip the `, scale N` and read more tersely at the call
//    site. Full grammar including the `radix N` qualifier in
//    [`macros/README.md`](macros/README.md).
let a = d38!(1.1, scale 12);                        // D38<12> — exactly 1.1

// 2) `FromStr` — parse a decimal string. Works without the
//    `macros` feature and accepts user input directly.
let b: D38s12 = "2.2".parse().unwrap();             // D38<12> — exactly 2.2

// 3) `from_bits` — for hot paths or when you already have the
//    raw integer (value × 10^SCALE). No parsing, no allocation.
let c = D38s12::from_bits(3_300_000_000_000);       // D38<12> — exactly 3.3

// Aliases like `D38s12` are just type aliases over `D38<12>`. The
// generic form works identically and is what you'd use when SCALE
// is itself a const generic in your code:
let _generic: decimal_scaled::D38<12> = D38s12::from_int(42);

// Arithmetic is plain operator overloads — exact for + / − / %,
// rounded (half-to-even) for × / ÷.
let sum     = a + b;                                // 3.3 exactly
let product = a * b;                                // 2.42 exactly
let half    = a / d38!(2, scale 12);

assert_eq!(sum, c);
assert_eq!(sum.to_string(), "3.3");
assert_eq!(a.to_bits(), 1_100_000_000_000);         // value × 10^12

// Constants are available where you need them.
let _zero = D38s12::ZERO;
let _one  = D38s12::ONE;

The macros feature is opt-in (it pulls in a proc_macro build dependency). Without it, the FromStr and from_bits paths are always available.


Type names

The number in each D<N> type name is the number of base-10 digits it can safely represent, not the bit-width of the underlying integer. The crate's home is decimal arithmetic, so it names its types in the unit users actually reason about. Mapping:

type constructor macro underlying signed integer safe decimal digits (= MAX_SCALE) max value at SCALE 0 required feature curated per-scale macros
D9<S> d9! i32 (32 bits) 9 ±2.1 × 10⁹ always available d9s0!, d9s2!, d9s4!, d9s6!, d9s9!
D18<S> d18! i64 (64 bits) 18 ±9.2 × 10¹⁸ always available d18s0!, d18s2!, d18s4!, d18s6!, d18s9!, d18s12!, d18s18!
D38<S> d38! i128 (128 bits) 38 ±1.7 × 10³⁸ always available d38s0!, d38s2!, d38s4!, d38s6!, d38s8!, d38s9!, d38s12!, d38s15!, d38s18!, d38s24!, d38s35!, d38s38!
D76<S> d76! Int256 (256 bits, in-tree wide-int) 76 ±5.8 × 10⁷⁶ d76 / wide d76s0!, d76s2!, d76s6!, d76s12!, d76s18!, d76s35!, d76s50!, d76s76!
D153<S> d153! Int512 (512 bits) 153 ±6.7 × 10¹⁵³ d153 / wide d153s0!, d153s35!, d153s75!, d153s150!, d153s153!
D307<S> d307! Int1024 (1024 bits) 307 ±9.0 × 10³⁰⁷ d307 / x-wide d307s0!, d307s35!, d307s150!, d307s300!, d307s307!

The number in each type name (9, 18, 38, …) is the type's MAX_SCALE — equivalently, the safe-decimal-digits count ⌊(bits − 1) · log₁₀ 2⌋. The largest scale at which every MAX_SCALE-digit decimal value (±999…9) fits the signed storage; also the largest S you can pass as the const generic parameter. D38<38> therefore represents values in [−1.7, 1.7] with 38 fractional digits; D38<0> represents integers up to ±10³⁸.

All constructor macros require the macros feature in addition to any per-tier feature listed above. Per-scale macros pre-bake scale N and forward every other qualifier (rounded, radix N) to the underlying constructor. Scales outside the curated subset remain reachable via the explicit , scale N qualifier on the main constructor.

Pick the narrowest tier whose range covers your values at the scale you need. Widening is free (From / widen()); narrowing is fallible (TryFrom / narrow()).


Why another numeric type?

Every numeric type makes a choice about which numbers it can represent exactly. There is no universal answer - the right choice depends on where the numbers come from.

The binary fraction problem

Standard floating-point types (f32, f64, f128) store values as:

value = mantissa × 2^exponent

This means the fractional part of any stored number must be expressible as a sum of negative powers of two: ½, ¼, ⅛, 1/16, …

The number 1.1 cannot be expressed this way. In binary it is:

1.0001100110011001100110011001100110011001100110011...  (repeating forever)

A 64-bit float truncates this at 52 mantissa bits. The value actually stored is:

1.100000000000000088817841970012523233890533447265625

This is not a bug - it is an unavoidable consequence of the representation. The same applies to 0.1, 0.2, 0.3, and most everyday decimal fractions. This is why 0.1 + 0.2 == 0.3 is false in every binary floating-point system.

The binary fixed-point alternative

The fixed crate (I64F64, I32F32, etc.) uses binary fixed-point: a fixed number of bits for the integer part and a fixed number of bits for the fractional part. A value is stored as:

value = raw_integer × 2^(-FRAC_BITS)

This eliminates the rounding from exponent adjustments, but the representable fractions are still powers of two. I64F64 cannot represent 0.1 exactly either. It excels at signal processing, physics simulations, and anywhere numbers arrive as binary data or are generated by mathematical operations.

Base-10 fixed-point: filling the gap

decimal-scaled uses base-10 fixed-point:

value = raw_integer × 10^(-SCALE)

With SCALE = 12, the number 1.1 is stored as the integer 1_100_000_000_000. It is exact. Every number a human can write with up to SCALE decimal digits is represented exactly. The tradeoff is that numbers like 1/3 or π still cannot be represented exactly - no finite representation can hold every number. The question is always which numbers you need to be exact.

Choosing the right number space

All numeric types have a finite number space. The choice is which region of the real line to cover densely and which values to round.

System Decimal places Exact for Rounds Best suited for
f64 dynamic (binary exponent, not decimal) powers-of-2 fractions decimal fractions like 0.1 scientific computation, computer-generated values
f128 dynamic (binary exponent, not decimal) powers-of-2 fractions (more precision) decimal fractions high-precision scientific work
fixed::I64F64 fixed (64 binary fractional bits, not decimal) binary fixed fractions decimal fractions digital signal processing, physics, binary data
rust_decimal variable per value (0–28, stored alongside each number) decimal fractions up to 28 digits repeating decimals finance, variable scale
bigdecimal variable per value (unbounded, heap-allocated) any terminating decimal repeating decimals arbitrary-precision decimal work
D38<S> (this crate) fixed to S at compile time decimal fractions up to S digits repeating decimals finance, computer-aided design, human-entered values

Use decimal-scaled when:

  • Values are entered by humans as decimal strings (prices, measurements, quantities)
  • You need deterministic, platform-identical results across every machine
  • The scale is known at compile time and you want zero-cost const-generic specialisation
  • You need no_std compatibility
  • You want a single canonical representation per value (no normalisation step)

Use f64 or f128 when:

  • Values come from sensors, physics engines, or mathematical operations
  • The number space is continuous and decimal fractions are not special
  • You need the dynamic range of IEEE 754 binary floating-point (from ~10⁻³⁰⁸ to ~10³⁰⁸)

Use fixed when:

  • Values are in a known integer-and-fraction format from binary protocols
  • You are doing digital signal processing or embedded arithmetic where binary fractions are natural
  • You need the best throughput on platforms without hardware decimal support

Use rust_decimal when:

  • Scale varies between values (e.g. mixing 0.1 and 0.001 in the same collection)
  • You need up to 28 significant decimal digits
  • You are happy to carry a per-value scale byte and pay normalisation cost on equality/hash

Use bigdecimal when:

  • Precision requirements are unbounded or unknown at compile time
  • Throughput is not a concern

What decimal-scaled provides

D38<const SCALE: u32> is a #[repr(transparent)] newtype around i128. The const generic SCALE is the base-10 exponent baked into the type at compile time. There is exactly one representation per value: no normalisation, no variable scale, no heap allocation.

stored = logical_value × 10^SCALE

With SCALE = 12, the value 1.5 is stored as 1_500_000_000_000i128.

Properties

  • Deterministic - arithmetic is pure integer; identical bit-pattern outputs on every platform.
  • Canonical - one scale means one representation per value. Hash, Eq, and Ord are derived directly from i128. Two values that are equal always hash identically, with no normalisation step.
  • no_std compatible - compiles with no_std + alloc when default features are disabled.
  • num-traits compatible - implements Zero, One, Num, Bounded, Signed, FromPrimitive, ToPrimitive, and the Checked* family.
  • serde support - canonical-string serialize/deserialize behind the serde feature (on by default).
  • Const-generic scale - additional scale variants (D38<6>, D38<18>) are free type aliases, not separate implementations.

Numeric comparison table

Type Storage Base 0.1 exact 1.1 exact Range Accuracy (error bound) no_std
f32 32-bit IEEE 754 2 No No ~±3.4 × 10³⁸ basic ops: ≤ 0.5 ULP (IEEE 754); transcendentals: libm-defined, not guaranteed Yes
f64 64-bit IEEE 754 2 No No ~±1.8 × 10³⁰⁸ basic ops: ≤ 0.5 ULP (IEEE 754); transcendentals: libm-defined, not guaranteed Yes
f128 128-bit IEEE 754 2 No No ~±1.2 × 10⁴⁹³² basic ops: ≤ 0.5 ULP (IEEE 754); transcendentals: libm-defined, not guaranteed Partial
fixed::I64F64 128-bit binary fixed 2 No No ~±9.2 × 10¹⁸ add/sub: exact; mul/div: ≤ 1 ULP; no transcendentals Yes
fixed::I32F32 64-bit binary fixed 2 No No ~±2.1 × 10⁹ add/sub: exact; mul/div: ≤ 1 ULP; no transcendentals Yes
rust_decimal 96-bit + per-value scale (0..=28) 10 Yes Yes ±7.9 × 10²⁸ add/sub: exact at common scale; mul/div: ≤ 1 ULP; transcendentals: software, not correctly rounded Yes
bigdecimal heap-allocated arbitrary precision 10 Yes Yes Unbounded exact at the configured precision; transcendentals: limited No
D38<S> (this) 128-bit integer, scale fixed at compile time, S ∈ 0..=38 10 Yes Yes ±i128::MAX / 10ˢ add/sub: exact; mul/div: ≤ 1 ULP; strict transcendentals: ≤ 0.5 ULP (correctly rounded) Yes
D76<S> / D153<S> / D307<S> (this, wide) 256 / 512 / 1024-bit integer, S up to 76 / 153 / 307 10 Yes Yes wider, S-dependent same accuracy as D38<S> Yes

The accuracy column gives the error bound on computed results, in ULPs (units in the last place). A 0.5 ULP bound — "correctly rounded" — is the IEEE-754 round-to-nearest contract and the strongest accuracy guarantee a finite numeric type can give. The floats meet it for basic arithmetic but not for transcendentals; decimal-scaled's strict transcendentals meet it for transcendentals as well, which is the capability the alternatives do not offer. The position of the ULP — the absolute size of 1 ULP — is the type's scale: for f64 it's a relative ~2⁻⁵² of the value's magnitude, for D38<S> it's exactly 10⁻ˢ at every value, fixed at compile time.

Hash and equality contracts

A well-behaved numeric type must satisfy: if a == b then hash(a) == hash(b). The way different types handle this for values like 1.1 and 1.10 varies significantly.

Type 1.10 == 1.1? hash(1.10) == hash(1.1)? Hash implemented? How
f32 / f64 Yes (same bit pattern) N/A No - Not-a-Number breaks the contract -
f128 Yes (same bit pattern) N/A No -
fixed::I64F64 Yes (same binary approximation) Yes Yes structural (one representation)
rust_decimal Yes Yes Yes normalises trailing zeros at comparison and hash time
bigdecimal Yes Yes Yes normalises at comparison and hash time
D38<S> (this) Yes Yes Yes structural - scale is fixed, one bit pattern per value

f32, f64, and f128 do not implement Hash in the Rust standard library because Not-a-Number values are not equal to themselves (NaN != NaN) while a structural hash would make all such values collide - the contract cannot be satisfied without special-casing.

For rust_decimal and bigdecimal, the normalisation is correct but carries a runtime cost on every comparison and hash call, and it means the stored representation is not canonical - you cannot memcmp two values.

D38<S> derives Hash, Eq, and Ord directly from i128. Because the scale is fixed at compile time there is exactly one i128 value per logical number. 1.10 and 1.1 parsed via FromStr both produce D38s12(1_100_000_000_000) - the same bit pattern - so equality and hashing are a single integer comparison with no runtime normalisation.

Key differences from fixed

The fixed crate's I64F64 has 64 bits of integer and 64 bits of binary fraction. Its least significant bit is 2⁻⁶⁴ ≈ 5.4 × 10⁻²⁰, and its maximum value is 2⁶³ - 1 ≈ 9.2 × 10¹⁸.

D38<20> has a least significant decimal digit of 10⁻²⁰ and a maximum value of i128::MAX / 10²⁰ ≈ 1.7 × 10¹⁸ model units. The two types offer comparable precision and range in this configuration, but with opposite trade-offs: I64F64 represents its fractional part in binary (exact for powers of two, rounded for decimal fractions), while D38<20> represents it in decimal (exact for decimal fractions, rounded for fractions like 1/3).

For human-scale decimal values D38 gives decimal-exact results with no rounding on input or output. For values derived from binary arithmetic or mathematical operations, I64F64 avoids the binary-to-decimal rounding boundary entirely.


Performance and accuracy

D38 arithmetic is a thin wrapper over i128: add / sub are a single instruction (~1 ns), mul / div carry a 256-bit widening step (~10 ns). The wide D76 tier keeps add / sub almost free but its mul / div pay for a 256-bit hand-rolled-integer divide — roughly 20× the D38 cost.

Transcendentals (ln, exp, sqrt, trig, …) come in two forms. The fast f64-bridge form (~40 ns) inherits f64's precision ceiling and is not platform-independent. The strict integer-only form — on by default — is correctly rounded to within 0.5 ULP of the exact result (the IEEE-754 round-to-nearest contract) and is no_std and platform-deterministic. Build with default-features = false, features = ["std", "serde"] to switch the plain ln / exp / … surface to the fast f64 bridge.

The full per-algorithm catalogue with citations, equations, and the Wikipedia / Wolfram MathWorld / author-homepage links lives in ALGORITHMS.md.

Transcendental accuracy comparison

A correctly rounded result is the exact mathematical value rounded to the nearest representable number — i.e. the error is at most half a ULP. It is the strongest accuracy guarantee a finite type can give, and the capability the alternatives do not offer:

Type Transcendentals Correctly rounded to 0.5 ULP Platform-deterministic
f32 / f64 (platform libm) yes no — libm is not guaranteed correctly rounded no
fixed (I64F64, …) none
bigdecimal none
rust_decimal (MathematicalOps) yes no — accurate, but not to the last place yes
decimal-scaled — fast (f64 bridge, opt-in) yes no — inherits f64 no
decimal-scaledstrict (default, *_strict) yes yes — within 0.5 ULP yes

For series functions the strict form costs ~700× the fast bridge; sqrt_strict is the exception — algebraic, so it ties the fast form. Full head-to-head measurements against bnum, ruint, rust_decimal, and fixed are in docs/benchmarks.md.


Scale aliases

Alias SCALE 1 least significant decimal digit Approximate range
D38s0 0 1 ±1.7 × 10³⁸
D38s2 2 0.01 (cents) ±1.7 × 10³⁶
D38s6 6 10⁻⁶ (µ) ±1.7 × 10³²
D38s12 12 10⁻¹² (p) ±1.7 × 10²⁶
D38s18 18 10⁻¹⁸ (a) ±1.7 × 10²⁰
D38s38 38 10⁻³⁸ ±1.7

Aliases D38s0 through D38s38 are all provided. SCALE = 39 would overflow i128.


The width family

D38 is the foundation, but it is one of six storage widths that share an identical API and the Decimal trait:

Type Storage MAX_SCALE Feature gate
D9 32-bit 9 always on
D18 64-bit 18 always on
D38 128-bit 38 always on
D76 256-bit 76 d76 / wide
D153 512-bit 153 d153 / wide
D307 1024-bit 307 d307 / wide

Pick the narrowest width whose range covers your values at the scale you need. Widening between widths is lossless (From); narrowing is fallible (TryFrom). The wide tier is backed by an in-tree hand-rolled wide-integer type — no external big-integer dependency — and is opt-in. See docs/widths.md.

Compile-time literals

With the macros feature, d38! writes D38 values at compile time with automatic scale inference:

use decimal_scaled::d38;

let price = d38!(19.99);              // D38<2>
let micro = d38!(1.234_567, scale 6); // D38<6>
let rnd   = d38!(1.235, scale 2, rounded); // 1.24 (half-to-even)

See docs/macros.md.


Features

Feature Default Description
std yes Platform f64-bridge transcendentals (used when strict is off). Pulls in alloc.
alloc yes String formatting and parsing on no_std. Required.
serde yes Serialize / Deserialize via serde_helpers.
strict yes Plain transcendentals (ln / sin / …) dispatch to the integer-only, 0.5 ULP, platform-deterministic path. no_std-compatible. The *_strict and *_fast named entry points are both always available; this feature only chooses what plain * resolves to.
macros no The d38! compile-time decimal-literal macro.
fast no Forces plain transcendentals to dispatch to the f64 bridge instead of *_strict, even when strict is also on. The integer-only *_strict and f64 *_fast named methods stay available either way.
rounding-* no Five mutually-exclusive flags that change the crate-wide default RoundingMode at compile time.
d76 / d153 / d307 no The wide decimal tiers (256 / 512 / 1024-bit storage), backed by an in-tree hand-rolled wide-integer type.
wide no Umbrella over d76 + d153 + d307.
experimental-floats no Nightly-only f16 / f128 entry points on the float bridge.

See docs/features.md for the full reference and common configurations.


Documentation

In-depth usage guides live in docs/:

  • Getting started — constructing values, arithmetic, formatting, parsing.
  • The width familyD9D307, scale ranges, the Decimal trait.
  • Conversions — integers, floats, cross-width widening / narrowing.
  • RoundingRoundingMode, rescale, the rounding-* features.
  • Strict mode — integer-only transcendentals.
  • The d38! macro — compile-time decimal literals.
  • Cargo features — every feature flag.
  • Benchmarks — head-to-head against bnum, ruint, rust_decimal, and fixed, plus fast vs strict.

API reference: https://docs.rs/decimal-scaled/.


License

Licensed under either of:

at your option.

Copyright 2026 John Moxley.

Third-party code attributions are listed in LICENSE-THIRD-PARTY.