Skip to main content

vortex_layout/layouts/zoned/
mod.rs

1//! Zoned layouts wrap a data layout with an auxiliary per-zone statistics layout.
2//!
3//! The zoned layout tree has exactly two children:
4//! - a transparent `data` child containing the underlying column data
5//! - an auxiliary `zones` child containing one row of aggregate statistics per zone
6//!
7//! Metadata stores the logical zone length in rows plus the aggregate functions present in the
8//! auxiliary table. During scans, pruning first evaluates a falsification predicate against the
9//! `zones` child and only forwards surviving rows to the underlying `data` child.
10
11// SPDX-License-Identifier: Apache-2.0
12// SPDX-FileCopyrightText: Copyright the Vortex contributors
13
14pub mod aggregates;
15mod builder;
16mod pruning;
17mod reader;
18mod schema;
19pub mod writer;
20pub mod zone_map;
21
22use std::num::NonZeroUsize;
23use std::sync::Arc;
24
25pub(crate) use builder::AggregateStatsAccumulator;
26pub(crate) use builder::aggregate_partials;
27use prost::Message;
28pub use schema::MAX_IS_TRUNCATED;
29pub use schema::MIN_IS_TRUNCATED;
30use vortex_array::DeserializeMetadata;
31use vortex_array::SerializeMetadata;
32use vortex_array::aggregate_fn::AggregateFnRef;
33use vortex_array::dtype::DType;
34use vortex_array::dtype::TryFromBytes;
35use vortex_array::expr::stats::Stat;
36use vortex_array::stats::as_stat_bitset_bytes;
37use vortex_array::stats::stats_from_bitset_bytes;
38use vortex_error::VortexExpect;
39use vortex_error::VortexResult;
40use vortex_error::vortex_bail;
41use vortex_error::vortex_ensure;
42use vortex_error::vortex_ensure_eq;
43use vortex_error::vortex_panic;
44use vortex_session::VortexSession;
45use vortex_session::registry::CachedId;
46
47use crate::Layout;
48use crate::LayoutChildType;
49use crate::LayoutDeserializeArgs;
50use crate::LayoutId;
51use crate::LayoutParts;
52use crate::LayoutReaderContext;
53use crate::LayoutReaderRef;
54use crate::LayoutRef;
55use crate::VTable;
56use crate::children::OwnedLayoutChildren;
57use crate::layouts::zoned::reader::ZonedReader;
58use crate::layouts::zoned::schema::AggregateSpecProto;
59use crate::layouts::zoned::schema::aggregate_specs_from_fns;
60use crate::layouts::zoned::schema::aggregate_stats_table_dtype;
61use crate::layouts::zoned::schema::legacy_stats_table_dtype;
62use crate::layouts::zoned::schema::try_aggregate_fns_from_specs;
63use crate::segments::SegmentSource;
64
65/// Zoned layout vtable.
66#[derive(Clone, Debug)]
67pub struct Zoned;
68
69/// Legacy `vortex.stats` layout vtable.
70#[derive(Clone, Debug)]
71pub struct LegacyStats;
72
73/// Backwards-compatible plugin names.
74pub use LegacyStats as LegacyStatsLayoutEncoding;
75pub use Zoned as ZonedLayoutEncoding;
76
77/// Zoned-layout-specific data.
78#[derive(Clone, Debug)]
79pub struct ZonedData {
80    zone_len: usize,
81    zone_map_schema: ZoneMapSchema,
82    stats_table_dtype: DType,
83}
84
85/// A layout annotating a data child with per-zone statistics.
86pub type ZonedLayout = Layout<Zoned>;
87/// A legacy `vortex.stats` layout using the same runtime data.
88pub type LegacyStatsLayout = Layout<LegacyStats>;
89
90impl VTable for Zoned {
91    type LayoutData = ZonedData;
92    type Metadata = ZonedMetadata;
93
94    fn id(&self) -> LayoutId {
95        static ID: CachedId = CachedId::new("vortex.zoned");
96        *ID
97    }
98
99    fn metadata(layout: &Layout<Self>) -> Self::Metadata {
100        ZonedMetadata {
101            zone_len: u32::try_from(layout.zone_len).vortex_expect("Invalid zone length"),
102            aggregate_specs: match &layout.zone_map_schema {
103                ZoneMapSchema::AggregateFns(aggregate_fns) => {
104                    aggregate_specs_from_fns(aggregate_fns).vortex_expect(
105                        "aggregate functions should be validated as serializable during build",
106                    )
107                }
108                ZoneMapSchema::LegacyStats(_) => {
109                    vortex_panic!("Cannot serialize legacy stats schema as vortex.zoned")
110                }
111            },
112        }
113    }
114
115    fn deserialize(
116        &self,
117        args: &LayoutDeserializeArgs<'_>,
118        metadata: &ZonedMetadata,
119    ) -> VortexResult<Self::LayoutData> {
120        vortex_ensure_eq!(
121            args.children.nchildren(),
122            2,
123            "ZonedLayout expects exactly 2 children (data, zones)"
124        );
125        vortex_ensure_eq!(args.children.child_row_count(0), args.row_count);
126        let Some(aggregate_fns) =
127            try_aggregate_fns_from_specs(&metadata.aggregate_specs, args.session)?
128        else {
129            args.children.child(0, args.dtype)?;
130            return Ok(ZonedData {
131                zone_len: 0,
132                zone_map_schema: ZoneMapSchema::AggregateFns(Arc::new([])),
133                stats_table_dtype: aggregate_stats_table_dtype(args.dtype, &[]),
134            });
135        };
136        aggregate_specs_from_fns(&aggregate_fns)?;
137        let stats_table_dtype = aggregate_stats_table_dtype(args.dtype, &aggregate_fns);
138        args.children.child(0, args.dtype)?;
139        args.children.child(1, &stats_table_dtype)?;
140        Ok(ZonedData {
141            zone_len: metadata.zone_len as usize,
142            zone_map_schema: ZoneMapSchema::AggregateFns(aggregate_fns),
143            stats_table_dtype,
144        })
145    }
146
147    fn child_dtype(layout: &Layout<Self>, idx: usize) -> VortexResult<DType> {
148        match idx {
149            0 => Ok(layout.dtype().clone()),
150            1 => Ok(layout.stats_table_dtype.clone()),
151            _ => vortex_bail!("Invalid child index: {idx}"),
152        }
153    }
154
155    fn child_type(_layout: &Layout<Self>, idx: usize) -> LayoutChildType {
156        match idx {
157            0 => LayoutChildType::Transparent("data".into()),
158            1 => LayoutChildType::Auxiliary("zones".into()),
159            _ => vortex_panic!("Invalid child index: {}", idx),
160        }
161    }
162
163    fn new_reader(
164        layout: &Layout<Self>,
165        name: Arc<str>,
166        segment_source: Arc<dyn SegmentSource>,
167        session: &VortexSession,
168        ctx: &LayoutReaderContext,
169    ) -> VortexResult<LayoutReaderRef> {
170        layout.zoned_reader(name, segment_source, session, ctx)
171    }
172}
173
174impl VTable for LegacyStats {
175    type LayoutData = ZonedData;
176    type Metadata = LegacyStatsMetadata;
177
178    fn id(&self) -> LayoutId {
179        static ID: CachedId = CachedId::new("vortex.stats");
180        *ID
181    }
182
183    fn metadata(layout: &Layout<Self>) -> Self::Metadata {
184        LegacyStatsMetadata {
185            zone_len: u32::try_from(layout.zone_len).vortex_expect("Invalid zone length"),
186            zone_map_schema: layout.zone_map_schema.clone(),
187        }
188    }
189
190    fn deserialize(
191        &self,
192        args: &LayoutDeserializeArgs<'_>,
193        metadata: &LegacyStatsMetadata,
194    ) -> VortexResult<Self::LayoutData> {
195        vortex_ensure_eq!(
196            args.children.nchildren(),
197            2,
198            "LegacyStatsLayout expects exactly 2 children (data, zones)"
199        );
200        let stats_table_dtype = match &metadata.zone_map_schema {
201            ZoneMapSchema::LegacyStats(stats) => legacy_stats_table_dtype(args.dtype, stats),
202            ZoneMapSchema::AggregateFns(aggregate_fns) => {
203                aggregate_stats_table_dtype(args.dtype, aggregate_fns)
204            }
205        };
206        args.children.child(0, args.dtype)?;
207        args.children.child(1, &stats_table_dtype)?;
208        Ok(ZonedData {
209            zone_len: metadata.zone_len as usize,
210            zone_map_schema: metadata.zone_map_schema.clone(),
211            stats_table_dtype,
212        })
213    }
214
215    fn child_dtype(layout: &Layout<Self>, idx: usize) -> VortexResult<DType> {
216        match idx {
217            0 => Ok(layout.dtype().clone()),
218            1 => Ok(layout.stats_table_dtype.clone()),
219            _ => vortex_bail!("Invalid child index: {idx}"),
220        }
221    }
222
223    fn child_type(_layout: &Layout<Self>, idx: usize) -> LayoutChildType {
224        match idx {
225            0 => LayoutChildType::Transparent("data".into()),
226            1 => LayoutChildType::Auxiliary("zones".into()),
227            _ => vortex_panic!("Invalid child index: {idx}"),
228        }
229    }
230
231    fn new_reader(
232        layout: &Layout<Self>,
233        name: Arc<str>,
234        segment_source: Arc<dyn SegmentSource>,
235        session: &VortexSession,
236        ctx: &LayoutReaderContext,
237    ) -> VortexResult<LayoutReaderRef> {
238        layout.zoned_reader(name, segment_source, session, ctx)
239    }
240}
241
242impl LegacyStatsLayout {
243    fn zoned_reader(
244        &self,
245        name: Arc<str>,
246        segment_source: Arc<dyn SegmentSource>,
247        session: &VortexSession,
248        ctx: &LayoutReaderContext,
249    ) -> VortexResult<LayoutReaderRef> {
250        if self.zone_len == 0 {
251            return self
252                .slot(0)?
253                .vortex_expect("ZonedLayout always has a data child")
254                .new_reader(name, segment_source, session, ctx);
255        }
256
257        Ok(Arc::new(ZonedReader::try_new(
258            self.clone(),
259            self.nzones(),
260            name,
261            segment_source,
262            session.clone(),
263            ctx.clone(),
264        )?))
265    }
266
267    pub fn nzones(&self) -> usize {
268        usize::try_from(self.children().child_row_count(1))
269            .vortex_expect("Invalid number of zones, cannot handle more than usize zones")
270    }
271
272    /// Returns display names for the zone-map aggregates stored by this layout.
273    pub fn present_aggregates(&self) -> Arc<[String]> {
274        present_aggregates(&self.zone_map_schema)
275    }
276}
277
278#[derive(Clone, Debug, PartialEq, Eq)]
279pub(crate) enum ZoneMapSchema {
280    LegacyStats(Arc<[Stat]>),
281    AggregateFns(Arc<[AggregateFnRef]>),
282}
283
284impl ZonedLayout {
285    /// Create a zoned layout from a data child, a zone-map child, a zone length, and the aggregate
286    /// functions stored in the zone map.
287    pub fn try_new(
288        data: LayoutRef,
289        zones: LayoutRef,
290        zone_len: NonZeroUsize,
291        aggregate_fns: Arc<[AggregateFnRef]>,
292    ) -> VortexResult<Self> {
293        let expected_dtype = aggregate_stats_table_dtype(data.dtype(), &aggregate_fns);
294        if zones.dtype() != &expected_dtype {
295            vortex_bail!("Invalid zone map layout: zones dtype does not match expected dtype");
296        }
297
298        // Verify that every aggregate is serializable.
299        aggregate_specs_from_fns(&aggregate_fns)?;
300
301        let dtype = data.dtype().clone();
302        let row_count = data.row_count();
303        Ok(LayoutParts::new(
304            Zoned,
305            dtype,
306            row_count,
307            Vec::new(),
308            OwnedLayoutChildren::layout_children(vec![data, zones]),
309            ZonedData {
310                zone_len: zone_len.get(),
311                zone_map_schema: ZoneMapSchema::AggregateFns(aggregate_fns),
312                stats_table_dtype: expected_dtype,
313            },
314        )
315        .into_typed())
316    }
317
318    pub fn zone_len(&self) -> usize {
319        self.zone_len
320    }
321
322    /// Returns display names for the zone-map aggregates stored by this layout.
323    pub fn present_aggregates(&self) -> Arc<[String]> {
324        present_aggregates(&self.zone_map_schema)
325    }
326
327    /// Builds a reader for a zoned layout, bypassing [`ZonedReader`] when the zone map is empty
328    /// (`zone_len == 0`) and reading the data child directly, since there is nothing to prune with.
329    /// This covers both legacy zero-length zones and layouts whose aggregates the session cannot
330    /// reconstruct.
331    fn zoned_reader(
332        &self,
333        name: Arc<str>,
334        segment_source: Arc<dyn SegmentSource>,
335        session: &VortexSession,
336        ctx: &LayoutReaderContext,
337    ) -> VortexResult<LayoutReaderRef> {
338        if self.zone_len == 0 {
339            return self
340                .slot(0)?
341                .vortex_expect("ZonedLayout always has a data child")
342                .new_reader(name, segment_source, session, ctx);
343        }
344
345        Ok(Arc::new(ZonedReader::try_new(
346            self.clone(),
347            self.nzones(),
348            name,
349            segment_source,
350            session.clone(),
351            ctx.clone(),
352        )?))
353    }
354
355    pub fn nzones(&self) -> usize {
356        usize::try_from(self.children().child_row_count(1))
357            .vortex_expect("Invalid number of zones, cannot handle more than usize zones")
358    }
359}
360
361impl ZonedData {
362    fn aggregate_fns(&self) -> Arc<[AggregateFnRef]> {
363        match &self.zone_map_schema {
364            ZoneMapSchema::LegacyStats(stats) => stats
365                .iter()
366                .filter_map(Stat::aggregate_fn)
367                .collect::<Vec<_>>()
368                .into(),
369            ZoneMapSchema::AggregateFns(aggregate_fns) => Arc::clone(aggregate_fns),
370        }
371    }
372}
373
374fn present_aggregates(schema: &ZoneMapSchema) -> Arc<[String]> {
375    match schema {
376        ZoneMapSchema::LegacyStats(stats) => stats
377            .iter()
378            .filter_map(Stat::aggregate_fn)
379            .map(|aggregate_fn| aggregate_fn.to_string())
380            .collect::<Vec<_>>()
381            .into(),
382        ZoneMapSchema::AggregateFns(aggregate_fns) => aggregate_fns
383            .iter()
384            .map(ToString::to_string)
385            .collect::<Vec<_>>()
386            .into(),
387    }
388}
389
390/// Serialized zoned-layout metadata.
391///
392/// `zone_len` is the logical row length of each zone. `aggregate_specs` is the ordered list of
393/// aggregate functions stored in the auxiliary stats-table child.
394#[derive(Debug, PartialEq, Eq, Clone)]
395pub struct ZonedMetadata {
396    pub(super) zone_len: u32,
397    pub(super) aggregate_specs: Arc<[AggregateSpecProto]>,
398}
399
400/// Serialized metadata for legacy `vortex.stats` layouts.
401#[derive(Debug, PartialEq, Eq, Clone)]
402pub struct LegacyStatsMetadata {
403    pub(super) zone_len: u32,
404    pub(crate) zone_map_schema: ZoneMapSchema,
405}
406
407const ZONED_METADATA_PROTO_VERSION: u8 = 1;
408
409#[derive(Clone, PartialEq, Message)]
410struct ZonedMetadataProto {
411    #[prost(uint32, tag = "1")]
412    zone_len: u32,
413    #[prost(message, repeated, tag = "2")]
414    aggregate_specs: Vec<AggregateSpecProto>,
415}
416
417impl DeserializeMetadata for ZonedMetadata {
418    type Output = Self;
419
420    fn deserialize(metadata: &[u8]) -> VortexResult<Self::Output> {
421        let Some((&version, proto_bytes)) = metadata.split_first() else {
422            vortex_bail!("Zoned metadata missing protobuf version");
423        };
424
425        vortex_ensure!(
426            version == ZONED_METADATA_PROTO_VERSION,
427            "Unsupported zoned metadata version: {}",
428            version
429        );
430        vortex_ensure!(!proto_bytes.is_empty(), "Zoned metadata missing protobuf");
431
432        let proto = ZonedMetadataProto::decode(proto_bytes)?;
433        Ok(Self {
434            zone_len: proto.zone_len,
435            aggregate_specs: proto.aggregate_specs.into(),
436        })
437    }
438}
439
440impl SerializeMetadata for ZonedMetadata {
441    fn serialize(self) -> Vec<u8> {
442        let proto = ZonedMetadataProto {
443            zone_len: self.zone_len,
444            aggregate_specs: self.aggregate_specs.to_vec(),
445        };
446        let mut metadata = vec![ZONED_METADATA_PROTO_VERSION];
447        metadata.extend(proto.encode_to_vec());
448        metadata
449    }
450}
451
452impl DeserializeMetadata for LegacyStatsMetadata {
453    type Output = Self;
454
455    fn deserialize(metadata: &[u8]) -> VortexResult<Self::Output> {
456        vortex_ensure!(
457            metadata.len() >= 4,
458            "Legacy zoned metadata must contain at least 4 bytes for zone length, got {}",
459            metadata.len()
460        );
461
462        // Backward compat: older files may encode `zone_len == 0`. Preserve the raw metadata on
463        // read and let the reader disable zoned pruning for those layouts instead of rejecting
464        // deserialization outright.
465        let zone_len = u32::try_from_le_bytes(&metadata[0..4])?;
466        let present_stats: Arc<[Stat]> = stats_from_bitset_bytes(&metadata[4..]).into();
467
468        Ok(Self {
469            zone_len,
470            zone_map_schema: ZoneMapSchema::LegacyStats(present_stats),
471        })
472    }
473}
474
475impl SerializeMetadata for LegacyStatsMetadata {
476    fn serialize(self) -> Vec<u8> {
477        match self.zone_map_schema {
478            ZoneMapSchema::LegacyStats(stats) => {
479                let mut metadata = self.zone_len.to_le_bytes().to_vec();
480                metadata.extend(as_stat_bitset_bytes(&stats));
481                metadata
482            }
483            ZoneMapSchema::AggregateFns(_) => {
484                vortex_panic!("Cannot serialize aggregate specs as legacy stats metadata")
485            }
486        }
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use std::panic;
493
494    use rstest::rstest;
495    use vortex_array::aggregate_fn::AggregateFnRef;
496    use vortex_array::aggregate_fn::AggregateFnVTableExt;
497    use vortex_array::aggregate_fn::NumericalAggregateOpts;
498    use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax;
499    use vortex_array::aggregate_fn::fns::bounded_max::BoundedMaxOptions;
500    use vortex_array::aggregate_fn::fns::max::Max;
501    use vortex_array::aggregate_fn::fns::min::Min;
502    use vortex_array::aggregate_fn::session::AggregateFnSession;
503    use vortex_array::dtype::DType;
504    use vortex_array::dtype::Nullability;
505    use vortex_array::dtype::PType;
506    use vortex_array::stats::as_stat_bitset_bytes;
507    use vortex_session::VortexSession;
508    use vortex_session::registry::ReadContext;
509
510    use super::*;
511    use crate::LayoutBuildContext;
512    use crate::children::OwnedLayoutChildren;
513    use crate::layouts::flat::FlatLayout;
514    use crate::layouts::zoned::aggregates::bloom_filter::BloomFilter;
515    use crate::layouts::zoned::aggregates::bloom_filter::BloomOptions;
516    use crate::segments::SegmentId;
517
518    fn aggregate_spec(aggregate_fn: AggregateFnRef) -> AggregateSpecProto {
519        AggregateSpecProto::try_from_aggregate_fn(&aggregate_fn).unwrap()
520    }
521
522    #[rstest]
523    #[case(ZonedMetadata {
524            zone_len: u32::MAX,
525            aggregate_specs: Arc::new([]),
526        })]
527    #[case::min_max(ZonedMetadata {
528            zone_len: 314,
529            aggregate_specs: Arc::new([
530                aggregate_spec(Max.bind(NumericalAggregateOpts::skip_nans())),
531                aggregate_spec(Min.bind(NumericalAggregateOpts::skip_nans())),
532            ]),
533        })]
534    #[case::bloom(ZonedMetadata {
535            zone_len: 1,
536            aggregate_specs: Arc::new([
537                aggregate_spec(BloomFilter.bind(BloomOptions::default())),
538            ]),
539        })]
540    fn test_metadata_serialization(#[case] metadata: ZonedMetadata) {
541        let serialized = metadata.clone().serialize();
542        assert_eq!(serialized[0], ZONED_METADATA_PROTO_VERSION);
543        let deserialized = ZonedMetadata::deserialize(&serialized).unwrap();
544        assert_eq!(deserialized, metadata);
545    }
546
547    #[test]
548    fn test_metadata_serialization_preserves_aggregate_options() -> VortexResult<()> {
549        let aggregate_fn = BoundedMax.bind(BoundedMaxOptions {
550            // SAFETY: 128 is non-zero.
551            max_bytes: unsafe { NonZeroUsize::new_unchecked(128) },
552        });
553        let metadata = ZonedMetadata {
554            zone_len: 314,
555            aggregate_specs: Arc::new([AggregateSpecProto::try_from_aggregate_fn(&aggregate_fn)?]),
556        };
557
558        let deserialized = ZonedMetadata::deserialize(&metadata.serialize())?;
559        let session = VortexSession::empty().with::<AggregateFnSession>();
560        let aggregate_fns = try_aggregate_fns_from_specs(&deserialized.aggregate_specs, &session)?
561            .expect("known aggregates resolve");
562
563        assert_eq!(aggregate_fns.as_ref(), std::slice::from_ref(&aggregate_fn));
564        Ok(())
565    }
566
567    #[test]
568    fn test_deserialize_legacy_stat_bitset_as_legacy_stats() {
569        let mut serialized = u32::MAX.to_le_bytes().to_vec();
570        serialized.extend(as_stat_bitset_bytes(&[
571            Stat::IsStrictSorted,
572            Stat::IsSorted,
573            Stat::Max,
574        ]));
575        let deserialized = LegacyStatsMetadata::deserialize(&serialized).unwrap();
576        let ZoneMapSchema::LegacyStats(legacy_stats) = deserialized.zone_map_schema else {
577            panic!("legacy bitset metadata should deserialize as legacy stats");
578        };
579
580        assert!(legacy_stats.is_sorted());
581        assert_eq!(
582            legacy_stats.as_ref(),
583            &[Stat::IsSorted, Stat::IsStrictSorted, Stat::Max]
584        );
585    }
586
587    #[rstest]
588    #[case::empty(vec![])]
589    #[case::unsupported_version(vec![0])]
590    #[case::missing_proto(vec![ZONED_METADATA_PROTO_VERSION])]
591    #[case::malformed_proto(vec![ZONED_METADATA_PROTO_VERSION, 0])]
592    fn test_deserialize_short_metadata_errors(#[case] metadata: Vec<u8>) {
593        assert!(ZonedMetadata::deserialize(&metadata).is_err());
594    }
595
596    #[test]
597    fn test_deserialize_short_metadata_returns_error_not_panic() {
598        let result = panic::catch_unwind(|| ZonedMetadata::deserialize(&[]));
599        assert!(
600            result.is_ok(),
601            "deserialize should return an error, not panic"
602        );
603        assert!(result.unwrap().is_err());
604    }
605
606    #[test]
607    fn test_deserialize_zero_zone_len_is_allowed_for_backcompat() {
608        let metadata = 0u32.to_le_bytes();
609        let deserialized = LegacyStatsMetadata::deserialize(&metadata).unwrap();
610        assert_eq!(deserialized.zone_len, 0);
611        let ZoneMapSchema::LegacyStats(legacy_stats) = deserialized.zone_map_schema else {
612            panic!("legacy bitset metadata should deserialize as legacy stats");
613        };
614        assert!(legacy_stats.is_empty());
615    }
616
617    #[test]
618    fn test_build_allows_zero_zone_len_for_backcompat() -> VortexResult<()> {
619        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
620        let read_ctx = ReadContext::new([]);
621        let children = OwnedLayoutChildren::layout_children(vec![
622            FlatLayout::new(0, dtype.clone(), SegmentId::from(0), read_ctx.clone()).into_layout(),
623            FlatLayout::new(
624                0,
625                legacy_stats_table_dtype(&dtype, &[]),
626                SegmentId::from(1),
627                read_ctx,
628            )
629            .into_layout(),
630        ]);
631        let session = vortex_array::array_session();
632        let build_read_ctx = ReadContext::new([]);
633        let build_ctx = LayoutBuildContext {
634            session: &session,
635            array_read_ctx: &build_read_ctx,
636        };
637
638        let layout = <LegacyStats as VTable>::build(
639            &LegacyStatsLayoutEncoding,
640            &dtype,
641            0,
642            &LegacyStatsMetadata {
643                zone_len: 0,
644                zone_map_schema: ZoneMapSchema::LegacyStats(Arc::new([])),
645            },
646            vec![],
647            children.as_ref(),
648            &build_ctx,
649        )?;
650
651        assert_eq!(layout.zone_len, 0);
652        Ok(())
653    }
654
655    #[test]
656    fn test_build_rejects_invalid_child_count() {
657        let metadata = ZonedMetadata {
658            zone_len: 3,
659            aggregate_specs: Arc::new([]),
660        };
661        let children = OwnedLayoutChildren::layout_children(vec![]);
662        let session = vortex_array::array_session();
663        let build_read_ctx = ReadContext::new([]);
664        let build_ctx = LayoutBuildContext {
665            session: &session,
666            array_read_ctx: &build_read_ctx,
667        };
668
669        let result = <Zoned as VTable>::build(
670            &ZonedLayoutEncoding,
671            &DType::Primitive(PType::I32, Nullability::NonNullable),
672            0,
673            &metadata,
674            vec![],
675            children.as_ref(),
676            &build_ctx,
677        );
678
679        assert!(result.is_err());
680    }
681
682    #[test]
683    fn test_build_unknown_aggregate_disables_pruning_when_allowed() -> VortexResult<()> {
684        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
685        let read_ctx = ReadContext::new([]);
686        let children = OwnedLayoutChildren::layout_children(vec![
687            FlatLayout::new(0, dtype.clone(), SegmentId::from(0), read_ctx.clone()).into_layout(),
688            FlatLayout::new(0, dtype.clone(), SegmentId::from(1), read_ctx).into_layout(),
689        ]);
690        let session = vortex_array::array_session();
691        session.allow_unknown();
692        let build_read_ctx = ReadContext::new([]);
693        let build_ctx = LayoutBuildContext {
694            session: &session,
695            array_read_ctx: &build_read_ctx,
696        };
697
698        let metadata = ZonedMetadata {
699            zone_len: 8,
700            aggregate_specs: Arc::new([AggregateSpecProto::new_unknown("vortex.test.unknown")]),
701        };
702
703        let layout = <Zoned as VTable>::build(
704            &ZonedLayoutEncoding,
705            &dtype,
706            0,
707            &metadata,
708            vec![],
709            children.as_ref(),
710            &build_ctx,
711        )?;
712
713        // An unknown aggregate disables zoned pruning (`zone_len == 0`) while leaving the data
714        // child readable, so the index is ignored rather than turned into a hard read error.
715        assert_eq!(layout.zone_len, 0);
716        Ok(())
717    }
718
719    #[test]
720    fn test_build_unknown_aggregate_errors_without_allow_unknown() {
721        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
722        let read_ctx = ReadContext::new([]);
723        let children = OwnedLayoutChildren::layout_children(vec![
724            FlatLayout::new(0, dtype.clone(), SegmentId::from(0), read_ctx.clone()).into_layout(),
725            FlatLayout::new(0, dtype.clone(), SegmentId::from(1), read_ctx).into_layout(),
726        ]);
727        let session = vortex_array::array_session();
728        let build_read_ctx = ReadContext::new([]);
729        let build_ctx = LayoutBuildContext {
730            session: &session,
731            array_read_ctx: &build_read_ctx,
732        };
733
734        let metadata = ZonedMetadata {
735            zone_len: 8,
736            aggregate_specs: Arc::new([AggregateSpecProto::new_unknown("vortex.test.unknown")]),
737        };
738
739        let result = <Zoned as VTable>::build(
740            &ZonedLayoutEncoding,
741            &dtype,
742            0,
743            &metadata,
744            vec![],
745            children.as_ref(),
746            &build_ctx,
747        );
748
749        assert!(result.is_err());
750    }
751}