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