# 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`:
```toml
[dependencies]
decimal-scaled = "0.1"
```
For `no_std` targets (disables `std` and `serde`):
```toml
[dependencies]
decimal-scaled = { version = "0.1", default-features = false }
```
To enable `serde` without `std`:
```toml
[dependencies]
decimal-scaled = { version = "0.1", default-features = false, features = ["serde", "alloc"] }
```
---
## Quick start
```rust
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.
| `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
| `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.
| `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
| `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
| `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:
- MIT license ([LICENSE-MIT](LICENSE-MIT))
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
at your option.
Copyright 2026 John Moxley.
Third-party code attributions are listed in [LICENSE-THIRD-PARTY](LICENSE-THIRD-PARTY).