Skip to main content

vortex_array/aggregate_fn/fns/min_max/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4mod bool;
5mod decimal;
6mod extension;
7mod primitive;
8mod varbinview;
9
10use std::sync::LazyLock;
11
12use vortex_error::VortexExpect;
13use vortex_error::VortexResult;
14use vortex_error::vortex_bail;
15use vortex_error::vortex_panic;
16use vortex_session::registry::CachedId;
17
18use self::bool::accumulate_bool;
19use self::decimal::accumulate_decimal;
20use self::extension::accumulate_extension;
21use self::primitive::accumulate_primitive;
22use self::varbinview::accumulate_varbinview;
23use crate::ArrayRef;
24use crate::Canonical;
25use crate::Columnar;
26use crate::ExecutionCtx;
27use crate::aggregate_fn::Accumulator;
28use crate::aggregate_fn::AggregateArgs;
29use crate::aggregate_fn::AggregateFnId;
30use crate::aggregate_fn::AggregateFnVTable;
31use crate::aggregate_fn::DynAccumulator;
32use crate::aggregate_fn::NumericalAggregateOpts;
33use crate::dtype::DType;
34use crate::dtype::FieldNames;
35use crate::dtype::Nullability;
36use crate::dtype::PType;
37use crate::dtype::StructFields;
38use crate::dtype::half::f16;
39use crate::expr::stats::Precision;
40use crate::expr::stats::Stat;
41use crate::expr::stats::StatsProvider;
42use crate::expr::stats::StatsProviderExt;
43use crate::partial_ord::partial_max;
44use crate::partial_ord::partial_min;
45use crate::scalar::Scalar;
46
47static NAMES: LazyLock<FieldNames> = LazyLock::new(|| FieldNames::from(["min", "max"]));
48
49/// The minimum and maximum non-null values of an array, or `None` if there are no non-null values.
50///
51/// NaN handling for float inputs is controlled by [`NumericalAggregateOpts`]: with `skip_nans` (the
52/// default) NaN values are ignored and the cached `Stat::Min`/`Stat::Max` statistics are consulted
53/// and updated. With `skip_nans=false`, any NaN value in a float array poisons both extrema to
54/// NaN; an exact `Stat::NaNCount` statistic shortcircuits the NaN scan in either direction.
55///
56/// The result scalars have the non-nullable version of the array dtype.
57/// This will update the stats set of the array as a side effect.
58pub fn min_max(
59    array: &ArrayRef,
60    ctx: &mut ExecutionCtx,
61    options: NumericalAggregateOpts,
62) -> VortexResult<Option<MinMaxResult>> {
63    if !options.skip_nans && array.dtype().is_float() {
64        match array.statistics().get_as::<u64>(Stat::NaNCount) {
65            // NaN-free: identical to the NaN-skipping path below, including its stat caching.
66            Precision::Exact(0) => {}
67            // At least one NaN value poisons both extrema.
68            Precision::Exact(_) => return Ok(Some(nan_minmax_result(array.dtype()))),
69            _ => {
70                if array.is_empty() || array.valid_count(ctx)? == 0 {
71                    return Ok(None);
72                }
73                // Compute with NaN-including options; the NaN-skipping `Stat::Min`/`Stat::Max`
74                // caches are neither read nor written.
75                let mut acc = Accumulator::try_new(MinMax, options, array.dtype().clone())?;
76                acc.accumulate(array, ctx)?;
77                return MinMaxResult::from_scalar(&acc.finish()?);
78            }
79        }
80    }
81
82    // NaN-skipping path. Also reached for NaN-free not-skipping float arrays and all non-float
83    // arrays, where `skip_nans` has no effect.
84
85    // Short-circuit using cached array statistics.
86    let cached_min = array.statistics().get(Stat::Min).as_exact();
87    let cached_max = array.statistics().get(Stat::Max).as_exact();
88    if let Some((min, max)) = cached_min.zip(cached_max) {
89        let non_nullable_dtype = array.dtype().as_nonnullable();
90        return Ok(Some(MinMaxResult {
91            min: min.cast(&non_nullable_dtype)?,
92            max: max.cast(&non_nullable_dtype)?,
93        }));
94    }
95
96    // Short-circuit for empty arrays or all-null arrays.
97    if array.is_empty() || array.valid_count(ctx)? == 0 {
98        return Ok(None);
99    }
100
101    // Short-circuit for dtypes this helper cannot currently compute.
102    if !minmax_compute_supported_dtype(array.dtype()) {
103        return Ok(None);
104    }
105
106    // Compute using Accumulator<MinMax>.
107    let mut acc = Accumulator::try_new(
108        MinMax,
109        NumericalAggregateOpts::default(),
110        array.dtype().clone(),
111    )?;
112    acc.accumulate(array, ctx)?;
113    let result_scalar = acc.finish()?;
114    let result = MinMaxResult::from_scalar(&result_scalar)?;
115
116    // Cache the computed min/max as statistics.
117    if let Some(r) = &result {
118        if let Some(min_value) = r.min.value() {
119            array
120                .statistics()
121                .set(Stat::Min, Precision::Exact(min_value.clone()));
122        }
123        if let Some(max_value) = r.max.value() {
124            array
125                .statistics()
126                .set(Stat::Max, Precision::Exact(max_value.clone()));
127        }
128    }
129
130    Ok(result)
131}
132
133/// A `{min: NaN, max: NaN}` result for a poisoned NaN-including min/max over `dtype`.
134fn nan_minmax_result(dtype: &DType) -> MinMaxResult {
135    let nan = nan_scalar(dtype);
136    MinMaxResult {
137        min: nan.clone(),
138        max: nan,
139    }
140}
141
142/// A non-nullable NaN scalar of the float `dtype`.
143pub(crate) fn nan_scalar(dtype: &DType) -> Scalar {
144    match dtype.as_ptype() {
145        PType::F16 => Scalar::primitive(f16::NAN, Nullability::NonNullable),
146        PType::F32 => Scalar::primitive(f32::NAN, Nullability::NonNullable),
147        PType::F64 => Scalar::primitive(f64::NAN, Nullability::NonNullable),
148        _ => vortex_panic!("NaN scalar requested for non-float dtype {dtype}"),
149    }
150}
151
152/// Whether a scalar holds a primitive float NaN value.
153pub(crate) fn scalar_is_nan(scalar: &Scalar) -> bool {
154    if !scalar.dtype().is_float() {
155        return false;
156    }
157
158    scalar.as_primitive_opt().is_some_and(|p| p.is_nan())
159}
160
161/// The minimum and maximum non-null values of an array.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct MinMaxResult {
164    pub min: Scalar,
165    pub max: Scalar,
166}
167
168impl MinMaxResult {
169    /// Extract a `MinMaxResult` from a struct scalar with `{min, max}` fields.
170    pub fn from_scalar(scalar: &Scalar) -> VortexResult<Option<Self>> {
171        if scalar.is_null() {
172            Ok(None)
173        } else {
174            let min = scalar
175                .as_struct()
176                .field_by_idx(0)
177                .vortex_expect("missing min field");
178            let max = scalar
179                .as_struct()
180                .field_by_idx(1)
181                .vortex_expect("missing max field");
182            Ok(Some(MinMaxResult { min, max }))
183        }
184    }
185}
186
187/// Compute the min and max of an array.
188///
189/// Returns a nullable struct scalar `{min: T, max: T}` where `T` is the non-nullable input dtype.
190/// The struct is null when the array is empty or all-null.
191///
192/// NaN handling for float inputs is controlled by [`NumericalAggregateOpts`]: with `skip_nans` (the
193/// default) NaN values are ignored, otherwise any NaN value poisons both extrema to NaN.
194#[derive(Clone, Debug)]
195pub struct MinMax;
196
197/// Partial accumulator state for min/max.
198pub struct MinMaxPartial {
199    min: Option<Scalar>,
200    max: Option<Scalar>,
201}
202
203impl MinMaxPartial {
204    /// Merge a local `MinMaxResult` into this partial state.
205    fn merge(
206        &mut self,
207        args: AggregateArgs<'_, NumericalAggregateOpts>,
208        local: Option<MinMaxResult>,
209    ) {
210        let Some(MinMaxResult { min, max }) = local else {
211            return;
212        };
213
214        // NaN scalars are incomparable under `partial_min`/`partial_max`, so they are handled
215        // explicitly: a NaN extremum poisons the partial state when NaNs participate, and is
216        // dropped when they are skipped.
217        if scalar_is_nan(&min) || scalar_is_nan(&max) || self.is_poisoned() {
218            if !args.options.skip_nans {
219                self.poison(args);
220            }
221            return;
222        }
223
224        self.min = Some(match self.min.take() {
225            Some(current) => partial_min(min, current).vortex_expect("incomparable min scalars"),
226            None => min,
227        });
228
229        self.max = Some(match self.max.take() {
230            Some(current) => partial_max(max, current).vortex_expect("incomparable max scalars"),
231            None => max,
232        });
233    }
234
235    /// Poison the partial state to `{min: NaN, max: NaN}`.
236    fn poison(&mut self, args: AggregateArgs<'_, NumericalAggregateOpts>) {
237        let nan = nan_scalar(args.dtype);
238        self.min = Some(nan.clone());
239        self.max = Some(nan);
240    }
241
242    /// Whether the partial state is poisoned to NaN.
243    fn is_poisoned(&self) -> bool {
244        self.min.as_ref().is_some_and(scalar_is_nan)
245    }
246}
247
248/// Creates the struct dtype `{min: T, max: T}` (nullable) used for min/max aggregate results.
249pub fn make_minmax_dtype(element_dtype: &DType) -> DType {
250    DType::Struct(
251        StructFields::new(
252            NAMES.clone(),
253            vec![
254                element_dtype.as_nonnullable(),
255                element_dtype.as_nonnullable(),
256            ],
257        ),
258        Nullability::Nullable,
259    )
260}
261
262fn minmax_supported_dtype(input_dtype: &DType) -> bool {
263    match input_dtype {
264        DType::Bool(_)
265        | DType::Primitive(..)
266        | DType::Decimal(..)
267        | DType::Utf8(..)
268        | DType::Binary(..)
269        | DType::Extension(..) => true,
270        DType::List(element_dtype, _) => minmax_supported_dtype(element_dtype),
271        DType::FixedSizeList(element_dtype, ..) => minmax_supported_dtype(element_dtype),
272        _ => false,
273    }
274}
275
276/// Returns whether [`min_max`] can currently compute extrema for this logical dtype.
277///
278/// This is intentionally narrower than [`minmax_supported_dtype`]. List and fixed-size-list
279/// extrema have a defined output dtype for aggregate expression lowering, but the accumulator does
280/// not yet implement lexicographic list comparison.
281fn minmax_compute_supported_dtype(input_dtype: &DType) -> bool {
282    matches!(
283        input_dtype,
284        DType::Bool(_)
285            | DType::Primitive(..)
286            | DType::Decimal(..)
287            | DType::Utf8(..)
288            | DType::Binary(..)
289            | DType::Extension(..)
290    )
291}
292
293impl AggregateFnVTable for MinMax {
294    type Options = NumericalAggregateOpts;
295    type Partial = MinMaxPartial;
296
297    fn id(&self) -> AggregateFnId {
298        static ID: CachedId = CachedId::new("vortex.min_max");
299        *ID
300    }
301
302    fn serialize(&self, _options: &Self::Options) -> VortexResult<Option<Vec<u8>>> {
303        Ok(None)
304    }
305
306    fn return_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option<DType> {
307        minmax_supported_dtype(input_dtype).then(|| make_minmax_dtype(input_dtype))
308    }
309
310    fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option<DType> {
311        self.return_dtype(options, input_dtype)
312    }
313
314    fn empty_partial(
315        &self,
316        _args: AggregateArgs<'_, Self::Options>,
317    ) -> VortexResult<Self::Partial> {
318        Ok(MinMaxPartial {
319            min: None,
320            max: None,
321        })
322    }
323
324    fn partial_from_scalar(
325        &self,
326        args: AggregateArgs<'_, Self::Options>,
327        scalar: &Scalar,
328    ) -> VortexResult<Self::Partial> {
329        let mut partial = MinMaxPartial {
330            min: None,
331            max: None,
332        };
333        // `merge` normalizes the parsed extrema: nulls stay empty and NaNs poison or drop.
334        partial.merge(args, MinMaxResult::from_scalar(scalar)?);
335        Ok(partial)
336    }
337
338    fn merge_partials(
339        &self,
340        args: AggregateArgs<'_, Self::Options>,
341        mut first: Self::Partial,
342        second: Self::Partial,
343    ) -> VortexResult<Self::Partial> {
344        if let (Some(min), Some(max)) = (second.min, second.max) {
345            first.merge(args, Some(MinMaxResult { min, max }));
346        }
347        Ok(first)
348    }
349
350    fn to_scalar(
351        &self,
352        args: AggregateArgs<'_, Self::Options>,
353        partial: &Self::Partial,
354    ) -> VortexResult<Scalar> {
355        let dtype = args.partial_dtype.clone();
356        Ok(match (&partial.min, &partial.max) {
357            (Some(min), Some(max)) => Scalar::struct_(dtype, vec![min.clone(), max.clone()]),
358            _ => Scalar::null(dtype),
359        })
360    }
361
362    #[inline]
363    fn is_saturated(
364        &self,
365        _args: AggregateArgs<'_, Self::Options>,
366        partial: &Self::Partial,
367    ) -> bool {
368        // A poisoned NaN-including min/max is fully determined.
369        partial.is_poisoned()
370    }
371
372    fn try_accumulate(
373        &self,
374        args: AggregateArgs<'_, Self::Options>,
375        partial: &mut Self::Partial,
376        batch: &ArrayRef,
377        _ctx: &mut ExecutionCtx,
378    ) -> VortexResult<bool> {
379        // NaN-aware shortcircuits only apply to NaN-including float min/max; everything else
380        // takes the default dispatch path.
381        if args.options.skip_nans || !args.dtype.is_float() {
382            return Ok(false);
383        }
384        match batch.statistics().get_as::<u64>(Stat::NaNCount) {
385            Precision::Exact(0) => {
386                // NaN-free batch: the cached NaN-skipping extrema (if any) are valid.
387                let cached_min = batch.statistics().get(Stat::Min).as_exact();
388                let cached_max = batch.statistics().get(Stat::Max).as_exact();
389                if let Some((min, max)) = cached_min.zip(cached_max) {
390                    // Cached float stats carry the (possibly nullable) array dtype; `to_scalar`
391                    // builds a struct with non-nullable fields, so normalise here.
392                    let non_nullable_dtype = args.dtype.as_nonnullable();
393                    partial.merge(
394                        args,
395                        Some(MinMaxResult {
396                            min: min.cast(&non_nullable_dtype)?,
397                            max: max.cast(&non_nullable_dtype)?,
398                        }),
399                    );
400                    return Ok(true);
401                }
402                Ok(false)
403            }
404            Precision::Exact(_) => {
405                // At least one NaN value poisons both extrema without scanning the batch.
406                partial.poison(args);
407                Ok(true)
408            }
409            _ => Ok(false),
410        }
411    }
412
413    fn accumulate(
414        &self,
415        args: AggregateArgs<'_, Self::Options>,
416        partial: &mut Self::Partial,
417        batch: &Columnar,
418        ctx: &mut ExecutionCtx,
419    ) -> VortexResult<()> {
420        match batch {
421            Columnar::Constant(c) => {
422                if c.is_empty() {
423                    return Ok(());
424                }
425
426                let scalar = c.scalar();
427                if scalar.is_null() {
428                    return Ok(());
429                }
430                // NaN float constants are skipped or poison the extrema, per the options.
431                if scalar_is_nan(scalar) {
432                    if !args.options.skip_nans {
433                        partial.poison(args);
434                    }
435                    return Ok(());
436                }
437                let non_nullable_dtype = scalar.dtype().as_nonnullable();
438                let cast = scalar.cast(&non_nullable_dtype)?;
439                partial.merge(
440                    args,
441                    Some(MinMaxResult {
442                        min: cast.clone(),
443                        max: cast,
444                    }),
445                );
446                Ok(())
447            }
448            Columnar::Canonical(c) => match c {
449                Canonical::Primitive(p) => accumulate_primitive(args, partial, p, ctx),
450                Canonical::Bool(b) => accumulate_bool(args, partial, b, ctx),
451                Canonical::VarBinView(v) => accumulate_varbinview(args, partial, v, ctx),
452                Canonical::Decimal(d) => accumulate_decimal(args, partial, d, ctx),
453                Canonical::Extension(e) => accumulate_extension(args, partial, e, ctx),
454                Canonical::Null(_) => Ok(()),
455                Canonical::Union(_) => {
456                    vortex_bail!("TODO(connor)[Union]: implement min_max for Union arrays")
457                }
458                Canonical::Struct(_)
459                | Canonical::List(_)
460                | Canonical::Map(_)
461                | Canonical::FixedSizeList(_)
462                | Canonical::Variant(_) => {
463                    vortex_bail!("Unsupported canonical type for min_max: {}", batch.dtype())
464                }
465            },
466        }
467    }
468
469    fn finalize(
470        &self,
471        _args: AggregateArgs<'_, Self::Options>,
472        partials: ArrayRef,
473    ) -> VortexResult<ArrayRef> {
474        Ok(partials)
475    }
476
477    fn finalize_scalar(
478        &self,
479        args: AggregateArgs<'_, Self::Options>,
480        partial: &Self::Partial,
481    ) -> VortexResult<Scalar> {
482        self.to_scalar(args, partial)
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use std::sync::Arc;
489    use std::sync::LazyLock;
490
491    use vortex_buffer::BitBuffer;
492    use vortex_buffer::buffer;
493    use vortex_error::VortexExpect;
494    use vortex_error::VortexResult;
495    use vortex_session::VortexSession;
496
497    use crate::IntoArray as _;
498    use crate::VortexSessionExecute;
499    use crate::aggregate_fn::Accumulator;
500    use crate::aggregate_fn::AggregateDTypes;
501    use crate::aggregate_fn::AggregateFnVTable;
502    use crate::aggregate_fn::DynAccumulator;
503    use crate::aggregate_fn::NumericalAggregateOpts;
504    use crate::aggregate_fn::fns::min_max::MinMax;
505    use crate::aggregate_fn::fns::min_max::MinMaxPartial;
506    use crate::aggregate_fn::fns::min_max::MinMaxResult;
507    use crate::aggregate_fn::fns::min_max::make_minmax_dtype;
508    use crate::aggregate_fn::fns::min_max::min_max;
509    use crate::arrays::BoolArray;
510    use crate::arrays::ChunkedArray;
511    use crate::arrays::ConstantArray;
512    use crate::arrays::DecimalArray;
513    use crate::arrays::FixedSizeListArray;
514    use crate::arrays::ListArray;
515    use crate::arrays::NullArray;
516    use crate::arrays::PrimitiveArray;
517    use crate::arrays::VarBinArray;
518    use crate::dtype::DType;
519    use crate::dtype::DecimalDType;
520    use crate::dtype::Nullability;
521    use crate::dtype::PType;
522    use crate::expr::stats::Precision;
523    use crate::expr::stats::Stat;
524    use crate::scalar::DecimalValue;
525    use crate::scalar::Scalar;
526    use crate::scalar::ScalarValue;
527    use crate::validity::Validity;
528
529    static SESSION: LazyLock<VortexSession> = LazyLock::new(vortex_array::array_session);
530
531    #[test]
532    fn test_prim_min_max() -> VortexResult<()> {
533        let p = PrimitiveArray::new(buffer![1, 2, 3], Validity::NonNullable).into_array();
534        let mut ctx = SESSION.create_execution_ctx();
535        assert_eq!(
536            min_max(&p, &mut ctx, NumericalAggregateOpts::default())?,
537            Some(MinMaxResult {
538                min: 1.into(),
539                max: 3.into()
540            })
541        );
542        Ok(())
543    }
544
545    #[test]
546    fn test_prim_min_max_multiple_null_runs() -> VortexResult<()> {
547        // Several disjoint valid runs separated by nulls exercise the per-run fold; the extrema
548        // (min 1, max 9) fall in different runs.
549        let p = PrimitiveArray::from_option_iter([
550            Some(5i32),
551            Some(3),
552            None,
553            None,
554            Some(9),
555            None,
556            Some(1),
557            Some(7),
558        ])
559        .into_array();
560        let mut ctx = SESSION.create_execution_ctx();
561        assert_eq!(
562            min_max(&p, &mut ctx, NumericalAggregateOpts::default())?,
563            Some(MinMaxResult {
564                min: 1.into(),
565                max: 9.into()
566            })
567        );
568        Ok(())
569    }
570
571    #[test]
572    fn test_bool_min_max() -> VortexResult<()> {
573        let mut ctx = SESSION.create_execution_ctx();
574
575        let all_true = BoolArray::new(
576            BitBuffer::from([true, true, true].as_slice()),
577            Validity::NonNullable,
578        )
579        .into_array();
580        assert_eq!(
581            min_max(&all_true, &mut ctx, NumericalAggregateOpts::default())?,
582            Some(MinMaxResult {
583                min: true.into(),
584                max: true.into()
585            })
586        );
587
588        let all_false = BoolArray::new(
589            BitBuffer::from([false, false, false].as_slice()),
590            Validity::NonNullable,
591        )
592        .into_array();
593        assert_eq!(
594            min_max(&all_false, &mut ctx, NumericalAggregateOpts::default())?,
595            Some(MinMaxResult {
596                min: false.into(),
597                max: false.into()
598            })
599        );
600
601        let mixed = BoolArray::new(
602            BitBuffer::from([false, true, false].as_slice()),
603            Validity::NonNullable,
604        )
605        .into_array();
606        assert_eq!(
607            min_max(&mixed, &mut ctx, NumericalAggregateOpts::default())?,
608            Some(MinMaxResult {
609                min: false.into(),
610                max: true.into()
611            })
612        );
613        Ok(())
614    }
615
616    #[test]
617    fn test_null_array() -> VortexResult<()> {
618        let p = NullArray::new(1).into_array();
619        let mut ctx = SESSION.create_execution_ctx();
620        assert_eq!(
621            min_max(&p, &mut ctx, NumericalAggregateOpts::default())?,
622            None
623        );
624        Ok(())
625    }
626
627    #[test]
628    fn test_prim_nan() -> VortexResult<()> {
629        let array = PrimitiveArray::new(
630            buffer![f32::NAN, -f32::NAN, -1.0, 1.0],
631            Validity::NonNullable,
632        );
633        let mut ctx = SESSION.create_execution_ctx();
634        let result = min_max(
635            &array.into_array(),
636            &mut ctx,
637            NumericalAggregateOpts::default(),
638        )?
639        .vortex_expect("should have result");
640        assert_eq!(f32::try_from(&result.min)?, -1.0);
641        assert_eq!(f32::try_from(&result.max)?, 1.0);
642        Ok(())
643    }
644
645    #[test]
646    fn test_prim_inf() -> VortexResult<()> {
647        let array = PrimitiveArray::new(
648            buffer![f32::INFINITY, f32::NEG_INFINITY, -1.0, 1.0],
649            Validity::NonNullable,
650        );
651        let mut ctx = SESSION.create_execution_ctx();
652        let result = min_max(
653            &array.into_array(),
654            &mut ctx,
655            NumericalAggregateOpts::default(),
656        )?
657        .vortex_expect("should have result");
658        assert_eq!(f32::try_from(&result.min)?, f32::NEG_INFINITY);
659        assert_eq!(f32::try_from(&result.max)?, f32::INFINITY);
660        Ok(())
661    }
662
663    #[test]
664    fn test_multi_batch() -> VortexResult<()> {
665        let mut ctx = SESSION.create_execution_ctx();
666        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
667        let mut acc = Accumulator::try_new(MinMax, NumericalAggregateOpts::default(), dtype)?;
668
669        let batch1 = PrimitiveArray::new(buffer![10i32, 20, 5], Validity::NonNullable).into_array();
670        acc.accumulate(&batch1, &mut ctx)?;
671
672        let batch2 = PrimitiveArray::new(buffer![3i32, 25], Validity::NonNullable).into_array();
673        acc.accumulate(&batch2, &mut ctx)?;
674
675        let result = MinMaxResult::from_scalar(&acc.finish()?)?.vortex_expect("should have result");
676        assert_eq!(result.min, Scalar::from(3i32));
677        assert_eq!(result.max, Scalar::from(25i32));
678        Ok(())
679    }
680
681    #[test]
682    fn test_finish_resets_state() -> VortexResult<()> {
683        let mut ctx = SESSION.create_execution_ctx();
684        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
685        let mut acc = Accumulator::try_new(MinMax, NumericalAggregateOpts::default(), dtype)?;
686
687        let batch1 = PrimitiveArray::new(buffer![10i32, 20], Validity::NonNullable).into_array();
688        acc.accumulate(&batch1, &mut ctx)?;
689        let result1 =
690            MinMaxResult::from_scalar(&acc.finish()?)?.vortex_expect("should have result");
691        assert_eq!(result1.min, Scalar::from(10i32));
692        assert_eq!(result1.max, Scalar::from(20i32));
693
694        let batch2 = PrimitiveArray::new(buffer![3i32, 6, 9], Validity::NonNullable).into_array();
695        acc.accumulate(&batch2, &mut ctx)?;
696        let result2 =
697            MinMaxResult::from_scalar(&acc.finish()?)?.vortex_expect("should have result");
698        assert_eq!(result2.min, Scalar::from(3i32));
699        assert_eq!(result2.max, Scalar::from(9i32));
700        Ok(())
701    }
702
703    #[test]
704    fn test_state_merge() -> VortexResult<()> {
705        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
706        let options = NumericalAggregateOpts::default();
707        let owned = AggregateDTypes::try_new(&MinMax, &options, dtype)?;
708        let args = owned.args(&options);
709        let partial_of = |min: i32, max: i32| MinMaxPartial {
710            min: Some(Scalar::from(min)),
711            max: Some(Scalar::from(max)),
712        };
713
714        let state = MinMax.merge_partials(args, partial_of(5, 15), partial_of(2, 10))?;
715
716        let result = MinMaxResult::from_scalar(&MinMax.to_scalar(args, &state)?)?
717            .vortex_expect("should have result");
718        assert_eq!(result.min, Scalar::from(2i32));
719        assert_eq!(result.max, Scalar::from(15i32));
720        Ok(())
721    }
722
723    #[test]
724    fn test_constant_nan() -> VortexResult<()> {
725        let scalar = Scalar::primitive(f16::NAN, Nullability::NonNullable);
726        let array = ConstantArray::new(scalar, 2).into_array();
727        let mut ctx = SESSION.create_execution_ctx();
728        assert_eq!(
729            min_max(&array, &mut ctx, NumericalAggregateOpts::default())?,
730            None
731        );
732        Ok(())
733    }
734
735    const KEEP_NANS: NumericalAggregateOpts = NumericalAggregateOpts::include_nans();
736
737    fn assert_poisoned(result: Option<MinMaxResult>) -> VortexResult<()> {
738        let result = result.vortex_expect("should have result");
739        assert!(f64::try_from(&result.min.cast(&result.min.dtype().as_nullable())?)?.is_nan());
740        assert!(f64::try_from(&result.max.cast(&result.max.dtype().as_nullable())?)?.is_nan());
741        Ok(())
742    }
743
744    #[test]
745    fn test_prim_nan_not_skipping() -> VortexResult<()> {
746        let array = PrimitiveArray::new(
747            buffer![f32::NAN, -f32::NAN, -1.0, 1.0],
748            Validity::NonNullable,
749        )
750        .into_array();
751        let mut ctx = SESSION.create_execution_ctx();
752        assert_poisoned(min_max(&array, &mut ctx, KEEP_NANS)?)
753    }
754
755    #[test]
756    fn test_prim_no_nan_not_skipping() -> VortexResult<()> {
757        let array =
758            PrimitiveArray::new(buffer![3.0f32, -1.0, 1.0], Validity::NonNullable).into_array();
759        let mut ctx = SESSION.create_execution_ctx();
760        let result = min_max(&array, &mut ctx, KEEP_NANS)?.vortex_expect("should have result");
761        assert_eq!(f32::try_from(&result.min)?, -1.0);
762        assert_eq!(f32::try_from(&result.max)?, 3.0);
763        Ok(())
764    }
765
766    #[test]
767    fn test_constant_nan_not_skipping() -> VortexResult<()> {
768        let scalar = Scalar::primitive(f64::NAN, Nullability::NonNullable);
769        let array = ConstantArray::new(scalar, 2).into_array();
770        let mut ctx = SESSION.create_execution_ctx();
771        assert_poisoned(min_max(&array, &mut ctx, KEEP_NANS)?)
772    }
773
774    #[test]
775    fn test_not_skipping_shortcircuits_on_exact_nan_count_stat() -> VortexResult<()> {
776        // The array has no NaNs; a planted exact NaNCount stat proves the poisoning came from
777        // the stat rather than a scan.
778        let array =
779            PrimitiveArray::new(buffer![1.0f64, 2.0, 3.0], Validity::NonNullable).into_array();
780        array
781            .statistics()
782            .set(Stat::NaNCount, Precision::Exact(ScalarValue::from(2u64)));
783        let mut ctx = SESSION.create_execution_ctx();
784        assert_poisoned(min_max(&array, &mut ctx, KEEP_NANS)?)
785    }
786
787    #[test]
788    fn test_not_skipping_uses_cached_stats_when_nan_free() -> VortexResult<()> {
789        // With an exact NaNCount of zero, the planted exact Min/Max stats are usable as-is.
790        let array =
791            PrimitiveArray::new(buffer![1.0f64, 2.0, 3.0], Validity::NonNullable).into_array();
792        array
793            .statistics()
794            .set(Stat::NaNCount, Precision::Exact(ScalarValue::from(0u64)));
795        array
796            .statistics()
797            .set(Stat::Min, Precision::Exact(ScalarValue::from(-10.0f64)));
798        array
799            .statistics()
800            .set(Stat::Max, Precision::Exact(ScalarValue::from(10.0f64)));
801        let mut ctx = SESSION.create_execution_ctx();
802        let result = min_max(&array, &mut ctx, KEEP_NANS)?.vortex_expect("should have result");
803        assert_eq!(f64::try_from(&result.min)?, -10.0);
804        assert_eq!(f64::try_from(&result.max)?, 10.0);
805        Ok(())
806    }
807
808    #[test]
809    fn test_accumulator_nan_including_nullable_cached_stats() -> VortexResult<()> {
810        // A nullable float array's cached Min/Max stats are reconstructed as nullable scalars.
811        // The NaN-including accumulator shortcircuit must normalise them to the non-nullable
812        // struct field dtype before building the result scalar.
813        let mut ctx = SESSION.create_execution_ctx();
814        let array =
815            PrimitiveArray::from_option_iter([Some(1.0f64), Some(2.0), Some(3.0)]).into_array();
816        array
817            .statistics()
818            .set(Stat::NaNCount, Precision::Exact(ScalarValue::from(0u64)));
819        array
820            .statistics()
821            .set(Stat::Min, Precision::Exact(ScalarValue::from(1.0f64)));
822        array
823            .statistics()
824            .set(Stat::Max, Precision::Exact(ScalarValue::from(3.0f64)));
825
826        let mut acc = Accumulator::try_new(MinMax, KEEP_NANS, array.dtype().clone())?;
827        acc.accumulate(&array, &mut ctx)?;
828        let result = MinMaxResult::from_scalar(&acc.finish()?)?.vortex_expect("should have result");
829        assert_eq!(f64::try_from(&result.min)?, 1.0);
830        assert_eq!(f64::try_from(&result.max)?, 3.0);
831        Ok(())
832    }
833
834    #[test]
835    fn test_multi_batch_nan_poisoning() -> VortexResult<()> {
836        let mut ctx = SESSION.create_execution_ctx();
837        let dtype = DType::Primitive(PType::F64, Nullability::NonNullable);
838        let mut acc = Accumulator::try_new(MinMax, KEEP_NANS, dtype)?;
839
840        let batch1 = PrimitiveArray::new(buffer![1.0f64, 2.0], Validity::NonNullable).into_array();
841        acc.accumulate(&batch1, &mut ctx)?;
842        assert!(!acc.is_saturated());
843
844        let batch2 = PrimitiveArray::new(buffer![f64::NAN], Validity::NonNullable).into_array();
845        acc.accumulate(&batch2, &mut ctx)?;
846        assert!(acc.is_saturated());
847
848        assert_poisoned(MinMaxResult::from_scalar(&acc.finish()?)?)
849    }
850
851    #[test]
852    fn test_chunked() -> VortexResult<()> {
853        let chunk1 = PrimitiveArray::from_option_iter([Some(5i32), None, Some(1)]);
854        let chunk2 = PrimitiveArray::from_option_iter([Some(10i32), Some(3), None]);
855        let dtype = chunk1.dtype().clone();
856        let chunked = ChunkedArray::try_new(vec![chunk1.into_array(), chunk2.into_array()], dtype)?;
857        let mut ctx = SESSION.create_execution_ctx();
858        let result = min_max(
859            &chunked.into_array(),
860            &mut ctx,
861            NumericalAggregateOpts::default(),
862        )?
863        .vortex_expect("should have result");
864        assert_eq!(result.min, Scalar::from(1i32));
865        assert_eq!(result.max, Scalar::from(10i32));
866        Ok(())
867    }
868
869    #[test]
870    fn test_all_null() -> VortexResult<()> {
871        let p = PrimitiveArray::from_option_iter::<i32, _>([None, None, None]);
872        let mut ctx = SESSION.create_execution_ctx();
873        assert_eq!(
874            min_max(&p.into_array(), &mut ctx, NumericalAggregateOpts::default())?,
875            None
876        );
877        Ok(())
878    }
879
880    #[test]
881    fn test_varbin() -> VortexResult<()> {
882        let array = VarBinArray::from_iter(
883            vec![
884                Some("hello world"),
885                None,
886                Some("hello world this is a long string"),
887                None,
888            ],
889            DType::Utf8(Nullability::Nullable),
890        );
891        let mut ctx = SESSION.create_execution_ctx();
892        let result = min_max(
893            &array.into_array(),
894            &mut ctx,
895            NumericalAggregateOpts::default(),
896        )?
897        .vortex_expect("should have result");
898        assert_eq!(
899            result.min,
900            Scalar::utf8("hello world", Nullability::NonNullable)
901        );
902        assert_eq!(
903            result.max,
904            Scalar::utf8(
905                "hello world this is a long string",
906                Nullability::NonNullable
907            )
908        );
909        Ok(())
910    }
911
912    #[test]
913    fn test_decimal() -> VortexResult<()> {
914        let decimal = DecimalArray::new(
915            buffer![100i32, 2000i32, 200i32],
916            DecimalDType::new(4, 2),
917            Validity::from_iter([true, false, true]),
918        );
919        let mut ctx = SESSION.create_execution_ctx();
920        let result = min_max(
921            &decimal.into_array(),
922            &mut ctx,
923            NumericalAggregateOpts::default(),
924        )?
925        .vortex_expect("should have result");
926
927        let non_nullable_dtype = DType::Decimal(DecimalDType::new(4, 2), Nullability::NonNullable);
928        let expected_min = Scalar::try_new(
929            non_nullable_dtype.clone(),
930            Some(ScalarValue::from(DecimalValue::from(100i32))),
931        )?;
932        let expected_max = Scalar::try_new(
933            non_nullable_dtype,
934            Some(ScalarValue::from(DecimalValue::from(200i32))),
935        )?;
936        assert_eq!(result.min, expected_min);
937        assert_eq!(result.max, expected_max);
938        Ok(())
939    }
940
941    #[test]
942    fn list_and_fixed_size_list_return_dtype() {
943        let element_dtype = DType::Primitive(PType::I32, Nullability::Nullable);
944        let list_dtype = DType::List(Arc::new(element_dtype.clone()), Nullability::Nullable);
945        let fixed_size_list_dtype =
946            DType::FixedSizeList(Arc::new(element_dtype), 1, Nullability::Nullable);
947
948        assert_eq!(
949            MinMax.return_dtype(&NumericalAggregateOpts::default(), &list_dtype),
950            Some(make_minmax_dtype(&list_dtype))
951        );
952        assert_eq!(
953            MinMax.return_dtype(&NumericalAggregateOpts::default(), &fixed_size_list_dtype),
954            Some(make_minmax_dtype(&fixed_size_list_dtype))
955        );
956    }
957
958    #[test]
959    fn list_and_fixed_size_list_min_max_returns_none() -> VortexResult<()> {
960        let mut ctx = SESSION.create_execution_ctx();
961
962        let list_array = ListArray::try_new(
963            buffer![1i32, 2, 3].into_array(),
964            buffer![0u32, 2, 3].into_array(),
965            Validity::NonNullable,
966        )?
967        .into_array();
968        assert_eq!(
969            min_max(&list_array, &mut ctx, NumericalAggregateOpts::default())?,
970            None
971        );
972
973        let fixed_size_list_array = FixedSizeListArray::try_new(
974            buffer![1i32, 2, 3, 4].into_array(),
975            2,
976            Validity::NonNullable,
977            2,
978        )?
979        .into_array();
980        assert_eq!(
981            min_max(
982                &fixed_size_list_array,
983                &mut ctx,
984                NumericalAggregateOpts::default()
985            )?,
986            None
987        );
988
989        Ok(())
990    }
991
992    use crate::dtype::half::f16;
993
994    #[test]
995    fn test_bool_with_nulls() -> VortexResult<()> {
996        let mut ctx = SESSION.create_execution_ctx();
997
998        let result = min_max(
999            &BoolArray::from_iter(vec![Some(true), Some(true), None, None]).into_array(),
1000            &mut ctx,
1001            NumericalAggregateOpts::default(),
1002        )?;
1003        assert_eq!(
1004            result,
1005            Some(MinMaxResult {
1006                min: Scalar::bool(true, Nullability::NonNullable),
1007                max: Scalar::bool(true, Nullability::NonNullable),
1008            })
1009        );
1010
1011        let result = min_max(
1012            &BoolArray::from_iter(vec![None, Some(true), Some(true)]).into_array(),
1013            &mut ctx,
1014            NumericalAggregateOpts::default(),
1015        )?;
1016        assert_eq!(
1017            result,
1018            Some(MinMaxResult {
1019                min: Scalar::bool(true, Nullability::NonNullable),
1020                max: Scalar::bool(true, Nullability::NonNullable),
1021            })
1022        );
1023
1024        let result = min_max(
1025            &BoolArray::from_iter(vec![None, Some(true), Some(true), None]).into_array(),
1026            &mut ctx,
1027            NumericalAggregateOpts::default(),
1028        )?;
1029        assert_eq!(
1030            result,
1031            Some(MinMaxResult {
1032                min: Scalar::bool(true, Nullability::NonNullable),
1033                max: Scalar::bool(true, Nullability::NonNullable),
1034            })
1035        );
1036
1037        let result = min_max(
1038            &BoolArray::from_iter(vec![Some(false), Some(false), None, None]).into_array(),
1039            &mut ctx,
1040            NumericalAggregateOpts::default(),
1041        )?;
1042        assert_eq!(
1043            result,
1044            Some(MinMaxResult {
1045                min: Scalar::bool(false, Nullability::NonNullable),
1046                max: Scalar::bool(false, Nullability::NonNullable),
1047            })
1048        );
1049        Ok(())
1050    }
1051
1052    /// Regression test for <https://github.com/vortex-data/vortex/issues/7074>.
1053    ///
1054    /// A chunked all-true bool array with an empty first chunk returned min=false because
1055    /// `accumulate_bool` on the empty chunk incorrectly merged min=false,max=false into the
1056    /// partial state.
1057    #[test]
1058    fn test_bool_chunked_with_empty_chunk() -> VortexResult<()> {
1059        let mut ctx = SESSION.create_execution_ctx();
1060
1061        let empty = BoolArray::new(BitBuffer::from([].as_slice()), Validity::NonNullable);
1062        let chunk1 = BoolArray::new(
1063            BitBuffer::from([true, true].as_slice()),
1064            Validity::NonNullable,
1065        );
1066        let chunk2 = BoolArray::new(
1067            BitBuffer::from([true, true, true].as_slice()),
1068            Validity::NonNullable,
1069        );
1070        let chunked = ChunkedArray::try_new(
1071            vec![empty.into_array(), chunk1.into_array(), chunk2.into_array()],
1072            DType::Bool(Nullability::NonNullable),
1073        )?;
1074
1075        let result = min_max(
1076            &chunked.into_array(),
1077            &mut ctx,
1078            NumericalAggregateOpts::default(),
1079        )?;
1080        assert_eq!(
1081            result,
1082            Some(MinMaxResult {
1083                min: Scalar::bool(true, Nullability::NonNullable),
1084                max: Scalar::bool(true, Nullability::NonNullable),
1085            })
1086        );
1087        Ok(())
1088    }
1089
1090    #[test]
1091    fn empty_constant_leaves_partial_empty() -> VortexResult<()> {
1092        let mut ctx = SESSION.create_execution_ctx();
1093        let array = ConstantArray::new(Scalar::from(99i32), 0).into_array();
1094        let options = NumericalAggregateOpts::default();
1095        let mut acc = Accumulator::try_new(MinMax, options, array.dtype().clone())?;
1096
1097        acc.accumulate(&array, &mut ctx)?;
1098
1099        assert!(acc.partial_scalar()?.is_null());
1100        Ok(())
1101    }
1102
1103    /// Regression test for <https://github.com/vortex-data/vortex/issues/8145>.
1104    ///
1105    /// A chunked array whose first chunk is an *empty* constant array — as produced by
1106    /// `fill_null` on an empty all-null chunk — returned `max = u32::MAX` because
1107    /// `ChunkedArrayAggregate` accumulated the empty chunk, folding its fill scalar into the
1108    /// running min/max. Empty chunks are now skipped during chunked aggregation.
1109    #[test]
1110    fn test_chunked_with_empty_constant_chunk() -> VortexResult<()> {
1111        let mut ctx = SESSION.create_execution_ctx();
1112
1113        let empty = ConstantArray::new(Scalar::primitive(u32::MAX, Nullability::NonNullable), 0)
1114            .into_array();
1115        let chunk1 = PrimitiveArray::new(buffer![7631471u32], Validity::NonNullable).into_array();
1116        let chunk2 = PrimitiveArray::new(buffer![0u32], Validity::NonNullable).into_array();
1117        let chunked = ChunkedArray::try_new(
1118            vec![empty, chunk1, chunk2],
1119            DType::Primitive(PType::U32, Nullability::NonNullable),
1120        )?;
1121
1122        assert_eq!(
1123            min_max(
1124                &chunked.into_array(),
1125                &mut ctx,
1126                NumericalAggregateOpts::default()
1127            )?,
1128            Some(MinMaxResult {
1129                min: Scalar::primitive(0u32, Nullability::NonNullable),
1130                max: Scalar::primitive(7631471u32, Nullability::NonNullable),
1131            })
1132        );
1133        Ok(())
1134    }
1135
1136    #[test]
1137    fn test_varbin_all_nulls() -> VortexResult<()> {
1138        let array = VarBinArray::from_iter(
1139            vec![Option::<&str>::None, None, None],
1140            DType::Utf8(Nullability::Nullable),
1141        );
1142        let mut ctx = SESSION.create_execution_ctx();
1143        assert_eq!(
1144            min_max(
1145                &array.into_array(),
1146                &mut ctx,
1147                NumericalAggregateOpts::default()
1148            )?,
1149            None
1150        );
1151        Ok(())
1152    }
1153}