Skip to main content

vortex_layout/layouts/zoned/
zone_map.rs

1//! Runtime view of a zoned layout's auxiliary per-zone statistics table.
2
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-FileCopyrightText: Copyright the Vortex contributors
5
6use std::sync::Arc;
7
8use vortex_array::ArrayRef;
9use vortex_array::IntoArray;
10use vortex_array::VortexSessionExecute;
11use vortex_array::aggregate_fn::AggregateFnRef;
12use vortex_array::aggregate_fn::AggregateFnSatisfaction;
13use vortex_array::aggregate_fn::fns::all_nan::AllNan;
14use vortex_array::aggregate_fn::fns::all_non_nan::AllNonNan;
15use vortex_array::aggregate_fn::fns::all_non_null::AllNonNull;
16use vortex_array::aggregate_fn::fns::all_null::AllNull;
17use vortex_array::aggregate_fn::fns::bounded_max::BOUNDED_MAX_BOUND;
18use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax;
19use vortex_array::arrays::ConstantArray;
20use vortex_array::arrays::PrimitiveArray;
21use vortex_array::arrays::StructArray;
22use vortex_array::arrays::struct_::StructArrayExt;
23use vortex_array::dtype::DType;
24use vortex_array::expr::BoundExpression;
25use vortex_array::expr::Expression;
26use vortex_array::expr::eq;
27use vortex_array::expr::get_item;
28use vortex_array::expr::lit;
29use vortex_array::expr::root;
30use vortex_array::expr::stats::Stat;
31use vortex_array::scalar_fn::EmptyOptions;
32use vortex_array::scalar_fn::ScalarFnVTableExt;
33use vortex_array::scalar_fn::internal::row_count::RowCount;
34use vortex_array::scalar_fn::internal::row_count::contains_row_count;
35use vortex_array::scalar_fn::internal::row_count::substitute_row_count;
36use vortex_array::stats::bind::StatBinder;
37use vortex_array::stats::bind::bind_stats;
38use vortex_array::validity::Validity;
39use vortex_buffer::buffer;
40use vortex_error::VortexResult;
41use vortex_error::vortex_bail;
42use vortex_error::vortex_ensure;
43use vortex_mask::Mask;
44use vortex_runend::RunEnd;
45use vortex_session::VortexSession;
46
47use crate::layouts::zoned::schema::aggregate_stats_table_dtype;
48use crate::layouts::zoned::schema::legacy_stats_table_dtype;
49
50/// A zone map containing statistics for a column.
51/// Each row of the zone map corresponds to a chunk of the column.
52///
53/// Note that it's possible for the zone map to have no statistics.
54#[derive(Clone)]
55pub struct ZoneMap {
56    // The dtype of the data column this zone map describes.
57    column_dtype: DType,
58    // The struct array backing the zone map
59    array: StructArray,
60    // Aggregate functions stored in the zone map, ordered by their stats-table fields.
61    aggregate_fns: Arc<[AggregateFnRef]>,
62    // The length of each zone in the zone map.
63    zone_len: u64,
64    // Number of rows that the zone map covers
65    row_count: u64,
66}
67
68impl ZoneMap {
69    /// Create [`ZoneMap`] of given column_dtype from given array. Validates that the array matches expected
70    /// structure for given list of stats.
71    pub fn try_new(
72        column_dtype: DType,
73        array: StructArray,
74        aggregate_fns: Arc<[AggregateFnRef]>,
75        zone_len: u64,
76        row_count: u64,
77    ) -> VortexResult<Self> {
78        let expected_dtype = aggregate_stats_table_dtype(&column_dtype, &aggregate_fns);
79        if &expected_dtype != array.dtype() {
80            vortex_bail!("Array dtype does not match expected zone map dtype: {expected_dtype}");
81        }
82
83        // SAFETY: We checked that the array matches the expected stats-table schema.
84        Ok(unsafe { Self::new_unchecked(column_dtype, array, aggregate_fns, zone_len, row_count) })
85    }
86
87    pub(super) unsafe fn new_unchecked(
88        column_dtype: DType,
89        array: StructArray,
90        aggregate_fns: Arc<[AggregateFnRef]>,
91        zone_len: u64,
92        row_count: u64,
93    ) -> Self {
94        Self {
95            column_dtype,
96            array,
97            aggregate_fns,
98            zone_len,
99            row_count,
100        }
101    }
102
103    /// Returns the [`DType`] of the statistics table given a set of statistics and column [`DType`].
104    ///
105    /// This remains as a compatibility wrapper around the zoned schema helper.
106    #[deprecated(note = "use aggregate-function zoned stats instead")]
107    pub fn dtype_for_stats_table(column_dtype: &DType, present_stats: &[Stat]) -> DType {
108        legacy_stats_table_dtype(column_dtype, present_stats)
109    }
110
111    #[cfg(test)]
112    fn try_new_legacy(
113        column_dtype: DType,
114        array: StructArray,
115        stats: Arc<[Stat]>,
116        zone_len: u64,
117        row_count: u64,
118    ) -> VortexResult<Self> {
119        let expected_dtype = legacy_stats_table_dtype(&column_dtype, &stats);
120        if &expected_dtype != array.dtype() {
121            vortex_bail!("Array dtype does not match expected zone map dtype: {expected_dtype}");
122        }
123
124        // SAFETY: We checked that the array matches the expected legacy stats-table schema.
125        Ok(unsafe { Self::new_unchecked(column_dtype, array, Arc::new([]), zone_len, row_count) })
126    }
127
128    /// Apply a pruning predicate to this zone map.
129    ///
130    /// `predicate` should be a stats rewrite expression such as the result of
131    /// [`BoundExpression::falsify`]. The returned mask has one value per zone, where
132    /// `true` means the zone cannot contain matching rows and can be skipped.
133    ///
134    /// If the predicate contains [`row_count`][vortex_array::scalar_fn::internal::row_count]
135    /// placeholders, they are replaced after [`ArrayRef::apply_bound`] with per-zone
136    /// counts derived from `zone_len` and `row_count`. Uniform zones use a
137    /// [`ConstantArray`]; a short final zone uses a run-end encoded array.
138    /// `row_count` is a layout property rather than a stored stats field, and the
139    /// final zone may be shorter than the nominal zone length, so it is materialized
140    /// only after the predicate has been lowered to the zone-map table.
141    pub fn prune(
142        &self,
143        predicate: &BoundExpression,
144        session: &VortexSession,
145    ) -> VortexResult<Mask> {
146        let mut ctx = session.create_execution_ctx();
147        let num_zones = self.array.len();
148        let predicate = self.lower_stats(predicate.clone())?;
149
150        let array = self.array.clone().into_array();
151        let applied = array.apply_bound(&predicate)?;
152
153        if !contains_row_count(&applied) {
154            return applied.null_as_false().execute(&mut ctx);
155        }
156
157        let row_count_array = row_count_array(self.zone_len, self.row_count, num_zones)?;
158        let substituted = substitute_row_count(applied, &row_count_array)?;
159        substituted.null_as_false().execute(&mut ctx)
160    }
161
162    fn lower_stats(&self, predicate: BoundExpression) -> VortexResult<BoundExpression> {
163        let binder = ZoneMapStatsBinder { zone_map: self };
164        bind_stats(predicate, &binder)
165    }
166}
167
168struct ZoneMapStatsBinder<'a> {
169    zone_map: &'a ZoneMap,
170}
171
172impl StatBinder for ZoneMapStatsBinder<'_> {
173    fn bind_aggregate(
174        &self,
175        input: &BoundExpression,
176        aggregate_fn: &AggregateFnRef,
177        _stat_dtype: &DType,
178    ) -> VortexResult<Option<BoundExpression>> {
179        if !input.is_root() {
180            return Ok(None);
181        }
182        vortex_ensure!(
183            input.dtype() == &self.zone_map.column_dtype,
184            "Stats predicate root dtype {} does not match zone-map column dtype {}",
185            input.dtype(),
186            self.zone_map.column_dtype
187        );
188
189        if let Some(stat_expr) = self.zone_map.aggregate_field_expr(aggregate_fn) {
190            return Ok(Some(self.bind_target(stat_expr)?));
191        }
192
193        if aggregate_fn.is::<AllNull>() {
194            return self
195                .zone_map
196                .stat_field_expr(Stat::NullCount)
197                .map(|null_count| self.bind_target(eq(null_count, row_count_expr())))
198                .transpose();
199        }
200
201        if aggregate_fn.is::<AllNonNull>() {
202            return self
203                .zone_map
204                .stat_field_expr(Stat::NullCount)
205                .map(|null_count| self.bind_target(eq(null_count, lit(0u64))))
206                .transpose();
207        }
208
209        if aggregate_fn.is::<AllNan>() {
210            return self
211                .zone_map
212                .stat_field_expr(Stat::NaNCount)
213                .map(|nan_count| self.bind_target(eq(nan_count, row_count_expr())))
214                .transpose();
215        }
216
217        if aggregate_fn.is::<AllNonNan>() {
218            return self
219                .zone_map
220                .stat_field_expr(Stat::NaNCount)
221                .map(|nan_count| self.bind_target(eq(nan_count, lit(0u64))))
222                .transpose();
223        }
224
225        if let Some(stat) = Stat::from_aggregate_fn(aggregate_fn) {
226            return self
227                .zone_map
228                .stat_field_expr(stat)
229                .map(|expr| self.bind_target(expr))
230                .transpose();
231        }
232
233        Ok(None)
234    }
235}
236
237impl ZoneMapStatsBinder<'_> {
238    fn bind_target(&self, expr: Expression) -> VortexResult<BoundExpression> {
239        expr.bind(self.zone_map.array.dtype())
240    }
241}
242
243impl ZoneMap {
244    fn aggregate_field_expr(&self, requested: &AggregateFnRef) -> Option<Expression> {
245        let field_name = requested.to_string();
246        if self.array.unmasked_field_by_name_opt(&field_name).is_some() {
247            return Some(aggregate_result_expr(
248                requested,
249                get_item(field_name, root()),
250            ));
251        }
252
253        let mut approximate = None;
254        for stored in self.aggregate_fns.iter() {
255            let field_name = stored.to_string();
256            if self.array.unmasked_field_by_name_opt(&field_name).is_none() {
257                continue;
258            }
259
260            match stored.can_satisfy(requested) {
261                AggregateFnSatisfaction::Exact => {
262                    return Some(aggregate_result_expr(stored, get_item(field_name, root())));
263                }
264                AggregateFnSatisfaction::Approximate => {
265                    approximate = Some(aggregate_result_expr(stored, get_item(field_name, root())));
266                }
267                AggregateFnSatisfaction::No => {}
268            }
269        }
270
271        approximate
272    }
273
274    fn stat_field_expr(&self, stat: Stat) -> Option<Expression> {
275        if let Some(aggregate_fn) = stat.aggregate_fn()
276            && let Some(expr) = self.aggregate_field_expr(&aggregate_fn)
277        {
278            return Some(expr);
279        }
280
281        self.legacy_stat_field_expr(stat)
282    }
283
284    fn legacy_stat_field_expr(&self, stat: Stat) -> Option<Expression> {
285        if self.array.unmasked_field_by_name_opt(stat.name()).is_some() {
286            return Some(get_item(stat.name(), root()));
287        }
288
289        None
290    }
291}
292
293fn aggregate_result_expr(stored: &AggregateFnRef, state_expr: Expression) -> Expression {
294    if stored.is::<BoundedMax>() {
295        get_item(BOUNDED_MAX_BOUND, state_expr)
296    } else {
297        state_expr
298    }
299}
300
301fn row_count_expr() -> Expression {
302    RowCount.new_expr(EmptyOptions, [])
303}
304
305/// Build per-zone row counts for a zone map.
306///
307/// `zone_len` is the nominal zone size; only the final zone may be shorter. The
308/// result is a [`ConstantArray`] for uniform zone sizes, otherwise a two-run
309/// run-end encoded array whose trailing run carries the final zone length.
310fn row_count_array(zone_len: u64, row_count: u64, num_zones: usize) -> VortexResult<ArrayRef> {
311    if num_zones == 0 {
312        return Ok(ConstantArray::new(0u64, 0).into_array());
313    }
314
315    let last_zone_len = row_count - zone_len.saturating_mul((num_zones as u64) - 1);
316    if num_zones == 1 || last_zone_len == zone_len {
317        return Ok(ConstantArray::new(last_zone_len, num_zones).into_array());
318    }
319
320    let ends = unsafe {
321        PrimitiveArray::new_unchecked(
322            buffer![num_zones as u64 - 1, num_zones as u64],
323            Validity::NonNullable,
324        )
325    }
326    .into_array();
327    let values = unsafe {
328        PrimitiveArray::new_unchecked(buffer![zone_len, last_zone_len], Validity::NonNullable)
329    }
330    .into_array();
331
332    // SAFETY: `ends` are strictly increasing, terminate at `num_zones`, and align one-to-one
333    // with the non-null run values.
334    Ok(unsafe { RunEnd::new_unchecked(ends, values, 0, num_zones) }.into_array())
335}
336
337#[cfg(test)]
338mod tests {
339    use std::num::NonZeroUsize;
340    use std::sync::Arc;
341
342    use vortex_array::IntoArray;
343    use vortex_array::VortexSessionExecute;
344    use vortex_array::aggregate_fn::AggregateFnVTableExt;
345    use vortex_array::aggregate_fn::EmptyOptions;
346    use vortex_array::aggregate_fn::NumericalAggregateOpts;
347    use vortex_array::aggregate_fn::fns::all_non_null::AllNonNull;
348    use vortex_array::aggregate_fn::fns::all_null::AllNull;
349    use vortex_array::aggregate_fn::fns::bounded_max::BOUNDED_MAX_BOUND;
350    use vortex_array::aggregate_fn::fns::bounded_max::BOUNDED_MAX_UNKNOWN;
351    use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax;
352    use vortex_array::aggregate_fn::fns::bounded_max::BoundedMaxOptions;
353    use vortex_array::aggregate_fn::fns::bounded_min::BoundedMin;
354    use vortex_array::aggregate_fn::fns::bounded_min::BoundedMinOptions;
355    use vortex_array::aggregate_fn::fns::max::Max;
356    use vortex_array::aggregate_fn::fns::min::Min;
357    use vortex_array::aggregate_fn::fns::nan_count::NanCount;
358    use vortex_array::aggregate_fn::fns::null_count::NullCount;
359    use vortex_array::arrays::BoolArray;
360    use vortex_array::arrays::PrimitiveArray;
361    use vortex_array::arrays::StructArray;
362    use vortex_array::assert_arrays_eq;
363    use vortex_array::dtype::DType;
364    use vortex_array::dtype::DecimalDType;
365    use vortex_array::dtype::FieldNames;
366    use vortex_array::dtype::Nullability;
367    use vortex_array::dtype::PType;
368    use vortex_array::expr::BoundExpression;
369    use vortex_array::expr::Expression;
370    use vortex_array::expr::cast;
371    use vortex_array::expr::gt;
372    use vortex_array::expr::gt_eq;
373    use vortex_array::expr::is_not_null;
374    use vortex_array::expr::is_null;
375    use vortex_array::expr::lit;
376    use vortex_array::expr::lt;
377    use vortex_array::expr::not_eq;
378    use vortex_array::expr::root;
379    use vortex_array::expr::stats::Stat;
380    use vortex_array::stats::all_nan;
381    use vortex_array::stats::all_non_nan;
382    use vortex_array::stats::all_non_null;
383    use vortex_array::stats::all_null;
384    use vortex_array::validity::Validity;
385    use vortex_buffer::buffer;
386    use vortex_error::VortexResult;
387    use vortex_mask::Mask;
388
389    use crate::layouts::zoned::zone_map::ZoneMap;
390    use crate::test::SESSION;
391
392    fn falsify(expr: &Expression, dtype: DType) -> BoundExpression {
393        expr.bind(&dtype)
394            .unwrap()
395            .falsify(&SESSION)
396            .unwrap()
397            .unwrap()
398    }
399
400    fn prune(zone_map: &ZoneMap, predicate: &Expression) -> VortexResult<Mask> {
401        zone_map.prune(&predicate.bind(&zone_map.column_dtype)?, &SESSION)
402    }
403
404    fn default_bounded_stat_max_bytes() -> NonZeroUsize {
405        // SAFETY: 64 is non-zero.
406        unsafe { NonZeroUsize::new_unchecked(64) }
407    }
408
409    #[test]
410    fn test_zone_map_prunes() {
411        // Construct a zone map with 3 zones:
412        //
413        // +----------+----------+
414        // |  a_min   |  a_max   |
415        // +----------+----------+
416        // |  1       |  5       |
417        // +----------+----------+
418        // |  2       |  6       |
419        // +----------+----------+
420        // |  3       |  7       |
421        // +----------+----------+
422        let max = Max.bind(NumericalAggregateOpts::skip_nans());
423        let min = Min.bind(NumericalAggregateOpts::skip_nans());
424        let zone_map = ZoneMap::try_new(
425            PType::I32.into(),
426            StructArray::from_fields(&[
427                (
428                    max.to_string(),
429                    PrimitiveArray::new(buffer![5i32, 6i32, 7i32], Validity::AllValid).into_array(),
430                ),
431                (
432                    min.to_string(),
433                    PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::AllValid).into_array(),
434                ),
435            ])
436            .unwrap(),
437            Arc::new([max, min]),
438            3,
439            10,
440        )
441        .unwrap();
442        let ctx = &mut SESSION.create_execution_ctx();
443
444        // A >= 6
445        // => A.max < 6
446        let expr = gt_eq(root(), lit(6i32));
447        let pruning_expr = falsify(&expr, PType::I32.into());
448        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
449        assert_arrays_eq!(
450            mask.into_array(),
451            BoolArray::from_iter([true, false, false]),
452            ctx
453        );
454
455        // A > 5
456        // => A.max <= 5
457        let expr = gt(root(), lit(5i32));
458        let pruning_expr = falsify(&expr, PType::I32.into());
459        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
460        assert_arrays_eq!(
461            mask.into_array(),
462            BoolArray::from_iter([true, false, false]),
463            ctx
464        );
465
466        // A < 2
467        // => A.min >= 2
468        let expr = lt(root(), lit(2i32));
469        let pruning_expr = falsify(&expr, PType::I32.into());
470        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
471        assert_arrays_eq!(
472            mask.into_array(),
473            BoolArray::from_iter([false, true, true]),
474            ctx
475        );
476    }
477
478    #[test]
479    fn bounded_display_names_satisfy_min_max_rewrites() {
480        let bounded_max = BoundedMax.bind(BoundedMaxOptions {
481            max_bytes: default_bounded_stat_max_bytes(),
482        });
483        let bounded_min = BoundedMin.bind(BoundedMinOptions {
484            max_bytes: default_bounded_stat_max_bytes(),
485        });
486        let zone_map = ZoneMap::try_new(
487            PType::I32.into(),
488            StructArray::from_fields(&[
489                (
490                    bounded_max.to_string(),
491                    StructArray::try_new(
492                        [BOUNDED_MAX_BOUND, BOUNDED_MAX_UNKNOWN].into(),
493                        vec![
494                            PrimitiveArray::new(buffer![5i32, 6i32, 7i32], Validity::AllValid)
495                                .into_array(),
496                            BoolArray::from_iter([false, false, false]).into_array(),
497                        ],
498                        3,
499                        Validity::AllValid,
500                    )
501                    .unwrap()
502                    .into_array(),
503                ),
504                (
505                    bounded_min.to_string(),
506                    PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::AllValid).into_array(),
507                ),
508            ])
509            .unwrap(),
510            Arc::new([bounded_max, bounded_min]),
511            3,
512            10,
513        )
514        .unwrap();
515        let ctx = &mut SESSION.create_execution_ctx();
516
517        let expr = gt(root(), lit(5i32));
518        let pruning_expr = falsify(&expr, PType::I32.into());
519        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
520        assert_arrays_eq!(
521            mask.into_array(),
522            BoolArray::from_iter([true, false, false]),
523            ctx
524        );
525
526        let expr = lt(root(), lit(2i32));
527        let pruning_expr = falsify(&expr, PType::I32.into());
528        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
529        assert_arrays_eq!(
530            mask.into_array(),
531            BoolArray::from_iter([false, true, true]),
532            ctx
533        );
534    }
535
536    #[test]
537    fn row_count_prunes_short_trailing_zone() {
538        let zone_map = ZoneMap::try_new_legacy(
539            PType::U64.into(),
540            StructArray::from_fields(&[(
541                "null_count",
542                PrimitiveArray::new(buffer![0u64, 0, 2], Validity::AllValid).into_array(),
543            )])
544            .unwrap(),
545            Arc::new([Stat::NullCount]),
546            4,
547            10,
548        )
549        .unwrap();
550
551        let expr = is_not_null(root());
552        let pruning_expr = falsify(&expr, PType::U64.into());
553
554        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
555        assert_arrays_eq!(
556            mask.into_array(),
557            BoolArray::from_iter([false, false, true]),
558            &mut SESSION.create_execution_ctx()
559        );
560    }
561
562    #[test]
563    fn row_count_substitution_handles_empty_zone_map() {
564        let zone_map = ZoneMap::try_new_legacy(
565            PType::U64.into(),
566            StructArray::from_fields(&[(
567                "null_count",
568                PrimitiveArray::new::<u64>(buffer![], Validity::AllValid).into_array(),
569            )])
570            .unwrap(),
571            Arc::new([Stat::NullCount]),
572            4,
573            0,
574        )
575        .unwrap();
576
577        let expr = is_not_null(root());
578        let pruning_expr = falsify(&expr, PType::U64.into());
579
580        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
581        assert_eq!(mask.len(), 0);
582    }
583
584    #[test]
585    fn is_null_falsification_uses_null_count() {
586        let zone_map = ZoneMap::try_new_legacy(
587            PType::U64.into(),
588            StructArray::from_fields(&[(
589                "null_count",
590                PrimitiveArray::new(buffer![0u64, 4, 2], Validity::AllValid).into_array(),
591            )])
592            .unwrap(),
593            Arc::new([Stat::NullCount]),
594            4,
595            10,
596        )
597        .unwrap();
598
599        let expr = is_null(root());
600        let pruning_expr = falsify(&expr, PType::U64.into());
601
602        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
603        assert_arrays_eq!(
604            mask.into_array(),
605            BoolArray::from_iter([true, false, false]),
606            &mut SESSION.create_execution_ctx()
607        );
608    }
609
610    #[test]
611    fn all_null_stat_fn_lowers_to_null_count_and_row_count() {
612        let zone_map = ZoneMap::try_new_legacy(
613            PType::U64.into(),
614            StructArray::from_fields(&[(
615                "null_count",
616                PrimitiveArray::new(buffer![0u64, 4, 2], Validity::AllValid).into_array(),
617            )])
618            .unwrap(),
619            Arc::new([Stat::NullCount]),
620            4,
621            10,
622        )
623        .unwrap();
624
625        let mask = prune(&zone_map, &all_null(root())).unwrap();
626        assert_arrays_eq!(
627            mask.into_array(),
628            BoolArray::from_iter([false, true, true]),
629            &mut SESSION.create_execution_ctx()
630        );
631    }
632
633    #[test]
634    fn all_non_null_stat_fn_lowers_to_null_count() {
635        let zone_map = ZoneMap::try_new_legacy(
636            PType::U64.into(),
637            StructArray::from_fields(&[(
638                "null_count",
639                PrimitiveArray::new(buffer![0u64, 4, 2], Validity::AllValid).into_array(),
640            )])
641            .unwrap(),
642            Arc::new([Stat::NullCount]),
643            4,
644            10,
645        )
646        .unwrap();
647
648        let mask = prune(&zone_map, &all_non_null(root())).unwrap();
649        assert_arrays_eq!(
650            mask.into_array(),
651            BoolArray::from_iter([true, false, false]),
652            &mut SESSION.create_execution_ctx()
653        );
654    }
655
656    #[test]
657    fn all_null_stat_fn_lowers_to_null_count_field() {
658        let null_count = NullCount.bind(EmptyOptions);
659        let zone_map = ZoneMap::try_new(
660            PType::U64.into(),
661            StructArray::from_fields(&[(
662                null_count.to_string(),
663                PrimitiveArray::new(buffer![4u64, 0, 2], Validity::AllValid).into_array(),
664            )])
665            .unwrap(),
666            Arc::new([null_count]),
667            4,
668            10,
669        )
670        .unwrap();
671        let ctx = &mut SESSION.create_execution_ctx();
672
673        let mask = prune(&zone_map, &all_null(root())).unwrap();
674        assert_arrays_eq!(
675            mask.into_array(),
676            BoolArray::from_iter([true, false, true]),
677            ctx
678        );
679
680        let mask = prune(&zone_map, &all_non_null(root())).unwrap();
681        assert_arrays_eq!(
682            mask.into_array(),
683            BoolArray::from_iter([false, true, false]),
684            ctx
685        );
686    }
687
688    #[test]
689    fn all_nan_stat_fn_lowers_to_nan_count_field() {
690        let nan_count = NanCount.bind(EmptyOptions);
691        let zone_map = ZoneMap::try_new(
692            PType::F32.into(),
693            StructArray::from_fields(&[(
694                nan_count.to_string(),
695                PrimitiveArray::new(buffer![4u64, 0, 2], Validity::AllValid).into_array(),
696            )])
697            .unwrap(),
698            Arc::new([nan_count]),
699            4,
700            10,
701        )
702        .unwrap();
703        let ctx = &mut SESSION.create_execution_ctx();
704
705        let mask = prune(&zone_map, &all_nan(root())).unwrap();
706        assert_arrays_eq!(
707            mask.into_array(),
708            BoolArray::from_iter([true, false, true]),
709            ctx
710        );
711
712        let mask = prune(&zone_map, &all_non_nan(root())).unwrap();
713        assert_arrays_eq!(
714            mask.into_array(),
715            BoolArray::from_iter([false, true, false]),
716            ctx
717        );
718    }
719
720    #[test]
721    fn non_float_nan_stat_fns_fail_to_bind() {
722        let dtype = DType::from(PType::I32);
723        for expr in [all_nan(root()), all_non_nan(root())] {
724            let error = expr.bind(&dtype).unwrap_err();
725            assert!(
726                error
727                    .to_string()
728                    .contains("does not support input dtype i32"),
729                "{error}"
730            );
731        }
732    }
733
734    #[test]
735    fn unavailable_stat_fn_lowers_to_unknown_mask() {
736        let zone_map = ZoneMap::try_new(
737            PType::U64.into(),
738            StructArray::try_new(FieldNames::empty(), vec![], 3, Validity::NonNullable).unwrap(),
739            Arc::new([]),
740            4,
741            10,
742        )
743        .unwrap();
744        let ctx = &mut SESSION.create_execution_ctx();
745
746        let mask = prune(&zone_map, &all_non_null(root())).unwrap();
747        assert_arrays_eq!(
748            mask.into_array(),
749            BoolArray::from_iter([false, false, false]),
750            ctx
751        );
752
753        let expr = gt(root(), lit(5u64));
754        let pruning_expr = falsify(&expr, PType::U64.into());
755        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
756        assert_arrays_eq!(
757            mask.into_array(),
758            BoolArray::from_iter([false, false, false]),
759            ctx
760        );
761    }
762
763    #[test]
764    fn float_min_max_stat_fn_requires_nan_count() {
765        let max = Max.bind(NumericalAggregateOpts::skip_nans());
766        let zone_map = ZoneMap::try_new(
767            PType::F32.into(),
768            StructArray::from_fields(&[(
769                max.to_string(),
770                PrimitiveArray::new(buffer![5.0f32, 6.0, 7.0], Validity::AllValid).into_array(),
771            )])
772            .unwrap(),
773            Arc::new([max.clone()]),
774            4,
775            12,
776        )
777        .unwrap();
778        let ctx = &mut SESSION.create_execution_ctx();
779
780        let expr = gt(root(), lit(5.0f32));
781        let pruning_expr = falsify(&expr, PType::F32.into());
782        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
783        assert_arrays_eq!(
784            mask.into_array(),
785            BoolArray::from_iter([false, false, false]),
786            ctx
787        );
788
789        let nan_count = NanCount.bind(EmptyOptions);
790        let zone_map = ZoneMap::try_new(
791            PType::F32.into(),
792            StructArray::from_fields(&[
793                (
794                    max.to_string(),
795                    PrimitiveArray::new(buffer![5.0f32, 6.0, 7.0], Validity::AllValid).into_array(),
796                ),
797                (
798                    nan_count.to_string(),
799                    PrimitiveArray::new(buffer![0u64, 0, 0], Validity::AllValid).into_array(),
800                ),
801            ])
802            .unwrap(),
803            Arc::new([max, nan_count]),
804            4,
805            12,
806        )
807        .unwrap();
808
809        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
810        assert_arrays_eq!(
811            mask.into_array(),
812            BoolArray::from_iter([true, false, false]),
813            ctx
814        );
815    }
816
817    #[test]
818    fn float_cast_min_max_stat_fn_uses_source_nan_count() {
819        let zone_map = ZoneMap::try_new_legacy(
820            PType::F32.into(),
821            StructArray::from_fields(&[
822                (
823                    "max",
824                    PrimitiveArray::new(buffer![5.0f32, 5.0], Validity::AllValid).into_array(),
825                ),
826                (
827                    "max_is_truncated",
828                    BoolArray::from_iter([false, false]).into_array(),
829                ),
830                (
831                    "min",
832                    PrimitiveArray::new(buffer![5.0f32, 5.0], Validity::AllValid).into_array(),
833                ),
834                (
835                    "min_is_truncated",
836                    BoolArray::from_iter([false, false]).into_array(),
837                ),
838                (
839                    "nan_count",
840                    PrimitiveArray::new(buffer![1u64, 0], Validity::AllValid).into_array(),
841                ),
842            ])
843            .unwrap(),
844            Arc::new([Stat::Max, Stat::Min, Stat::NaNCount]),
845            4,
846            8,
847        )
848        .unwrap();
849
850        let cast_dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
851        let expr = not_eq(cast(root(), cast_dtype), lit(5i32));
852        let pruning_expr = falsify(&expr, PType::F32.into());
853
854        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
855        assert_arrays_eq!(
856            mask.into_array(),
857            BoolArray::from_iter([false, true]),
858            &mut SESSION.create_execution_ctx()
859        );
860    }
861
862    #[test]
863    fn fixed_size_list_min_max_stat_fn_lowers_to_unknown_mask() {
864        // Regression test for issue #8189: Min/Max is defined for FixedSizeList<T>
865        // when T is orderable. If the zone map does not carry the requested stat,
866        // lowering should produce an unknown typed null rather than rejecting the dtype.
867        let elem_dtype = Arc::new(DType::Decimal(
868            DecimalDType::new(10, 2),
869            Nullability::Nullable,
870        ));
871        let column_dtype = DType::FixedSizeList(elem_dtype, 1, Nullability::Nullable);
872
873        let zone_map = ZoneMap::try_new(
874            column_dtype,
875            StructArray::try_new(FieldNames::empty(), vec![], 3, Validity::NonNullable).unwrap(),
876            Arc::new([]),
877            4,
878            10,
879        )
880        .unwrap();
881
882        let max_fn = Stat::Max
883            .aggregate_fn()
884            .expect("max should have an aggregate function");
885        let predicate = is_null(vortex_array::stats::stat(root(), max_fn));
886
887        // Missing StatFn lowers to a nullable null literal, so `is_null(...)` is true for every zone.
888        let mask = prune(&zone_map, &predicate).unwrap();
889        assert_arrays_eq!(
890            mask.into_array(),
891            BoolArray::from_iter([true, true, true]),
892            &mut SESSION.create_execution_ctx()
893        );
894    }
895
896    #[test]
897    fn unsupported_aggregate_input_dtype_errors() {
898        let zone_map = ZoneMap::try_new(
899            DType::Null,
900            StructArray::try_new(FieldNames::empty(), vec![], 3, Validity::NonNullable).unwrap(),
901            Arc::new([]),
902            4,
903            10,
904        )
905        .unwrap();
906
907        let max_fn = Stat::Max
908            .aggregate_fn()
909            .expect("max should have an aggregate function");
910        let predicate = is_null(vortex_array::stats::stat(root(), max_fn));
911        let error = prune(&zone_map, &predicate).unwrap_err();
912
913        assert!(
914            error
915                .to_string()
916                .contains("Aggregate function vortex.max() does not support input dtype null"),
917            "{error}"
918        );
919    }
920
921    #[test]
922    fn row_count_prunes_all_null_uniform_zones() {
923        let zone_map = ZoneMap::try_new_legacy(
924            PType::U64.into(),
925            StructArray::from_fields(&[(
926                "null_count",
927                PrimitiveArray::new(buffer![0u64, 4, 0], Validity::AllValid).into_array(),
928            )])
929            .unwrap(),
930            Arc::new([Stat::NullCount]),
931            4,
932            12,
933        )
934        .unwrap();
935
936        let expr = is_not_null(root());
937        let pruning_expr = falsify(&expr, PType::U64.into());
938
939        // All three zones have length 4 (total rows = 12).
940        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
941        assert_arrays_eq!(
942            mask.into_array(),
943            BoolArray::from_iter([false, true, false]),
944            &mut SESSION.create_execution_ctx()
945        );
946    }
947
948    #[test]
949    fn all_null_stat_fn_lowers_to_aggregate_field() {
950        let all_null_agg = AllNull.bind(EmptyOptions);
951        let zone_map = ZoneMap::try_new(
952            PType::U64.into(),
953            StructArray::from_fields(&[(
954                all_null_agg.to_string(),
955                BoolArray::from_iter([Some(false), Some(true), Some(true)]).into_array(),
956            )])
957            .unwrap(),
958            Arc::new([all_null_agg]),
959            4,
960            10,
961        )
962        .unwrap();
963
964        let mask = prune(&zone_map, &all_null(root())).unwrap();
965        assert_arrays_eq!(
966            mask.into_array(),
967            BoolArray::from_iter([false, true, true]),
968            &mut SESSION.create_execution_ctx()
969        );
970    }
971
972    #[test]
973    fn all_non_null_stat_fn_lowers_to_aggregate_field() {
974        let all_non_null_agg = AllNonNull.bind(EmptyOptions);
975        let zone_map = ZoneMap::try_new(
976            PType::U64.into(),
977            StructArray::from_fields(&[(
978                all_non_null_agg.to_string(),
979                BoolArray::from_iter([Some(true), Some(false), Some(false)]).into_array(),
980            )])
981            .unwrap(),
982            Arc::new([all_non_null_agg]),
983            4,
984            10,
985        )
986        .unwrap();
987
988        let mask = prune(&zone_map, &all_non_null(root())).unwrap();
989        assert_arrays_eq!(
990            mask.into_array(),
991            BoolArray::from_iter([true, false, false]),
992            &mut SESSION.create_execution_ctx()
993        );
994    }
995}