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::Columnar;
10use vortex_array::ExecutionCtx;
11use vortex_array::IntoArray;
12use vortex_array::VortexSessionExecute;
13use vortex_array::aggregate_fn::AggregateFnRef;
14use vortex_array::aggregate_fn::AggregateFnSatisfaction;
15use vortex_array::aggregate_fn::fns::all_nan::AllNan;
16use vortex_array::aggregate_fn::fns::all_non_nan::AllNonNan;
17use vortex_array::aggregate_fn::fns::all_non_null::AllNonNull;
18use vortex_array::aggregate_fn::fns::all_null::AllNull;
19use vortex_array::aggregate_fn::fns::bounded_max::BOUNDED_MAX_BOUND;
20use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax;
21use vortex_array::arrays::BoolArray;
22use vortex_array::arrays::ConstantArray;
23use vortex_array::arrays::PrimitiveArray;
24use vortex_array::arrays::StructArray;
25use vortex_array::arrays::struct_::StructArrayExt;
26use vortex_array::dtype::DType;
27use vortex_array::expr::BoundExpression;
28use vortex_array::expr::Expression;
29use vortex_array::expr::eq;
30use vortex_array::expr::get_item;
31use vortex_array::expr::lit;
32use vortex_array::expr::root;
33use vortex_array::expr::stats::Stat;
34use vortex_array::scalar_fn::EmptyOptions;
35use vortex_array::scalar_fn::ScalarFnVTableExt;
36use vortex_array::scalar_fn::internal::row_count::RowCount;
37use vortex_array::scalar_fn::internal::row_count::contains_row_count;
38use vortex_array::scalar_fn::internal::row_count::substitute_row_count;
39use vortex_array::stats::bind::StatBinder;
40use vortex_array::stats::bind::bind_stats;
41use vortex_array::validity::Validity;
42use vortex_buffer::buffer;
43use vortex_error::VortexResult;
44use vortex_error::vortex_bail;
45use vortex_error::vortex_ensure_eq;
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    /// [`BoundExpression::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_bound`] 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(
145        &self,
146        predicate: &BoundExpression,
147        session: &VortexSession,
148    ) -> VortexResult<Mask> {
149        let mut ctx = session.create_execution_ctx();
150        let num_zones = self.array.len();
151        let predicate = self.lower_stats(predicate.clone())?;
152
153        let array = self.array.clone().into_array();
154        let applied = array.apply_bound(&predicate)?;
155
156        if !contains_row_count(&applied) {
157            return null_as_false_mask(applied, &mut ctx);
158        }
159
160        let row_count_array = row_count_array(self.zone_len, self.row_count, num_zones)?;
161        let substituted = substitute_row_count(applied, &row_count_array)?;
162        null_as_false_mask(substituted, &mut ctx)
163    }
164
165    fn lower_stats(&self, predicate: BoundExpression) -> VortexResult<BoundExpression> {
166        let binder = ZoneMapStatsBinder { zone_map: self };
167        bind_stats(predicate, &binder)
168    }
169}
170
171/// This is equivalent to `fill_null(false)?.execute::<Mask>(ctx)`.
172/// However, in the case of very frequend and very short operations the cost of
173/// filling nulls and then canonicalizing is greater than canonicalizing and
174/// then intersecting the bitbuffer.
175fn null_as_false_mask(array: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<Mask> {
176    if !array.dtype().is_nullable() {
177        return array.execute::<Mask>(ctx);
178    }
179    let len = array.len();
180    Ok(match array.execute::<Columnar>(ctx)? {
181        Columnar::Constant(c) => Mask::new(len, c.scalar().as_bool().value().unwrap_or(false)),
182        Columnar::Canonical(c) => {
183            let bool = c.into_array().execute::<BoolArray>(ctx)?;
184            match bool.as_ref().validity()? {
185                Validity::NonNullable | Validity::AllValid => {
186                    Mask::from_buffer(bool.into_bit_buffer())
187                }
188                Validity::AllInvalid => Mask::new_false(len),
189                Validity::Array(v) => {
190                    let validity_bits = v.execute::<BoolArray>(ctx)?.into_bit_buffer();
191                    Mask::from_buffer(bool.into_bit_buffer() & &validity_bits)
192                }
193            }
194        }
195    })
196}
197
198struct ZoneMapStatsBinder<'a> {
199    zone_map: &'a ZoneMap,
200}
201
202impl StatBinder for ZoneMapStatsBinder<'_> {
203    fn bind_aggregate(
204        &self,
205        input: &BoundExpression,
206        aggregate_fn: &AggregateFnRef,
207        _stat_dtype: &DType,
208    ) -> VortexResult<Option<BoundExpression>> {
209        if !input.is_root() {
210            return Ok(None);
211        }
212        vortex_ensure_eq!(
213            input.dtype(),
214            &self.zone_map.column_dtype,
215            "Stats predicate root dtype does not match zone-map column dtype"
216        );
217
218        if let Some(stat_expr) = self.zone_map.aggregate_field_expr(aggregate_fn) {
219            return Ok(Some(self.bind_target(stat_expr)?));
220        }
221
222        if aggregate_fn.is::<AllNull>() {
223            return self
224                .zone_map
225                .stat_field_expr(Stat::NullCount)
226                .map(|null_count| self.bind_target(eq(null_count, row_count_expr())))
227                .transpose();
228        }
229
230        if aggregate_fn.is::<AllNonNull>() {
231            return self
232                .zone_map
233                .stat_field_expr(Stat::NullCount)
234                .map(|null_count| self.bind_target(eq(null_count, lit(0u64))))
235                .transpose();
236        }
237
238        if aggregate_fn.is::<AllNan>() {
239            return self
240                .zone_map
241                .stat_field_expr(Stat::NaNCount)
242                .map(|nan_count| self.bind_target(eq(nan_count, row_count_expr())))
243                .transpose();
244        }
245
246        if aggregate_fn.is::<AllNonNan>() {
247            return self
248                .zone_map
249                .stat_field_expr(Stat::NaNCount)
250                .map(|nan_count| self.bind_target(eq(nan_count, lit(0u64))))
251                .transpose();
252        }
253
254        if let Some(stat) = Stat::from_aggregate_fn(aggregate_fn) {
255            return self
256                .zone_map
257                .stat_field_expr(stat)
258                .map(|expr| self.bind_target(expr))
259                .transpose();
260        }
261
262        Ok(None)
263    }
264}
265
266impl ZoneMapStatsBinder<'_> {
267    fn bind_target(&self, expr: Expression) -> VortexResult<BoundExpression> {
268        expr.bind(self.zone_map.array.dtype())
269    }
270}
271
272impl ZoneMap {
273    fn aggregate_field_expr(&self, requested: &AggregateFnRef) -> Option<Expression> {
274        let field_name = requested.to_string();
275        if self.array.unmasked_field_by_name_opt(&field_name).is_some() {
276            return Some(aggregate_result_expr(
277                requested,
278                get_item(field_name, root()),
279            ));
280        }
281
282        let mut approximate = None;
283        for stored in self.aggregate_fns.iter() {
284            let field_name = stored.to_string();
285            if self.array.unmasked_field_by_name_opt(&field_name).is_none() {
286                continue;
287            }
288
289            match stored.can_satisfy(requested) {
290                AggregateFnSatisfaction::Exact => {
291                    return Some(aggregate_result_expr(stored, get_item(field_name, root())));
292                }
293                AggregateFnSatisfaction::Approximate => {
294                    approximate = Some(aggregate_result_expr(stored, get_item(field_name, root())));
295                }
296                AggregateFnSatisfaction::No => {}
297            }
298        }
299
300        approximate
301    }
302
303    fn stat_field_expr(&self, stat: Stat) -> Option<Expression> {
304        if let Some(aggregate_fn) = stat.aggregate_fn()
305            && let Some(expr) = self.aggregate_field_expr(&aggregate_fn)
306        {
307            return Some(expr);
308        }
309
310        self.legacy_stat_field_expr(stat)
311    }
312
313    fn legacy_stat_field_expr(&self, stat: Stat) -> Option<Expression> {
314        if self.array.unmasked_field_by_name_opt(stat.name()).is_some() {
315            return Some(get_item(stat.name(), root()));
316        }
317
318        None
319    }
320}
321
322fn aggregate_result_expr(stored: &AggregateFnRef, state_expr: Expression) -> Expression {
323    if stored.is::<BoundedMax>() {
324        get_item(BOUNDED_MAX_BOUND, state_expr)
325    } else {
326        state_expr
327    }
328}
329
330fn row_count_expr() -> Expression {
331    RowCount.new_expr(EmptyOptions, [])
332}
333
334/// Build per-zone row counts for a zone map.
335///
336/// `zone_len` is the nominal zone size; only the final zone may be shorter. The
337/// result is a [`ConstantArray`] for uniform zone sizes, otherwise a two-run
338/// run-end encoded array whose trailing run carries the final zone length.
339///
340/// All three arguments come from the file — `zone_len` from the zoned layout's metadata,
341/// `row_count` from the layout node, `num_zones` from the zone map's own length — and nothing ties
342/// them together, so a file may declare more zone coverage than the layout has rows.
343fn row_count_array(zone_len: u64, row_count: u64, num_zones: usize) -> VortexResult<ArrayRef> {
344    if num_zones == 0 {
345        return Ok(ConstantArray::new(0u64, 0).into_array());
346    }
347
348    let leading_rows = zone_len.saturating_mul((num_zones as u64) - 1);
349    let Some(last_zone_len) = row_count.checked_sub(leading_rows) else {
350        vortex_bail!(
351            "Zone map declares {num_zones} zones of {zone_len} rows, which is more than the \
352             {row_count} rows of the layout"
353        );
354    };
355    if num_zones == 1 || last_zone_len == zone_len {
356        return Ok(ConstantArray::new(last_zone_len, num_zones).into_array());
357    }
358
359    let ends = unsafe {
360        PrimitiveArray::new_unchecked(
361            buffer![num_zones as u64 - 1, num_zones as u64],
362            Validity::NonNullable,
363        )
364    }
365    .into_array();
366    let values = unsafe {
367        PrimitiveArray::new_unchecked(buffer![zone_len, last_zone_len], Validity::NonNullable)
368    }
369    .into_array();
370
371    // SAFETY: `ends` are strictly increasing, terminate at `num_zones`, and align one-to-one
372    // with the non-null run values.
373    Ok(unsafe { RunEnd::new_unchecked(ends, values, 0, num_zones) }.into_array())
374}
375
376#[cfg(test)]
377mod tests {
378    use std::num::NonZeroUsize;
379    use std::sync::Arc;
380
381    use rstest::rstest;
382    use vortex_array::IntoArray;
383    use vortex_array::VortexSessionExecute;
384    use vortex_array::aggregate_fn::AggregateFnVTableExt;
385    use vortex_array::aggregate_fn::EmptyOptions;
386    use vortex_array::aggregate_fn::NumericalAggregateOpts;
387    use vortex_array::aggregate_fn::fns::all_non_null::AllNonNull;
388    use vortex_array::aggregate_fn::fns::all_null::AllNull;
389    use vortex_array::aggregate_fn::fns::bounded_max::BOUNDED_MAX_BOUND;
390    use vortex_array::aggregate_fn::fns::bounded_max::BOUNDED_MAX_UNKNOWN;
391    use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax;
392    use vortex_array::aggregate_fn::fns::bounded_max::BoundedMaxOptions;
393    use vortex_array::aggregate_fn::fns::bounded_min::BoundedMin;
394    use vortex_array::aggregate_fn::fns::bounded_min::BoundedMinOptions;
395    use vortex_array::aggregate_fn::fns::max::Max;
396    use vortex_array::aggregate_fn::fns::min::Min;
397    use vortex_array::aggregate_fn::fns::nan_count::NanCount;
398    use vortex_array::aggregate_fn::fns::null_count::NullCount;
399    use vortex_array::arrays::BoolArray;
400    use vortex_array::arrays::PrimitiveArray;
401    use vortex_array::arrays::StructArray;
402    use vortex_array::assert_arrays_eq;
403    use vortex_array::dtype::DType;
404    use vortex_array::dtype::DecimalDType;
405    use vortex_array::dtype::FieldNames;
406    use vortex_array::dtype::Nullability;
407    use vortex_array::dtype::PType;
408    use vortex_array::expr::BoundExpression;
409    use vortex_array::expr::Expression;
410    use vortex_array::expr::cast;
411    use vortex_array::expr::gt;
412    use vortex_array::expr::gt_eq;
413    use vortex_array::expr::is_not_null;
414    use vortex_array::expr::is_null;
415    use vortex_array::expr::lit;
416    use vortex_array::expr::lt;
417    use vortex_array::expr::not_eq;
418    use vortex_array::expr::root;
419    use vortex_array::expr::stats::Stat;
420    use vortex_array::stats::all_nan;
421    use vortex_array::stats::all_non_nan;
422    use vortex_array::stats::all_non_null;
423    use vortex_array::stats::all_null;
424    use vortex_array::validity::Validity;
425    use vortex_buffer::Buffer;
426    use vortex_buffer::buffer;
427    use vortex_error::VortexResult;
428    use vortex_mask::Mask;
429
430    use crate::layouts::zoned::zone_map::ZoneMap;
431    use crate::test::SESSION;
432
433    fn falsify(expr: &Expression, dtype: DType) -> BoundExpression {
434        expr.bind(&dtype)
435            .unwrap()
436            .falsify(&SESSION)
437            .unwrap()
438            .unwrap()
439    }
440
441    fn prune(zone_map: &ZoneMap, predicate: &Expression) -> VortexResult<Mask> {
442        zone_map.prune(&predicate.bind(&zone_map.column_dtype)?, &SESSION)
443    }
444
445    fn default_bounded_stat_max_bytes() -> NonZeroUsize {
446        // SAFETY: 64 is non-zero.
447        unsafe { NonZeroUsize::new_unchecked(64) }
448    }
449
450    #[test]
451    fn test_zone_map_prunes() {
452        // Construct a zone map with 3 zones:
453        //
454        // +----------+----------+
455        // |  a_min   |  a_max   |
456        // +----------+----------+
457        // |  1       |  5       |
458        // +----------+----------+
459        // |  2       |  6       |
460        // +----------+----------+
461        // |  3       |  7       |
462        // +----------+----------+
463        let max = Max.bind(NumericalAggregateOpts::skip_nans());
464        let min = Min.bind(NumericalAggregateOpts::skip_nans());
465        let zone_map = ZoneMap::try_new(
466            PType::I32.into(),
467            StructArray::from_fields(&[
468                (
469                    max.to_string(),
470                    PrimitiveArray::new(buffer![5i32, 6i32, 7i32], Validity::AllValid).into_array(),
471                ),
472                (
473                    min.to_string(),
474                    PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::AllValid).into_array(),
475                ),
476            ])
477            .unwrap(),
478            Arc::new([max, min]),
479            3,
480            10,
481        )
482        .unwrap();
483        let ctx = &mut SESSION.create_execution_ctx();
484
485        // A >= 6
486        // => A.max < 6
487        let expr = gt_eq(root(), lit(6i32));
488        let pruning_expr = falsify(&expr, PType::I32.into());
489        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
490        assert_arrays_eq!(
491            mask.into_array(),
492            BoolArray::from_iter([true, false, false]),
493            ctx
494        );
495
496        // A > 5
497        // => A.max <= 5
498        let expr = gt(root(), lit(5i32));
499        let pruning_expr = falsify(&expr, PType::I32.into());
500        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
501        assert_arrays_eq!(
502            mask.into_array(),
503            BoolArray::from_iter([true, false, false]),
504            ctx
505        );
506
507        // A < 2
508        // => A.min >= 2
509        let expr = lt(root(), lit(2i32));
510        let pruning_expr = falsify(&expr, PType::I32.into());
511        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
512        assert_arrays_eq!(
513            mask.into_array(),
514            BoolArray::from_iter([false, true, true]),
515            ctx
516        );
517    }
518
519    #[test]
520    fn bounded_display_names_satisfy_min_max_rewrites() {
521        let bounded_max = BoundedMax.bind(BoundedMaxOptions {
522            max_bytes: default_bounded_stat_max_bytes(),
523        });
524        let bounded_min = BoundedMin.bind(BoundedMinOptions {
525            max_bytes: default_bounded_stat_max_bytes(),
526        });
527        let zone_map = ZoneMap::try_new(
528            PType::I32.into(),
529            StructArray::from_fields(&[
530                (
531                    bounded_max.to_string(),
532                    StructArray::try_new(
533                        [BOUNDED_MAX_BOUND, BOUNDED_MAX_UNKNOWN].into(),
534                        vec![
535                            PrimitiveArray::new(buffer![5i32, 6i32, 7i32], Validity::AllValid)
536                                .into_array(),
537                            BoolArray::from_iter([false, false, false]).into_array(),
538                        ],
539                        3,
540                        Validity::AllValid,
541                    )
542                    .unwrap()
543                    .into_array(),
544                ),
545                (
546                    bounded_min.to_string(),
547                    PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::AllValid).into_array(),
548                ),
549            ])
550            .unwrap(),
551            Arc::new([bounded_max, bounded_min]),
552            3,
553            10,
554        )
555        .unwrap();
556        let ctx = &mut SESSION.create_execution_ctx();
557
558        let expr = gt(root(), lit(5i32));
559        let pruning_expr = falsify(&expr, PType::I32.into());
560        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
561        assert_arrays_eq!(
562            mask.into_array(),
563            BoolArray::from_iter([true, false, false]),
564            ctx
565        );
566
567        let expr = lt(root(), lit(2i32));
568        let pruning_expr = falsify(&expr, PType::I32.into());
569        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
570        assert_arrays_eq!(
571            mask.into_array(),
572            BoolArray::from_iter([false, true, true]),
573            ctx
574        );
575    }
576
577    #[test]
578    fn row_count_prunes_short_trailing_zone() {
579        let zone_map = ZoneMap::try_new_legacy(
580            PType::U64.into(),
581            StructArray::from_fields(&[(
582                "null_count",
583                PrimitiveArray::new(buffer![0u64, 0, 2], Validity::AllValid).into_array(),
584            )])
585            .unwrap(),
586            Arc::new([Stat::NullCount]),
587            4,
588            10,
589        )
590        .unwrap();
591
592        let expr = is_not_null(root());
593        let pruning_expr = falsify(&expr, PType::U64.into());
594
595        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
596        assert_arrays_eq!(
597            mask.into_array(),
598            BoolArray::from_iter([false, false, true]),
599            &mut SESSION.create_execution_ctx()
600        );
601    }
602
603    /// `zone_len`, `row_count` and the zone-map length all come from the file and nothing ties them
604    /// together, so a file can claim more zone coverage than the layout has rows. Pruning must
605    /// report that rather than underflowing the per-zone row-count arithmetic.
606    #[rstest]
607    // 4 zones of 4 rows claims 12 rows of leading coverage for a 10-row layout. (3 zones of 4 is
608    // legal — 4 + 4 + 2 — and is what `row_count_prunes_short_trailing_zone` covers.)
609    #[case::zones_overrun_row_count(4, 4, 10)]
610    // The leading zones alone already exceed the row count.
611    #[case::leading_zones_overrun(8, 3, 10)]
612    // A zero row count with a non-empty zone map.
613    #[case::no_rows_but_zones_present(4, 2, 0)]
614    fn row_count_rejects_zones_overrunning_the_layout(
615        #[case] zone_len: u64,
616        #[case] num_zones: usize,
617        #[case] row_count: u64,
618    ) {
619        let null_counts = PrimitiveArray::new(
620            Buffer::<u64>::from_iter(std::iter::repeat_n(0u64, num_zones)),
621            Validity::AllValid,
622        )
623        .into_array();
624        let zone_map = ZoneMap::try_new_legacy(
625            PType::U64.into(),
626            StructArray::from_fields(&[("null_count", null_counts)]).unwrap(),
627            Arc::new([Stat::NullCount]),
628            zone_len,
629            row_count,
630        )
631        .unwrap();
632
633        let pruning_expr = falsify(&is_not_null(root()), PType::U64.into());
634        let message = zone_map
635            .prune(&pruning_expr, &SESSION)
636            .err()
637            .map(|err| err.to_string())
638            .unwrap_or_default();
639        assert!(
640            message.contains("more than the"),
641            "zone_len {zone_len} x {num_zones} zones over {row_count} rows should have been \
642             rejected, got: {message}"
643        );
644    }
645
646    #[test]
647    fn row_count_substitution_handles_empty_zone_map() {
648        let zone_map = ZoneMap::try_new_legacy(
649            PType::U64.into(),
650            StructArray::from_fields(&[(
651                "null_count",
652                PrimitiveArray::new::<u64>(buffer![], Validity::AllValid).into_array(),
653            )])
654            .unwrap(),
655            Arc::new([Stat::NullCount]),
656            4,
657            0,
658        )
659        .unwrap();
660
661        let expr = is_not_null(root());
662        let pruning_expr = falsify(&expr, PType::U64.into());
663
664        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
665        assert_eq!(mask.len(), 0);
666    }
667
668    #[test]
669    fn is_null_falsification_uses_null_count() {
670        let zone_map = ZoneMap::try_new_legacy(
671            PType::U64.into(),
672            StructArray::from_fields(&[(
673                "null_count",
674                PrimitiveArray::new(buffer![0u64, 4, 2], Validity::AllValid).into_array(),
675            )])
676            .unwrap(),
677            Arc::new([Stat::NullCount]),
678            4,
679            10,
680        )
681        .unwrap();
682
683        let expr = is_null(root());
684        let pruning_expr = falsify(&expr, PType::U64.into());
685
686        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
687        assert_arrays_eq!(
688            mask.into_array(),
689            BoolArray::from_iter([true, false, false]),
690            &mut SESSION.create_execution_ctx()
691        );
692    }
693
694    #[test]
695    fn all_null_stat_fn_lowers_to_null_count_and_row_count() {
696        let zone_map = ZoneMap::try_new_legacy(
697            PType::U64.into(),
698            StructArray::from_fields(&[(
699                "null_count",
700                PrimitiveArray::new(buffer![0u64, 4, 2], Validity::AllValid).into_array(),
701            )])
702            .unwrap(),
703            Arc::new([Stat::NullCount]),
704            4,
705            10,
706        )
707        .unwrap();
708
709        let mask = prune(&zone_map, &all_null(root())).unwrap();
710        assert_arrays_eq!(
711            mask.into_array(),
712            BoolArray::from_iter([false, true, true]),
713            &mut SESSION.create_execution_ctx()
714        );
715    }
716
717    #[test]
718    fn all_non_null_stat_fn_lowers_to_null_count() {
719        let zone_map = ZoneMap::try_new_legacy(
720            PType::U64.into(),
721            StructArray::from_fields(&[(
722                "null_count",
723                PrimitiveArray::new(buffer![0u64, 4, 2], Validity::AllValid).into_array(),
724            )])
725            .unwrap(),
726            Arc::new([Stat::NullCount]),
727            4,
728            10,
729        )
730        .unwrap();
731
732        let mask = prune(&zone_map, &all_non_null(root())).unwrap();
733        assert_arrays_eq!(
734            mask.into_array(),
735            BoolArray::from_iter([true, false, false]),
736            &mut SESSION.create_execution_ctx()
737        );
738    }
739
740    #[test]
741    fn all_null_stat_fn_lowers_to_null_count_field() {
742        let null_count = NullCount.bind(EmptyOptions);
743        let zone_map = ZoneMap::try_new(
744            PType::U64.into(),
745            StructArray::from_fields(&[(
746                null_count.to_string(),
747                PrimitiveArray::new(buffer![4u64, 0, 2], Validity::AllValid).into_array(),
748            )])
749            .unwrap(),
750            Arc::new([null_count]),
751            4,
752            10,
753        )
754        .unwrap();
755        let ctx = &mut SESSION.create_execution_ctx();
756
757        let mask = prune(&zone_map, &all_null(root())).unwrap();
758        assert_arrays_eq!(
759            mask.into_array(),
760            BoolArray::from_iter([true, false, true]),
761            ctx
762        );
763
764        let mask = prune(&zone_map, &all_non_null(root())).unwrap();
765        assert_arrays_eq!(
766            mask.into_array(),
767            BoolArray::from_iter([false, true, false]),
768            ctx
769        );
770    }
771
772    #[test]
773    fn all_nan_stat_fn_lowers_to_nan_count_field() {
774        let nan_count = NanCount.bind(EmptyOptions);
775        let zone_map = ZoneMap::try_new(
776            PType::F32.into(),
777            StructArray::from_fields(&[(
778                nan_count.to_string(),
779                PrimitiveArray::new(buffer![4u64, 0, 2], Validity::AllValid).into_array(),
780            )])
781            .unwrap(),
782            Arc::new([nan_count]),
783            4,
784            10,
785        )
786        .unwrap();
787        let ctx = &mut SESSION.create_execution_ctx();
788
789        let mask = prune(&zone_map, &all_nan(root())).unwrap();
790        assert_arrays_eq!(
791            mask.into_array(),
792            BoolArray::from_iter([true, false, true]),
793            ctx
794        );
795
796        let mask = prune(&zone_map, &all_non_nan(root())).unwrap();
797        assert_arrays_eq!(
798            mask.into_array(),
799            BoolArray::from_iter([false, true, false]),
800            ctx
801        );
802    }
803
804    #[test]
805    fn non_float_nan_stat_fns_fail_to_bind() {
806        let dtype = DType::from(PType::I32);
807        for expr in [all_nan(root()), all_non_nan(root())] {
808            let error = expr.bind(&dtype).unwrap_err();
809            assert!(
810                error
811                    .to_string()
812                    .contains("does not support input dtype i32"),
813                "{error}"
814            );
815        }
816    }
817
818    #[test]
819    fn unavailable_stat_fn_lowers_to_unknown_mask() {
820        let zone_map = ZoneMap::try_new(
821            PType::U64.into(),
822            StructArray::try_new(FieldNames::empty(), vec![], 3, Validity::NonNullable).unwrap(),
823            Arc::new([]),
824            4,
825            10,
826        )
827        .unwrap();
828        let ctx = &mut SESSION.create_execution_ctx();
829
830        let mask = prune(&zone_map, &all_non_null(root())).unwrap();
831        assert_arrays_eq!(
832            mask.into_array(),
833            BoolArray::from_iter([false, false, false]),
834            ctx
835        );
836
837        let expr = gt(root(), lit(5u64));
838        let pruning_expr = falsify(&expr, PType::U64.into());
839        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
840        assert_arrays_eq!(
841            mask.into_array(),
842            BoolArray::from_iter([false, false, false]),
843            ctx
844        );
845    }
846
847    #[test]
848    fn float_min_max_stat_fn_requires_nan_count() {
849        let max = Max.bind(NumericalAggregateOpts::skip_nans());
850        let zone_map = ZoneMap::try_new(
851            PType::F32.into(),
852            StructArray::from_fields(&[(
853                max.to_string(),
854                PrimitiveArray::new(buffer![5.0f32, 6.0, 7.0], Validity::AllValid).into_array(),
855            )])
856            .unwrap(),
857            Arc::new([max.clone()]),
858            4,
859            12,
860        )
861        .unwrap();
862        let ctx = &mut SESSION.create_execution_ctx();
863
864        let expr = gt(root(), lit(5.0f32));
865        let pruning_expr = falsify(&expr, PType::F32.into());
866        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
867        assert_arrays_eq!(
868            mask.into_array(),
869            BoolArray::from_iter([false, false, false]),
870            ctx
871        );
872
873        let nan_count = NanCount.bind(EmptyOptions);
874        let zone_map = ZoneMap::try_new(
875            PType::F32.into(),
876            StructArray::from_fields(&[
877                (
878                    max.to_string(),
879                    PrimitiveArray::new(buffer![5.0f32, 6.0, 7.0], Validity::AllValid).into_array(),
880                ),
881                (
882                    nan_count.to_string(),
883                    PrimitiveArray::new(buffer![0u64, 0, 0], Validity::AllValid).into_array(),
884                ),
885            ])
886            .unwrap(),
887            Arc::new([max, nan_count]),
888            4,
889            12,
890        )
891        .unwrap();
892
893        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
894        assert_arrays_eq!(
895            mask.into_array(),
896            BoolArray::from_iter([true, false, false]),
897            ctx
898        );
899    }
900
901    #[test]
902    fn float_cast_min_max_stat_fn_uses_source_nan_count() {
903        let zone_map = ZoneMap::try_new_legacy(
904            PType::F32.into(),
905            StructArray::from_fields(&[
906                (
907                    "max",
908                    PrimitiveArray::new(buffer![5.0f32, 5.0], Validity::AllValid).into_array(),
909                ),
910                (
911                    "max_is_truncated",
912                    BoolArray::from_iter([false, false]).into_array(),
913                ),
914                (
915                    "min",
916                    PrimitiveArray::new(buffer![5.0f32, 5.0], Validity::AllValid).into_array(),
917                ),
918                (
919                    "min_is_truncated",
920                    BoolArray::from_iter([false, false]).into_array(),
921                ),
922                (
923                    "nan_count",
924                    PrimitiveArray::new(buffer![1u64, 0], Validity::AllValid).into_array(),
925                ),
926            ])
927            .unwrap(),
928            Arc::new([Stat::Max, Stat::Min, Stat::NaNCount]),
929            4,
930            8,
931        )
932        .unwrap();
933
934        let cast_dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
935        let expr = not_eq(cast(root(), cast_dtype), lit(5i32));
936        let pruning_expr = falsify(&expr, PType::F32.into());
937
938        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
939        assert_arrays_eq!(
940            mask.into_array(),
941            BoolArray::from_iter([false, true]),
942            &mut SESSION.create_execution_ctx()
943        );
944    }
945
946    #[test]
947    fn fixed_size_list_min_max_stat_fn_lowers_to_unknown_mask() {
948        // Regression test for issue #8189: Min/Max is defined for FixedSizeList<T>
949        // when T is orderable. If the zone map does not carry the requested stat,
950        // lowering should produce an unknown typed null rather than rejecting the dtype.
951        let elem_dtype = Arc::new(DType::Decimal(
952            DecimalDType::new(10, 2),
953            Nullability::Nullable,
954        ));
955        let column_dtype = DType::FixedSizeList(elem_dtype, 1, Nullability::Nullable);
956
957        let zone_map = ZoneMap::try_new(
958            column_dtype,
959            StructArray::try_new(FieldNames::empty(), vec![], 3, Validity::NonNullable).unwrap(),
960            Arc::new([]),
961            4,
962            10,
963        )
964        .unwrap();
965
966        let max_fn = Stat::Max
967            .aggregate_fn()
968            .expect("max should have an aggregate function");
969        let predicate = is_null(vortex_array::stats::stat(root(), max_fn));
970
971        // Missing StatFn lowers to a nullable null literal, so `is_null(...)` is true for every zone.
972        let mask = prune(&zone_map, &predicate).unwrap();
973        assert_arrays_eq!(
974            mask.into_array(),
975            BoolArray::from_iter([true, true, true]),
976            &mut SESSION.create_execution_ctx()
977        );
978    }
979
980    #[test]
981    fn unsupported_aggregate_input_dtype_errors() {
982        let zone_map = ZoneMap::try_new(
983            DType::Null,
984            StructArray::try_new(FieldNames::empty(), vec![], 3, Validity::NonNullable).unwrap(),
985            Arc::new([]),
986            4,
987            10,
988        )
989        .unwrap();
990
991        let max_fn = Stat::Max
992            .aggregate_fn()
993            .expect("max should have an aggregate function");
994        let predicate = is_null(vortex_array::stats::stat(root(), max_fn));
995        let error = prune(&zone_map, &predicate).unwrap_err();
996
997        assert!(
998            error
999                .to_string()
1000                .contains("Aggregate function vortex.max() does not support input dtype null"),
1001            "{error}"
1002        );
1003    }
1004
1005    #[test]
1006    fn row_count_prunes_all_null_uniform_zones() {
1007        let zone_map = ZoneMap::try_new_legacy(
1008            PType::U64.into(),
1009            StructArray::from_fields(&[(
1010                "null_count",
1011                PrimitiveArray::new(buffer![0u64, 4, 0], Validity::AllValid).into_array(),
1012            )])
1013            .unwrap(),
1014            Arc::new([Stat::NullCount]),
1015            4,
1016            12,
1017        )
1018        .unwrap();
1019
1020        let expr = is_not_null(root());
1021        let pruning_expr = falsify(&expr, PType::U64.into());
1022
1023        // All three zones have length 4 (total rows = 12).
1024        let mask = zone_map.prune(&pruning_expr, &SESSION).unwrap();
1025        assert_arrays_eq!(
1026            mask.into_array(),
1027            BoolArray::from_iter([false, true, false]),
1028            &mut SESSION.create_execution_ctx()
1029        );
1030    }
1031
1032    #[test]
1033    fn all_null_stat_fn_lowers_to_aggregate_field() {
1034        let all_null_agg = AllNull.bind(EmptyOptions);
1035        let zone_map = ZoneMap::try_new(
1036            PType::U64.into(),
1037            StructArray::from_fields(&[(
1038                all_null_agg.to_string(),
1039                BoolArray::from_iter([Some(false), Some(true), Some(true)]).into_array(),
1040            )])
1041            .unwrap(),
1042            Arc::new([all_null_agg]),
1043            4,
1044            10,
1045        )
1046        .unwrap();
1047
1048        let mask = prune(&zone_map, &all_null(root())).unwrap();
1049        assert_arrays_eq!(
1050            mask.into_array(),
1051            BoolArray::from_iter([false, true, true]),
1052            &mut SESSION.create_execution_ctx()
1053        );
1054    }
1055
1056    #[test]
1057    fn all_non_null_stat_fn_lowers_to_aggregate_field() {
1058        let all_non_null_agg = AllNonNull.bind(EmptyOptions);
1059        let zone_map = ZoneMap::try_new(
1060            PType::U64.into(),
1061            StructArray::from_fields(&[(
1062                all_non_null_agg.to_string(),
1063                BoolArray::from_iter([Some(true), Some(false), Some(false)]).into_array(),
1064            )])
1065            .unwrap(),
1066            Arc::new([all_non_null_agg]),
1067            4,
1068            10,
1069        )
1070        .unwrap();
1071
1072        let mask = prune(&zone_map, &all_non_null(root())).unwrap();
1073        assert_arrays_eq!(
1074            mask.into_array(),
1075            BoolArray::from_iter([true, false, false]),
1076            &mut SESSION.create_execution_ctx()
1077        );
1078    }
1079}