Skip to main content

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}