Skip to main content

vortex_array/compute/conformance/
binary_numeric.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! # Binary Numeric Conformance Tests
5//!
6//! This module provides conformance testing for binary numeric operations on Vortex arrays.
7//! It ensures that all numeric array encodings produce identical results when performing
8//! arithmetic operations (add, subtract, multiply, divide).
9//!
10//! ## Test Strategy
11//!
12//! For each array encoding, we test:
13//! 1. All binary numeric operators against a constant scalar value
14//! 2. Both left-hand and right-hand side operations (e.g., array + 1 and 1 + array)
15//! 3. That results match the canonical primitive array implementation
16//!
17//! ## Supported Operations
18//!
19//! - Addition (`+`)
20//! - Subtraction (`-`)
21//! - Multiplication (`*`)
22//! - Division (`/`)
23
24use std::fmt::Debug;
25
26use itertools::Itertools;
27use num_traits::Bounded;
28use num_traits::CheckedAdd;
29use num_traits::CheckedDiv;
30use num_traits::CheckedMul;
31use num_traits::CheckedSub;
32use num_traits::Float;
33use num_traits::Num;
34use num_traits::Signed;
35use vortex_error::VortexExpect;
36use vortex_error::vortex_panic;
37
38use crate::ArrayRef;
39use crate::Canonical;
40use crate::ExecutionCtx;
41use crate::IntoArray;
42use crate::RecursiveCanonical;
43use crate::arrays::ConstantArray;
44use crate::builtins::ArrayBuiltins;
45use crate::dtype::DType;
46use crate::dtype::DecimalDType;
47use crate::dtype::NativeDecimalType;
48use crate::dtype::NativePType;
49use crate::dtype::PType;
50use crate::dtype::i256;
51use crate::scalar::DecimalValue;
52use crate::scalar::NumericOperator;
53use crate::scalar::PrimitiveScalar;
54use crate::scalar::Scalar;
55use crate::scalar_fn::fns::binary::numeric_op_result_decimal_dtype;
56
57fn to_vec_of_scalar(array: &ArrayRef, ctx: &mut ExecutionCtx) -> Vec<Scalar> {
58    // Not fast, but obviously correct
59    (0..array.len())
60        .map(|index| {
61            array
62                .execute_scalar(index, ctx)
63                .vortex_expect("scalar_at should succeed in conformance test")
64        })
65        .collect_vec()
66}
67
68/// Tests binary numeric operations for conformance across array encodings.
69///
70/// # Type Parameters
71///
72/// * `T` - The native numeric type (e.g., i32, f64) that the array contains
73///
74/// # Arguments
75///
76/// * `array` - The array to test, which should contain numeric values of type `T`
77///
78/// # Test Details
79///
80/// This function:
81/// 1. Canonicalizes the input array to primitive form to get expected values
82/// 2. Tests all binary numeric operators against a constant value of 1
83/// 3. Verifies results match the expected primitive array computation
84/// 4. Tests both array-operator-scalar and scalar-operator-array forms
85/// 5. Gracefully skips operations that would cause overflow/underflow
86///
87/// # Panics
88///
89/// Panics if:
90/// - The array cannot be converted to primitive form
91/// - Results don't match expected values (for operations that don't overflow)
92fn test_binary_numeric_conformance<T: NativePType + Num + Copy>(
93    array: &ArrayRef,
94    ctx: &mut ExecutionCtx,
95) where
96    Scalar: From<T>,
97{
98    // First test with the standard scalar value of 1
99    test_binary_numeric_with_scalar(array, T::one(), &[true, false], ctx);
100
101    // Then test edge cases
102    test_binary_numeric_edge_cases(array, ctx);
103}
104
105/// Entry point for binary numeric conformance testing for any array type.
106///
107/// This function automatically detects the array's numeric type and runs
108/// the appropriate tests. It's designed to be called from rstest parameterized
109/// tests without requiring explicit type parameters.
110///
111/// # Example
112///
113/// ```ignore
114/// #[rstest]
115/// #[case::i32_array(create_i32_array())]
116/// #[case::f64_array(create_f64_array())]
117/// fn test_my_encoding_binary_numeric(#[case] array: MyArray) {
118///     test_binary_numeric_array(array.into_array());
119/// }
120/// ```
121pub fn test_binary_numeric_array(array: &ArrayRef, ctx: &mut ExecutionCtx) {
122    match array.dtype() {
123        DType::Primitive(ptype, _) => match ptype {
124            PType::I8 => test_binary_numeric_conformance::<i8>(array, ctx),
125            PType::I16 => test_binary_numeric_conformance::<i16>(array, ctx),
126            PType::I32 => test_binary_numeric_conformance::<i32>(array, ctx),
127            PType::I64 => test_binary_numeric_conformance::<i64>(array, ctx),
128            PType::U8 => test_binary_numeric_conformance::<u8>(array, ctx),
129            PType::U16 => test_binary_numeric_conformance::<u16>(array, ctx),
130            PType::U32 => test_binary_numeric_conformance::<u32>(array, ctx),
131            PType::U64 => test_binary_numeric_conformance::<u64>(array, ctx),
132            PType::F16 => {
133                // F16 not supported in num-traits, skip
134                eprintln!("Skipping f16 binary numeric tests (not supported)");
135            }
136            PType::F32 => test_binary_numeric_conformance::<f32>(array, ctx),
137            PType::F64 => test_binary_numeric_conformance::<f64>(array, ctx),
138        },
139        DType::Decimal(decimal_dtype, _) => {
140            test_binary_numeric_conformance_decimal(array, *decimal_dtype, ctx)
141        }
142        dtype => vortex_panic!(
143            "Binary numeric tests are only supported for primitive and decimal types, got {dtype}",
144        ),
145    }
146}
147
148/// Tests binary numeric operations on a decimal array against the decimal scalar
149/// implementation, using a set of representative constants: stored `0`, `1`, `-1`, the decimal
150/// `1.0` (when it fits the precision), and the largest stored value for the precision.
151fn test_binary_numeric_conformance_decimal(
152    array: &ArrayRef,
153    decimal_dtype: DecimalDType,
154    ctx: &mut ExecutionCtx,
155) {
156    let precision = decimal_dtype.precision() as usize;
157    let prec_max = <i256 as NativeDecimalType>::MAX_BY_PRECISION[precision];
158
159    let mut constants = vec![
160        i256::from_i128(0),
161        i256::from_i128(1),
162        i256::from_i128(-1),
163        prec_max,
164    ];
165    // The decimal value 1.0 (stored 10^s), when representable within the precision.
166    if decimal_dtype.scale() >= 0
167        && let Some(one) = i256::from_i128(10).checked_pow(decimal_dtype.scale() as u32)
168        && one <= prec_max
169    {
170        constants.push(one);
171    }
172
173    for constant in constants {
174        let value = DecimalValue::try_from_i256(constant, decimal_dtype)
175            .vortex_expect("conformance constants fit the precision");
176        test_decimal_binary_numeric_with_scalar(array, value, decimal_dtype, ctx);
177    }
178}
179
180fn test_decimal_binary_numeric_with_scalar(
181    array: &ArrayRef,
182    value: DecimalValue,
183    decimal_dtype: DecimalDType,
184    ctx: &mut ExecutionCtx,
185) {
186    let canonicalized_array = array
187        .clone()
188        .execute::<Canonical>(ctx)
189        .vortex_expect("Must be able to canonicalise")
190        .into_array();
191    let original_values = to_vec_of_scalar(&canonicalized_array, ctx);
192
193    let scalar = Scalar::decimal(value, decimal_dtype, array.dtype().nullability());
194
195    let mut operators = vec![
196        NumericOperator::Add,
197        NumericOperator::Sub,
198        NumericOperator::Mul,
199    ];
200    if !value.is_zero() {
201        operators.push(NumericOperator::Div);
202    }
203
204    for operator in operators {
205        for lhs_is_array in [true, false] {
206            test_decimal_binary_numeric_direction(
207                array,
208                &original_values,
209                &scalar,
210                decimal_dtype,
211                operator,
212                lhs_is_array,
213                ctx,
214            );
215        }
216    }
217}
218
219fn test_decimal_binary_numeric_direction(
220    array: &ArrayRef,
221    original_values: &[Scalar],
222    scalar: &Scalar,
223    decimal_dtype: DecimalDType,
224    operator: NumericOperator,
225    lhs_is_array: bool,
226    ctx: &mut ExecutionCtx,
227) {
228    let result_decimal_dtype = numeric_op_result_decimal_dtype(decimal_dtype, operator)
229        .vortex_expect("decimal arithmetic must have a result dtype");
230    let result_dtype = DType::Decimal(result_decimal_dtype, array.dtype().nullability());
231    let expected_results = expected_decimal_results(
232        original_values,
233        scalar,
234        operator,
235        result_decimal_dtype,
236        &result_dtype,
237        lhs_is_array,
238    );
239
240    let constant = ConstantArray::new(scalar.clone(), array.len()).into_array();
241    let (lhs, rhs) = if lhs_is_array {
242        (array.clone(), constant)
243    } else {
244        (constant, array.clone())
245    };
246    let result = lhs
247        .binary(rhs, operator.into())
248        .vortex_expect("binary decimal op shouldn't fail")
249        .execute::<RecursiveCanonical>(ctx)
250        .map(|c| c.0.into_array());
251
252    assert_decimal_results(
253        result,
254        &expected_results,
255        array,
256        scalar,
257        operator,
258        lhs_is_array,
259        ctx,
260    );
261}
262
263fn expected_decimal_results(
264    original_values: &[Scalar],
265    scalar: &Scalar,
266    operator: NumericOperator,
267    result_decimal_dtype: DecimalDType,
268    result_dtype: &DType,
269    lhs_is_array: bool,
270) -> Vec<Option<Scalar>> {
271    original_values
272        .iter()
273        .map(|value| {
274            let (lhs, rhs) = if lhs_is_array {
275                (value.as_decimal(), scalar.as_decimal())
276            } else {
277                (scalar.as_decimal(), value.as_decimal())
278            };
279            let (Some(lhs), Some(rhs)) = (lhs.decimal_value(), rhs.decimal_value()) else {
280                return Some(Scalar::null(result_dtype.clone()));
281            };
282            let lhs = lhs.as_i256();
283            let rhs = rhs.as_i256();
284            let value = match operator {
285                NumericOperator::Add => lhs.checked_add(&rhs),
286                NumericOperator::Sub => lhs.checked_sub(&rhs),
287                NumericOperator::Mul => lhs.checked_mul(&rhs),
288                NumericOperator::Div => {
289                    let scale_power = result_decimal_dtype.scale();
290                    let factor =
291                        i256::from_i128(10).checked_pow(scale_power.unsigned_abs() as u32)?;
292                    if scale_power >= 0 {
293                        lhs.checked_mul(&factor)?.checked_div(&rhs)
294                    } else {
295                        lhs.checked_div(&rhs.checked_mul(&factor)?)
296                    }
297                }
298            }?;
299            let value = DecimalValue::try_from_i256(value, result_decimal_dtype).ok()?;
300            Some(Scalar::decimal(
301                value,
302                result_decimal_dtype,
303                result_dtype.nullability(),
304            ))
305        })
306        .collect()
307}
308
309fn assert_decimal_results(
310    result: vortex_error::VortexResult<ArrayRef>,
311    expected_results: &[Option<Scalar>],
312    array: &ArrayRef,
313    scalar: &Scalar,
314    operator: NumericOperator,
315    lhs_is_array: bool,
316    ctx: &mut ExecutionCtx,
317) {
318    if expected_results.iter().any(Option::is_none) {
319        assert!(
320            result.is_err(),
321            "Decimal binary numeric operation should overflow for encoding {}: \
322             {operator:?} {scalar} (lhs_is_array: {lhs_is_array})",
323            array.encoding_id(),
324        );
325        return;
326    }
327
328    let result = result.unwrap_or_else(|err| {
329        vortex_panic!(
330            "Decimal binary numeric operation unexpectedly failed for encoding {}: \
331             {operator:?} {scalar} (lhs_is_array: {lhs_is_array}): {err}",
332            array.encoding_id(),
333        )
334    });
335
336    assert_eq!(result.len(), expected_results.len());
337    let actual_values = to_vec_of_scalar(&result, ctx);
338    for (idx, (actual, expected)) in actual_values.iter().zip(expected_results).enumerate() {
339        let expected_value = expected
340            .as_ref()
341            .vortex_expect("non-overflowing decimal lane must have an expected value");
342        assert_eq!(
343            actual,
344            expected_value,
345            "Decimal binary numeric operation failed for encoding {} at index {}: \
346             ({array:?})[{idx}] {operator:?} {scalar} (lhs_is_array: {lhs_is_array}) \
347             expected {expected_value:?}, got {actual:?}",
348            array.encoding_id(),
349            idx,
350        );
351    }
352}
353
354/// Tests binary numeric operations with edge case scalar values.
355///
356/// This function tests operations with scalar values:
357/// - Zero (identity for addition/subtraction, absorbing for multiplication)
358/// - Negative one (tests signed arithmetic)
359/// - Maximum value (tests overflow behavior)
360/// - Minimum value (tests underflow behavior)
361fn test_binary_numeric_edge_cases(array: &ArrayRef, ctx: &mut ExecutionCtx) {
362    match array.dtype() {
363        DType::Primitive(ptype, _) => match ptype {
364            PType::I8 => test_binary_numeric_edge_cases_signed::<i8>(array, ctx),
365            PType::I16 => test_binary_numeric_edge_cases_signed::<i16>(array, ctx),
366            PType::I32 => test_binary_numeric_edge_cases_signed::<i32>(array, ctx),
367            PType::I64 => test_binary_numeric_edge_cases_signed::<i64>(array, ctx),
368            PType::U8 => test_binary_numeric_edge_cases_unsigned::<u8>(array, ctx),
369            PType::U16 => test_binary_numeric_edge_cases_unsigned::<u16>(array, ctx),
370            PType::U32 => test_binary_numeric_edge_cases_unsigned::<u32>(array, ctx),
371            PType::U64 => test_binary_numeric_edge_cases_unsigned::<u64>(array, ctx),
372            PType::F16 => {
373                eprintln!("Skipping f16 edge case tests (not supported)");
374            }
375            PType::F32 => test_binary_numeric_edge_cases_float::<f32>(array, ctx),
376            PType::F64 => test_binary_numeric_edge_cases_float::<f64>(array, ctx),
377        },
378        dtype => vortex_panic!(
379            "Binary numeric edge case tests are only supported for primitive numeric types, got {dtype}"
380        ),
381    }
382}
383
384fn test_binary_numeric_edge_cases_signed<T>(array: &ArrayRef, ctx: &mut ExecutionCtx)
385where
386    T: NativePType + Num + Copy + Debug + Bounded + Signed,
387    Scalar: From<T>,
388{
389    // Test with zero
390    test_binary_numeric_with_scalar(array, T::zero(), &[true], ctx);
391
392    // Test with -1
393    test_binary_numeric_with_scalar(array, -T::one(), &[true], ctx);
394
395    // Test with max value
396    test_binary_numeric_with_scalar(array, T::max_value(), &[true], ctx);
397
398    // Test with min value
399    test_binary_numeric_with_scalar(array, T::min_value(), &[true], ctx);
400}
401
402fn test_binary_numeric_edge_cases_unsigned<T>(array: &ArrayRef, ctx: &mut ExecutionCtx)
403where
404    T: NativePType + Num + Copy + Debug + Bounded,
405    Scalar: From<T>,
406{
407    // Test with zero
408    test_binary_numeric_with_scalar(array, T::zero(), &[true], ctx);
409
410    // Test with max value
411    test_binary_numeric_with_scalar(array, T::max_value(), &[true], ctx);
412}
413
414fn test_binary_numeric_edge_cases_float<T>(array: &ArrayRef, ctx: &mut ExecutionCtx)
415where
416    T: NativePType + Num + Copy + Debug + Float,
417    Scalar: From<T>,
418{
419    // Test with zero
420    test_binary_numeric_with_scalar(array, T::zero(), &[true], ctx);
421
422    // Test with -1
423    test_binary_numeric_with_scalar(array, -T::one(), &[true], ctx);
424
425    // Test with max value
426    test_binary_numeric_with_scalar(array, T::max_value(), &[true], ctx);
427
428    // Test with min value
429    test_binary_numeric_with_scalar(array, T::min_value(), &[true], ctx);
430
431    // Test with small positive value
432    test_binary_numeric_with_scalar(array, T::epsilon(), &[true], ctx);
433
434    // Test with min positive value (subnormal)
435    test_binary_numeric_with_scalar(array, T::min_positive_value(), &[true], ctx);
436
437    // Test with special float values (NaN, Infinity)
438    test_binary_numeric_with_scalar(array, T::nan(), &[true], ctx);
439    test_binary_numeric_with_scalar(array, T::infinity(), &[true], ctx);
440    test_binary_numeric_with_scalar(array, T::neg_infinity(), &[true], ctx);
441}
442
443fn test_binary_numeric_with_scalar<T>(
444    array: &ArrayRef,
445    scalar_value: T,
446    directions: &[bool],
447    ctx: &mut ExecutionCtx,
448) where
449    T: NativePType + Num + Copy + Debug,
450    Scalar: From<T>,
451{
452    let canonicalized_array = array
453        .clone()
454        .execute::<Canonical>(ctx)
455        .vortex_expect("Must be able to canonicalise")
456        .into_array();
457    let original_values = to_vec_of_scalar(&canonicalized_array, ctx);
458    let scalar = Scalar::from(scalar_value)
459        .cast(array.dtype())
460        .vortex_expect("constant must fit the array dtype");
461
462    for operator in [
463        NumericOperator::Add,
464        NumericOperator::Sub,
465        NumericOperator::Mul,
466        NumericOperator::Div,
467    ] {
468        if operator == NumericOperator::Div && scalar_value == T::zero() {
469            continue;
470        }
471        for &lhs_is_array in directions {
472            let expected_results: Vec<Option<Scalar>> = original_values
473                .iter()
474                .map(|value| {
475                    let (lhs, rhs) = if lhs_is_array {
476                        (value.as_primitive(), scalar.as_primitive())
477                    } else {
478                        (scalar.as_primitive(), value.as_primitive())
479                    };
480                    lhs.checked_binary_numeric(&rhs, operator)
481                        .map(<Scalar as From<PrimitiveScalar<'_>>>::from)
482                })
483                .collect();
484            let constant = ConstantArray::new(scalar.clone(), array.len()).into_array();
485            let (lhs, rhs) = if lhs_is_array {
486                (array.clone(), constant)
487            } else {
488                (constant, array.clone())
489            };
490            let result = lhs
491                .binary(rhs, operator.into())
492                .vortex_expect("binary numeric construction must succeed")
493                .execute::<RecursiveCanonical>(ctx);
494            let result = match result {
495                Ok(result) => result.0.into_array(),
496                Err(err) => {
497                    assert!(
498                        expected_results.iter().any(Option::is_none),
499                        "Binary numeric operation unexpectedly failed for encoding {}: \
500                         {operator:?} {scalar} (lhs_is_array: {lhs_is_array}): {err}",
501                        array.encoding_id(),
502                    );
503                    continue;
504                }
505            };
506            assert_eq!(result.len(), expected_results.len());
507            assert_eq!(result.dtype(), array.dtype());
508            let actual_values = to_vec_of_scalar(&result, ctx);
509            for (idx, (actual, expected)) in actual_values.iter().zip(&expected_results).enumerate()
510            {
511                if let Some(expected) = expected {
512                    assert_eq!(
513                        actual,
514                        expected,
515                        "Binary numeric operation failed for encoding {} at index {idx}: \
516                         {operator:?} {scalar} (lhs_is_array: {lhs_is_array})",
517                        array.encoding_id(),
518                    );
519                }
520            }
521        }
522    }
523}