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;
15
16use self::bool::accumulate_bool;
17use self::decimal::accumulate_decimal;
18use self::extension::accumulate_extension;
19use self::primitive::accumulate_primitive;
20use self::varbin::accumulate_varbinview;
21use crate::ArrayRef;
22use crate::Canonical;
23use crate::Columnar;
24use crate::ExecutionCtx;
25use crate::aggregate_fn::Accumulator;
26use crate::aggregate_fn::AggregateFnId;
27use crate::aggregate_fn::AggregateFnVTable;
28use crate::aggregate_fn::DynAccumulator;
29use crate::aggregate_fn::EmptyOptions;
30use crate::dtype::DType;
31use crate::dtype::FieldNames;
32use crate::dtype::Nullability;
33use crate::dtype::StructFields;
34use crate::expr::stats::Precision;
35use crate::expr::stats::Stat;
36use crate::expr::stats::StatsProvider;
37use crate::partial_ord::partial_max;
38use crate::partial_ord::partial_min;
39use crate::scalar::Scalar;
40
41static NAMES: LazyLock<FieldNames> = LazyLock::new(|| FieldNames::from(["min", "max"]));
42
43/// The minimum and maximum non-null values of an array, or `None` if there are no non-null values.
44///
45/// The result scalars have the non-nullable version of the array dtype.
46/// This will update the stats set of the array as a side effect.
47pub fn min_max(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<Option<MinMaxResult>> {
48    // Short-circuit using cached array statistics.
49    let cached_min = array.statistics().get(Stat::Min).as_exact();
50    let cached_max = array.statistics().get(Stat::Max).as_exact();
51    if let Some((min, max)) = cached_min.zip(cached_max) {
52        let non_nullable_dtype = array.dtype().as_nonnullable();
53        return Ok(Some(MinMaxResult {
54            min: min.cast(&non_nullable_dtype)?,
55            max: max.cast(&non_nullable_dtype)?,
56        }));
57    }
58
59    // Short-circuit for empty arrays or all-null arrays.
60    if array.is_empty() || array.valid_count(ctx)? == 0 {
61        return Ok(None);
62    }
63
64    // Short-circuit for dtypes this helper cannot currently compute.
65    if !minmax_compute_supported_dtype(array.dtype()) {
66        return Ok(None);
67    }
68
69    // Compute using Accumulator<MinMax>.
70    let mut acc = Accumulator::try_new(MinMax, EmptyOptions, array.dtype().clone())?;
71    acc.accumulate(array, ctx)?;
72    let result_scalar = acc.finish()?;
73    let result = MinMaxResult::from_scalar(result_scalar)?;
74
75    // Cache the computed min/max as statistics.
76    if let Some(r) = &result {
77        if let Some(min_value) = r.min.value() {
78            array
79                .statistics()
80                .set(Stat::Min, Precision::Exact(min_value.clone()));
81        }
82        if let Some(max_value) = r.max.value() {
83            array
84                .statistics()
85                .set(Stat::Max, Precision::Exact(max_value.clone()));
86        }
87    }
88
89    Ok(result)
90}
91
92/// The minimum and maximum non-null values of an array.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct MinMaxResult {
95    pub min: Scalar,
96    pub max: Scalar,
97}
98
99impl MinMaxResult {
100    /// Extract a `MinMaxResult` from a struct scalar with `{min, max}` fields.
101    pub fn from_scalar(scalar: Scalar) -> VortexResult<Option<Self>> {
102        if scalar.is_null() {
103            Ok(None)
104        } else {
105            let min = scalar
106                .as_struct()
107                .field_by_idx(0)
108                .vortex_expect("missing min field");
109            let max = scalar
110                .as_struct()
111                .field_by_idx(1)
112                .vortex_expect("missing max field");
113            Ok(Some(MinMaxResult { min, max }))
114        }
115    }
116}
117
118/// Compute the min and max of an array.
119///
120/// Returns a nullable struct scalar `{min: T, max: T}` where `T` is the non-nullable input dtype.
121/// The struct is null when the array is empty or all-null.
122#[derive(Clone, Debug)]
123pub struct MinMax;
124
125/// Partial accumulator state for min/max.
126pub struct MinMaxPartial {
127    min: Option<Scalar>,
128    max: Option<Scalar>,
129    element_dtype: DType,
130}
131
132impl MinMaxPartial {
133    /// Merge a local `MinMaxResult` into this partial state.
134    fn merge(&mut self, local: Option<MinMaxResult>) {
135        let Some(MinMaxResult { min, max }) = local else {
136            return;
137        };
138
139        self.min = Some(match self.min.take() {
140            Some(current) => partial_min(min, current).vortex_expect("incomparable min scalars"),
141            None => min,
142        });
143
144        self.max = Some(match self.max.take() {
145            Some(current) => partial_max(max, current).vortex_expect("incomparable max scalars"),
146            None => max,
147        });
148    }
149}
150
151/// Creates the struct dtype `{min: T, max: T}` (nullable) used for min/max aggregate results.
152pub fn make_minmax_dtype(element_dtype: &DType) -> DType {
153    DType::Struct(
154        StructFields::new(
155            NAMES.clone(),
156            vec![
157                element_dtype.as_nonnullable(),
158                element_dtype.as_nonnullable(),
159            ],
160        ),
161        Nullability::Nullable,
162    )
163}
164
165fn minmax_supported_dtype(input_dtype: &DType) -> bool {
166    match input_dtype {
167        DType::Bool(_)
168        | DType::Primitive(..)
169        | DType::Decimal(..)
170        | DType::Utf8(..)
171        | DType::Binary(..)
172        | DType::Extension(..) => true,
173        DType::List(element_dtype, _) => minmax_supported_dtype(element_dtype),
174        DType::FixedSizeList(element_dtype, ..) => minmax_supported_dtype(element_dtype),
175        _ => false,
176    }
177}
178
179/// Returns whether [`min_max`] can currently compute extrema for this logical dtype.
180///
181/// This is intentionally narrower than [`minmax_supported_dtype`]. List and fixed-size-list
182/// extrema have a defined output dtype for aggregate expression lowering, but the accumulator does
183/// not yet implement lexicographic list comparison.
184fn minmax_compute_supported_dtype(input_dtype: &DType) -> bool {
185    matches!(
186        input_dtype,
187        DType::Bool(_)
188            | DType::Primitive(..)
189            | DType::Decimal(..)
190            | DType::Utf8(..)
191            | DType::Binary(..)
192            | DType::Extension(..)
193    )
194}
195
196impl AggregateFnVTable for MinMax {
197    type Options = EmptyOptions;
198    type Partial = MinMaxPartial;
199
200    fn id(&self) -> AggregateFnId {
201        AggregateFnId::new("vortex.min_max")
202    }
203
204    fn serialize(&self, _options: &Self::Options) -> VortexResult<Option<Vec<u8>>> {
205        Ok(None)
206    }
207
208    fn return_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option<DType> {
209        minmax_supported_dtype(input_dtype).then(|| make_minmax_dtype(input_dtype))
210    }
211
212    fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option<DType> {
213        self.return_dtype(options, input_dtype)
214    }
215
216    fn empty_partial(
217        &self,
218        _options: &Self::Options,
219        input_dtype: &DType,
220    ) -> VortexResult<Self::Partial> {
221        Ok(MinMaxPartial {
222            min: None,
223            max: None,
224            element_dtype: input_dtype.clone(),
225        })
226    }
227
228    fn combine_partials(&self, partial: &mut Self::Partial, other: Scalar) -> VortexResult<()> {
229        let local = MinMaxResult::from_scalar(other)?;
230        partial.merge(local);
231        Ok(())
232    }
233
234    fn to_scalar(&self, partial: &Self::Partial) -> VortexResult<Scalar> {
235        let dtype = make_minmax_dtype(&partial.element_dtype);
236        Ok(match (&partial.min, &partial.max) {
237            (Some(min), Some(max)) => Scalar::struct_(dtype, vec![min.clone(), max.clone()]),
238            _ => Scalar::null(dtype),
239        })
240    }
241
242    fn reset(&self, partial: &mut Self::Partial) {
243        partial.min = None;
244        partial.max = None;
245    }
246
247    #[inline]
248    fn is_saturated(&self, _partial: &Self::Partial) -> bool {
249        false
250    }
251
252    fn accumulate(
253        &self,
254        partial: &mut Self::Partial,
255        batch: &Columnar,
256        ctx: &mut ExecutionCtx,
257    ) -> VortexResult<()> {
258        match batch {
259            Columnar::Constant(c) => {
260                let scalar = c.scalar();
261                if scalar.is_null() {
262                    return Ok(());
263                }
264                // Skip NaN float constants
265                if scalar.as_primitive_opt().is_some_and(|p| p.is_nan()) {
266                    return Ok(());
267                }
268                let non_nullable_dtype = scalar.dtype().as_nonnullable();
269                let cast = scalar.cast(&non_nullable_dtype)?;
270                partial.merge(Some(MinMaxResult {
271                    min: cast.clone(),
272                    max: cast,
273                }));
274                Ok(())
275            }
276            Columnar::Canonical(c) => match c {
277                Canonical::Primitive(p) => accumulate_primitive(partial, p, ctx),
278                Canonical::Bool(b) => accumulate_bool(partial, b, ctx),
279                Canonical::VarBinView(v) => accumulate_varbinview(partial, v),
280                Canonical::Decimal(d) => accumulate_decimal(partial, d, ctx),
281                Canonical::Extension(e) => accumulate_extension(partial, e, ctx),
282                Canonical::Null(_) => Ok(()),
283                Canonical::Struct(_)
284                | Canonical::List(_)
285                | Canonical::FixedSizeList(_)
286                | Canonical::Variant(_) => {
287                    vortex_bail!("Unsupported canonical type for min_max: {}", batch.dtype())
288                }
289            },
290        }
291    }
292
293    fn finalize(&self, partials: ArrayRef) -> VortexResult<ArrayRef> {
294        Ok(partials)
295    }
296
297    fn finalize_scalar(&self, partial: &Self::Partial) -> VortexResult<Scalar> {
298        self.to_scalar(partial)
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use std::sync::Arc;
305
306    use vortex_buffer::BitBuffer;
307    use vortex_buffer::buffer;
308    use vortex_error::VortexExpect;
309    use vortex_error::VortexResult;
310
311    use crate::IntoArray as _;
312    use crate::LEGACY_SESSION;
313    use crate::VortexSessionExecute;
314    use crate::aggregate_fn::Accumulator;
315    use crate::aggregate_fn::AggregateFnVTable;
316    use crate::aggregate_fn::DynAccumulator;
317    use crate::aggregate_fn::EmptyOptions;
318    use crate::aggregate_fn::fns::min_max::MinMax;
319    use crate::aggregate_fn::fns::min_max::MinMaxResult;
320    use crate::aggregate_fn::fns::min_max::make_minmax_dtype;
321    use crate::aggregate_fn::fns::min_max::min_max;
322    use crate::arrays::BoolArray;
323    use crate::arrays::ChunkedArray;
324    use crate::arrays::ConstantArray;
325    use crate::arrays::DecimalArray;
326    use crate::arrays::FixedSizeListArray;
327    use crate::arrays::ListArray;
328    use crate::arrays::NullArray;
329    use crate::arrays::PrimitiveArray;
330    use crate::arrays::VarBinArray;
331    use crate::dtype::DType;
332    use crate::dtype::DecimalDType;
333    use crate::dtype::Nullability;
334    use crate::dtype::PType;
335    use crate::scalar::DecimalValue;
336    use crate::scalar::Scalar;
337    use crate::scalar::ScalarValue;
338    use crate::validity::Validity;
339
340    #[test]
341    fn test_prim_min_max() -> VortexResult<()> {
342        let p = PrimitiveArray::new(buffer![1, 2, 3], Validity::NonNullable).into_array();
343        let mut ctx = LEGACY_SESSION.create_execution_ctx();
344        assert_eq!(
345            min_max(&p, &mut ctx)?,
346            Some(MinMaxResult {
347                min: 1.into(),
348                max: 3.into()
349            })
350        );
351        Ok(())
352    }
353
354    #[test]
355    fn test_prim_min_max_multiple_null_runs() -> VortexResult<()> {
356        // Several disjoint valid runs separated by nulls exercise the per-run fold; the extrema
357        // (min 1, max 9) fall in different runs.
358        let p = PrimitiveArray::from_option_iter([
359            Some(5i32),
360            Some(3),
361            None,
362            None,
363            Some(9),
364            None,
365            Some(1),
366            Some(7),
367        ])
368        .into_array();
369        let mut ctx = LEGACY_SESSION.create_execution_ctx();
370        assert_eq!(
371            min_max(&p, &mut ctx)?,
372            Some(MinMaxResult {
373                min: 1.into(),
374                max: 9.into()
375            })
376        );
377        Ok(())
378    }
379
380    #[test]
381    fn test_bool_min_max() -> VortexResult<()> {
382        let mut ctx = LEGACY_SESSION.create_execution_ctx();
383
384        let all_true = BoolArray::new(
385            BitBuffer::from([true, true, true].as_slice()),
386            Validity::NonNullable,
387        )
388        .into_array();
389        assert_eq!(
390            min_max(&all_true, &mut ctx)?,
391            Some(MinMaxResult {
392                min: true.into(),
393                max: true.into()
394            })
395        );
396
397        let all_false = BoolArray::new(
398            BitBuffer::from([false, false, false].as_slice()),
399            Validity::NonNullable,
400        )
401        .into_array();
402        assert_eq!(
403            min_max(&all_false, &mut ctx)?,
404            Some(MinMaxResult {
405                min: false.into(),
406                max: false.into()
407            })
408        );
409
410        let mixed = BoolArray::new(
411            BitBuffer::from([false, true, false].as_slice()),
412            Validity::NonNullable,
413        )
414        .into_array();
415        assert_eq!(
416            min_max(&mixed, &mut ctx)?,
417            Some(MinMaxResult {
418                min: false.into(),
419                max: true.into()
420            })
421        );
422        Ok(())
423    }
424
425    #[test]
426    fn test_null_array() -> VortexResult<()> {
427        let p = NullArray::new(1).into_array();
428        let mut ctx = LEGACY_SESSION.create_execution_ctx();
429        assert_eq!(min_max(&p, &mut ctx)?, None);
430        Ok(())
431    }
432
433    #[test]
434    fn test_prim_nan() -> VortexResult<()> {
435        let array = PrimitiveArray::new(
436            buffer![f32::NAN, -f32::NAN, -1.0, 1.0],
437            Validity::NonNullable,
438        );
439        let mut ctx = LEGACY_SESSION.create_execution_ctx();
440        let result = min_max(&array.into_array(), &mut ctx)?.vortex_expect("should have result");
441        assert_eq!(f32::try_from(&result.min)?, -1.0);
442        assert_eq!(f32::try_from(&result.max)?, 1.0);
443        Ok(())
444    }
445
446    #[test]
447    fn test_prim_inf() -> VortexResult<()> {
448        let array = PrimitiveArray::new(
449            buffer![f32::INFINITY, f32::NEG_INFINITY, -1.0, 1.0],
450            Validity::NonNullable,
451        );
452        let mut ctx = LEGACY_SESSION.create_execution_ctx();
453        let result = min_max(&array.into_array(), &mut ctx)?.vortex_expect("should have result");
454        assert_eq!(f32::try_from(&result.min)?, f32::NEG_INFINITY);
455        assert_eq!(f32::try_from(&result.max)?, f32::INFINITY);
456        Ok(())
457    }
458
459    #[test]
460    fn test_multi_batch() -> VortexResult<()> {
461        let mut ctx = LEGACY_SESSION.create_execution_ctx();
462        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
463        let mut acc = Accumulator::try_new(MinMax, EmptyOptions, dtype)?;
464
465        let batch1 = PrimitiveArray::new(buffer![10i32, 20, 5], Validity::NonNullable).into_array();
466        acc.accumulate(&batch1, &mut ctx)?;
467
468        let batch2 = PrimitiveArray::new(buffer![3i32, 25], Validity::NonNullable).into_array();
469        acc.accumulate(&batch2, &mut ctx)?;
470
471        let result = MinMaxResult::from_scalar(acc.finish()?)?.vortex_expect("should have result");
472        assert_eq!(result.min, Scalar::from(3i32));
473        assert_eq!(result.max, Scalar::from(25i32));
474        Ok(())
475    }
476
477    #[test]
478    fn test_finish_resets_state() -> VortexResult<()> {
479        let mut ctx = LEGACY_SESSION.create_execution_ctx();
480        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
481        let mut acc = Accumulator::try_new(MinMax, EmptyOptions, dtype)?;
482
483        let batch1 = PrimitiveArray::new(buffer![10i32, 20], Validity::NonNullable).into_array();
484        acc.accumulate(&batch1, &mut ctx)?;
485        let result1 = MinMaxResult::from_scalar(acc.finish()?)?.vortex_expect("should have result");
486        assert_eq!(result1.min, Scalar::from(10i32));
487        assert_eq!(result1.max, Scalar::from(20i32));
488
489        let batch2 = PrimitiveArray::new(buffer![3i32, 6, 9], Validity::NonNullable).into_array();
490        acc.accumulate(&batch2, &mut ctx)?;
491        let result2 = MinMaxResult::from_scalar(acc.finish()?)?.vortex_expect("should have result");
492        assert_eq!(result2.min, Scalar::from(3i32));
493        assert_eq!(result2.max, Scalar::from(9i32));
494        Ok(())
495    }
496
497    #[test]
498    fn test_state_merge() -> VortexResult<()> {
499        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
500        let mut state = MinMax.empty_partial(&EmptyOptions, &dtype)?;
501
502        let struct_dtype = make_minmax_dtype(&dtype);
503        let scalar1 = Scalar::struct_(
504            struct_dtype.clone(),
505            vec![Scalar::from(5i32), Scalar::from(15i32)],
506        );
507        MinMax.combine_partials(&mut state, scalar1)?;
508
509        let scalar2 = Scalar::struct_(struct_dtype, vec![Scalar::from(2i32), Scalar::from(10i32)]);
510        MinMax.combine_partials(&mut state, scalar2)?;
511
512        let result = MinMaxResult::from_scalar(MinMax.to_scalar(&state)?)?
513            .vortex_expect("should have result");
514        assert_eq!(result.min, Scalar::from(2i32));
515        assert_eq!(result.max, Scalar::from(15i32));
516        Ok(())
517    }
518
519    #[test]
520    fn test_constant_nan() -> VortexResult<()> {
521        let scalar = Scalar::primitive(f16::NAN, Nullability::NonNullable);
522        let array = ConstantArray::new(scalar, 2).into_array();
523        let mut ctx = LEGACY_SESSION.create_execution_ctx();
524        assert_eq!(min_max(&array, &mut ctx)?, None);
525        Ok(())
526    }
527
528    #[test]
529    fn test_chunked() -> VortexResult<()> {
530        let chunk1 = PrimitiveArray::from_option_iter([Some(5i32), None, Some(1)]);
531        let chunk2 = PrimitiveArray::from_option_iter([Some(10i32), Some(3), None]);
532        let dtype = chunk1.dtype().clone();
533        let chunked = ChunkedArray::try_new(vec![chunk1.into_array(), chunk2.into_array()], dtype)?;
534        let mut ctx = LEGACY_SESSION.create_execution_ctx();
535        let result = min_max(&chunked.into_array(), &mut ctx)?.vortex_expect("should have result");
536        assert_eq!(result.min, Scalar::from(1i32));
537        assert_eq!(result.max, Scalar::from(10i32));
538        Ok(())
539    }
540
541    #[test]
542    fn test_all_null() -> VortexResult<()> {
543        let p = PrimitiveArray::from_option_iter::<i32, _>([None, None, None]);
544        let mut ctx = LEGACY_SESSION.create_execution_ctx();
545        assert_eq!(min_max(&p.into_array(), &mut ctx)?, None);
546        Ok(())
547    }
548
549    #[test]
550    fn test_varbin() -> VortexResult<()> {
551        let array = VarBinArray::from_iter(
552            vec![
553                Some("hello world"),
554                None,
555                Some("hello world this is a long string"),
556                None,
557            ],
558            DType::Utf8(Nullability::Nullable),
559        );
560        let mut ctx = LEGACY_SESSION.create_execution_ctx();
561        let result = min_max(&array.into_array(), &mut ctx)?.vortex_expect("should have result");
562        assert_eq!(
563            result.min,
564            Scalar::utf8("hello world", Nullability::NonNullable)
565        );
566        assert_eq!(
567            result.max,
568            Scalar::utf8(
569                "hello world this is a long string",
570                Nullability::NonNullable
571            )
572        );
573        Ok(())
574    }
575
576    #[test]
577    fn test_decimal() -> VortexResult<()> {
578        let decimal = DecimalArray::new(
579            buffer![100i32, 2000i32, 200i32],
580            DecimalDType::new(4, 2),
581            Validity::from_iter([true, false, true]),
582        );
583        let mut ctx = LEGACY_SESSION.create_execution_ctx();
584        let result = min_max(&decimal.into_array(), &mut ctx)?.vortex_expect("should have result");
585
586        let non_nullable_dtype = DType::Decimal(DecimalDType::new(4, 2), Nullability::NonNullable);
587        let expected_min = Scalar::try_new(
588            non_nullable_dtype.clone(),
589            Some(ScalarValue::from(DecimalValue::from(100i32))),
590        )?;
591        let expected_max = Scalar::try_new(
592            non_nullable_dtype,
593            Some(ScalarValue::from(DecimalValue::from(200i32))),
594        )?;
595        assert_eq!(result.min, expected_min);
596        assert_eq!(result.max, expected_max);
597        Ok(())
598    }
599
600    #[test]
601    fn list_and_fixed_size_list_return_dtype() {
602        let element_dtype = DType::Primitive(PType::I32, Nullability::Nullable);
603        let list_dtype = DType::List(Arc::new(element_dtype.clone()), Nullability::Nullable);
604        let fixed_size_list_dtype =
605            DType::FixedSizeList(Arc::new(element_dtype), 1, Nullability::Nullable);
606
607        assert_eq!(
608            MinMax.return_dtype(&EmptyOptions, &list_dtype),
609            Some(make_minmax_dtype(&list_dtype))
610        );
611        assert_eq!(
612            MinMax.return_dtype(&EmptyOptions, &fixed_size_list_dtype),
613            Some(make_minmax_dtype(&fixed_size_list_dtype))
614        );
615    }
616
617    #[test]
618    fn list_and_fixed_size_list_min_max_returns_none() -> VortexResult<()> {
619        let mut ctx = LEGACY_SESSION.create_execution_ctx();
620
621        let list_array = ListArray::try_new(
622            buffer![1i32, 2, 3].into_array(),
623            buffer![0u32, 2, 3].into_array(),
624            Validity::NonNullable,
625        )?
626        .into_array();
627        assert_eq!(min_max(&list_array, &mut ctx)?, None);
628
629        let fixed_size_list_array = FixedSizeListArray::try_new(
630            buffer![1i32, 2, 3, 4].into_array(),
631            2,
632            Validity::NonNullable,
633            2,
634        )?
635        .into_array();
636        assert_eq!(min_max(&fixed_size_list_array, &mut ctx)?, None);
637
638        Ok(())
639    }
640
641    use crate::dtype::half::f16;
642
643    #[test]
644    fn test_bool_with_nulls() -> VortexResult<()> {
645        let mut ctx = LEGACY_SESSION.create_execution_ctx();
646
647        let result = min_max(
648            &BoolArray::from_iter(vec![Some(true), Some(true), None, None]).into_array(),
649            &mut ctx,
650        )?;
651        assert_eq!(
652            result,
653            Some(MinMaxResult {
654                min: Scalar::bool(true, Nullability::NonNullable),
655                max: Scalar::bool(true, Nullability::NonNullable),
656            })
657        );
658
659        let result = min_max(
660            &BoolArray::from_iter(vec![None, Some(true), Some(true)]).into_array(),
661            &mut ctx,
662        )?;
663        assert_eq!(
664            result,
665            Some(MinMaxResult {
666                min: Scalar::bool(true, Nullability::NonNullable),
667                max: Scalar::bool(true, Nullability::NonNullable),
668            })
669        );
670
671        let result = min_max(
672            &BoolArray::from_iter(vec![None, Some(true), Some(true), None]).into_array(),
673            &mut ctx,
674        )?;
675        assert_eq!(
676            result,
677            Some(MinMaxResult {
678                min: Scalar::bool(true, Nullability::NonNullable),
679                max: Scalar::bool(true, Nullability::NonNullable),
680            })
681        );
682
683        let result = min_max(
684            &BoolArray::from_iter(vec![Some(false), Some(false), None, None]).into_array(),
685            &mut ctx,
686        )?;
687        assert_eq!(
688            result,
689            Some(MinMaxResult {
690                min: Scalar::bool(false, Nullability::NonNullable),
691                max: Scalar::bool(false, Nullability::NonNullable),
692            })
693        );
694        Ok(())
695    }
696
697    /// Regression test for <https://github.com/vortex-data/vortex/issues/7074>.
698    ///
699    /// A chunked all-true bool array with an empty first chunk returned min=false because
700    /// `accumulate_bool` on the empty chunk incorrectly merged min=false,max=false into the
701    /// partial state.
702    #[test]
703    fn test_bool_chunked_with_empty_chunk() -> VortexResult<()> {
704        let mut ctx = LEGACY_SESSION.create_execution_ctx();
705
706        let empty = BoolArray::new(BitBuffer::from([].as_slice()), Validity::NonNullable);
707        let chunk1 = BoolArray::new(
708            BitBuffer::from([true, true].as_slice()),
709            Validity::NonNullable,
710        );
711        let chunk2 = BoolArray::new(
712            BitBuffer::from([true, true, true].as_slice()),
713            Validity::NonNullable,
714        );
715        let chunked = ChunkedArray::try_new(
716            vec![empty.into_array(), chunk1.into_array(), chunk2.into_array()],
717            DType::Bool(Nullability::NonNullable),
718        )?;
719
720        let result = min_max(&chunked.into_array(), &mut ctx)?;
721        assert_eq!(
722            result,
723            Some(MinMaxResult {
724                min: Scalar::bool(true, Nullability::NonNullable),
725                max: Scalar::bool(true, Nullability::NonNullable),
726            })
727        );
728        Ok(())
729    }
730
731    /// Regression test for <https://github.com/vortex-data/vortex/issues/8145>.
732    ///
733    /// A chunked array whose first chunk is an *empty* constant array — as produced by
734    /// `fill_null` on an empty all-null chunk — returned `max = u32::MAX` because
735    /// `ChunkedArrayAggregate` accumulated the empty chunk, folding its fill scalar into the
736    /// running min/max. Empty chunks are now skipped during chunked aggregation.
737    #[test]
738    fn test_chunked_with_empty_constant_chunk() -> VortexResult<()> {
739        let mut ctx = LEGACY_SESSION.create_execution_ctx();
740
741        let empty = ConstantArray::new(Scalar::primitive(u32::MAX, Nullability::NonNullable), 0)
742            .into_array();
743        let chunk1 = PrimitiveArray::new(buffer![7631471u32], Validity::NonNullable).into_array();
744        let chunk2 = PrimitiveArray::new(buffer![0u32], Validity::NonNullable).into_array();
745        let chunked = ChunkedArray::try_new(
746            vec![empty, chunk1, chunk2],
747            DType::Primitive(PType::U32, Nullability::NonNullable),
748        )?;
749
750        assert_eq!(
751            min_max(&chunked.into_array(), &mut ctx)?,
752            Some(MinMaxResult {
753                min: Scalar::primitive(0u32, Nullability::NonNullable),
754                max: Scalar::primitive(7631471u32, Nullability::NonNullable),
755            })
756        );
757        Ok(())
758    }
759
760    #[test]
761    fn test_varbin_all_nulls() -> VortexResult<()> {
762        let array = VarBinArray::from_iter(
763            vec![Option::<&str>::None, None, None],
764            DType::Utf8(Nullability::Nullable),
765        );
766        let mut ctx = LEGACY_SESSION.create_execution_ctx();
767        assert_eq!(min_max(&array.into_array(), &mut ctx)?, None);
768        Ok(())
769    }
770}