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