Browse documentation
Language

Decimal arithmetic

Preserve exact admitted decimal inputs with 34 significant digits, explicit rounding, typed arrays, CSV columns and exact parallel values.

API reference

Decimal is an immutable, finite base ten scalar. Use it when decimal input values must be represented exactly within 34 significant digits. D is a concise constructor alias; neither spelling requires an import.

aner
print(D(0.1) + D(0.2) == D(0.3))       // true
print(D("0.1") + D("0.2") == D("0.3")) // true
let amount: Decimal = Decimal("12.30")
print(amount + 2)                       // 14.30

Decimal arithmetic and approximate comparison serve different purposes. Decimal preserves admitted decimal inputs. ~== compares binary numerical results under an error margin. Neither eliminates finite precision rounding or measurement uncertainty.

Exact construction

FormMeaning
D(0.1) or Decimal(0.1)Read a direct decimal source literal before conversion to binary floating point.
D("0.1") or Decimal(text)Parse exact decimal text at runtime.
D(integer)Convert any supported integer exactly.
D(decimal)Preserve an existing Decimal value.

Direct numeric literal forms accept an optional minus, exponent notation and the usual source separators, for example D(-1_000.25) or D(1e-1000). Decimal literal construction is also supported in parameter defaults. The type of an ordinary unwrapped 0.1 remains Float64.

D(value) rejects Float32 and Float64 variables and arithmetic expressions. In D(0.1 + 0.2), the addition would already be a binary floating calculation; a constructor cannot recover the original decimal intent. Construct the operands first: D(0.1) + D(0.2). A parenthesized floating literal such as D((0.1)) is an ordinary floating expression rather than the direct decimal literal form.

Runtime Strings contain decimal digits, an optional sign, decimal point and exponent. Whitespace, numeric separators, embedded NUL, NaN and infinity are rejected. Input text is bounded to 4096 bytes. Construction must preserve the numerical value exactly: input requiring more than 34 significant digits or outside the admitted range fails rather than silently rounding it. Redundant trailing zeros may be reduced without changing the value.

Arithmetic and rounding

+, -, *, / and unary - return Decimal when applied to Decimal values. A concrete integer can promote exactly to Decimal in arithmetic, comparison, an annotated binding or a typed parameter/result. Decimal division returns Decimal; the existing integer/binary-float division rules are unchanged.

Each arithmetic operation uses a fresh fixed context with 34 significant decimal digits and nearest rounding with ties to even. There is no mutable global, thread local or caller inherited decimal context. A repeating quotient still rounds:

aner
print(D(1) / D(3)) // 0.3333333333333333333333333333333333

For a chosen number of decimal places, round once at the intended boundary:

aner
print(D("2.345").round(places: 2))                  // 2.34
print(D("2.345").round(places: 2, mode: "half_up")) // 2.35
print(Decimal.round(D("123.45"), places: -1))      // 1.2e+2

The original value remains unchanged. places is an Int64 in [-6144, 6176]; a negative value rounds to a power of ten. The rounded result must still fit the Decimal format. Named arguments evaluate once in source order.

ModeRule
"half_even"Nearest; an exact halfway case selects the even retained digit. Default.
"half_up"Nearest; an exact halfway case rounds away from zero.
"toward_zero"Discard the fractional part at the requested scale.
"floor"Round toward negative infinity.
"ceil"Round toward positive infinity.

Precision and decimal places are different. Arithmetic has 34 significant digits; .round(places: 2) selects two places after the decimal point. Rounding an already rounded quotient to a second scale can differ from directly rounding its exact mathematical value. Choose the operation sequence and error budget deliberately.

Comparison, conversion and storage

==, !=, <, <=, > and >= compare Decimal values numerically. D("1.0") == D("1.00") is true, and signed zeros compare equal. Dictionary/Set keys use the same equality and canonical hashing within the Decimal type. Concrete type tags remain distinct in heterogeneous collections: Decimal one and Int64 one are separate keys.

Decimal does not mix implicitly with Float32, Float64, Number, Any or physical quantities. Typed Decimal arrays use their own explicit dtype; mixed array dtypes do not promote. Number retains its existing integer/binary-float contract. An integer valued Number can be explicitly passed to D; a floating valued Number fails. Cast an Any to its concrete supported type first.

Float64(decimal) explicitly permits binary rounding, with finite overflow checks; a sufficiently tiny value may round to zero. Integer constructors truncate toward zero and check the destination range. Direct Decimal to Float32 conversion is unsupported; do not assume that routing through Float64 gives one correctly rounded Float32 conversion.

Decimal is supported in functions, class fields, generic classes, Optional/Result values and heterogeneous List/Dictionary/Set collections. type, type_of and dtype report Decimal. print and String retain decimal text, and Development inspection stores the value as exact text rather than a binary JSON number.

Array<Decimal>, DynamicArray<Decimal> and NDArray<Decimal> store Decimal values without binary conversion. Concrete integer element arguments promote exactly; binary floating values are rejected. Construct Decimal from original text or direct source literals before storage. The containers retain their existing mutable buffer ownership rules even though each Decimal value is immutable.

aner
let values = DynamicArray<Decimal>(capacity: 2)
values.append(D("0.1"))
values.append(D("0.2"))
print(values.sum() == D("0.3"))                 // true
print(type(values.mean()))                     // Decimal
let matrix = values.reshape(List(1, 2))
print(matrix.sum() == D("0.3"))                 // true

