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