Skip to main content

vortex_array/aggregate_fn/fns/sum_v2/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! SQL-style sums with explicit overflow and empty-input state.
5//!
6//! [`SumV2`] returns null when there are no valid values, while [`Sum`] returns zero. Tracking empty
7//! input separately from overflow requires a different partial representation. A separate aggregate
8//! preserves compatibility with the scalar partials stored by older Vortex files.
9
10mod grouped;
11pub(crate) use grouped::PrimitiveGroupedSumV2EncodingKernel;
12use vortex_error::VortexExpect;
13use vortex_error::VortexResult;
14use vortex_error::vortex_bail;
15use vortex_error::vortex_ensure;
16use vortex_error::vortex_err;
17use vortex_session::VortexSession;
18use vortex_session::registry::CachedId;
19
20use crate::ArrayRef;
21use crate::ArrayView;
22use crate::Canonical;
23use crate::Columnar;
24use crate::ExecutionCtx;
25use crate::aggregate_fn::Accumulator;
26use crate::aggregate_fn::AggregateArgs;
27use crate::aggregate_fn::AggregateFnId;
28use crate::aggregate_fn::AggregateFnVTable;
29use crate::aggregate_fn::DynAccumulator;
30use crate::aggregate_fn::NumericalAggregateOpts;
31use crate::aggregate_fn::fns::sum::Sum;
32use crate::aggregate_fn::fns::sum::SumState;
33use crate::aggregate_fn::fns::sum::accumulate_bool;
34use crate::aggregate_fn::fns::sum::accumulate_decimal;
35use crate::aggregate_fn::fns::sum::accumulate_primitive;
36use crate::aggregate_fn::fns::sum::checked_add_sum_states;
37use crate::aggregate_fn::fns::sum::make_zero_state;
38use crate::aggregate_fn::fns::sum::multiply_constant;
39use crate::arrays::Struct;
40use crate::arrays::struct_::StructArrayExt;
41use crate::builtins::ArrayBuiltins;
42use crate::dtype::DType;
43use crate::dtype::FieldName;
44use crate::dtype::FieldNames;
45use crate::dtype::Nullability;
46use crate::dtype::PType;
47use crate::dtype::StructFields;
48use crate::expr::stats::Precision;
49use crate::expr::stats::Stat;
50use crate::expr::stats::StatsProviderExt;
51use crate::scalar::Scalar;
52use crate::scalar::ScalarValue;
53use crate::scalar_fn::fns::operators::Operator;
54use crate::validity::Validity;
55
56const SUM_FIELD: &str = "sum";
57const IS_OVERFLOW_FIELD: &str = "is_overflow";
58const IS_EMPTY_FIELD: &str = "is_empty";
59
60/// Return the SQL-style sum of an array.
61///
62/// Unlike [`sum`](crate::aggregate_fn::fns::sum::sum), this returns null when the array has no
63/// valid values.
64pub fn sum_v2(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<Scalar> {
65    let mut acc = Accumulator::try_new(
66        SumV2,
67        NumericalAggregateOpts::default(),
68        array.dtype().clone(),
69    )?;
70    acc.accumulate(array, ctx)?;
71    acc.finish()
72}
73
74/// Sum an array, returning null when it has no valid values or if the sum overflows.
75///
76/// This aggregate intentionally has a distinct ID and partial representation from the legacy
77/// [`Sum`]. Keeping `vortex.sum` unchanged preserves the scalar partials stored by older Vortex
78/// files, while `SumV2` can use an explicit `{ sum, is_overflow, is_empty }` state.
79///
80/// NaN handling for float inputs is controlled by [`NumericalAggregateOpts`]. With `skip_nans`
81/// (the default), NaN values contribute nothing but still make the input non-empty. Otherwise,
82/// any NaN value poisons the sum to NaN.
83#[derive(Clone, Copy, Debug)]
84pub struct SumV2;
85
86impl SumV2 {
87    /// Build an encoding kernel's partial from a widened primitive sum.
88    ///
89    /// `sum` must have dtype `u64`, `i64`, or `f64`. A null sum records overflow and overrides
90    /// `is_empty`. Set `is_empty` only when there were no valid inputs. Valid NaNs make the input
91    /// non-empty even when skipped. The returned struct and its fields are non-null.
92    pub fn partial_from_sum(sum: Scalar, is_empty: bool) -> VortexResult<Scalar> {
93        vortex_ensure!(
94            matches!(
95                sum.dtype(),
96                DType::Primitive(PType::U64 | PType::I64 | PType::F64, _)
97            ),
98            "Expected a widened primitive sum, got {}",
99            sum.dtype(),
100        );
101
102        let sum_dtype = sum.dtype().as_nonnullable();
103        let is_overflow = sum.is_null();
104        let sum = if is_overflow {
105            Scalar::zero_value(&sum_dtype)
106        } else {
107            sum.cast(&sum_dtype)?
108        };
109
110        Ok(Scalar::struct_(
111            sum_v2_partial_dtype(sum_dtype),
112            vec![
113                sum,
114                Scalar::bool(is_overflow, Nullability::NonNullable),
115                Scalar::bool(is_empty && !is_overflow, Nullability::NonNullable),
116            ],
117        ))
118    }
119}
120
121impl AggregateFnVTable for SumV2 {
122    type Options = NumericalAggregateOpts;
123    type Partial = SumV2Partial;
124
125    fn id(&self) -> AggregateFnId {
126        static ID: CachedId = CachedId::new("vortex.sum_v2");
127        *ID
128    }
129
130    fn serialize(&self, options: &Self::Options) -> VortexResult<Option<Vec<u8>>> {
131        Ok(Some(options.serialize()))
132    }
133
134    fn deserialize(
135        &self,
136        metadata: &[u8],
137        _session: &VortexSession,
138    ) -> VortexResult<Self::Options> {
139        NumericalAggregateOpts::deserialize(metadata)
140    }
141
142    fn return_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option<DType> {
143        Sum.return_dtype(options, input_dtype)
144    }
145
146    fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option<DType> {
147        self.return_dtype(options, input_dtype)
148            .map(sum_v2_partial_dtype)
149    }
150
151    fn empty_partial(&self, args: AggregateArgs<'_, Self::Options>) -> VortexResult<Self::Partial> {
152        Ok(SumV2Partial::empty(args.return_dtype))
153    }
154
155    fn partial_from_scalar(
156        &self,
157        args: AggregateArgs<'_, Self::Options>,
158        scalar: &Scalar,
159    ) -> VortexResult<Self::Partial> {
160        let mut partial = SumV2Partial::empty(args.return_dtype);
161        let (sum, is_overflow, is_empty) = decode_partial_scalar(scalar, args.return_dtype)?;
162
163        // Adding the parsed value to the zero state cannot overflow; treat a decimal value that
164        // no longer fits its precision as an already-overflowed partial.
165        let overflowed = checked_add_sum_value(&mut partial.sum, args.return_dtype, sum)?;
166        partial.is_overflow = is_overflow || overflowed;
167        partial.is_empty = is_empty && !partial.is_overflow;
168        Ok(partial)
169    }
170
171    fn merge_partials(
172        &self,
173        args: AggregateArgs<'_, Self::Options>,
174        mut acc: Self::Partial,
175        partial: Self::Partial,
176    ) -> VortexResult<Self::Partial> {
177        if acc.is_overflow {
178            return Ok(acc);
179        }
180        if partial.is_overflow {
181            // An overflowed state keeps its last valid sum, so an empty accumulator adopts the
182            // incoming sum rather than reporting zero.
183            if acc.is_empty {
184                acc.sum = partial.sum;
185            }
186            acc.is_overflow = true;
187            acc.is_empty = false;
188            return Ok(acc);
189        }
190        if partial.is_empty {
191            return Ok(acc);
192        }
193        acc.is_overflow = checked_add_sum_states(&mut acc.sum, args.return_dtype, &partial.sum)?;
194        acc.is_empty = false;
195        Ok(acc)
196    }
197
198    fn to_scalar(
199        &self,
200        args: AggregateArgs<'_, Self::Options>,
201        partial: &Self::Partial,
202    ) -> VortexResult<Scalar> {
203        Ok(Scalar::struct_(
204            args.partial_dtype.clone(),
205            vec![
206                sum_state_scalar(partial, args.return_dtype, Nullability::NonNullable),
207                Scalar::bool(partial.is_overflow, Nullability::NonNullable),
208                Scalar::bool(partial.is_empty, Nullability::NonNullable),
209            ],
210        ))
211    }
212
213    fn is_saturated(
214        &self,
215        _args: AggregateArgs<'_, Self::Options>,
216        partial: &Self::Partial,
217    ) -> bool {
218        partial.is_overflow || matches!(&partial.sum, SumState::Float(value) if value.is_nan())
219    }
220
221    fn try_accumulate(
222        &self,
223        args: AggregateArgs<'_, Self::Options>,
224        partial: &mut Self::Partial,
225        batch: &ArrayRef,
226        _ctx: &mut ExecutionCtx,
227    ) -> VortexResult<bool> {
228        if args.options.skip_nans || !matches!(&partial.sum, SumState::Float(_)) {
229            return Ok(false);
230        }
231
232        match batch.statistics().get_as::<u64>(Stat::NaNCount) {
233            Precision::Exact(0) => Ok(false),
234            Precision::Exact(_) => {
235                let SumState::Float(sum) = &mut partial.sum else {
236                    unreachable!("checked float sum state")
237                };
238                *sum = f64::NAN;
239                partial.is_empty = false;
240                Ok(true)
241            }
242            _ => Ok(false),
243        }
244    }
245
246    fn accumulate(
247        &self,
248        args: AggregateArgs<'_, Self::Options>,
249        partial: &mut Self::Partial,
250        batch: &Columnar,
251        ctx: &mut ExecutionCtx,
252    ) -> VortexResult<()> {
253        if partial.is_overflow {
254            return Ok(());
255        }
256
257        if let Columnar::Constant(constant) = batch {
258            if !constant.scalar().is_null() && !constant.is_empty() {
259                partial.is_empty = false;
260            }
261            if args.options.skip_nans
262                && constant
263                    .scalar()
264                    .as_primitive_opt()
265                    .is_some_and(|primitive| primitive.is_nan())
266            {
267                return Ok(());
268            }
269            if let Some(product) =
270                multiply_constant(constant.scalar(), constant.len(), args.return_dtype)?
271            {
272                if product.is_null() {
273                    partial.is_overflow = true;
274                    partial.is_empty = false;
275                } else {
276                    partial.is_overflow =
277                        checked_add_sum_state(&mut partial.sum, args.return_dtype, &product)?;
278                    partial.is_empty = false;
279                }
280            }
281            return Ok(());
282        }
283
284        let any_valid = partial.is_empty && has_valid_value(batch, ctx)?;
285        let result = match batch {
286            Columnar::Canonical(canonical) => match canonical {
287                Canonical::Primitive(array) => {
288                    accumulate_primitive(&mut partial.sum, array, ctx, args.options.skip_nans)
289                }
290                Canonical::Bool(array) => accumulate_bool(&mut partial.sum, array, ctx),
291                Canonical::Decimal(array) => {
292                    accumulate_decimal(&mut partial.sum, args.return_dtype, array, ctx)
293                }
294                _ => vortex_bail!("Unsupported canonical type for sum_v2: {}", batch.dtype()),
295            },
296            Columnar::Constant(_) => unreachable!(),
297        };
298
299        if any_valid {
300            partial.is_empty = false;
301        }
302        if result? {
303            partial.is_overflow = true;
304            partial.is_empty = false;
305        }
306        Ok(())
307    }
308
309    fn finalize(
310        &self,
311        _args: AggregateArgs<'_, Self::Options>,
312        partials: ArrayRef,
313    ) -> VortexResult<ArrayRef> {
314        if let Some(partials) = partials.as_opt::<Struct>() {
315            return finalize_struct(partials);
316        }
317
318        let sum = partials.get_item(SUM_FIELD)?;
319        let is_invalid = partials
320            .get_item(IS_OVERFLOW_FIELD)?
321            .binary(partials.get_item(IS_EMPTY_FIELD)?, Operator::Or)?
322            .fill_null(true)?;
323        sum.mask(is_invalid.not()?)
324    }
325
326    fn finalize_scalar(
327        &self,
328        args: AggregateArgs<'_, Self::Options>,
329        partial: &Self::Partial,
330    ) -> VortexResult<Scalar> {
331        if partial.is_overflow || partial.is_empty {
332            return Ok(Scalar::null(args.return_dtype.as_nullable()));
333        }
334        Ok(sum_state_scalar(
335            partial,
336            args.return_dtype,
337            Nullability::Nullable,
338        ))
339    }
340}
341
342fn finalize_struct(partials: ArrayView<'_, Struct>) -> VortexResult<ArrayRef> {
343    let sum = partials.unmasked_field_by_name(SUM_FIELD)?.clone();
344    let is_overflow = partials.unmasked_field_by_name(IS_OVERFLOW_FIELD)?.clone();
345    let is_empty = partials.unmasked_field_by_name(IS_EMPTY_FIELD)?.clone();
346
347    let is_invalid = is_overflow
348        .binary(is_empty, Operator::Or)?
349        .fill_null(true)?;
350    let mut is_valid = is_invalid.not()?;
351    match partials.struct_validity() {
352        Validity::NonNullable | Validity::AllValid => {}
353        validity => {
354            is_valid = is_valid.binary(validity.to_array(partials.len()), Operator::And)?;
355        }
356    }
357
358    sum.mask(is_valid)
359}
360
361/// In-memory state for SumV2 accumulation.
362pub struct SumV2Partial {
363    sum: SumState,
364    is_overflow: bool,
365    is_empty: bool,
366}
367
368impl SumV2Partial {
369    /// The state of a group with no accumulated values.
370    fn empty(return_dtype: &DType) -> Self {
371        Self {
372            sum: make_zero_state(return_dtype),
373            is_overflow: false,
374            is_empty: true,
375        }
376    }
377}
378
379fn has_valid_value(batch: &Columnar, ctx: &mut ExecutionCtx) -> VortexResult<bool> {
380    let (validity, len) = match batch {
381        Columnar::Canonical(Canonical::Primitive(array)) => {
382            (array.as_ref().validity()?, array.as_ref().len())
383        }
384        Columnar::Canonical(Canonical::Bool(array)) => {
385            (array.as_ref().validity()?, array.as_ref().len())
386        }
387        Columnar::Canonical(Canonical::Decimal(array)) => {
388            (array.as_ref().validity()?, array.as_ref().len())
389        }
390        Columnar::Canonical(_) => return Ok(false),
391        Columnar::Constant(constant) => {
392            return Ok(!constant.is_empty() && !constant.scalar().is_null());
393        }
394    };
395    Ok(validity.execute_mask(len, ctx)?.true_count() > 0)
396}
397
398/// Reads the `sum`, `is_overflow` and `is_empty` fields of a partial scalar.
399///
400/// This reads the struct values directly, because building a [`Scalar`] for each field costs more
401/// than the merge itself.
402fn decode_partial_scalar<'a>(
403    scalar: &'a Scalar,
404    return_dtype: &DType,
405) -> VortexResult<(&'a ScalarValue, bool, bool)> {
406    vortex_ensure!(!scalar.is_null(), "SumV2 partial must not be null");
407
408    let (DType::Struct(fields, _), Some(ScalarValue::Tuple(values))) =
409        (scalar.dtype(), scalar.value())
410    else {
411        vortex_bail!("SumV2 partial must be a struct, got {}", scalar.dtype());
412    };
413
414    let field_index = |name: &str| {
415        fields
416            .find(name)
417            .ok_or_else(|| vortex_err!("SumV2 partial is missing the {name} field"))
418    };
419    let bool_field = |name: &str| -> VortexResult<bool> {
420        match &values[field_index(name)?] {
421            Some(ScalarValue::Bool(value)) => Ok(*value),
422            _ => vortex_bail!("SumV2 partial field {name} must be a non-null bool"),
423        }
424    };
425
426    let sum_index = field_index(SUM_FIELD)?;
427    let sum_dtype = fields
428        .field_dtypes()
429        .nth(sum_index)
430        .vortex_expect("field index from find is in bounds");
431    validate_sum_field_dtype(sum_dtype, return_dtype)?;
432
433    let sum = values[sum_index]
434        .as_ref()
435        .ok_or_else(|| vortex_err!("SumV2 partial sum must not be null"))?;
436
437    Ok((
438        sum,
439        bool_field(IS_OVERFLOW_FIELD)?,
440        bool_field(IS_EMPTY_FIELD)?,
441    ))
442}
443
444fn validate_sum_field_dtype(sum_dtype: &DType, return_dtype: &DType) -> VortexResult<()> {
445    vortex_ensure!(
446        sum_dtype.nullability() == Nullability::NonNullable
447            && sum_dtype.eq_ignore_nullability(return_dtype),
448        "SumV2 partial value has dtype {}, expected {}",
449        sum_dtype,
450        return_dtype.as_nonnullable(),
451    );
452    Ok(())
453}
454
455fn checked_add_sum_state(
456    state: &mut SumState,
457    return_dtype: &DType,
458    other: &Scalar,
459) -> VortexResult<bool> {
460    let other = other
461        .value()
462        .ok_or_else(|| vortex_err!("Can't extract present value from null scalar"))?;
463    checked_add_sum_value(state, return_dtype, other)
464}
465
466fn checked_add_sum_value(
467    state: &mut SumState,
468    return_dtype: &DType,
469    other: &ScalarValue,
470) -> VortexResult<bool> {
471    Ok(match (state, other) {
472        (SumState::Unsigned(sum), ScalarValue::Primitive(other)) => {
473            checked_add_u64(sum, other.cast::<u64>()?)
474        }
475        (SumState::Signed(sum), ScalarValue::Primitive(other)) => {
476            checked_add_i64(sum, other.cast::<i64>()?)
477        }
478        (SumState::Float(sum), ScalarValue::Primitive(other)) => {
479            *sum += other.cast::<f64>()?;
480            false
481        }
482        (SumState::Decimal(value), ScalarValue::Decimal(other)) => {
483            let dtype = return_dtype
484                .as_decimal_opt()
485                .vortex_expect("decimal sum result dtype");
486            match value.checked_add(other) {
487                Some(result) if result.fits_in_precision(*dtype) => {
488                    *value = result;
489                    false
490                }
491                Some(_) | None => true,
492            }
493        }
494        (_, other) => vortex_bail!("SumV2 cannot add {other} to a {return_dtype} sum"),
495    })
496}
497
498fn sum_v2_partial_dtype(sum_dtype: DType) -> DType {
499    DType::Struct(sum_v2_partial_fields(sum_dtype), Nullability::Nullable)
500}
501
502fn sum_v2_partial_fields(sum_dtype: DType) -> StructFields {
503    StructFields::new(
504        FieldNames::from_iter([
505            FieldName::from(SUM_FIELD),
506            FieldName::from(IS_OVERFLOW_FIELD),
507            FieldName::from(IS_EMPTY_FIELD),
508        ]),
509        vec![
510            sum_dtype.as_nonnullable(),
511            DType::Bool(Nullability::NonNullable),
512            DType::Bool(Nullability::NonNullable),
513        ],
514    )
515}
516
517fn sum_state_scalar(
518    partial: &SumV2Partial,
519    return_dtype: &DType,
520    nullability: Nullability,
521) -> Scalar {
522    match &partial.sum {
523        SumState::Unsigned(value) => Scalar::primitive(*value, nullability),
524        SumState::Signed(value) => Scalar::primitive(*value, nullability),
525        SumState::Float(value) => Scalar::primitive(*value, nullability),
526        SumState::Decimal(value) => Scalar::decimal(
527            *value,
528            *return_dtype
529                .as_decimal_opt()
530                .vortex_expect("decimal sum result dtype"),
531            nullability,
532        ),
533    }
534}
535
536fn checked_add_u64(sum: &mut u64, value: u64) -> bool {
537    match sum.checked_add(value) {
538        Some(result) => {
539            *sum = result;
540            false
541        }
542        None => true,
543    }
544}
545
546fn checked_add_i64(sum: &mut i64, value: i64) -> bool {
547    match sum.checked_add(value) {
548        Some(result) => {
549            *sum = result;
550            false
551        }
552        None => true,
553    }
554}
555
556#[cfg(test)]
557mod tests;