Arithmetic and reductions use the same 34 digit half even policy as scalars. Sum, dot, cumulative sums, extrema and arithmetic results retain Decimal; mean() also returns Decimal. NDArray supports same dtype broadcasting and rank two matrix products. Decimal variance and standard deviation are unavailable. Repeating division and long accumulations can round; Decimal does not provide unlimited precision. See Array, DynamicArray and NDArray.

Integer arrays can convert losslessly with .astype("Decimal"); Decimal arrays can use the same target for an independent exact copy. Binary floating arrays cannot convert to Decimal because the original decimal input is unavailable.

.to_float() or .astype("Float64") makes an independent binary array and explicitly permits rounding and tiny values becoming zero; finite overflow fails. Decimal .astype("Float32"), direct Tensor conversion and accelerators remain unsupported. Native compilation supports Decimal scalars, helper functions and the Decimal Array/DynamicArray/NDArray methods described above, including checked mutation, reshaping, broadcasting and matrix multiplication. Unsupported operations fail before compilation with a source location; there is no interpreter fallback.

Exact parallel values

The existing task syntax supports Decimal directly. Supply scalar Decimal values in parameters: List(...), use .snapshot() for immutable FrozenArray<Decimal> inputs, and declare output: OutputRegion<Decimal> as the fourth task parameter. The task returns Unit and parallel.repeat produces immutable OutputResults<Decimal>.

aner
import aner.parallel
fn add(parameters: List, iteration: Int64, seed: Int64,
       output: OutputRegion<Decimal>) -> Unit {
    let left = cast<Decimal>(parameters.get(0))
    output[0] = left + D(0.2)
}
let results = parallel.repeat(2, add, List(D("0.1000")),
    output_shape: List(1), execution: parallel.local(1))
print(results[0, 0] == D(0.3))
print(String(results[0, 0])) // 0.3000

Local copies and versioned process transport preserve the Decimal coefficient, exponent, scale and signed zero. Worker counts and transport do not change the fixed arithmetic policy. Packed output and wire payloads use 24 bytes per Decimal cell; a frozen array's owned immutable handles use the separately accounted array storage. Frozen Decimal imports decode into owned storage; they do not claim shared page numerical access. Completed Decimal outputs use packed immutable storage and may use the typed output system's shared or resident paths.

Native compilation admits a flat parameter List containing supported scalar types, Decimal and FrozenArray. Nested Lists and passing OutputResults directly as compiled parameters remain unsupported. The interpreted route retains its existing broader checked input graphs and completed result reuse. The three parameter SimulationResults overload retains Float64 storage and rejects Decimal results; use the four parameter typed region for an exact result.

Run the complete Decimal task example interpreted or compiled. The native task contract and typed output guide describe ownership, coverage, limits and worker compatibility. Native artifacts use native-subset-v4 and ANERNATIVE/3; rebuild older executables and workers with the matching SDK.

Use the remote Decimal guide to configure distinct worker machines and verify scalar parameters, frozen arrays and typed results with exact text oracles. The recorded macOS arm64 to Linux x86_64 run passed all 1,443 comparisons, including 481 remote Decimal results. Local process tests and a successful admission handshake remain separate from cross machine result verification.

Exact CSV columns

Declare Decimal explicitly; automatic CSV discovery keeps its existing Int64/Float64/String recommendations.

aner
import aner.data
let schema = data.schema().column("price", "Decimal", false)
let table = data.scan_csv("prices.csv", schema, "NA")
    .collect(max_rows: 0, max_bytes: 1048576, batch_rows: 256)
let prices: Array<Decimal> = table.decimal_values("price")
print(prices.sum())
print(table.decimal(0, "price"))

The loader validates each present field exactly against Decimal precision and range. Collection, automatic disk storage and CSV export preserve decimal text without converting through Float64. decimal(row, column) rejects missing cells; decimal_values(column) rejects a column containing any missing observation. Use an explicit missing data filter or fill_missing(column, "0.00") before extraction. Decimal conditions accept Decimal and Int64 operands and reject Float64 mixing. Float64 report means and direct Tensor/numerical exports reject Decimal; extract a Decimal array for arithmetic or explicitly call .to_float() when rounding is intended. See the CSV example and data reference.

Range and failures

The precision and exponent bounds follow decimal128: at most 34 significant digits, largest finite magnitude 9.999999999999999999999999999999999e6144, and smallest positive subnormal 1e-6176. This is a numerical contract; it does not specify a 16 byte in memory layout for Aner values.

Only finite Decimal values are admitted. Division by zero, invalid text, inexact construction, overflow, and a nonzero arithmetic result that underflows to zero fail with a source located R4201/E_DECIMAL_* diagnostic. Invalid direct source literals fail during checking/parsing. Explicit binary/integer conversion failures use R1003. Representable finite subnormal results remain valid. Array operations report Decimal arithmetic failures under R3001; data ingestion/access reports R2601.

Native build receipts and --aner-native-info record the Decimal precision, rounding mode, numerical range and backend version under decimal. Generated native programs use the same numerical helper as interpreted execution. Development inspection preserves each observed Decimal as exact text, so inspecting a value does not round it through a binary number.

Run the complete Decimal example:

sh
aner check examples/decimal_basic.aner
aner run examples/decimal_basic.aner
Aner documentation · Reference and guides