# 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`**](docs/README.md). API reference on
[docs.rs](https://docs.rs/decimal-scaled/).

> **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][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`](ALGORITHMS.md).
>
> [ULP]: https://en.wikipedia.org/wiki/Unit_in_the_last_place

---

## Installation

Add to your `Cargo.toml`:

```toml
[dependencies]
decimal-scaled = "0.2.0"
```

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:

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

For `no_std` targets (`alloc` is still required):

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

---

## Quick start

```toml
[dependencies]
decimal-scaled = { version = "0.2.0", features = ["macros"] }
```

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

```rust
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](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

---

## 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](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, 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](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.

### 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`](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-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 [`docs/benchmarks.md`](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`](docs/widths.md).

## Compile-time literals

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

```rust
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`](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`](docs/features.md) for the full reference and
common configurations.

---

## Documentation

In-depth usage guides live in [`docs/`](docs/README.md):

- [Getting started](docs/getting-started.md) — constructing values, arithmetic, formatting, parsing.
- [The width family](docs/widths.md) — `D9` … `D307`, scale ranges, the `Decimal` trait.
- [Conversions](docs/conversions.md) — integers, floats, cross-width widening / narrowing.
- [Rounding](docs/rounding.md) — `RoundingMode`, `rescale`, the `rounding-*` features.
- [Strict mode](docs/strict-mode.md) — integer-only transcendentals.
- [The `d38!` macro](docs/macros.md) — compile-time decimal literals.
- [Cargo features](docs/features.md) — every feature flag.
- [Benchmarks](docs/benchmarks.md) — 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:

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