Skip to content

Commit ad46b6c

Browse files
abnobdossAbanoub Dossclaude
authored
fix: preserve operand width in DecimalValue checked arithmetic (#7380)
## Summary Closes: #7022 `DecimalValue::checked_add/sub/mul/div` unconditionally upcast both operands to `i256` and returned `DecimalValue::I256`, producing unnecessarily wide scalars even when both inputs were narrow (e.g. `I32 + I32 → I256`). Operate at `max(self, other)` width instead, matching the pattern in `aggregate_fn/fns/sum/decimal.rs`. The old `checked_binary_op` helper was replaced with a local macro so each op dispatches with its own trait. **AI disclosure:** Analysis, implementation, and tests were done with Claude Code under my direction and review. ## API Changes No public API signature changes (verified via `./scripts/public-api.sh`). Overflow is now caught at the target width rather than silently widening (e.g. `I8(i8::MAX) + I8(1)` now returns `None` instead of `I256(128)`). **This felt like the most faithful reading of the issue, but I'd appreciate a sanity check that returning `None` on target-width overflow is the desired semantics rather than, say, promoting to the next wider variant.** No in-tree caller depends on the old behavior: `sum/mod.rs` pre-widens the accumulator by +10 precision, `decimal/scalar.rs::checked_binary_numeric` requires both operands to share the same width, and `sum/constant.rs` uses `I128` as the multiplier. ## Testing - Updated `test_decimal_value_checked_*` to assert the correct variant. - Added tests for variant preservation, mixed-width promotion, and overflow at the target width (including `i8::MIN / -1`). - All existing tests pass (212 decimal, 14 sum). --------- Signed-off-by: Abanoub Doss <abanoub.doss@gmail.com> Signed-off-by: Abanoub Doss <abnobdoss@proton.me> Signed-off-by: Claude <noreply@anthropic.com> Signed-off-by: abnobdoss <abanoubdoss@gmail.com> Co-authored-by: Abanoub Doss <abanoub.doss@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f170ce commit ad46b6c

2 files changed

Lines changed: 88 additions & 21 deletions

File tree

vortex-array/src/scalar/typed_view/decimal/dvalue.rs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,23 @@ use crate::dtype::NativeDecimalType;
1919
use crate::dtype::ToI256;
2020
use crate::dtype::i256;
2121
use crate::match_each_decimal_value;
22+
use crate::match_each_decimal_value_type;
23+
24+
/// Widens both operands to the larger of their two decimal types, then applies the checked op.
25+
macro_rules! checked_widening_binary_op {
26+
($self:expr, $other:expr, $op:path) => {{
27+
let target = $self.decimal_type().max($other.decimal_type());
28+
match_each_decimal_value_type!(target, |T| {
29+
let a: T = $self
30+
.cast()
31+
.vortex_expect("widening cast to wider decimal type must always succeed");
32+
let b: T = $other
33+
.cast()
34+
.vortex_expect("widening cast to wider decimal type must always succeed");
35+
Some(DecimalValue::from($op(&a, &b)?))
36+
})
37+
}};
38+
}
2239

2340
/// A decimal value that can be stored in various integer widths.
2441
///
@@ -134,36 +151,24 @@ impl DecimalValue {
134151
value_i256 > min_value && value_i256 < max_value
135152
}
136153

137-
/// Helper function to perform a checked binary operation on two decimal values.
138-
///
139-
/// Both values are upcast to i256 before the operation, and the result is returned as I256.
140-
fn checked_binary_op<F>(&self, other: &Self, op: F) -> Option<Self>
141-
where
142-
F: FnOnce(i256, i256) -> Option<i256>,
143-
{
144-
let self_upcast = self.as_i256();
145-
let other_upcast = other.as_i256();
146-
op(self_upcast, other_upcast).map(DecimalValue::I256)
147-
}
148-
149154
/// Checked addition. Returns `None` on overflow.
150155
pub fn checked_add(&self, other: &Self) -> Option<Self> {
151-
self.checked_binary_op(other, |a, b| a.checked_add(&b))
156+
checked_widening_binary_op!(self, other, CheckedAdd::checked_add)
152157
}
153158

154159
/// Checked subtraction. Returns `None` on overflow.
155160
pub fn checked_sub(&self, other: &Self) -> Option<Self> {
156-
self.checked_binary_op(other, |a, b| a.checked_sub(&b))
161+
checked_widening_binary_op!(self, other, CheckedSub::checked_sub)
157162
}
158163

159164
/// Checked multiplication. Returns `None` on overflow.
160165
pub fn checked_mul(&self, other: &Self) -> Option<Self> {
161-
self.checked_binary_op(other, |a, b| a.checked_mul(&b))
166+
checked_widening_binary_op!(self, other, CheckedMul::checked_mul)
162167
}
163168

164169
/// Checked division. Returns `None` on overflow or division by zero.
165170
pub fn checked_div(&self, other: &Self) -> Option<Self> {
166-
self.checked_binary_op(other, |a, b| a.checked_div(&b))
171+
checked_widening_binary_op!(self, other, CheckedDiv::checked_div)
167172
}
168173
}
169174

