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