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