Skip to content

Cross-scale operations

The crate's same-width same-SCALE operators (+, -, *, /, %) are the fast path: they're const fn for the narrow tiers and compile down to inlined integer arithmetic with no rescaling step. For everything else - mixing SCALEs, mixing widths, comparing across types without an explicit .widen() - the crate ships a cross-scale operations surface in two layers.

Layer API shape Toolchain Notes
1 (stable) D{N}<SCALE>::{op}_of(a, b) on every width stable Target width and SCALE chosen by the caller via the receiver type.
2 (nightly) decimal_scaled::cross::{op}(a, b) free functions nightly Output type auto-inferred (max(SCALE_a, SCALE_b)) via generic_const_exprs.

Both layers preserve the same 0.5 ULP correctness contract as the same-width same-SCALE operators - the result is exact at the target type's last representable place. The only rounding step is the rescale of inputs to the target SCALE; that step uses the crate's default rounding mode (HalfToEven unless a rounding-* feature overrides it) or a caller-provided RoundingMode via the matching *_with(mode) sibling.

Layer 1 - stable, explicit target

The receiver type names the destination width and SCALE; the operands may be any width less-than-or-equal to it and any SCALE.

use decimal_scaled::{D18s4, D18s6, D38, D38s12};

let a = D18s4::try_from(5i64).unwrap();          // D18<4>
let b = D18s6::try_from(7i64).unwrap();          // D18<6>

// Target = D38<12>. Operands widen to Int<2>, rescale to SCALE=12,
// then multiply at the same width / scale.
let product: D38s12 = D38s12::mul_of(a, b);
assert_eq!(product, D38s12::try_from(35i64).unwrap());

The same shape is available for every op:

use decimal_scaled::{D38, D38s6, D38s12};

let x: D38s6 = D38::<6>::try_from(20i64).unwrap();
let y: D38s12 = D38::<12>::try_from(3i64).unwrap();

let sum:  D38<10> = D38::<10>::add_of(x, y);   // 23
let diff: D38<10> = D38::<10>::sub_of(x, y);   // 17
let prod: D38<10> = D38::<10>::mul_of(x, y);   // 60
let quot: D38<10> = D38::<10>::div_of(x, y);   // 6 (rem 2)
let rem:  D38<10> = D38::<10>::rem_of(x, y);   // 2

Explicit rounding

Each constructor has a _with(mode) sibling that takes an explicit RoundingMode:

use decimal_scaled::{D38, Int, RoundingMode};

let a: D38<1> = D38::<1>::from_bits(Int::<2>::from(15i64));  // 1.5
let b: D38<0> = D38::<0>::try_from(1i64).unwrap();                        // 1

let trunc = D38::<0>::mul_of_with(a, b, RoundingMode::Trunc);
assert_eq!(trunc.to_bits(), 1i128);

let away  = D38::<0>::mul_of_with(a, b, RoundingMode::HalfAwayFromZero);
assert_eq!(away.to_bits(), 2i128);

Max / min / clamp

max_of, min_of, and clamp_of accept any-width any-SCALE operands and rescale the winner into the destination type:

use decimal_scaled::{D18s4, D18s6, D18s9, D38s12};

let a = D18s6::try_from(3i64).unwrap();
let b = D18s9::try_from(2i64).unwrap();
let m: D38s12 = D38s12::max_of(a, b);
assert_eq!(m, D38s12::try_from(3i64).unwrap());

let v  = D38s12::try_from(15i64).unwrap();
let lo = D18s4::try_from(0i64).unwrap();
let hi = D18s9::try_from(10i64).unwrap();
let c: D38s12 = D38s12::clamp_of(v, lo, hi);
assert_eq!(c, D38s12::try_from(10i64).unwrap());

Comparators

cmp_of and its boolean friends (eq_of, ne_of, lt_of, le_of, gt_of, ge_of) compare a decimal against any narrower-or-equal-width value at any SCALE. Both sides UP-rescale to the higher SCALE (lossless) before the storage Ord is invoked:

use decimal_scaled::{D18s6, D38s12};

let a = D38s12::try_from(5i64).unwrap();
let b = D18s6::try_from(5i64).unwrap();

assert!(a.eq_of(b));
assert_eq!(a.cmp_of(b), std::cmp::Ordering::Equal);

Cross-width == / < operator overloads (same SCALE)

For the common case of comparing across widths at the same SCALE, the operator overloads work directly:

use decimal_scaled::{D18, D38};

let small: D18<12> = D18::<12>::try_from(5i64).unwrap();
let big:   D38<12> = D38::<12>::try_from(5i64).unwrap();
assert!(small == big);   // works without .widen()
assert!(big >= small);

(Cross-SCALE operator overloads are nightly-only - they require generic_const_exprs to compute the common-scale type at the impl site.)

Layer 2 - nightly, auto-inferred output

With the cross-scale-ops feature enabled (nightly required), a cross free-function module infers the output SCALE from the operands:

# Cargo.toml
[dependencies]
decimal-scaled = { version = "0.5", features = ["cross-scale-ops"] }
#![feature(generic_const_exprs)]
use decimal_scaled::{D38, cross};

let a: D38<6>  = D38::<6>::try_from(7i64).unwrap();
let b: D38<12> = D38::<12>::try_from(11i64).unwrap();
let c = cross::mul(a, b);     // type: D38<12>, value: 77

The output SCALE is max(SCALE_a, SCALE_b) and the output width is the operands' shared width (cross-width auto-inference would need a type-level WiderOf chain on top of generic_const_exprs; in practice it stresses the incomplete-feature corners enough that the stable Layer-1 form is the recommended path for cross-width work).

Surface: cross::mul, cross::add, cross::sub, cross::div, cross::rem, plus the cross::max_const(a, b) -> u32 const fn that backs the generic clauses (re-exported so user code can build on it).

0.5 ULP guarantee

Every cross-scale op runs as: widen both operands → rescale to the common precision → execute the same-width same-SCALE operator. The rescale step is the only place rounding occurs, and it follows exactly the same rule as the standalone rescale_with(mode). The arithmetic step inherits the same-width operator's 0.5 ULP contract.

For max_of / min_of / cmp_of the comparison runs at the higher of the two operand SCALEs - both sides UP-rescale, which is exact - so the comparison itself never loses precision, only the final rescale to the destination type does (and only when the destination SCALE is narrower than the operand SCALE).