rustledger_core/decimal.rs
1//! Decimal arithmetic with Python `decimal` scale semantics.
2//!
3//! `rust_decimal` and Python's `decimal` agree on the VALUE of a sum but not
4//! always on its SCALE, and BQL renders a naked decimal at its intrinsic scale
5//! (bean-query's `DecimalRenderer` pads for alignment only — it never
6//! quantizes). So a scale difference is a visible output difference.
7//!
8//! Python's rule for `+`/`-` is that an exact result carries
9//! `max(scale(a), scale(b))`:
10//!
11//! ```text
12//! Decimal("0.00") + Decimal("1") == Decimal("1.00")
13//! ```
14//!
15//! `rust_decimal` matches that for ordinary operands (`2.00 + 1 == 3.00`) but
16//! not when one side is ZERO: its addition returns the other operand
17//! unchanged, so the zero's scale is discarded and `0.00 + 1 == 1`.
18//!
19//! That makes an accumulation order-dependent, which is how it surfaced. A
20//! `SUM` over `-1, 1, -111.11, 111.11, -2, 2` passes through `0.00` at the
21//! fourth term; the `-2` that follows then resets the running total to scale
22//! 0 and the query prints `0` where bean-query prints `0.00`. Move the
23//! fractional pair last and the same multiset prints `0.00` — same value,
24//! same inputs, different rendering.
25
26use rust_decimal::Decimal;
27
28/// Add two decimals with Python `decimal`'s scale rule.
29///
30/// The value is `a + b` either way; this only restores the scale
31/// `rust_decimal` drops when an operand is zero (see the module docs). The
32/// result is padded UP to `max(scale(a), scale(b))` and never truncated, so
33/// it cannot lose significant digits.
34///
35/// Rescaling is best-effort: `Decimal::rescale` is a no-op when the target
36/// scale would overflow the 96-bit mantissa (a value near `Decimal::MAX` at
37/// high scale). Such a value cannot be represented at that scale at all, so
38/// there is nothing to restore and the unrescaled sum is the best available
39/// answer — the same one we returned before this function existed.
40#[must_use]
41pub fn add_python_scale(a: Decimal, b: Decimal) -> Decimal {
42 let mut sum = a + b;
43 let target = a.scale().max(b.scale());
44 if sum.scale() < target {
45 sum.rescale(target);
46 }
47 sum
48}
49
50/// Subtract with Python `decimal`'s scale rule — see [`add_python_scale`].
51#[must_use]
52pub fn sub_python_scale(a: Decimal, b: Decimal) -> Decimal {
53 let mut diff = a - b;
54 let target = a.scale().max(b.scale());
55 if diff.scale() < target {
56 diff.rescale(target);
57 }
58 diff
59}
60
61/// Overflow-checked [`add_python_scale`].
62///
63/// `None` on value-range overflow, matching `Decimal::checked_add` — BQL maps
64/// that to NULL rather than panicking, so the checked form is what the
65/// expression evaluator needs.
66#[must_use]
67pub fn checked_add_python_scale(a: Decimal, b: Decimal) -> Option<Decimal> {
68 let mut sum = a.checked_add(b)?;
69 let target = a.scale().max(b.scale());
70 if sum.scale() < target {
71 sum.rescale(target);
72 }
73 Some(sum)
74}
75
76/// Overflow-checked [`sub_python_scale`] — see [`checked_add_python_scale`].
77#[must_use]
78pub fn checked_sub_python_scale(a: Decimal, b: Decimal) -> Option<Decimal> {
79 let mut diff = a.checked_sub(b)?;
80 let target = a.scale().max(b.scale());
81 if diff.scale() < target {
82 diff.rescale(target);
83 }
84 Some(diff)
85}
86
87/// Divide with Python `decimal`'s scale rule.
88///
89/// Python defines an *ideal exponent* for division: `exp(a) - exp(b)`, i.e.
90/// an ideal SCALE of `scale(a) - scale(b)`. An exact quotient is reduced
91/// toward that scale but never below the scale exactness requires:
92///
93/// ```text
94/// 0.00 / 4 -> 0.00 ideal 2, exact needs 0 -> 2
95/// 7 / 2 -> 3.5 ideal 0, exact needs 1 -> 1
96/// 1.00 / 2 -> 0.50 ideal 2, exact needs 1 -> 2
97/// 1.000 / 8 -> 0.125 ideal 3, exact needs 3 -> 3
98/// ```
99///
100/// `rust_decimal` misses this in BOTH directions, so a pad-only fix would be
101/// half a fix:
102///
103/// * **Under**, on a zero dividend — the same zero shortcut behind
104/// [`add_python_scale`]. `0.00 / 4` gives `0`, dropping the scale.
105/// * **Over**, on an exact quotient — `7 / 2` gives `3.50`, one trailing
106/// zero more than the ideal exponent allows.
107///
108/// So the quotient is stripped to its minimal form and then padded up to the
109/// ideal scale; the two steps together land on Python's answer from either
110/// side. An inexact quotient (`1 / 3`) has no trailing zeros to strip and a
111/// scale far past the ideal, so both steps are no-ops and it is returned as
112/// `rust_decimal` computed it.
113///
114/// Returns `None` on divide-by-zero or overflow, matching
115/// `Decimal::checked_div` — BQL maps that to NULL rather than panicking.
116#[must_use]
117pub fn checked_div_python_scale(a: Decimal, b: Decimal) -> Option<Decimal> {
118 let quotient = a.checked_div(b)?;
119
120 // `scale()` is u32; the ideal can be negative (a coarser dividend than
121 // divisor), which simply means "no padding required".
122 let ideal_scale = i64::from(a.scale()) - i64::from(b.scale());
123
124 // Minimal form first: this is what removes the EXTRA trailing zero in
125 // `7 / 2 -> 3.50`. `normalize` never loses value.
126 let mut result = quotient.normalize();
127 let target = ideal_scale.max(i64::from(result.scale()));
128
129 // `rescale` takes u32 and is a no-op past the mantissa's capacity; the
130 // clamp keeps a pathological ideal from wrapping on the cast.
131 if let Ok(target) = u32::try_from(target)
132 && result.scale() < target
133 {
134 result.rescale(target);
135 }
136 Some(result)
137}
138
139/// Negate with Python `decimal`'s sign rule for zero.
140///
141/// Python defines unary minus as `0 - x`, so negating ANY zero yields a
142/// POSITIVE zero:
143///
144/// ```text
145/// -Decimal("0.00") == 0.00
146/// -Decimal("-0.00") == 0.00
147/// ```
148///
149/// `rust_decimal`'s `Neg` flips the sign bit unconditionally, so `-dec!(0.00)`
150/// is a signed zero that renders `-0.00`. beancount normalizes it away — a
151/// ledger posting written `-0.00 CNY` loads as `Decimal('0.00')` there, and
152/// bean-query prints `0.00` — while rledger's parser applies the sign with a
153/// bare `-n` and kept the `-0.00` all the way to the output.
154///
155/// A zero has no sign in bookkeeping; this is the canonical negation for any
156/// site that flips a parsed or computed amount.
157#[must_use]
158pub fn negate_python(number: Decimal) -> Decimal {
159 if number.is_zero() {
160 // `abs` clears the sign bit and keeps the scale — `normalize` would
161 // strip the scale too, and `+ ZERO` collapses it to `0`.
162 number.abs()
163 } else {
164 -number
165 }
166}
167
168/// Round to `dp` decimal places with Python `decimal`'s sign rule for zero.
169///
170/// Python's `quantize` keeps the sign when a small negative rounds away:
171///
172/// ```text
173/// Decimal("-0.00495").quantize(Decimal("0.01")) == -0.00
174/// Decimal("-0.0000001").quantize(Decimal("0.01")) == -0.00
175/// ```
176///
177/// `rust_decimal`'s `round_dp` returns an UNSIGNED zero there, which loses
178/// the only information the cell still carries — that the underlying balance
179/// is negative rather than exactly zero. bean-query renders `-0.00 USD` for a
180/// `-0.00495 USD` position; rledger rendered `0.00 USD` and read as flat.
181///
182/// The result is padded to exactly `dp` (see the caller's note on `round_dp`
183/// only ever reducing the scale).
184#[must_use]
185pub fn round_dp_python(number: Decimal, dp: u32) -> Decimal {
186 let mut rounded = number.round_dp(dp);
187 rounded.rescale(dp);
188 if rounded.is_zero() && number.is_sign_negative() {
189 // Re-apply the sign the rounding dropped. Negating a positive zero is
190 // exactly how a signed zero is constructed in `rust_decimal`.
191 return -rounded;
192 }
193 rounded
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use rust_decimal_macros::dec;
200 use std::str::FromStr;
201
202 /// The exact shape `rust_decimal` gets wrong: a zero operand's scale is
203 /// dropped. Both orders, since its zero shortcut applies to either side.
204 ///
205 /// Asserts on `to_string()`, NOT on `Decimal` equality. `==` compares
206 /// value and ignores scale — `dec!(1) == dec!(1.00)` — so an
207 /// `assert_eq!(add_python_scale(..), dec!(1.00))` here would pass against
208 /// a plain `a + b` and pin nothing. That is exactly what this test looked
209 /// like when Copilot caught it on #2046; the rendered form is the whole
210 /// subject of the divergence, so it is what gets asserted.
211 #[test]
212 fn a_zero_operand_keeps_its_scale() {
213 assert_eq!(add_python_scale(dec!(0.00), dec!(1)).to_string(), "1.00");
214 assert_eq!(add_python_scale(dec!(1), dec!(0.00)).to_string(), "1.00");
215 assert_eq!(sub_python_scale(dec!(0.00), dec!(1)).to_string(), "-1.00");
216 assert_eq!(sub_python_scale(dec!(1), dec!(0.00)).to_string(), "1.00");
217 }
218
219 /// The trap the test above avoids, pinned so it cannot silently return:
220 /// `Decimal`'s `==` cannot see a scale difference, and the raw operator
221 /// really does drop it.
222 #[test]
223 fn decimal_equality_cannot_see_the_bug_but_rendering_can() {
224 assert_eq!(dec!(1), dec!(1.00), "== ignores scale");
225 assert_eq!((dec!(0.00) + dec!(1)).to_string(), "1", "the bug itself");
226 assert_eq!(add_python_scale(dec!(0.00), dec!(1)).to_string(), "1.00");
227 }
228
229 /// Ordinary operands already behaved; the fix must not disturb them.
230 #[test]
231 fn non_zero_operands_are_unchanged() {
232 for (a, b, want) in [
233 (dec!(2.00), dec!(1), "3.00"),
234 (dec!(1), dec!(2.00), "3.00"),
235 (dec!(2.50), dec!(2.50), "5.00"),
236 (dec!(111.11), dec!(-111.11), "0.00"),
237 (dec!(1), dec!(2), "3"),
238 ] {
239 assert_eq!(add_python_scale(a, b).to_string(), want, "{a} + {b}");
240 }
241 }
242
243 /// Padding is upward only — a result that already carries more scale than
244 /// either operand (impossible for `+`, but the guard is what makes that
245 /// true) keeps it, and no significant digit is ever dropped.
246 #[test]
247 fn never_truncates() {
248 assert_eq!(add_python_scale(dec!(0.5), dec!(0.25)).to_string(), "0.75");
249 assert_eq!(
250 add_python_scale(dec!(0), dec!(1.2345)).to_string(),
251 "1.2345"
252 );
253 }
254
255 /// The running-total shape from the compat corpus: passing through zero
256 /// mid-accumulation must not reset the scale for the terms that follow.
257 #[test]
258 fn accumulating_through_zero_keeps_the_widest_scale() {
259 let terms = [
260 dec!(-1),
261 dec!(1),
262 dec!(-111.11),
263 dec!(111.11),
264 dec!(-2),
265 dec!(2),
266 ];
267
268 let mut python_like = Decimal::ZERO;
269 let mut naive = Decimal::ZERO;
270 for t in terms {
271 python_like = add_python_scale(python_like, t);
272 naive += t;
273 }
274
275 assert_eq!(python_like.to_string(), "0.00");
276 assert_eq!(naive.to_string(), "0", "pins the pre-fix behavior");
277 }
278
279 /// Division scale, against Python `decimal`'s answers verbatim.
280 ///
281 /// Every expectation below was produced by running the case through
282 /// `CPython`'s `decimal` (3.13) rather than reasoned out — the ideal-exponent
283 /// rule is easy to state and easy to get subtly wrong, and a table of
284 /// hand-derived expectations would just encode the same misreading twice.
285 ///
286 /// Asserts on `to_string()`: `Decimal`'s `==` ignores scale, which is the
287 /// entire subject here.
288 #[test]
289 fn division_matches_python_decimal_scale() {
290 // (dividend, divisor, python's rendering)
291 let cases = [
292 // Zero dividend — `rust_decimal` drops the scale (gives `0`).
293 ("0.00", "4", "0.00"),
294 ("0.000", "3", "0.000"),
295 ("0.0", "7", "0.0"),
296 ("0.00", "2.0", "0.0"),
297 ("0.00", "1", "0.00"),
298 ("-0.00", "3", "0.00"), // see the sign note below
299 // Zero with no scale to keep.
300 ("0", "4", "0"),
301 // Exact quotients — `rust_decimal` OVER-pads `7 / 2` to `3.50`.
302 ("7", "2", "3.5"),
303 ("5", "4", "1.25"),
304 ("1.0", "2.00", "0.5"),
305 // Exact quotients both already agree on.
306 ("1.00", "2", "0.50"),
307 ("3.00", "3", "1.00"),
308 ("1.000", "8", "0.125"),
309 ("10.00", "4", "2.50"),
310 ("2.50", "5", "0.50"),
311 ("100.00", "8", "12.50"),
312 ("12.345", "5", "2.469"),
313 // Inexact: no trailing zeros to strip, scale far past the ideal,
314 // so the rule leaves `rust_decimal`'s result alone.
315 ("1", "3", "0.3333333333333333333333333333"),
316 ];
317
318 for (a, b, want) in cases {
319 let a = Decimal::from_str(a).expect("dividend parses");
320 let b = Decimal::from_str(b).expect("divisor parses");
321 let got = checked_div_python_scale(a, b).expect("no overflow");
322 assert_eq!(got.to_string(), want, "{a} / {b}");
323 }
324 }
325
326 /// `from_str` drops a literal minus on zero, but the TYPE can hold a
327 /// signed zero — the two are different things, and #2049 conflated them.
328 ///
329 /// That commit asserted "the type has no signed zero" from this same
330 /// `from_str` evidence. It does: `-dec!(0.00)` renders `-0.00`, which is
331 /// exactly the value the parser used to archive for a `-0.00` literal.
332 /// The narrower true statement is pinned here instead, since the division
333 /// table above depends on it: a `-0.00` DIVIDEND reaching
334 /// `checked_div_python_scale` via `from_str` is already unsigned, so the
335 /// `-0.00` Python would print is unreachable by that route.
336 #[test]
337 fn from_str_drops_the_sign_on_zero_though_the_type_can_carry_one() {
338 let parsed = Decimal::from_str("-0.00").expect("parses");
339 assert_eq!(parsed.to_string(), "0.00", "from_str drops it");
340 assert!(!parsed.is_sign_negative());
341
342 // ...but the type carries one when constructed by negation.
343 assert_eq!((-Decimal::from_str("0.00").unwrap()).to_string(), "-0.00");
344
345 assert_eq!(
346 checked_div_python_scale(parsed, Decimal::from(3))
347 .expect("no overflow")
348 .to_string(),
349 "0.00",
350 );
351 }
352
353 /// Divide-by-zero is `None`, not a panic — BQL renders it NULL.
354 #[test]
355 fn division_by_zero_is_none() {
356 assert_eq!(
357 checked_div_python_scale(dec!(1.00), Decimal::ZERO),
358 None,
359 "div-by-zero must not panic",
360 );
361 }
362
363 /// Negating a zero must yield a POSITIVE zero, as Python does.
364 ///
365 /// `rust_decimal`'s `Neg` flips the sign bit unconditionally, so a bare
366 /// `-dec!(0.00)` renders `-0.00`. beancount loads a ledger posting written
367 /// `-0.00 CNY` as `Decimal('0.00')` and bean-query prints `0.00`, so the
368 /// signed zero was ours alone.
369 ///
370 /// Asserts on `to_string()` — `==` cannot see a sign on zero any more than
371 /// it can see scale (`dec!(0.00) == -dec!(0.00)`), so a value-level
372 /// assertion would pass against the bug.
373 #[test]
374 fn negating_a_zero_gives_an_unsigned_zero() {
375 assert_eq!((-dec!(0.00)).to_string(), "-0.00", "the bug itself");
376 assert_eq!(negate_python(dec!(0.00)).to_string(), "0.00");
377 assert_eq!(negate_python(-dec!(0.00)).to_string(), "0.00");
378 // Scale survives — `normalize()` would have flattened it to `0`.
379 assert_eq!(negate_python(dec!(0.0000)).to_string(), "0.0000");
380 // Non-zero negation is untouched.
381 assert_eq!(negate_python(dec!(1.25)).to_string(), "-1.25");
382 assert_eq!(negate_python(dec!(-1.25)).to_string(), "1.25");
383 }
384
385 /// Rounding a small negative to zero must KEEP the sign, as Python's
386 /// `quantize` does — the opposite direction from the negation rule above,
387 /// which is why one fix could not serve both.
388 ///
389 /// Expectations taken from `CPython`: `Decimal("-0.00495").quantize(
390 /// Decimal("0.01"))` is `-0.00`. bean-query renders `-0.00 USD` for such a
391 /// position; rledger rendered `0.00 USD`, which reads as an exactly flat
392 /// balance when it is not.
393 #[test]
394 fn rounding_a_small_negative_to_zero_keeps_the_sign() {
395 assert_eq!(dec!(-0.00495).round_dp(2).to_string(), "0.00", "the bug");
396
397 for (value, dp, want) in [
398 (dec!(-0.00495), 2, "-0.00"),
399 (dec!(-0.004), 2, "-0.00"),
400 (dec!(-0.0000001), 2, "-0.00"),
401 (dec!(0.00495), 2, "0.00"),
402 (dec!(-1.004), 2, "-1.00"),
403 (dec!(-0.00495), 5, "-0.00495"),
404 (dec!(1), 2, "1.00"),
405 ] {
406 assert_eq!(
407 round_dp_python(value, dp).to_string(),
408 want,
409 "{value} at {dp}dp",
410 );
411 }
412 }
413
414 /// The checked variants exist so BQL can map a value-range overflow to
415 /// NULL instead of panicking (`rust_decimal` panics on raw `+`). That
416 /// path had no test — the one behavior the checked form is FOR.
417 #[test]
418 fn checked_variants_return_none_on_overflow() {
419 assert_eq!(checked_add_python_scale(Decimal::MAX, Decimal::MAX), None);
420 assert_eq!(checked_sub_python_scale(Decimal::MIN, Decimal::MAX), None);
421
422 // And still compute the ordinary case, with the scale rule applied.
423 assert_eq!(
424 checked_add_python_scale(dec!(0.00), dec!(1))
425 .expect("no overflow")
426 .to_string(),
427 "1.00"
428 );
429 assert_eq!(
430 checked_sub_python_scale(dec!(1), dec!(0.00))
431 .expect("no overflow")
432 .to_string(),
433 "1.00"
434 );
435 }
436
437 /// The rescale step must never corrupt a value it cannot widen.
438 ///
439 /// A sum near `Decimal::MAX` has no room for extra fractional digits, so
440 /// the requested scale is unreachable. `Decimal::rescale` is documented
441 /// to leave the value alone in that case rather than truncating, and this
442 /// pins that we depend on it: if it ever became saturating or truncating,
443 /// the failure mode is a silently WRONG money value rather than a panic
444 /// or an error, which nothing else here would catch.
445 ///
446 /// Verified against the real type rather than assumed — `MAX.rescale(2)`
447 /// is a no-op returning `79228162514264337593543950335`.
448 #[test]
449 fn rescale_beyond_capacity_preserves_the_value() {
450 let near_max = Decimal::MAX - Decimal::ONE;
451 let sum = add_python_scale(near_max, dec!(0.00));
452
453 assert_eq!(
454 sum, near_max,
455 "a value too large to carry the target scale must keep its VALUE",
456 );
457 assert_eq!(sum.to_string(), near_max.to_string());
458 }
459}