std::decimal
Exact decimal arithmetic, for the numbers that are counted rather than measured.
docs/positioning.md opens by saying Khora is well suited to financial
reconciliation, and until this module there was no way to write ten pence.
Float is IEEE and the numbers design note explains at length why it
implements neither Eq nor Ord: 0.1 + 0.2 is not 0.3 and never was.
A ledger cannot be built on a type whose equality is a trap.
What a Decimal is
Section titled “What a Decimal is”A scaled integer: a significand counted in steps of 10^-scale.
Decimal::scaled(1, 2) is one hundredth. That is the representation SQL
NUMERIC, Java’s BigDecimal and .NET’s decimal all approximate, and it
is exact for anything written in decimal — which is what money is.
A hundred and twenty-eight bits of significand, carried as two Int
fields because Khora has no 128-bit integer and adding one to hold a library
type would be a large language change for a small gain. The halves are never
arithmetic here: every operation on them happens in the runtime, where the
significand is one number.
Sixty-four was tried first, on the argument that eighteen significant digits
is every currency amount anybody transacts. True about amounts and false
about arithmetic on them. add brings both operands to the larger scale
before adding, so a hundred million against a rate to twelve places needed
1e10 × 10^10 against a ceiling of 9.22e18 and stopped the program. That
is unremarkable finance. mul is worse, because it adds the scales, which
is what makes it exact.
A hundred and twenty-eight covers that, NUMERIC as real schemas declare
it, and an eighteen-decimal token balance — where sixty-four bits ran out at
9.2 whole units.
What it does when it cannot answer
Section titled “What it does when it cannot answer”It stops the program, like every other number in Khora. numbers.md
§“Overflow traps, in every build” argues that case and none of the argument
changes here: a total that silently wrapped is worse than one that never
arrived. That covers add, sub, mul, at_scale and divide alike.
Division is not an exception to it. It takes a scale and a rounding because
a quotient usually has no exact decimal form, and answers None when the
divisor is zero because that is a thing data does — but an answer too large
for the significand stops the program, the same as anywhere else. It used to
saturate, which meant Decimal::divide(10d, 1d, 18, HalfEven) answered
9.223372036854775807: in range, printed to the right number of places, and
wrong. Somebody writing a reconciler found it in the first ten minutes.
The three that cannot stop the program are comparing, reading and
printing, and each was made not to. Comparing is a question about order
and needs no common scale to exist. Reading text that is not a number —
including a numeral too long to hold — is what of_string’s Option is
for. And show is string work, so it prints every scale a Decimal can
carry rather than the first eighteen.
API at a glance
Section titled “API at a glance”Methods: Decimal
Trait implementations: Eq for Decimal · Ord for Decimal · Show for Decimal
Decimal
Section titled “Decimal”pub type Decimal = { hi: Int, lo: Int, scale: Int,};A number counted in steps of 10^-scale.
hi and lo are one number in two halves, the upper and lower
sixty-four bits of a signed hundred-and-twenty-eight bit significand. They
are two fields because only scalars cross into the runtime — see
The ffi design note — and they are never taken apart anywhere else.
The fields are public because a decimal is its representation and hiding it
would only mean writing accessors for all three. scale is never negative:
the constructors clamp it, because a negative scale is a large number
spelled confusingly and every caller who wants one means a larger
significand.
Rounding
Section titled “Rounding”pub type Rounding = | HalfEven | HalfUp | Towards;How divide breaks a tie.
HalfEven
Section titled “HalfEven”| HalfEvenTo the nearer, and to the even one when exactly between.
The default, and the one every accounting standard names: rounding halves up biases a long column of figures upward, and over a ledger that bias is money.
HalfUp
Section titled “HalfUp”| HalfUpTo the nearer, and away from zero when exactly between. What most people mean by “round it”.
Towards
Section titled “Towards”| TowardsTowards zero, always. What a truncation is.
Methods
Section titled “Methods”Decimal
Section titled “Decimal”impl Decimalof_parts
Section titled “of_parts”pub fn of_parts(hi: Int, lo: Int, scale: Int) -> DecimalA significand given as its two halves, counted in steps of 10^-scale.
The total constructor, and the widest one: it is the only way to build
a Decimal whose significand does not fit an Int, which is why a 0.01d
literal desugars to this and why a decoder reading a 128-bit NUMERIC
column wants it. Both halves are already numbers, so it cannot fail.
A negative scale is refused by clamping to zero rather than by raising, since it can only arise from a caller that computed it.
of_int
Section titled “of_int”pub fn of_int(units: Int) -> DecimalA whole number.
scaled
Section titled “scaled”pub fn scaled(units: Int, scale: Int) -> Decimalunits counted in steps of 10^-scale.
The constructor for a significand that fits an Int, which is nearly all
of them; of_parts is the one for the rest.
pub fn zero() -> DecimalZero.
at_scale
Section titled “at_scale”pub fn at_scale(self, scale: Int) -> DecimalThe same number at a larger scale.
Exact, and stops the program if the significand cannot hold it — going
from two decimal places to thirty-eight is a multiplication by ten to the
thirty-sixth, and most numbers do not survive it. A smaller scale is
answered unchanged, because dropping places is rounding and rounded is
where a caller says how.
pub fn add(self, other: Decimal) -> DecimalThe sum. Exact, and stops the program if it does not fit.
Both operands come to the larger of their two scales first, which is the step that made sixty-four bits too narrow: the operands fit and their aligned forms did not.
pub fn sub(self, other: Decimal) -> DecimalThe difference. Exact, and stops the program if it does not fit.
pub fn mul(self, other: Decimal) -> DecimalThe product, exactly.
The scales add, which is what makes this exact and also what makes it
overflow sooner than people expect: two numbers with four decimal places
each give one with eight, and the significand has to hold all of them.
rounded afterwards is how a caller comes back to a sensible scale.
divide
Section titled “divide”pub fn divide(self, other: Decimal, scale: Int, how: Rounding) -> Option<Decimal>The quotient, to scale decimal places, rounded as asked.
The one operation that cannot be exact, so it is the one that makes the caller say what it wants. A quotient with no finite decimal form — one third — has to be rounded somewhere, and a library that picked silently would be picking on behalf of a ledger.
None when other is zero. Not a trap, because dividing by a quantity
that turned out to be zero is an ordinary thing for data to do, and
numbers.md reserves stopping the program for a bug.
Stops the program when the answer does not fit, which is a different
thing and gets the different treatment. There is no honest answer to give:
the nearest one is a different number, and handing back a different number
is the failure this whole type exists to prevent. rounded goes through
here, so it stops too.
rounded
Section titled “rounded”pub fn rounded(self, scale: Int, how: Rounding) -> DecimalThe same number at scale places, rounded as asked.
The way back from mul’s widening scales, and the way a total becomes a
figure somebody can be invoiced for.
negate
Section titled “negate”pub fn negate(self) -> DecimalThe number with the opposite sign.
Subtraction from zero rather than a negation of its own, because the two halves are not a number this language can negate — and because the one significand with no positive twin should stop the program rather than answer itself.
is_negative
Section titled “is_negative”pub fn is_negative(self) -> BoolWhether it is less than zero.
zero_at
Section titled “zero_at”pub fn zero_at(scale: Int) -> DecimalZero, written to scale places.
zero() is scale nought and prints as 0, which is wrong at the
bottom of a column of 1250.00s – and a total that starts from
Decimal::zero() and adds nothing keeps that scale all the way to the
report. This is the empty total for a column that has a shape.
pub fn abs(self) -> DecimalThe magnitude, keeping the scale.
Stops the program on the one significand with no positive twin, for the
reason negate does: there is no honest answer, and the alternative is
to return the negative number it was handed.
pub fn min(self, other: Decimal) -> DecimalThe smaller of two, by value rather than by representation.
So 1.0d and 1.00d are the same number and either may come back; the
left one does, which is the choice List::min makes for the same reason.
pub fn max(self, other: Decimal) -> DecimalThe larger of two, and the left one on a tie.
is_zero
Section titled “is_zero”pub fn is_zero(self) -> BoolWhether it is exactly zero.
At any scale: zero has both halves clear however many places it was written to.
truncated
Section titled “truncated”pub fn truncated(self) -> IntThe whole part, towards zero.
Stops the program if the whole part is wider than an Int, which is
the one place a Decimal has to become one and may not fit. Past
thirty-eight places there is no whole part to find and the answer is zero.
pub fn total(numbers: List<Decimal>) -> DecimalEvery one of them added up.
The column total, which every ledger writes and nobody should write
twice. add takes the larger of two scales, so a total of numbers
written to two places is written to two places, and one containing a
twelve-place rate carries twelve.
An empty list totals zero(), which is scale nought and prints as 0 –
under a column of 1250.00s that is the wrong shape, and
Decimal::total(rows).at_scale(2) is the fix. at_scale only ever
raises, so it cannot round a total that was already wider.
Stops the program if the sum does not fit, like every addition here.
of_string
Section titled “of_string”pub fn of_string(text: String) -> Option<Decimal>A decimal written out, or None if the text is not one.
Accepts a leading sign, digits, and at most one point. Rejects
everything else on purpose, including the exponent notation Float
takes: a number arriving as 1e-3 is a measurement that has been through
a float somewhere, and taking it here would launder that history.
The exact grammar, because “a leading sign” and “at most one point” each hide a case somebody meets:
+is a leading sign here.Int::of_stringrefuses one, and says why at some length – so the two disagree, and a program reading+5out of a configuration file gets a number from one andNonefrom the other. Neither behaviour is being changed here, because changing either is a compatibility decision about a published parser rather than a repair; what is fixed is that the disagreement was written down in neither place.- The point may be leading or trailing.
"5."is five and".5"is a half, both accepted.
A numeral too long for the significand is a rejection too, which is what
the Option is already there for. This is the function that reads numbers
out of files somebody else wrote, and one long cell must not kill the
process.
Trait implementations
Section titled “Trait implementations”Eq for Decimal
Section titled “Eq for Decimal”impl Eq for Decimalfn eq(self, other: Decimal) -> BoolBy value, not by representation. 1.0 and 1.00 are the same number
written twice, and a ledger that thought otherwise would report a
difference where there is none. This is the whole reason Float cannot
have an Eq and this can.
And it cannot stop the program. Bringing both operands to a common
scale, the way add does, would ask for a multiplication by 10^12 to
compare a hundred million against a rate to twelve places — which no
significand survives, so two perfectly legal numbers would halt a
reconciler. An equality that traps is a worse trap
than one that surprises, and this one is sold as the reason to prefer
Decimal at all.
Ord for Decimal
Section titled “Ord for Decimal”impl Ord for Decimalfn cmp(self, other: Decimal) -> OrderingThe same comparison, and the same reason it does not go through add’s
alignment.
Show for Decimal
Section titled “Show for Decimal”impl Show for Decimalfn show(self) -> StringEvery place the scale says, including trailing zeros.
Decimal::scaled(150, 2) shows as 1.50 and not 1.5, because the
scale is part of what was meant: a price to two places stays a price to
two places, and dropping the zero is how a total stops lining up in a
column.
Done as text rather than as arithmetic, which is what lets it print
every number a Decimal can hold. Splitting the significand with
units / 10^scale needs 10^scale to be a number, and past the widest
power it is not — so mul on two numbers with twenty places each produced
a value that was perfectly legal and stopped the program when printed.
Cutting a string at a position needs nothing to fit.
It also means the most negative significand prints. Negating it to take a magnitude would overflow, so the runtime writes the magnitude and the sign is a character here.
Khora 0.1.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource