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