decimal-scaled 0.1.0

Generic scaled fixed-point decimal types for deterministic arithmetic. Multiply / divide use a 256-bit widening intermediate with the Moller-Granlund 2011 magic-number divide algorithm (adapted from ConstScaleFpdec, MIT-licensed; see LICENSE-THIRD-PARTY).
Documentation
decimal-scaled-0.1.0 has been yanked.

decimal-scaled

A Rust library providing const-generic base-10 fixed-point decimal types with deterministic, bit-exact arithmetic.


Installation

Add to your Cargo.toml:

[dependencies]
decimal-scaled = "0.1"

For no_std targets (disables std and serde):

[dependencies]
decimal-scaled = { version = "0.1", default-features = false }

To enable serde without std:

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

Quick start

use decimal_scaled::I128s12;

// Construct from raw integer storage (value × 10^12)
let a = I128s12::from_bits(1_100_000_000_000); // exactly 1.1
let b = I128s12::from_bits(2_200_000_000_000); // exactly 2.2

// Constants
let zero = I128s12::ZERO;
let one  = I128s12::ONE;

// Raw storage
assert_eq!(a.to_bits(), 1_100_000_000_000);
assert_eq!(I128s12::multiplier(), 1_000_000_000_000);

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
I128<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 do not need no_std and can afford heap-allocated metadata

Use bigdecimal when:

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

What decimal-scaled provides

I128<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 - future scale variants (I128<6>, I128<18>) are free type aliases, not separate implementations.

Numeric comparison table

Type Storage Base 0.1 exact 1.1 exact Range no_std
f32 32-bit IEEE 754 (binary floating-point standard) 2 No No ~±3.4 × 10³⁸ Yes
f64 64-bit IEEE 754 (binary floating-point standard) 2 No No ~±1.8 × 10³⁰⁸ Yes
f128 128-bit IEEE 754 (binary floating-point standard) 2 No No ~±1.2 × 10⁴⁹³² Partial
fixed::I64F64 128-bit binary fixed 2 No No ~±9.2 × 10¹⁸ Yes
fixed::I32F32 64-bit binary fixed 2 No No ~±2.1 × 10⁹ Yes
rust_decimal 96-bit + scale byte 10 Yes Yes ±7.9 × 10²⁸ No
bigdecimal heap-allocated 10 Yes Yes Unbounded No
I128<12> or I128s12 (this) 128-bit integer 10 Yes Yes ~±1.7 × 10²⁶ Yes
I128<6> or I128s6 (this) 128-bit integer 10 Yes Yes ~±1.7 × 10³² Yes

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
I128<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.

I128<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 I128s12(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¹⁸.

I128<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 I128<20> represents it in decimal (exact for decimal fractions, rounded for fractions like 1/3).

For human-scale decimal values I128 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.


Scale aliases

Alias SCALE 1 least significant decimal digit Approximate range
I128s0 0 1 ±1.7 × 10³⁸
I128s2 2 0.01 (cents) ±1.7 × 10³⁶
I128s6 6 10⁻⁶ (µ) ±1.7 × 10³²
I128s12 12 10⁻¹² (p) ±1.7 × 10²⁶
I128s18 18 10⁻¹⁸ (a) ±1.7 × 10²⁰
I128s38 38 10⁻³⁸ ±1.7

Aliases I128s0 through I128s38 are all provided. SCALE = 39 would overflow i128.


Features

Feature Default Description
std yes Enables FromStr, Display, logarithm/exponential, trigonometry
alloc yes Enables string formatting on no_std
serde yes Enables Serialize/Deserialize via serde_helpers

License

Licensed under either of:

at your option.

Copyright 2026 John Moxley.

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