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::DecimalValue;
52use crate::scalar::Scalar;
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)?;
162        validate_sum_field_dtype(&sum, args.return_dtype)?;
163
164        // Adding the parsed value to the zero state cannot overflow; treat a decimal value that
165        // no longer fits its precision as an already-overflowed partial.
166        let overflowed = checked_add_sum_state(&mut partial.sum, args.return_dtype, &sum)?;
167        partial.is_overflow = is_overflow || overflowed;
168        partial.is_empty = is_empty && !partial.is_overflow;
169        Ok(partial)
170    }
171
172    fn merge_partials(
173        &self,
174        args: AggregateArgs<'_, Self::Options>,
175        mut acc: Self::Partial,
176        partial: Self::Partial,
177    ) -> VortexResult<Self::Partial> {
178        if acc.is_overflow {
179            return Ok(acc);
180        }
181        if partial.is_overflow {
182            // An overflowed state keeps its last valid sum, so an empty accumulator adopts the
183            // incoming sum rather than reporting zero.
184            if acc.is_empty {
185                acc.sum = partial.sum;
186            }
187            acc.is_overflow = true;
188            acc.is_empty = false;
189            return Ok(acc);
190        }
191        if partial.is_empty {
192            return Ok(acc);
193        }
194        acc.is_overflow = checked_add_sum_states(&mut acc.sum, args.return_dtype, &partial.sum)?;
195        acc.is_empty = false;
196        Ok(acc)
197    }
198
199    fn to_scalar(
200        &self,
201        args: AggregateArgs<'_, Self::Options>,
202        partial: &Self::Partial,
203    ) -> VortexResult<Scalar> {
204        Ok(Scalar::struct_(
205            args.partial_dtype.clone(),
206            vec![
207                sum_state_scalar(partial, args.return_dtype, Nullability::NonNullable),
208                Scalar::bool(partial.is_overflow, Nullability::NonNullable),
209                Scalar::bool(partial.is_empty, Nullability::NonNullable),
210            ],
211        ))
212    }
213
214    fn is_saturated(
215        &self,
216        _args: AggregateArgs<'_, Self::Options>,
217        partial: &Self::Partial,
218    ) -> bool {
219        partial.is_overflow || matches!(&partial.sum, SumState::Float(value) if value.is_nan())
220    }
221
222    fn try_accumulate(
223        &self,
224        args: AggregateArgs<'_, Self::Options>,
225        partial: &mut Self::Partial,
226        batch: &ArrayRef,
227        _ctx: &mut ExecutionCtx,
228    ) -> VortexResult<bool> {
229        if args.options.skip_nans || !matches!(&partial.sum, SumState::Float(_)) {
230            return Ok(false);
231        }
232
233        match batch.statistics().get_as::<u64>(Stat::NaNCount) {
234            Precision::Exact(0) => Ok(false),
235            Precision::Exact(_) => {
236                let SumState::Float(sum) = &mut partial.sum else {
237                    unreachable!("checked float sum state")
238                };
239                *sum = f64::NAN;
240                partial.is_empty = false;
241                Ok(true)
242            }
243            _ => Ok(false),
244        }
245    }
246
247    fn accumulate(
248        &self,
249        args: AggregateArgs<'_, Self::Options>,
250        partial: &mut Self::Partial,
251        batch: &Columnar,
252        ctx: &mut ExecutionCtx,
253    ) -> VortexResult<()> {
254        if partial.is_overflow {
255            return Ok(());
256        }
257
258        if let Columnar::Constant(constant) = batch {
259            if !constant.scalar().is_null() && !constant.is_empty() {
260                partial.is_empty = false;
261            }
262            if args.options.skip_nans
263                && constant
264                    .scalar()
265                    .as_primitive_opt()
266                    .is_some_and(|primitive| primitive.is_nan())
267            {
268                return Ok(());
269            }
270            if let Some(product) =
271                multiply_constant(constant.scalar(), constant.len(), args.return_dtype)?
272            {
273                if product.is_null() {
274                    partial.is_overflow = true;
275                    partial.is_empty = false;
276                } else {
277                    partial.is_overflow =
278                        checked_add_sum_state(&mut partial.sum, args.return_dtype, &product)?;
279                    partial.is_empty = false;
280                }
281            }
282            return Ok(());
283        }
284
285        let any_valid = partial.is_empty && has_valid_value(batch, ctx)?;
286        let result = match batch {
287            Columnar::Canonical(canonical) => match canonical {
288                Canonical::Primitive(array) => {
289                    accumulate_primitive(&mut partial.sum, array, ctx, args.options.skip_nans)
290                }
291                Canonical::Bool(array) => accumulate_bool(&mut partial.sum, array, ctx),
292                Canonical::Decimal(array) => {
293                    accumulate_decimal(&mut partial.sum, args.return_dtype, array, ctx)
294                }
295                _ => vortex_bail!("Unsupported canonical type for sum_v2: {}", batch.dtype()),
296            },
297            Columnar::Constant(_) => unreachable!(),
298        };
299
300        if any_valid {
301            partial.is_empty = false;
302        }
303        if result? {
304            partial.is_overflow = true;
305            partial.is_empty = false;
306        }
307        Ok(())
308    }
309
310    fn finalize(
311        &self,
312        _args: AggregateArgs<'_, Self::Options>,
313        partials: ArrayRef,
314    ) -> VortexResult<ArrayRef> {
315        if let Some(partials) = partials.as_opt::<Struct>() {
316            return finalize_struct(partials);
317        }
318
319        let sum = partials.get_item(SUM_FIELD)?;
320        let is_invalid = partials
321            .get_item(IS_OVERFLOW_FIELD)?
322            .binary(partials.get_item(IS_EMPTY_FIELD)?, Operator::Or)?
323            .fill_null(true)?;
324        sum.mask(is_invalid.not()?)
325    }
326
327    fn finalize_scalar(
328        &self,
329        args: AggregateArgs<'_, Self::Options>,
330        partial: &Self::Partial,
331    ) -> VortexResult<Scalar> {
332        if partial.is_overflow || partial.is_empty {
333            return Ok(Scalar::null(args.return_dtype.as_nullable()));
334        }
335        Ok(sum_state_scalar(
336            partial,
337            args.return_dtype,
338            Nullability::Nullable,
339        ))
340    }
341}
342
343fn finalize_struct(partials: ArrayView<'_, Struct>) -> VortexResult<ArrayRef> {
344    let sum = partials.unmasked_field_by_name(SUM_FIELD)?.clone();
345    let is_overflow = partials.unmasked_field_by_name(IS_OVERFLOW_FIELD)?.clone();
346    let is_empty = partials.unmasked_field_by_name(IS_EMPTY_FIELD)?.clone();
347
348    let is_invalid = is_overflow
349        .binary(is_empty, Operator::Or)?
350        .fill_null(true)?;
351    let mut is_valid = is_invalid.not()?;
352    match partials.struct_validity() {
353        Validity::NonNullable | Validity::AllValid => {}
354        validity => {
355            is_valid = is_valid.binary(validity.to_array(partials.len()), Operator::And)?;
356        }
357    }
358
359    sum.mask(is_valid)
360}
361
362/// In-memory state for SumV2 accumulation.
363pub struct SumV2Partial {
364    sum: SumState,
365    is_overflow: bool,
366    is_empty: bool,
367}
368
369impl SumV2Partial {
370    /// The state of a group with no accumulated values.
371    fn empty(return_dtype: &DType) -> Self {
372        Self {
373            sum: make_zero_state(return_dtype),
374            is_overflow: false,
375            is_empty: true,
376        }
377    }
378}
379
380fn has_valid_value(batch: &Columnar, ctx: &mut ExecutionCtx) -> VortexResult<bool> {
381    let (validity, len) = match batch {
382        Columnar::Canonical(Canonical::Primitive(array)) => {
383            (array.as_ref().validity()?, array.as_ref().len())
384        }
385        Columnar::Canonical(Canonical::Bool(array)) => {
386            (array.as_ref().validity()?, array.as_ref().len())
387        }
388        Columnar::Canonical(Canonical::Decimal(array)) => {
389            (array.as_ref().validity()?, array.as_ref().len())
390        }
391        Columnar::Canonical(_) => return Ok(false),
392        Columnar::Constant(constant) => {
393            return Ok(!constant.is_empty() && !constant.scalar().is_null());
394        }
395    };
396    Ok(validity.execute_mask(len, ctx)?.true_count() > 0)
397}
398
399fn decode_partial_scalar(scalar: Scalar) -> VortexResult<(Scalar, bool, bool)> {
400    vortex_ensure!(!scalar.is_null(), "SumV2 partial must not be null");
401
402    let Some(fields) = scalar.as_struct_opt() else {
403        vortex_bail!("SumV2 partial must be a struct, got {}", scalar.dtype());
404    };
405    let sum = fields
406        .field(SUM_FIELD)
407        .ok_or_else(|| vortex_err!("SumV2 partial is missing the sum field"))?;
408    let is_overflow = bool::try_from(
409        &fields
410            .field(IS_OVERFLOW_FIELD)
411            .ok_or_else(|| vortex_err!("SumV2 partial is missing the is_overflow field"))?,
412    )?;
413    let is_empty = bool::try_from(
414        &fields
415            .field(IS_EMPTY_FIELD)
416            .ok_or_else(|| vortex_err!("SumV2 partial is missing the is_empty field"))?,
417    )?;
418
419    Ok((sum, is_overflow, is_empty))
420}
421
422fn validate_sum_field_dtype(sum: &Scalar, return_dtype: &DType) -> VortexResult<()> {
423    vortex_ensure!(
424        sum.dtype().nullability() == Nullability::NonNullable
425            && sum.dtype().eq_ignore_nullability(return_dtype),
426        "SumV2 partial value has dtype {}, expected {}",
427        sum.dtype(),
428        return_dtype.as_nonnullable(),
429    );
430    Ok(())
431}
432
433fn checked_add_sum_state(
434    state: &mut SumState,
435    return_dtype: &DType,
436    other: &Scalar,
437) -> VortexResult<bool> {
438    Ok(match state {
439        SumState::Unsigned(sum) => checked_add_u64(sum, u64::try_from(other)?),
440        SumState::Signed(sum) => checked_add_i64(sum, i64::try_from(other)?),
441        SumState::Float(sum) => {
442            *sum += f64::try_from(other)?;
443            false
444        }
445        SumState::Decimal(value) => {
446            let dtype = return_dtype
447                .as_decimal_opt()
448                .vortex_expect("decimal sum result dtype");
449            let other = DecimalValue::try_from(other)?;
450            match value.checked_add(&other) {
451                Some(result) if result.fits_in_precision(*dtype) => {
452                    *value = result;
453                    false
454                }
455                Some(_) | None => true,
456            }
457        }
458    })
459}
460
461fn sum_v2_partial_dtype(sum_dtype: DType) -> DType {
462    DType::Struct(sum_v2_partial_fields(sum_dtype), Nullability::Nullable)
463}
464
465fn sum_v2_partial_fields(sum_dtype: DType) -> StructFields {
466    StructFields::new(
467        FieldNames::from_iter([
468            FieldName::from(SUM_FIELD),
469            FieldName::from(IS_OVERFLOW_FIELD),
470            FieldName::from(IS_EMPTY_FIELD),
471        ]),
472        vec![
473            sum_dtype.as_nonnullable(),
474            DType::Bool(Nullability::NonNullable),
475            DType::Bool(Nullability::NonNullable),
476        ],
477    )
478}
479
480fn sum_state_scalar(
481    partial: &SumV2Partial,
482    return_dtype: &DType,
483    nullability: Nullability,
484) -> Scalar {
485    match &partial.sum {
486        SumState::Unsigned(value) => Scalar::primitive(*value, nullability),
487        SumState::Signed(value) => Scalar::primitive(*value, nullability),
488        SumState::Float(value) => Scalar::primitive(*value, nullability),
489        SumState::Decimal(value) => Scalar::decimal(
490            *value,
491            *return_dtype
492                .as_decimal_opt()
493                .vortex_expect("decimal sum result dtype"),
494            nullability,
495        ),
496    }
497}
498
499fn checked_add_u64(sum: &mut u64, value: u64) -> bool {
500    match sum.checked_add(value) {
501        Some(result) => {
502            *sum = result;
503            false
504        }
505        None => true,
506    }
507}
508
509fn checked_add_i64(sum: &mut i64, value: i64) -> bool {
510    match sum.checked_add(value) {
511        Some(result) => {
512            *sum = result;
513            false
514        }
515        None => true,
516    }
517}
518
519#[cfg(test)]
520mod tests;