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