vortex-array/src/scalar/typed_view/decimal/tests.rs

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -992,31 +992,31 @@ fn test_decimal_value_checked_add() {
992992
let a = DecimalValue::I64(100);
993993
let b = DecimalValue::I64(200);
994994
let result = a.checked_add(&b).unwrap();
995-
assert_eq!(result, DecimalValue::I256(i256::from_i128(300)));
995+
assert_eq!(result, DecimalValue::I64(300));
996996
}
997997

998998
#[test]
999999
fn test_decimal_value_checked_sub() {
10001000
let a = DecimalValue::I64(500);
10011001
let b = DecimalValue::I64(200);
10021002
let result = a.checked_sub(&b).unwrap();
1003-
assert_eq!(result, DecimalValue::I256(i256::from_i128(300)));
1003+
assert_eq!(result, DecimalValue::I64(300));
10041004
}
10051005

10061006
#[test]
10071007
fn test_decimal_value_checked_mul() {
10081008
let a = DecimalValue::I32(50);
10091009
let b = DecimalValue::I32(10);
10101010
let result = a.checked_mul(&b).unwrap();
1011-
assert_eq!(result, DecimalValue::I256(i256::from_i128(500)));
1011+
assert_eq!(result, DecimalValue::I32(500));
10121012
}
10131013

10141014
#[test]
10151015
fn test_decimal_value_checked_div() {
10161016
let a = DecimalValue::I64(1000);
10171017
let b = DecimalValue::I64(10);
10181018
let result = a.checked_div(&b).unwrap();
1019-
assert_eq!(result, DecimalValue::I256(i256::from_i128(100)));
1019+
assert_eq!(result, DecimalValue::I64(100));
10201020
}
10211021

10221022
#[test]
@@ -1033,7 +1033,69 @@ fn test_decimal_value_mixed_types() {
10331033
let a = DecimalValue::I8(10);
10341034
let b = DecimalValue::I128(20);
10351035
let result = a.checked_add(&b).unwrap();
1036-
assert_eq!(result, DecimalValue::I256(i256::from_i128(30)));
1036+
assert_eq!(result, DecimalValue::I128(30));
1037+
}
1038+
1039+
#[test]
1040+
fn test_checked_ops_preserve_type() {
1041+
// Operations should return the wider of the two operand types, not unconditionally upcast to I256
1042+
let add = DecimalValue::I32(5)
1043+
.checked_add(&DecimalValue::I32(3))
1044+
.unwrap();
1045+
assert_eq!(add.decimal_type(), DecimalType::I32);
1046+
1047+
let sub = DecimalValue::I64(10)
1048+
.checked_sub(&DecimalValue::I64(3))
1049+
.unwrap();
1050+
assert_eq!(sub.decimal_type(), DecimalType::I64);
1051+
1052+
let mul = DecimalValue::I8(2)
1053+
.checked_mul(&DecimalValue::I8(3))
1054+
.unwrap();
1055+
assert_eq!(mul.decimal_type(), DecimalType::I8);
1056+
1057+
let div = DecimalValue::I128(10)
1058+
.checked_div(&DecimalValue::I128(2))
1059+
.unwrap();
1060+
assert_eq!(div.decimal_type(), DecimalType::I128);
1061+
1062+
let add_i256 = DecimalValue::I256(i256::from_i128(1))
1063+
.checked_add(&DecimalValue::I256(i256::from_i128(2)))
1064+
.unwrap();
1065+
assert_eq!(add_i256.decimal_type(), DecimalType::I256);
1066+
}
1067+
1068+
#[test]
1069+
fn test_checked_ops_mixed_types_use_wider() {
1070+
let add = DecimalValue::I8(1)
1071+
.checked_add(&DecimalValue::I64(2))
1072+
.unwrap();
1073+
assert_eq!(add.decimal_type(), DecimalType::I64);
1074+
1075+
let sub = DecimalValue::I32(10)
1076+
.checked_sub(&DecimalValue::I128(3))
1077+
.unwrap();
1078+
assert_eq!(sub.decimal_type(), DecimalType::I128);
1079+
}
1080+
1081+
#[test]
1082+
fn test_checked_ops_overflow_at_target_width() {
1083+
assert_eq!(
1084+
DecimalValue::I8(i8::MAX).checked_add(&DecimalValue::I8(1)),
1085+
None
1086+
);
1087+
assert_eq!(
1088+
DecimalValue::I16(i16::MIN).checked_sub(&DecimalValue::I16(1)),
1089+
None
1090+
);
1091+
assert_eq!(
1092+
DecimalValue::I32(i32::MAX).checked_mul(&DecimalValue::I32(2)),
1093+
None
1094+
);
1095+
assert_eq!(
1096+
DecimalValue::I8(i8::MIN).checked_div(&DecimalValue::I8(-1)),
1097+
None
1098+
);
10371099
}
10381100

10391101
#[test]

0 commit comments

Comments
 (0)