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