decimal-scaled 0.4.1

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
# Comparisons with other numeric types


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
```

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]https://en.wikipedia.org/wiki/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

## 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]https://en.wikipedia.org/wiki/Unit_in_the_last_place (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, `S ∈ 0..=37` at compile time | 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 75 / 152 / 306 | 10 | Yes | Yes | wider, S-dependent | same accuracy as `D38<S>` | Yes |

The accuracy column gives the error bound on computed results, in
[ULPs](https://en.wikipedia.org/wiki/Unit_in_the_last_place) (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.

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

## Transcendental accuracy


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](https://en.wikipedia.org/wiki/Unit_in_the_last_place). It is the
strongest accuracy guarantee a finite type can give, and the
capability the alternatives below 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-scaled`**strict** (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 [`benchmarks.md`](benchmarks.md).