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_err;
37use vortex_error::vortex_panic;
38
39use crate::ArrayRef;
40use crate::Canonical;
41use crate::ExecutionCtx;
42use crate::IntoArray;
43use crate::RecursiveCanonical;
44use crate::arrays::ConstantArray;
45use crate::builtins::ArrayBuiltins;
46use crate::dtype::DType;
47use crate::dtype::DecimalDType;
48use crate::dtype::NativeDecimalType;
49use crate::dtype::NativePType;
50use crate::dtype::PType;
51use crate::dtype::i256;
52use crate::scalar::DecimalValue;
53use crate::scalar::NumericOperator;
54use crate::scalar::PrimitiveScalar;
55use crate::scalar::Scalar;
56use crate::scalar_fn::fns::binary::numeric_op_result_decimal_dtype;
57
58fn to_vec_of_scalar(array: &ArrayRef, ctx: &mut ExecutionCtx) -> Vec<Scalar> {
59    // Not fast, but obviously correct
60    (0..array.len())
61        .map(|index| {
62            array
63                .execute_scalar(index, ctx)
64                .vortex_expect("scalar_at should succeed in conformance test")
65        })
66        .collect_vec()
67}
68
69/// Tests binary numeric operations for conformance across array encodings.
70///
71/// # Type Parameters
72///
73/// * `T` - The native numeric type (e.g., i32, f64) that the array contains
74///
75/// # Arguments
76///
77/// * `array` - The array to test, which should contain numeric values of type `T`
78///
79/// # Test Details
80///
81/// This function:
82/// 1. Canonicalizes the input array to primitive form to get expected values
83/// 2. Tests all binary numeric operators against a constant value of 1
84/// 3. Verifies results match the expected primitive array computation
85/// 4. Tests both array-operator-scalar and scalar-operator-array forms
86/// 5. Gracefully skips operations that would cause overflow/underflow
87///
88/// # Panics
89///
90/// Panics if:
91/// - The array cannot be converted to primitive form
92/// - Results don't match expected values (for operations that don't overflow)
93fn test_binary_numeric_conformance<T: NativePType + Num + Copy>(
94    array: &ArrayRef,
95    ctx: &mut ExecutionCtx,
96) where
97    Scalar: From<T>,
98{
99    // First test with the standard scalar value of 1
100    test_standard_binary_numeric::<T>(array, ctx);
101
102    // Then test edge cases
103    test_binary_numeric_edge_cases(array, ctx);
104}
105
106fn test_standard_binary_numeric<T: NativePType + Num + Copy>(
107    array: &ArrayRef,
108    ctx: &mut ExecutionCtx,
109) where
110    Scalar: From<T>,
111{
112    let canonicalized_array = array
113        .clone()
114        .execute::<Canonical>(ctx)
115        .vortex_expect("Must be able to canonicalise")
116        .into_array();
117    let original_values = to_vec_of_scalar(&canonicalized_array, ctx);
118
119    let one = T::from(1)
120        .ok_or_else(|| vortex_err!("could not convert 1 into array native type"))
121        .vortex_expect("operation should succeed in conformance test");
122    let scalar_one = Scalar::from(one)
123        .cast(array.dtype())
124        .vortex_expect("operation should succeed in conformance test");
125
126    let operators: [NumericOperator; 4] = [
127        NumericOperator::Add,
128        NumericOperator::Sub,
129        NumericOperator::Mul,
130        NumericOperator::Div,
131    ];
132
133    for operator in operators {
134        let op = operator;
135        let rhs_const = ConstantArray::new(scalar_one.clone(), array.len()).into_array();
136
137        // Test array operator scalar (e.g., array + 1)
138        let result = array
139            .binary(rhs_const.clone(), op.into())
140            .vortex_expect("apply shouldn't fail")
141            .execute::<RecursiveCanonical>(ctx)
142            .map(|c| c.0.into_array());
143
144        // Skip this operator if the entire operation fails
145        // This can happen for some edge cases in specific encodings
146        let Ok(result) = result else {
147            continue;
148        };
149
150        let actual_values = to_vec_of_scalar(&result, ctx);
151
152        // Check each element for overflow/underflow
153        let expected_results: Vec<Option<Scalar>> = original_values
154            .iter()
155            .map(|x| {
156                x.as_primitive()
157                    .checked_binary_numeric(&scalar_one.as_primitive(), op)
158                    .map(<Scalar as From<PrimitiveScalar<'_>>>::from)
159            })
160            .collect();
161
162        // For elements that didn't overflow, check they match
163        for (idx, (actual, expected)) in actual_values.iter().zip(&expected_results).enumerate() {
164            if let Some(expected_value) = expected {
165                assert_eq!(
166                    actual,
167                    expected_value,
168                    "Binary numeric operation failed for encoding {} at index {}: \
169                     ({array:?})[{idx}] {operator:?} {scalar_one} \
170                     expected {expected_value:?}, got {actual:?}",
171                    array.encoding_id(),
172                    idx,
173                );
174            }
175        }
176
177        // Test scalar operator array (e.g., 1 + array)
178        let result = rhs_const.binary(array.clone(), op.into()).and_then(|a| {
179            a.execute::<RecursiveCanonical>(ctx)
180                .map(|c| c.0.into_array())
181        });
182
183        // Skip this operator if the entire operation fails
184        let Ok(result) = result else {
185            continue;
186        };
187
188        let actual_values = to_vec_of_scalar(&result, ctx);
189
190        // Check each element for overflow/underflow
191        let expected_results: Vec<Option<Scalar>> = original_values
192            .iter()
193            .map(|x| {
194                scalar_one
195                    .as_primitive()
196                    .checked_binary_numeric(&x.as_primitive(), op)
197                    .map(<Scalar as From<PrimitiveScalar<'_>>>::from)
198            })
199            .collect();
200
201        // For elements that didn't overflow, check they match
202        for (idx, (actual, expected)) in actual_values.iter().zip(&expected_results).enumerate() {
203            if let Some(expected_value) = expected {
204                assert_eq!(
205                    actual,
206                    expected_value,
207                    "Binary numeric operation failed for encoding {} at index {}: \
208                     {scalar_one} {operator:?} ({array:?})[{idx}] \
209                     expected {expected_value:?}, got {actual:?}",
210                    array.encoding_id(),
211                    idx,
212                );
213            }
214        }
215    }
216}
217
218/// Entry point for binary numeric conformance testing for any array type.
219///
220/// This function automatically detects the array's numeric type and runs
221/// the appropriate tests. It's designed to be called from rstest parameterized
222/// tests without requiring explicit type parameters.
223///
224/// # Example
225///
226/// ```ignore
227/// #[rstest]
228/// #[case::i32_array(create_i32_array())]
229/// #[case::f64_array(create_f64_array())]
230/// fn test_my_encoding_binary_numeric(#[case] array: MyArray) {
231///     test_binary_numeric_array(array.into_array());
232/// }
233/// ```
234pub fn test_binary_numeric_array(array: &ArrayRef, ctx: &mut ExecutionCtx) {
235    match array.dtype() {
236        DType::Primitive(ptype, _) => match ptype {
237            PType::I8 => test_binary_numeric_conformance::<i8>(array, ctx),
238            PType::I16 => test_binary_numeric_conformance::<i16>(array, ctx),
239            PType::I32 => test_binary_numeric_conformance::<i32>(array, ctx),
240            PType::I64 => test_binary_numeric_conformance::<i64>(array, ctx),
241            PType::U8 => test_binary_numeric_conformance::<u8>(array, ctx),
242            PType::U16 => test_binary_numeric_conformance::<u16>(array, ctx),
243            PType::U32 => test_binary_numeric_conformance::<u32>(array, ctx),
244            PType::U64 => test_binary_numeric_conformance::<u64>(array, ctx),
245            PType::F16 => {
246                // F16 not supported in num-traits, skip
247                eprintln!("Skipping f16 binary numeric tests (not supported)");
248            }
249            PType::F32 => test_binary_numeric_conformance::<f32>(array, ctx),
250            PType::F64 => test_binary_numeric_conformance::<f64>(array, ctx),
251        },
252        DType::Decimal(decimal_dtype, _) => {
253            test_binary_numeric_conformance_decimal(array, *decimal_dtype, ctx)
254        }
255        dtype => vortex_panic!(
256            "Binary numeric tests are only supported for primitive and decimal types, got {dtype}",
257        ),
258    }
259}
260
261/// Tests binary numeric operations on a decimal array against the decimal scalar
262/// implementation, using a set of representative constants: stored `0`, `1`, `-1`, the decimal
263/// `1.0` (when it fits the precision), and the largest stored value for the precision.
264fn test_binary_numeric_conformance_decimal(
265    array: &ArrayRef,
266    decimal_dtype: DecimalDType,
267    ctx: &mut ExecutionCtx,
268) {
269    let precision = decimal_dtype.precision() as usize;
270    let prec_max = <i256 as NativeDecimalType>::MAX_BY_PRECISION[precision];
271
272    let mut constants = vec![
273        i256::from_i128(0),
274        i256::from_i128(1),
275        i256::from_i128(-1),
276        prec_max,
277    ];
278    // The decimal value 1.0 (stored 10^s), when representable within the precision.
279    if decimal_dtype.scale() >= 0
280        && let Some(one) = i256::from_i128(10).checked_pow(decimal_dtype.scale() as u32)
281        && one <= prec_max
282    {
283        constants.push(one);
284    }
285
286    for constant in constants {
287        let value = DecimalValue::try_from_i256(constant, decimal_dtype)
288            .vortex_expect("conformance constants fit the precision");
289        test_decimal_binary_numeric_with_scalar(array, value, decimal_dtype, ctx);
290    }
291}
292
293fn test_decimal_binary_numeric_with_scalar(
294    array: &ArrayRef,
295    value: DecimalValue,
296    decimal_dtype: DecimalDType,
297    ctx: &mut ExecutionCtx,
298) {
299    let canonicalized_array = array
300        .clone()
301        .execute::<Canonical>(ctx)
302        .vortex_expect("Must be able to canonicalise")
303        .into_array();
304    let original_values = to_vec_of_scalar(&canonicalized_array, ctx);
305
306    let scalar = Scalar::decimal(value, decimal_dtype, array.dtype().nullability());
307
308    let mut operators = vec![
309        NumericOperator::Add,
310        NumericOperator::Sub,
311        NumericOperator::Mul,
312    ];
313    if !value.is_zero() {
314        operators.push(NumericOperator::Div);
315    }
316
317    for operator in operators {
318        for lhs_is_array in [true, false] {
319            test_decimal_binary_numeric_direction(
320                array,
321                &original_values,
322                &scalar,
323                decimal_dtype,
324                operator,
325                lhs_is_array,
326                ctx,
327            );
328        }
329    }
330}
331
332fn test_decimal_binary_numeric_direction(
333    array: &ArrayRef,
334    original_values: &[Scalar],
335    scalar: &Scalar,
336    decimal_dtype: DecimalDType,
337    operator: NumericOperator,
338    lhs_is_array: bool,
339    ctx: &mut ExecutionCtx,
340) {
341    let result_decimal_dtype = numeric_op_result_decimal_dtype(decimal_dtype, operator)
342        .vortex_expect("decimal arithmetic must have a result dtype");
343    let result_dtype = DType::Decimal(result_decimal_dtype, array.dtype().nullability());
344    let expected_results = expected_decimal_results(
345        original_values,
346        scalar,
347        operator,
348        result_decimal_dtype,
349        &result_dtype,
350        lhs_is_array,
351    );
352
353    let constant = ConstantArray::new(scalar.clone(), array.len()).into_array();
354    let (lhs, rhs) = if lhs_is_array {
355        (array.clone(), constant)
356    } else {
357        (constant, array.clone())
358    };
359    let result = lhs
360        .binary(rhs, operator.into())
361        .vortex_expect("binary decimal op shouldn't fail")
362        .execute::<RecursiveCanonical>(ctx)
363        .map(|c| c.0.into_array());
364
365    assert_decimal_results(
366        result,
367        &expected_results,
368        array,
369        scalar,
370        operator,
371        lhs_is_array,
372        ctx,
373    );
374}
375
376fn expected_decimal_results(
377    original_values: &[Scalar],
378    scalar: &Scalar,
379    operator: NumericOperator,
380    result_decimal_dtype: DecimalDType,
381    result_dtype: &DType,
382    lhs_is_array: bool,
383) -> Vec<Option<Scalar>> {
384    original_values
385        .iter()
386        .map(|value| {
387            let (lhs, rhs) = if lhs_is_array {
388                (value.as_decimal(), scalar.as_decimal())
389            } else {
390                (scalar.as_decimal(), value.as_decimal())
391            };
392            let (Some(lhs), Some(rhs)) = (lhs.decimal_value(), rhs.decimal_value()) else {
393                return Some(Scalar::null(result_dtype.clone()));
394            };
395            let lhs = lhs.as_i256();
396            let rhs = rhs.as_i256();
397            let value = match operator {
398                NumericOperator::Add => lhs.checked_add(&rhs),
399                NumericOperator::Sub => lhs.checked_sub(&rhs),
400                NumericOperator::Mul => lhs.checked_mul(&rhs),
401                NumericOperator::Div => {
402                    let scale_power = result_decimal_dtype.scale();
403                    let factor =
404                        i256::from_i128(10).checked_pow(scale_power.unsigned_abs() as u32)?;
405                    if scale_power >= 0 {
406                        lhs.checked_mul(&factor)?.checked_div(&rhs)
407                    } else {
408                        lhs.checked_div(&rhs.checked_mul(&factor)?)
409                    }
410                }
411            }?;
412            let value = DecimalValue::try_from_i256(value, result_decimal_dtype).ok()?;
413            Some(Scalar::decimal(
414                value,
415                result_decimal_dtype,
416                result_dtype.nullability(),
417            ))
418        })
419        .collect()
420}
421
422fn assert_decimal_results(
423    result: vortex_error::VortexResult<ArrayRef>,
424    expected_results: &[Option<Scalar>],
425    array: &ArrayRef,
426    scalar: &Scalar,
427    operator: NumericOperator,
428    lhs_is_array: bool,
429    ctx: &mut ExecutionCtx,
430) {
431    if expected_results.iter().any(Option::is_none) {
432        assert!(
433            result.is_err(),
434            "Decimal binary numeric operation should overflow for encoding {}: \
435             {operator:?} {scalar} (lhs_is_array: {lhs_is_array})",
436            array.encoding_id(),
437        );
438        return;
439    }
440
441    let result = result.unwrap_or_else(|err| {
442        vortex_panic!(
443            "Decimal binary numeric operation unexpectedly failed for encoding {}: \
444             {operator:?} {scalar} (lhs_is_array: {lhs_is_array}): {err}",
445            array.encoding_id(),
446        )
447    });
448
449    let actual_values = to_vec_of_scalar(&result, ctx);
450    for (idx, (actual, expected)) in actual_values.iter().zip(expected_results).enumerate() {
451        let expected_value = expected
452            .as_ref()
453            .vortex_expect("non-overflowing decimal lane must have an expected value");
454        assert_eq!(
455            actual,
456            expected_value,
457            "Decimal binary numeric operation failed for encoding {} at index {}: \
458             ({array:?})[{idx}] {operator:?} {scalar} (lhs_is_array: {lhs_is_array}) \
459             expected {expected_value:?}, got {actual:?}",
460            array.encoding_id(),
461            idx,
462        );
463    }
464}
465
466/// Tests binary numeric operations with edge case scalar values.
467///
468/// This function tests operations with scalar values:
469/// - Zero (identity for addition/subtraction, absorbing for multiplication)
470/// - Negative one (tests signed arithmetic)
471/// - Maximum value (tests overflow behavior)
472/// - Minimum value (tests underflow behavior)
473fn test_binary_numeric_edge_cases(array: &ArrayRef, ctx: &mut ExecutionCtx) {
474    match array.dtype() {
475        DType::Primitive(ptype, _) => match ptype {
476            PType::I8 => test_binary_numeric_edge_cases_signed::<i8>(array, ctx),
477            PType::I16 => test_binary_numeric_edge_cases_signed::<i16>(array, ctx),
478            PType::I32 => test_binary_numeric_edge_cases_signed::<i32>(array, ctx),
479            PType::I64 => test_binary_numeric_edge_cases_signed::<i64>(array, ctx),
480            PType::U8 => test_binary_numeric_edge_cases_unsigned::<u8>(array, ctx),
481            PType::U16 => test_binary_numeric_edge_cases_unsigned::<u16>(array, ctx),
482            PType::U32 => test_binary_numeric_edge_cases_unsigned::<u32>(array, ctx),
483            PType::U64 => test_binary_numeric_edge_cases_unsigned::<u64>(array, ctx),
484            PType::F16 => {
485                eprintln!("Skipping f16 edge case tests (not supported)");
486            }
487            PType::F32 => test_binary_numeric_edge_cases_float::<f32>(array, ctx),
488            PType::F64 => test_binary_numeric_edge_cases_float::<f64>(array, ctx),
489        },
490        dtype => vortex_panic!(
491            "Binary numeric edge case tests are only supported for primitive numeric types, got {dtype}"
492        ),
493    }
494}
495
496fn test_binary_numeric_edge_cases_signed<T>(array: &ArrayRef, ctx: &mut ExecutionCtx)
497where
498    T: NativePType + Num + Copy + Debug + Bounded + Signed,
499    Scalar: From<T>,
500{
501    // Test with zero
502    test_binary_numeric_with_scalar(array, T::zero(), ctx);
503
504    // Test with -1
505    test_binary_numeric_with_scalar(array, -T::one(), ctx);
506
507    // Test with max value
508    test_binary_numeric_with_scalar(array, T::max_value(), ctx);
509
510    // Test with min value
511    test_binary_numeric_with_scalar(array, T::min_value(), ctx);
512}
513
514fn test_binary_numeric_edge_cases_unsigned<T>(array: &ArrayRef, ctx: &mut ExecutionCtx)
515where
516    T: NativePType + Num + Copy + Debug + Bounded,
517    Scalar: From<T>,
518{
519    // Test with zero
520    test_binary_numeric_with_scalar(array, T::zero(), ctx);
521
522    // Test with max value
523    test_binary_numeric_with_scalar(array, T::max_value(), ctx);
524}
525
526fn test_binary_numeric_edge_cases_float<T>(array: &ArrayRef, ctx: &mut ExecutionCtx)
527where
528    T: NativePType + Num + Copy + Debug + Float,
529    Scalar: From<T>,
530{
531    // Test with zero
532    test_binary_numeric_with_scalar(array, T::zero(), ctx);
533
534    // Test with -1
535    test_binary_numeric_with_scalar(array, -T::one(), ctx);
536
537    // Test with max value
538    test_binary_numeric_with_scalar(array, T::max_value(), ctx);
539
540    // Test with min value
541    test_binary_numeric_with_scalar(array, T::min_value(), ctx);
542
543    // Test with small positive value
544    test_binary_numeric_with_scalar(array, T::epsilon(), ctx);
545
546    // Test with min positive value (subnormal)
547    test_binary_numeric_with_scalar(array, T::min_positive_value(), ctx);
548
549    // Test with special float values (NaN, Infinity)
550    test_binary_numeric_with_scalar(array, T::nan(), ctx);
551    test_binary_numeric_with_scalar(array, T::infinity(), ctx);
552    test_binary_numeric_with_scalar(array, T::neg_infinity(), ctx);
553}
554
555fn test_binary_numeric_with_scalar<T>(array: &ArrayRef, scalar_value: T, ctx: &mut ExecutionCtx)
556where
557    T: NativePType + Num + Copy + Debug,
558    Scalar: From<T>,
559{
560    let canonicalized_array = array
561        .clone()
562        .execute::<Canonical>(ctx)
563        .vortex_expect("Must be able to canonicalise")
564        .into_array();
565    let original_values = to_vec_of_scalar(&canonicalized_array, ctx);
566
567    let scalar = Scalar::from(scalar_value)
568        .cast(array.dtype())
569        .vortex_expect("operation should succeed in conformance test");
570
571    // Only test operators that make sense for the given scalar
572    let operators = if scalar_value == T::zero() {
573        // Skip division by zero
574        vec![
575            NumericOperator::Add,
576            NumericOperator::Sub,
577            NumericOperator::Mul,
578        ]
579    } else {
580        vec![
581            NumericOperator::Add,
582            NumericOperator::Sub,
583            NumericOperator::Mul,
584            NumericOperator::Div,
585        ]
586    };
587
588    for operator in operators {
589        let op = operator;
590        let rhs_const = ConstantArray::new(scalar.clone(), array.len()).into_array();
591
592        // Test array operator scalar
593        let result = array
594            .binary(rhs_const, op.into())
595            .vortex_expect("apply failed")
596            .execute::<RecursiveCanonical>(ctx)
597            .map(|x| x.0.into_array());
598
599        // Skip if the entire operation fails
600        // TODO(joe): this is odd.
601        if result.is_err() {
602            continue;
603        }
604
605        let result = result.vortex_expect("operation should succeed in conformance test");
606        let actual_values = to_vec_of_scalar(&result, ctx);
607
608        // Check each element for overflow/underflow
609        let expected_results: Vec<Option<Scalar>> = original_values
610            .iter()
611            .map(|x| {
612                x.as_primitive()
613                    .checked_binary_numeric(&scalar.as_primitive(), op)
614                    .map(<Scalar as From<PrimitiveScalar<'_>>>::from)
615            })
616            .collect();
617
618        // For elements that didn't overflow, check they match
619        for (idx, (actual, expected)) in actual_values.iter().zip(&expected_results).enumerate() {
620            if let Some(expected_value) = expected {
621                assert_eq!(
622                    actual,
623                    expected_value,
624                    "Binary numeric operation failed for encoding {} at index {} with scalar {:?}: \
625                     ({array:?})[{idx}] {operator:?} {scalar} \
626                     expected {expected_value:?}, got {actual:?}",
627                    array.encoding_id(),
628                    idx,
629                    scalar_value,
630                );
631            }
632        }
633    }
634}