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.
310///
311/// All three arguments come from the file — `zone_len` from the zoned layout's metadata,
312/// `row_count` from the layout node, `num_zones` from the zone map's own length — and nothing ties
313/// them together, so a file may declare more zone coverage than the layout has rows.
314fn row_count_array(zone_len: u64, row_count: u64, num_zones: usize) -> VortexResult<ArrayRef> {
315    if num_zones == 0 {
316        return Ok(ConstantArray::new(0u64, 0).into_array());
317    }
318
319    let leading_rows = zone_len.saturating_mul((num_zones as u64) - 1);
320    let Some(last_zone_len) = row_count.checked_sub(leading_rows) else {
321        vortex_bail!(
322            "Zone map declares {num_zones} zones of {zone_len} rows, which is more than the \
323             {row_count} rows of the layout"
324        );
325    };
326    if num_zones == 1 || last_zone_len == zone_len {
327        return Ok(ConstantArray::new(last_zone_len, num_zones).into_array());
328    }
329
330    let ends = unsafe {
331        PrimitiveArray::new_unchecked(
332            buffer![num_zones as u64 - 1, num_zones as u64],
333            Validity::NonNullable,
334        )
335    }
336    .into_array();
337    let values = unsafe {
338        PrimitiveArray::new_unchecked(buffer![zone_len, last_zone_len], Validity::NonNullable)
339    }
340    .into_array();
341
342    // SAFETY: `ends` are strictly increasing, terminate at `num_zones`, and align one-to-one
343    // with the non-null run values.
344    Ok(unsafe { RunEnd::new_unchecked(ends, values, 0, num_zones) }.into_array())
345}
346
347#[cfg(test)]
348mod tests {
349    use std::num::NonZeroUsize;
350    use std::sync::Arc;
351
352    use rstest::rstest;
353    use vortex_array::IntoArray;
354    use vortex_array::VortexSessionExecute;
355    use vortex_array::aggregate_fn::AggregateFnVTableExt;
356    use vortex_array::aggregate_fn::EmptyOptions;
357    use vortex_array::aggregate_fn::NumericalAggregateOpts;
358    use vortex_array::aggregate_fn::fns::all_non_null::AllNonNull;
359    use vortex_array::aggregate_fn::fns::all_null::AllNull;
360    use vortex_array::aggregate_fn::fns::bounded_max::BOUNDED_MAX_BOUND;
361    use vortex_array::aggregate_fn::fns::bounded_max::BOUNDED_MAX_UNKNOWN;
362    use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax;
363    use vortex_array::aggregate_fn::fns::bounded_max::BoundedMaxOptions;
364    use vortex_array::aggregate_fn::fns::bounded_min::BoundedMin;
365    use vortex_array::aggregate_fn::fns::bounded_min::BoundedMinOptions;
366    use vortex_array::aggregate_fn::fns::max::Max;
367    use vortex_array::aggregate_fn::fns::min::Min;
368    use vortex_array::aggregate_fn::fns::nan_count::NanCount;
369    use vortex_array::aggregate_fn::fns::null_count::NullCount;
370    use vortex_array::arrays::BoolArray;
371    use vortex_array::arrays::PrimitiveArray;
372    use vortex_array::arrays::StructArray;
373    use vortex_array::assert_arrays_eq;
374    use vortex_array::dtype::DType;
375    use vortex_array::dtype::DecimalDType;
376    use vortex_array::dtype::FieldNames;
377    use vortex_array::dtype::Nullability;
378    use vortex_array::dtype::PType;
379    use vortex_array::expr::BoundExpression;
380    use vortex_array::expr::Expression;
381    use vortex_array::expr::cast;
382    use vortex_array::expr::gt;
383    use vortex_array::expr::gt_eq;
384    use vortex_array::expr::is_not_null;
385    use vortex_array::expr::is_null;
386    use vortex_array::expr::lit;
387    use vortex_array::expr::lt;
388    use vortex_array::expr::not_eq;
389    use vortex_array::expr::root;
390    use vortex_array::expr::stats::Stat;
391    use vortex_array::stats::all_nan;
392    use vortex_array::stats::all_non_nan;
393    use vortex_array::stats::all_non_null;
394    use vortex_array::stats::all_null;
395    use vortex_array::validity::Validity;
396    use vortex_buffer::Buffer;
397    use vortex_buffer::buffer;
398    use vortex_error::VortexResult;
399    use vortex_mask::Mask;
400
401    use crate::layouts::zoned::zone_map::ZoneMap;
402    use crate::test::SESSION;
403
404    fn falsify(expr: &Expression, dtype: DType) -> BoundExpression {
405        expr.bind(&dtype)
406            .unwrap()
407            .falsify(&SESSION)
408            .unwrap()
409            .unwrap()
410    }
411
412    fn prune(zone_map: &ZoneMap, predicate: &Expression) -> VortexResult<Mask> {
413        zone_map.prune(&predicate.bind(&zone_map.column_dtype)?, &SESSION)
414    }
415
416    fn default_bounded_stat_max_bytes() -> NonZeroUsize {
417        // SAFETY: 64 is non-zero.
418        unsafe { NonZeroUsize::new_unchecked(64) }
419    }
420
421    #[test]
422    fn test_zone_map_prunes() {
423        // Construct a zone map with 3 zones:
424        //
425        // +----------+----------+
426        // |  a_min   |  a_max   |
427        // +----------+----------+
428        // |  1       |  5       |
429        // +----------+----------+
430        // |  2       |  6       |
431        // +----------+----------+
432        // |  3       |  7       |
433        // +----------+----------+
434        let max = Max.bind(NumericalAggregateOpts::skip_nans());
435        let min = Min.bind(NumericalAggregateOpts::skip_nans());
436        let zone_map = ZoneMap::try_new(
437            PType::I32.into(),
438            StructArray::from_fields(&[
439                (
440                    max.to_string(),
441                    PrimitiveArray::new(buffer![5i32, 6i32, 7i32], Validity::AllValid).into_array(),
442                ),
443                (
444                    min.to_string(),
445                    PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::AllValid).into_array(),
446                ),
447            ])
448            .unwrap(),
449            Arc::new([max, min]),
450            3,
451            10,
452        )
453        .unwrap();
454        let ctx = &mut SESSION.create_execution_ctx();
455
456        // A >= 6
457        // => A.max < 6
458        let expr = gt_eq(root(), lit(6i32));
459        let pruning_expr = falsify(&expr, PType::I32.into());
460        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
461        assert_arrays_eq!(
462            mask.into_array(),
463            BoolArray::from_iter([true, false, false]),
464            ctx
465        );
466
467        // A > 5
468        // => A.max <= 5
469        let expr = gt(root(), lit(5i32));
470        let pruning_expr = falsify(&expr, PType::I32.into());
471        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
472        assert_arrays_eq!(
473            mask.into_array(),
474            BoolArray::from_iter([true, false, false]),
475            ctx
476        );
477
478        // A < 2
479        // => A.min >= 2
480        let expr = lt(root(), lit(2i32));
481        let pruning_expr = falsify(&expr, PType::I32.into());
482        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
483        assert_arrays_eq!(
484            mask.into_array(),
485            BoolArray::from_iter([false, true, true]),
486            ctx
487        );
488    }
489
490    #[test]
491    fn bounded_display_names_satisfy_min_max_rewrites() {
492        let bounded_max = BoundedMax.bind(BoundedMaxOptions {
493            max_bytes: default_bounded_stat_max_bytes(),
494        });
495        let bounded_min = BoundedMin.bind(BoundedMinOptions {
496            max_bytes: default_bounded_stat_max_bytes(),
497        });
498        let zone_map = ZoneMap::try_new(
499            PType::I32.into(),
500            StructArray::from_fields(&[
501                (
502                    bounded_max.to_string(),
503                    StructArray::try_new(
504                        [BOUNDED_MAX_BOUND, BOUNDED_MAX_UNKNOWN].into(),
505                        vec![
506                            PrimitiveArray::new(buffer![5i32, 6i32, 7i32], Validity::AllValid)
507                                .into_array(),
508                            BoolArray::from_iter([false, false, false]).into_array(),
509                        ],
510                        3,
511                        Validity::AllValid,
512                    )
513                    .unwrap()
514                    .into_array(),
515                ),
516                (
517                    bounded_min.to_string(),
518                    PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::AllValid).into_array(),
519                ),
520            ])
521            .unwrap(),
522            Arc::new([bounded_max, bounded_min]),
523            3,
524            10,
525        )
526        .unwrap();
527        let ctx = &mut SESSION.create_execution_ctx();
528
529        let expr = gt(root(), lit(5i32));
530        let pruning_expr = falsify(&expr, PType::I32.into());
531        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
532        assert_arrays_eq!(
533            mask.into_array(),
534            BoolArray::from_iter([true, false, false]),
535            ctx
536        );
537
538        let expr = lt(root(), lit(2i32));
539        let pruning_expr = falsify(&expr, PType::I32.into());
540        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
541        assert_arrays_eq!(
542            mask.into_array(),
543            BoolArray::from_iter([false, true, true]),
544            ctx
545        );
546    }
547
548    #[test]
549    fn row_count_prunes_short_trailing_zone() {
550        let zone_map = ZoneMap::try_new_legacy(
551            PType::U64.into(),
552            StructArray::from_fields(&[(
553                "null_count",
554                PrimitiveArray::new(buffer![0u64, 0, 2], Validity::AllValid).into_array(),
555            )])
556            .unwrap(),
557            Arc::new([Stat::NullCount]),
558            4,
559            10,
560        )
561        .unwrap();
562
563        let expr = is_not_null(root());
564        let pruning_expr = falsify(&expr, PType::U64.into());
565
566        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
567        assert_arrays_eq!(
568            mask.into_array(),
569            BoolArray::from_iter([false, false, true]),
570            &mut SESSION.create_execution_ctx()
571        );
572    }
573
574    /// `zone_len`, `row_count` and the zone-map length all come from the file and nothing ties them
575    /// together, so a file can claim more zone coverage than the layout has rows. Pruning must
576    /// report that rather than underflowing the per-zone row-count arithmetic.
577    #[rstest]
578    // 4 zones of 4 rows claims 12 rows of leading coverage for a 10-row layout. (3 zones of 4 is
579    // legal — 4 + 4 + 2 — and is what `row_count_prunes_short_trailing_zone` covers.)
580    #[case::zones_overrun_row_count(4, 4, 10)]
581    // The leading zones alone already exceed the row count.
582    #[case::leading_zones_overrun(8, 3, 10)]
583    // A zero row count with a non-empty zone map.
584    #[case::no_rows_but_zones_present(4, 2, 0)]
585    fn row_count_rejects_zones_overrunning_the_layout(
586        #[case] zone_len: u64,
587        #[case] num_zones: usize,
588        #[case] row_count: u64,
589    ) {
590        let null_counts = PrimitiveArray::new(
591            Buffer::<u64>::from_iter(std::iter::repeat_n(0u64, num_zones)),
592            Validity::AllValid,
593        )
594        .into_array();
595        let zone_map = ZoneMap::try_new_legacy(
596            PType::U64.into(),
597            StructArray::from_fields(&[("null_count", null_counts)]).unwrap(),
598            Arc::new([Stat::NullCount]),
599            zone_len,
600            row_count,
601        )
602        .unwrap();
603
604        let pruning_expr = falsify(&is_not_null(root()), PType::U64.into());
605        let message = zone_map
606            .prune(&pruning_expr, &SESSION)
607            .err()
608            .map(|err| err.to_string())
609            .unwrap_or_default();
610        assert!(
611            message.contains("more than the"),
612            "zone_len {zone_len} x {num_zones} zones over {row_count} rows should have been \
613             rejected, got: {message}"
614        );
615    }
616
617    #[test]
618    fn row_count_substitution_handles_empty_zone_map() {
619        let zone_map = ZoneMap::try_new_legacy(
620            PType::U64.into(),
621            StructArray::from_fields(&[(
622                "null_count",
623                PrimitiveArray::new::<u64>(buffer![], Validity::AllValid).into_array(),
624            )])
625            .unwrap(),
626            Arc::new([Stat::NullCount]),
627            4,
628            0,
629        )
630        .unwrap();
631
632        let expr = is_not_null(root());
633        let pruning_expr = falsify(&expr, PType::U64.into());
634
635        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
636        assert_eq!(mask.len(), 0);
637    }
638
639    #[test]
640    fn is_null_falsification_uses_null_count() {
641        let zone_map = ZoneMap::try_new_legacy(
642            PType::U64.into(),
643            StructArray::from_fields(&[(
644                "null_count",
645                PrimitiveArray::new(buffer![0u64, 4, 2], Validity::AllValid).into_array(),
646            )])
647            .unwrap(),
648            Arc::new([Stat::NullCount]),
649            4,
650            10,
651        )
652        .unwrap();
653
654        let expr = is_null(root());
655        let pruning_expr = falsify(&expr, PType::U64.into());
656
657        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
658        assert_arrays_eq!(
659            mask.into_array(),
660            BoolArray::from_iter([true, false, false]),
661            &mut SESSION.create_execution_ctx()
662        );
663    }
664
665    #[test]
666    fn all_null_stat_fn_lowers_to_null_count_and_row_count() {
667        let zone_map = ZoneMap::try_new_legacy(
668            PType::U64.into(),
669            StructArray::from_fields(&[(
670                "null_count",
671                PrimitiveArray::new(buffer![0u64, 4, 2], Validity::AllValid).into_array(),
672            )])
673            .unwrap(),
674            Arc::new([Stat::NullCount]),
675            4,
676            10,
677        )
678        .unwrap();
679
680        let mask = prune(&zone_map, &all_null(root())).unwrap();
681        assert_arrays_eq!(
682            mask.into_array(),
683            BoolArray::from_iter([false, true, true]),
684            &mut SESSION.create_execution_ctx()
685        );
686    }
687
688    #[test]
689    fn all_non_null_stat_fn_lowers_to_null_count() {
690        let zone_map = ZoneMap::try_new_legacy(
691            PType::U64.into(),
692            StructArray::from_fields(&[(
693                "null_count",
694                PrimitiveArray::new(buffer![0u64, 4, 2], Validity::AllValid).into_array(),
695            )])
696            .unwrap(),
697            Arc::new([Stat::NullCount]),
698            4,
699            10,
700        )
701        .unwrap();
702
703        let mask = prune(&zone_map, &all_non_null(root())).unwrap();
704        assert_arrays_eq!(
705            mask.into_array(),
706            BoolArray::from_iter([true, false, false]),
707            &mut SESSION.create_execution_ctx()
708        );
709    }
710
711    #[test]
712    fn all_null_stat_fn_lowers_to_null_count_field() {
713        let null_count = NullCount.bind(EmptyOptions);
714        let zone_map = ZoneMap::try_new(
715            PType::U64.into(),
716            StructArray::from_fields(&[(
717                null_count.to_string(),
718                PrimitiveArray::new(buffer![4u64, 0, 2], Validity::AllValid).into_array(),
719            )])
720            .unwrap(),
721            Arc::new([null_count]),
722            4,
723            10,
724        )
725        .unwrap();
726        let ctx = &mut SESSION.create_execution_ctx();
727
728        let mask = prune(&zone_map, &all_null(root())).unwrap();
729        assert_arrays_eq!(
730            mask.into_array(),
731            BoolArray::from_iter([true, false, true]),
732            ctx
733        );
734
735        let mask = prune(&zone_map, &all_non_null(root())).unwrap();
736        assert_arrays_eq!(
737            mask.into_array(),
738            BoolArray::from_iter([false, true, false]),
739            ctx
740        );
741    }
742
743    #[test]
744    fn all_nan_stat_fn_lowers_to_nan_count_field() {
745        let nan_count = NanCount.bind(EmptyOptions);
746        let zone_map = ZoneMap::try_new(
747            PType::F32.into(),
748            StructArray::from_fields(&[(
749                nan_count.to_string(),
750                PrimitiveArray::new(buffer![4u64, 0, 2], Validity::AllValid).into_array(),
751            )])
752            .unwrap(),
753            Arc::new([nan_count]),
754            4,
755            10,
756        )
757        .unwrap();
758        let ctx = &mut SESSION.create_execution_ctx();
759
760        let mask = prune(&zone_map, &all_nan(root())).unwrap();
761        assert_arrays_eq!(
762            mask.into_array(),
763            BoolArray::from_iter([true, false, true]),
764            ctx
765        );
766
767        let mask = prune(&zone_map, &all_non_nan(root())).unwrap();
768        assert_arrays_eq!(
769            mask.into_array(),
770            BoolArray::from_iter([false, true, false]),
771            ctx
772        );
773    }
774
775    #[test]
776    fn non_float_nan_stat_fns_fail_to_bind() {
777        let dtype = DType::from(PType::I32);
778        for expr in [all_nan(root()), all_non_nan(root())] {
779            let error = expr.bind(&dtype).unwrap_err();
780            assert!(
781                error
782                    .to_string()
783                    .contains("does not support input dtype i32"),
784                "{error}"
785            );
786        }
787    }
788
789    #[test]
790    fn unavailable_stat_fn_lowers_to_unknown_mask() {
791        let zone_map = ZoneMap::try_new(
792            PType::U64.into(),
793            StructArray::try_new(FieldNames::empty(), vec![], 3, Validity::NonNullable).unwrap(),
794            Arc::new([]),
795            4,
796            10,
797        )
798        .unwrap();
799        let ctx = &mut SESSION.create_execution_ctx();
800
801        let mask = prune(&zone_map, &all_non_null(root())).unwrap();
802        assert_arrays_eq!(
803            mask.into_array(),
804            BoolArray::from_iter([false, false, false]),
805            ctx
806        );
807
808        let expr = gt(root(), lit(5u64));
809        let pruning_expr = falsify(&expr, PType::U64.into());
810        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
811        assert_arrays_eq!(
812            mask.into_array(),
813            BoolArray::from_iter([false, false, false]),
814            ctx
815        );
816    }
817
818    #[test]
819    fn float_min_max_stat_fn_requires_nan_count() {
820        let max = Max.bind(NumericalAggregateOpts::skip_nans());
821        let zone_map = ZoneMap::try_new(
822            PType::F32.into(),
823            StructArray::from_fields(&[(
824                max.to_string(),
825                PrimitiveArray::new(buffer![5.0f32, 6.0, 7.0], Validity::AllValid).into_array(),
826            )])
827            .unwrap(),
828            Arc::new([max.clone()]),
829            4,
830            12,
831        )
832        .unwrap();
833        let ctx = &mut SESSION.create_execution_ctx();
834
835        let expr = gt(root(), lit(5.0f32));
836        let pruning_expr = falsify(&expr, PType::F32.into());
837        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
838        assert_arrays_eq!(
839            mask.into_array(),
840            BoolArray::from_iter([false, false, false]),
841            ctx
842        );
843
844        let nan_count = NanCount.bind(EmptyOptions);
845        let zone_map = ZoneMap::try_new(
846            PType::F32.into(),
847            StructArray::from_fields(&[
848                (
849                    max.to_string(),
850                    PrimitiveArray::new(buffer![5.0f32, 6.0, 7.0], Validity::AllValid).into_array(),
851                ),
852                (
853                    nan_count.to_string(),
854                    PrimitiveArray::new(buffer![0u64, 0, 0], Validity::AllValid).into_array(),
855                ),
856            ])
857            .unwrap(),
858            Arc::new([max, nan_count]),
859            4,
860            12,
861        )
862        .unwrap();
863
864        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
865        assert_arrays_eq!(
866            mask.into_array(),
867            BoolArray::from_iter([true, false, false]),
868            ctx
869        );
870    }
871
872    #[test]
873    fn float_cast_min_max_stat_fn_uses_source_nan_count() {
874        let zone_map = ZoneMap::try_new_legacy(
875            PType::F32.into(),
876            StructArray::from_fields(&[
877                (
878                    "max",
879                    PrimitiveArray::new(buffer![5.0f32, 5.0], Validity::AllValid).into_array(),
880                ),
881                (
882                    "max_is_truncated",
883                    BoolArray::from_iter([false, false]).into_array(),
884                ),
885                (
886                    "min",
887                    PrimitiveArray::new(buffer![5.0f32, 5.0], Validity::AllValid).into_array(),
888                ),
889                (
890                    "min_is_truncated",
891                    BoolArray::from_iter([false, false]).into_array(),
892                ),
893                (
894                    "nan_count",
895                    PrimitiveArray::new(buffer![1u64, 0], Validity::AllValid).into_array(),
896                ),
897            ])
898            .unwrap(),
899            Arc::new([Stat::Max, Stat::Min, Stat::NaNCount]),
900            4,
901            8,
902        )
903        .unwrap();
904
905        let cast_dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
906        let expr = not_eq(cast(root(), cast_dtype), lit(5i32));
907        let pruning_expr = falsify(&expr, PType::F32.into());
908
909        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
910        assert_arrays_eq!(
911            mask.into_array(),
912            BoolArray::from_iter([false, true]),
913            &mut SESSION.create_execution_ctx()
914        );
915    }
916
917    #[test]
918    fn fixed_size_list_min_max_stat_fn_lowers_to_unknown_mask() {
919        // Regression test for issue #8189: Min/Max is defined for FixedSizeList<T>
920        // when T is orderable. If the zone map does not carry the requested stat,
921        // lowering should produce an unknown typed null rather than rejecting the dtype.
922        let elem_dtype = Arc::new(DType::Decimal(
923            DecimalDType::new(10, 2),
924            Nullability::Nullable,
925        ));
926        let column_dtype = DType::FixedSizeList(elem_dtype, 1, Nullability::Nullable);
927
928        let zone_map = ZoneMap::try_new(
929            column_dtype,
930            StructArray::try_new(FieldNames::empty(), vec![], 3, Validity::NonNullable).unwrap(),
931            Arc::new([]),
932            4,
933            10,
934        )
935        .unwrap();
936
937        let max_fn = Stat::Max
938            .aggregate_fn()
939            .expect("max should have an aggregate function");
940        let predicate = is_null(vortex_array::stats::stat(root(), max_fn));
941
942        // Missing StatFn lowers to a nullable null literal, so `is_null(...)` is true for every zone.
943        let mask = prune(&zone_map, &predicate).unwrap();
944        assert_arrays_eq!(
945            mask.into_array(),
946            BoolArray::from_iter([true, true, true]),
947            &mut SESSION.create_execution_ctx()
948        );
949    }
950
951    #[test]
952    fn unsupported_aggregate_input_dtype_errors() {
953        let zone_map = ZoneMap::try_new(
954            DType::Null,
955            StructArray::try_new(FieldNames::empty(), vec![], 3, Validity::NonNullable).unwrap(),
956            Arc::new([]),
957            4,
958            10,
959        )
960        .unwrap();
961
962        let max_fn = Stat::Max
963            .aggregate_fn()
964            .expect("max should have an aggregate function");
965        let predicate = is_null(vortex_array::stats::stat(root(), max_fn));
966        let error = prune(&zone_map, &predicate).unwrap_err();
967
968        assert!(
969            error
970                .to_string()
971                .contains("Aggregate function vortex.max() does not support input dtype null"),
972            "{error}"
973        );
974    }
975
976    #[test]
977    fn row_count_prunes_all_null_uniform_zones() {
978        let zone_map = ZoneMap::try_new_legacy(
979            PType::U64.into(),
980            StructArray::from_fields(&[(
981                "null_count",
982                PrimitiveArray::new(buffer![0u64, 4, 0], Validity::AllValid).into_array(),
983            )])
984            .unwrap(),
985            Arc::new([Stat::NullCount]),
986            4,
987            12,
988        )
989        .unwrap();
990
991        let expr = is_not_null(root());
992        let pruning_expr = falsify(&expr, PType::U64.into());
993
994        // All three zones have length 4 (total rows = 12).
995        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
996        assert_arrays_eq!(
997            mask.into_array(),
998            BoolArray::from_iter([false, true, false]),
999            &mut SESSION.create_execution_ctx()
1000        );
1001    }
1002
1003    #[test]
1004    fn all_null_stat_fn_lowers_to_aggregate_field() {
1005        let all_null_agg = AllNull.bind(EmptyOptions);
1006        let zone_map = ZoneMap::try_new(
1007            PType::U64.into(),
1008            StructArray::from_fields(&[(
1009                all_null_agg.to_string(),
1010                BoolArray::from_iter([Some(false), Some(true), Some(true)]).into_array(),
1011            )])
1012            .unwrap(),
1013            Arc::new([all_null_agg]),
1014            4,
1015            10,
1016        )
1017        .unwrap();
1018
1019        let mask = prune(&zone_map, &all_null(root())).unwrap();
1020        assert_arrays_eq!(
1021            mask.into_array(),
1022            BoolArray::from_iter([false, true, true]),
1023            &mut SESSION.create_execution_ctx()
1024        );
1025    }
1026
1027    #[test]
1028    fn all_non_null_stat_fn_lowers_to_aggregate_field() {
1029        let all_non_null_agg = AllNonNull.bind(EmptyOptions);
1030        let zone_map = ZoneMap::try_new(
1031            PType::U64.into(),
1032            StructArray::from_fields(&[(
1033                all_non_null_agg.to_string(),
1034                BoolArray::from_iter([Some(true), Some(false), Some(false)]).into_array(),
1035            )])
1036            .unwrap(),
1037            Arc::new([all_non_null_agg]),
1038            4,
1039            10,
1040        )
1041        .unwrap();
1042
1043        let mask = prune(&zone_map, &all_non_null(root())).unwrap();
1044        assert_arrays_eq!(
1045            mask.into_array(),
1046            BoolArray::from_iter([true, false, false]),
1047            &mut SESSION.create_execution_ctx()
1048        );
1049    }
1050}