Skip to main content

core.text.numeric.decimal

Decimal is a foundational stdlib type for use cases where binary float (Float = f64) is unsuitable: monetary amounts, financial calculations, scientific instrument readings, and PostgreSQL NUMERIC parameter binding. Values are represented exactly as

value = coefficient × 10^(-scale)

The implementation is pure stdlib — no new AST nodes, no new compiler intrinsics. Every operation is checked against overflow and surfaces a precise DecimalError variant when the representation cannot hold the result.

When to use Decimal​

WorkloadUseReason
Money (USD, EUR, JPY, …)DecimalFloat rounding produces real money loss at scale.
PG NUMERIC parameter bindingDecimalBinary encode now bridges through encode_numeric_from_decimal.
Scientific readings with known scaleDecimalExact preservation of the instrument's resolution.
Statistics, fast loops, MLFloatDecimal is per-op slower; reach for it only when correctness needs it.
Sub-nanosecond timestampsInt nsTime should not round.

Type surface​

public type Decimal is {
coefficient: Int, // signed i64; sign of value tracks sign of coefficient
scale: Int, // 0 ≤ scale ≤ 18
};

public type DecimalError is
ParseEmpty
| ParseInvalidChar { byte_offset: Int, byte: Int }
| ParseInvalidShape { reason: Text }
| ScaleOutOfRange { scale: Int }
| Overflow { op: Text }
| DivByZero;

public type RoundingMode is
HalfEven // banker's rounding (IEEE 754 default; eliminates +0.5 bias)
| HalfUp // halves away from zero
| HalfDown // halves towards zero
| Truncate; // unconditional truncate towards zero

MAX_SCALE = 18 — the largest scale that fits inside an i64 coefficient with at least one significant digit. Construction beyond this surfaces ScaleOutOfRange { scale }.

Constructors​

implement Decimal {
public fn zero() -> Decimal;
public fn one() -> Decimal;
public fn from_int(n: Int) -> Decimal;
public fn from_parts(c: Int, s: Int) -> Result<Decimal, DecimalError>;
}

zero and one are canonical: (coefficient: 0, scale: 0) and (coefficient: 1, scale: 0). from_int cannot fail. from_parts rejects scale ∉ [0, 18].

Predicates​

public fn is_zero(&self) -> Bool;
public fn is_negative(&self) -> Bool;
public fn is_positive(&self) -> Bool;
public fn abs(&self) -> Decimal;
public fn neg(&self) -> Decimal;

Arithmetic​

public fn add(&self, other: &Decimal) -> Result<Decimal, DecimalError>;
public fn sub(&self, other: &Decimal) -> Result<Decimal, DecimalError>;
public fn mul(&self, other: &Decimal) -> Result<Decimal, DecimalError>;
public fn div(
&self,
other: &Decimal,
precision: Int,
mode: RoundingMode,
) -> Result<Decimal, DecimalError>;
public fn div_round(&self, other: &Decimal, precision: Int) -> Result<Decimal, DecimalError>;
public fn div_trunc(&self, other: &Decimal) -> Result<Decimal, DecimalError>;

add / sub align scales via the 19-entry POW10 table, then add or subtract coefficients with checked_add / checked_sub. mul uses i128 intermediate for the coefficient product before re-narrowing.

div runs textbook long division on the i64 coefficients, iterating precision + 1 long-division steps (the +1 is the rounding digit), tracks a sticky bit through the post-loop remainder, and applies the rounding policy on the (round_digit, sticky) tuple. Division is sign-aware: (-7) / 2 HalfEven yields -3.5.

let a = Decimal.from_parts(50, 1)?; // 5.0
let b = Decimal.from_parts(20, 1)?; // 2.0
let q = a.div(&b, 2, RoundingMode.HalfEven)?;
// q = Decimal { coefficient: 250, scale: 2 } → "2.50"

Rounding mode semantics​

For the canonical 5/2 = 2.5 half-tie:

ModeResultNotes
HalfEven2last representable digit even (2 even, 3 odd → 2)
HalfUp3rounds halves away from zero
HalfDown2rounds halves towards zero
Truncate2discards round digit

HalfEven is the default for div_round because it eliminates the systematic +0.5 bias that plain HalfUp introduces over many operations.

Comparison​

public fn compare(&self, other: &Decimal) -> Ordering;
public fn eq(&self, other: &Decimal) -> Bool;
public fn lt(&self, other: &Decimal) -> Bool;
public fn gt(&self, other: &Decimal) -> Bool;
public fn le(&self, other: &Decimal) -> Bool;
public fn ge(&self, other: &Decimal) -> Bool;

compare is sign-fast-path: differing signs decide ordering without touching coefficients. Same-sign values align scales and compare coefficients directly. In the degenerate case where scale-alignment itself would overflow, compare falls back to comparing the rendered text — slow but always correct.

Parse and render​

public fn parse_decimal(text: &Text) -> Result<Decimal, DecimalError>;
public fn to_text(&self) -> Text;

parse_decimal accepts the canonical decimal grammar:

decimal = [sign] digit+ ['.' digit+]
sign = '+' | '-'

No scientific notation in V0 — exponential parsing is a V1 follow-up. Trailing zeros in the fractional part are preserved: parse_decimal("1.50") produces (coefficient: 150, scale: 2), to_text emits "1.50".

to_text is byte-exact relative to the canonical text format. Round-trip property: parse_decimal(d.to_text()) = Ok(d) for every well-formed Decimal d.

Integration with PostgreSQL NUMERIC​

The PG wire codec at core.db.postgres.codec exposes two encoders that both bridge through Decimal:

// Integer fast-path (no Decimal allocation, V0).
public fn encode_numeric_from_int(
buf: &mut WireBuf,
arena: &Arena,
n: Int,
) -> Bool;

// Decimal-backed encoder (V1; non-integer values).
public fn encode_numeric_from_decimal(
buf: &mut WireBuf,
arena: &Arena,
d: &Decimal,
) -> Bool;

// Convenience wrapper: Text → Decimal → encode.
public fn encode_numeric_from_text(
buf: &mut WireBuf,
arena: &Arena,
s: &Text,
) -> Bool;

Before V1, non-integer NUMERIC parameter binding fell back to FmtText mode. After V1 every NUMERIC binding takes the binary path with full PG-side fidelity.

V0 boundaries and V1 follow-ups​

SurfaceV0V1 plan
Coefficient precisionInt (i64, ~18 sig digits)BigInt coefficient
Scale range[0, 18]Tracked together with BigInt
Scientific-notation parseout of scope1.5e3 → Decimal
Square root, transcendentalsout of scopeNeeds extended-precision intermediate
Divisionshipped (HalfEven default)—
Banker's roundingshipped (RoundingMode.HalfEven)—

Testing​

The exhaustive functional test fixture lives at vcs/specs/L2-standard/text/numeric_decimal.vr. It exercises:

  • Every constructor + every error path.
  • Add / sub / mul scale alignment + overflow detection.
  • All four rounding modes on the canonical 5/2 half-tie.
  • Sign propagation including i64::MIN saturation.
  • Round-trip parse / render fidelity including trailing-zero preservation.

Cross-references​