Skip to main content

vortex_compressor/
compressor.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Cascading array compression implementation.
5
6use vortex_array::ArrayRef;
7use vortex_array::ArraySlots;
8use vortex_array::Canonical;
9use vortex_array::CanonicalValidity;
10use vortex_array::ExecutionCtx;
11use vortex_array::IntoArray;
12use vortex_array::arrays::ConstantArray;
13use vortex_array::arrays::ExtensionArray;
14use vortex_array::arrays::FixedSizeListArray;
15use vortex_array::arrays::ListArray;
16use vortex_array::arrays::ListViewArray;
17use vortex_array::arrays::PrimitiveArray;
18use vortex_array::arrays::StructArray;
19use vortex_array::arrays::Variant;
20use vortex_array::arrays::VariantArray;
21use vortex_array::arrays::extension::ExtensionArrayExt;
22use vortex_array::arrays::fixed_size_list::FixedSizeListArrayExt;
23use vortex_array::arrays::list::ListArrayExt;
24use vortex_array::arrays::listview::ListViewArrayExt;
25use vortex_array::arrays::listview::list_from_list_view;
26use vortex_array::arrays::primitive::PrimitiveArrayExt;
27use vortex_array::arrays::scalar_fn::AnyScalarFn;
28use vortex_array::arrays::struct_::StructArrayExt;
29use vortex_array::arrays::variant::VariantArrayExt;
30use vortex_array::scalar::Scalar;
31use vortex_error::VortexResult;
32
33use crate::builtins::IntDictScheme;
34use crate::ctx::CompressorContext;
35use crate::estimate::CompressionEstimate;
36use crate::estimate::DeferredEstimate;
37use crate::estimate::EstimateScore;
38use crate::estimate::EstimateVerdict;
39use crate::estimate::WinnerEstimate;
40use crate::estimate::estimate_compression_ratio_with_sampling;
41use crate::estimate::is_better_score;
42use crate::scheme::ChildSelection;
43use crate::scheme::DescendantExclusion;
44use crate::scheme::Scheme;
45use crate::scheme::SchemeExt;
46use crate::scheme::SchemeId;
47use crate::stats::ArrayAndStats;
48use crate::stats::GenerateStatsOptions;
49use crate::trace;
50
51/// Synthetic scheme ID used for the compressor's own root-level cascading.
52pub(crate) const ROOT_SCHEME_ID: SchemeId = SchemeId {
53    name: "vortex.compressor.root",
54};
55
56/// Child indices for the compressor's list/listview compression.
57mod root_list_children {
58    /// List/ListView offsets child.
59    pub const OFFSETS: usize = 1;
60    /// ListView sizes child.
61    pub const SIZES: usize = 2;
62}
63
64/// The main compressor type implementing cascading adaptive compression.
65///
66/// This compressor applies adaptive compression [`Scheme`]s to arrays based on their data types and
67/// characteristics. It recursively compresses nested structures like structs and lists, and chooses
68/// optimal compression schemes for leaf types.
69///
70/// The compressor works by:
71/// 1. Canonicalizing input arrays to a standard representation.
72/// 2. Pre-filtering schemes by [`Scheme::matches`] and exclusion rules.
73/// 3. Evaluating each matching scheme's compression estimate and resolving deferred work.
74/// 4. Compressing with the best scheme and verifying the result is smaller.
75///
76/// No scheme may appear twice in a cascade chain. The compressor enforces this automatically
77/// along with push/pull exclusion rules declared by each scheme.
78///
79/// Downstream crates usually wrap this type with a preconfigured scheme set. Use it directly when
80/// embedding a custom fixed scheme list or testing scheme interactions.
81#[derive(Debug, Clone)]
82pub struct CascadingCompressor {
83    /// The enabled compression schemes.
84    schemes: Vec<&'static dyn Scheme>,
85
86    /// Descendant exclusion rules for the compressor's own cascading (e.g. excluding Dict from
87    /// list offsets).
88    root_exclusions: Vec<DescendantExclusion>,
89}
90
91impl CascadingCompressor {
92    /// Creates a new compressor with the given schemes.
93    ///
94    /// Root-level exclusion rules (e.g. excluding Dict from list offsets) are built
95    /// automatically.
96    pub fn new(schemes: Vec<&'static dyn Scheme>) -> Self {
97        // Root exclusion: exclude IntDict from list/listview offsets (monotonically
98        // increasing data where dictionary encoding is wasteful).
99        let root_exclusions = vec![DescendantExclusion {
100            excluded: IntDictScheme.id(),
101            children: ChildSelection::One(root_list_children::OFFSETS),
102        }];
103        Self {
104            schemes,
105            root_exclusions,
106        }
107    }
108
109    /// Compresses an array using cascading adaptive compression.
110    ///
111    /// First canonicalizes and compacts the array, then applies optimal compression schemes.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if canonicalization or compression fails.
116    pub fn compress(
117        &self,
118        array: &ArrayRef,
119        exec_ctx: &mut ExecutionCtx,
120    ) -> VortexResult<ArrayRef> {
121        let before_nbytes = array.nbytes();
122        let span = trace::compress_span(array.len(), array.dtype(), before_nbytes);
123        let _enter = span.enter();
124
125        let canonical = array.clone().execute::<CanonicalValidity>(exec_ctx)?.0;
126        let compact = canonical.compact(exec_ctx)?;
127        let compressed = self.compress_canonical(compact, CompressorContext::new(), exec_ctx)?;
128
129        trace::record_compress_outcome(&span, before_nbytes, compressed.nbytes());
130
131        Ok(compressed)
132    }
133
134    /// Compresses a child array produced by a cascading scheme.
135    ///
136    /// If the cascade budget is exhausted, the canonical array is returned as-is. Otherwise, the
137    /// child context is created by descending and recording the parent scheme + child index, and
138    /// compression proceeds normally.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if compression fails.
143    pub fn compress_child(
144        &self,
145        child: &ArrayRef,
146        parent_ctx: &CompressorContext,
147        parent_id: SchemeId,
148        child_index: usize,
149        exec_ctx: &mut ExecutionCtx,
150    ) -> VortexResult<ArrayRef> {
151        if parent_ctx.finished_cascading() {
152            trace::cascade_exhausted(parent_id, child_index);
153            return Ok(child.clone());
154        }
155
156        let canonical = child.clone().execute::<CanonicalValidity>(exec_ctx)?.0;
157        let compact = canonical.compact(exec_ctx)?;
158
159        let child_ctx = parent_ctx
160            .clone()
161            .descend_with_scheme(parent_id, child_index);
162        self.compress_canonical(compact, child_ctx, exec_ctx)
163    }
164
165    /// Compresses a canonical array by dispatching to type-specific logic.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if compression of any sub-array fails.
170    fn compress_canonical(
171        &self,
172        array: Canonical,
173        compress_ctx: CompressorContext,
174        exec_ctx: &mut ExecutionCtx,
175    ) -> VortexResult<ArrayRef> {
176        match array {
177            Canonical::Null(null_array) => Ok(null_array.into_array()),
178            Canonical::Bool(bool_array) => {
179                self.choose_and_compress(Canonical::Bool(bool_array), compress_ctx, exec_ctx)
180            }
181            Canonical::Primitive(primitive) => {
182                self.choose_and_compress(Canonical::Primitive(primitive), compress_ctx, exec_ctx)
183            }
184            Canonical::Decimal(decimal) => {
185                self.choose_and_compress(Canonical::Decimal(decimal), compress_ctx, exec_ctx)
186            }
187            Canonical::Struct(struct_array) => {
188                let fields = struct_array
189                    .iter_unmasked_fields()
190                    .map(|field| self.compress(field, exec_ctx))
191                    .collect::<Result<Vec<_>, _>>()?;
192
193                Ok(StructArray::try_new(
194                    struct_array.names().clone(),
195                    fields,
196                    struct_array.len(),
197                    struct_array.validity()?,
198                )?
199                .into_array())
200            }
201            Canonical::List(list_view_array) => {
202                if list_view_array.is_zero_copy_to_list() || list_view_array.elements().is_empty() {
203                    let list_array = list_from_list_view(list_view_array, exec_ctx)?;
204                    self.compress_list_array(list_array, compress_ctx, exec_ctx)
205                } else {
206                    self.compress_list_view_array(list_view_array, compress_ctx, exec_ctx)
207                }
208            }
209            Canonical::FixedSizeList(fsl_array) => {
210                let compressed_elems = self.compress(fsl_array.elements(), exec_ctx)?;
211
212                Ok(FixedSizeListArray::try_new(
213                    compressed_elems,
214                    fsl_array.list_size(),
215                    fsl_array.validity()?,
216                    fsl_array.len(),
217                )?
218                .into_array())
219            }
220            Canonical::VarBinView(varbinview) => {
221                self.choose_and_compress(Canonical::VarBinView(varbinview), compress_ctx, exec_ctx)
222            }
223            Canonical::Extension(ext_array) => {
224                // Try scheme-based compression first.
225                let scheme_compressed = self.choose_and_compress(
226                    Canonical::Extension(ext_array.clone()),
227                    compress_ctx,
228                    exec_ctx,
229                )?;
230                // TODO(connor): HACK TO SUPPORT L2 DENORMALIZATION!!!
231                if scheme_compressed.is::<AnyScalarFn>() {
232                    return Ok(scheme_compressed);
233                }
234
235                // Also compress the underlying storage array. Some extension schemes can beat the
236                // extension storage but still lose to ordinary storage compression.
237                let compressed_storage = self.compress(ext_array.storage_array(), exec_ctx)?;
238                let storage_compressed =
239                    ExtensionArray::new(ext_array.ext_dtype().clone(), compressed_storage)
240                        .into_array();
241
242                if scheme_compressed.nbytes() < storage_compressed.nbytes() {
243                    Ok(scheme_compressed)
244                } else {
245                    Ok(storage_compressed)
246                }
247            }
248            Canonical::Variant(variant_array) => {
249                let core_storage =
250                    self.compress_physical_slots(variant_array.core_storage(), exec_ctx)?;
251                let shredded = variant_array
252                    .shredded()
253                    .map(|arr| {
254                        // Avoid stack-overflow for variant shredded values
255                        if arr.is::<Variant>() {
256                            self.compress_physical_slots(arr, exec_ctx)
257                        } else {
258                            self.compress(arr, exec_ctx)
259                        }
260                    })
261                    .transpose()?;
262
263                Ok(VariantArray::try_new(core_storage, shredded)?.into_array())
264            }
265        }
266    }
267
268    /// The main scheme-selection entry point for a single leaf array.
269    ///
270    /// Filters allowed schemes by [`matches`] and exclusion rules, merges their [`stats_options`]
271    /// into a single [`GenerateStatsOptions`], and picks the winner by estimated compression
272    /// ratio.
273    ///
274    /// If a winner is found and its compressed output is actually smaller, that output is
275    /// returned. Otherwise, the original array is returned unchanged.
276    ///
277    /// Empty and all-null arrays are short-circuited before any scheme evaluation.
278    ///
279    /// [`matches`]: Scheme::matches
280    /// [`stats_options`]: Scheme::stats_options
281    fn choose_and_compress(
282        &self,
283        canonical: Canonical,
284        compress_ctx: CompressorContext,
285        exec_ctx: &mut ExecutionCtx,
286    ) -> VortexResult<ArrayRef> {
287        let eligible_schemes: Vec<&'static dyn Scheme> = self
288            .schemes
289            .iter()
290            .copied()
291            .filter(|s| s.matches(&canonical) && !self.is_excluded(*s, &compress_ctx))
292            .collect();
293
294        let array: ArrayRef = canonical.into();
295
296        if eligible_schemes.is_empty() || array.is_empty() {
297            return Ok(array);
298        }
299
300        if array.all_invalid(exec_ctx)? {
301            return Ok(
302                ConstantArray::new(Scalar::null(array.dtype().clone()), array.len()).into_array(),
303            );
304        }
305
306        let before_nbytes = array.nbytes();
307
308        let merged_opts = eligible_schemes
309            .iter()
310            .fold(GenerateStatsOptions::default(), |acc, s| {
311                acc.merge(s.stats_options())
312            });
313        let compress_ctx = compress_ctx.with_merged_stats_options(merged_opts);
314
315        let data = ArrayAndStats::new(array, merged_opts);
316
317        let Some((winner, winner_estimate)) =
318            self.choose_best_scheme(&eligible_schemes, &data, compress_ctx.clone(), exec_ctx)?
319        else {
320            return Ok(data.into_array());
321        };
322
323        // Run the winning scheme's `compress`. On failure, emit an ERROR event carrying the
324        // scheme name and cascade history before propagating.
325        let error_ctx = trace::enabled_error_context(&compress_ctx);
326        let _winner_span = trace::winner_compress_span(winner.id(), before_nbytes).entered();
327        let compressed = winner
328            .compress(self, &data, compress_ctx, exec_ctx)
329            .inspect_err(|err| {
330                // NB: this is the only way we can tell which scheme panicked / bailed on their
331                // data, especially for third-party schemes where the error site may not carry any
332                // compressor context.
333                trace::scheme_compress_failed(winner.id(), before_nbytes, error_ctx.as_ref(), err);
334            })?;
335
336        let after_nbytes = compressed.nbytes();
337        let actual_ratio = (after_nbytes != 0).then(|| before_nbytes as f64 / after_nbytes as f64);
338
339        // TODO(connor): HACK TO SUPPORT L2 DENORMALIZATION!!!
340        let accepted = after_nbytes < before_nbytes || compressed.is::<AnyScalarFn>();
341
342        trace::record_winner_compress_result(
343            after_nbytes,
344            winner_estimate.trace_ratio(),
345            actual_ratio,
346            accepted,
347        );
348
349        if accepted {
350            Ok(compressed)
351        } else {
352            Ok(data.into_array())
353        }
354    }
355
356    /// Calls [`expected_compression_ratio`] on each candidate and returns the winning scheme along
357    /// with its resolved winner estimate, or `None` if no scheme beats the canonical encoding.
358    ///
359    /// Selection runs in two passes. Pass 1 evaluates every immediate
360    /// [`CompressionEstimate::Verdict`] and tracks the running best. [`Scheme`]s returning
361    /// [`CompressionEstimate::Deferred`] are stashed for pass 2 so that we do not make any
362    /// expensive computations if we don't have to.
363    ///
364    /// Pass 2 evaluates the deferred work and, for each [`DeferredEstimate::Callback`], passes the
365    /// current best [`EstimateScore`] as an early-exit hint so the callback can return
366    /// [`EstimateVerdict::Skip`] without doing expensive work when it cannot beat the threshold.
367    ///
368    /// Ties are broken by registration order within each pass.
369    ///
370    /// [`expected_compression_ratio`]: Scheme::expected_compression_ratio
371    fn choose_best_scheme(
372        &self,
373        schemes: &[&'static dyn Scheme],
374        data: &ArrayAndStats,
375        compress_ctx: CompressorContext,
376        exec_ctx: &mut ExecutionCtx,
377    ) -> VortexResult<Option<(&'static dyn Scheme, WinnerEstimate)>> {
378        let mut best: Option<(&'static dyn Scheme, EstimateScore)> = None;
379        let mut deferred: Vec<(&'static dyn Scheme, DeferredEstimate)> = Vec::new();
380
381        // Pass 1: evaluate every immediate verdict. Stash deferred work for pass 2.
382        {
383            let _verdict_pass = trace::verdict_pass_span().entered();
384            for &scheme in schemes {
385                match scheme.expected_compression_ratio(data, compress_ctx.clone(), exec_ctx) {
386                    CompressionEstimate::Verdict(EstimateVerdict::Skip) => {}
387                    CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse) => {
388                        return Ok(Some((scheme, WinnerEstimate::AlwaysUse)));
389                    }
390                    CompressionEstimate::Verdict(EstimateVerdict::Ratio(ratio)) => {
391                        let score = EstimateScore::FiniteCompression(ratio);
392
393                        if is_better_score(score, best.as_ref()) {
394                            best = Some((scheme, score));
395                        }
396                    }
397                    CompressionEstimate::Deferred(deferred_estimate) => {
398                        deferred.push((scheme, deferred_estimate));
399                    }
400                }
401            }
402        }
403
404        // Pass 2: run deferred work. Callbacks receive the current best as a threshold so they can
405        // short-circuit with `Skip` when they cannot beat it.
406        for (scheme, deferred_estimate) in deferred {
407            let _span = trace::scheme_eval_span(scheme.id()).entered();
408            let threshold: Option<EstimateScore> = best.map(|(_, score)| score);
409            match deferred_estimate {
410                DeferredEstimate::Sample => {
411                    let score = estimate_compression_ratio_with_sampling(
412                        self,
413                        scheme,
414                        data.array(),
415                        compress_ctx.clone(),
416                        exec_ctx,
417                    )?;
418
419                    if is_better_score(score, best.as_ref()) {
420                        best = Some((scheme, score));
421                    }
422                }
423                DeferredEstimate::Callback(callback) => {
424                    match callback(self, data, threshold, compress_ctx.clone(), exec_ctx)? {
425                        EstimateVerdict::Skip => {}
426                        EstimateVerdict::AlwaysUse => {
427                            return Ok(Some((scheme, WinnerEstimate::AlwaysUse)));
428                        }
429                        EstimateVerdict::Ratio(ratio) => {
430                            let score = EstimateScore::FiniteCompression(ratio);
431
432                            if is_better_score(score, best.as_ref()) {
433                                best = Some((scheme, score));
434                            }
435                        }
436                    }
437                }
438            }
439        }
440
441        Ok(best.map(|(scheme, score)| (scheme, WinnerEstimate::Score(score))))
442    }
443
444    // TODO(connor): Lots of room for optimization here.
445    /// Returns `true` if the candidate scheme should be excluded based on the cascade history and
446    /// exclusion rules.
447    fn is_excluded(&self, candidate: &dyn Scheme, ctx: &CompressorContext) -> bool {
448        let id = candidate.id();
449        let history = ctx.cascade_history();
450
451        // Self-exclusion: no scheme appears twice in any chain.
452        if history.iter().any(|&(sid, _)| sid == id) {
453            return true;
454        }
455
456        let mut iter = history.iter().copied().peekable();
457
458        // The root entry is always first in the history (if present). Check if the root has
459        // excluded us.
460        if let Some((_, child_idx)) = iter.next_if(|&(sid, _)| sid == ROOT_SCHEME_ID)
461            && self
462                .root_exclusions
463                .iter()
464                .any(|rule| rule.excluded == id && rule.children.contains(child_idx))
465        {
466            return true;
467        }
468
469        // Push rules: Check if any of our ancestors have excluded us.
470        for (ancestor_id, child_idx) in iter {
471            if let Some(ancestor) = self.schemes.iter().find(|s| s.id() == ancestor_id)
472                && ancestor
473                    .descendant_exclusions()
474                    .iter()
475                    .any(|rule| rule.excluded == id && rule.children.contains(child_idx))
476            {
477                return true;
478            }
479        }
480
481        // Pull rules: Check if we have excluded ourselves because of our ancestors.
482        for rule in candidate.ancestor_exclusions() {
483            if history
484                .iter()
485                .any(|(sid, cidx)| *sid == rule.ancestor && rule.children.contains(*cidx))
486            {
487                return true;
488            }
489        }
490
491        false
492    }
493
494    /// Compresses a [`ListArray`] by narrowing offsets and recursively compressing elements.
495    fn compress_list_array(
496        &self,
497        list_array: ListArray,
498        compress_ctx: CompressorContext,
499        exec_ctx: &mut ExecutionCtx,
500    ) -> VortexResult<ArrayRef> {
501        let list_array = list_array.reset_offsets(true, exec_ctx)?;
502
503        let compressed_elems = self.compress(list_array.elements(), exec_ctx)?;
504
505        // Record the root scheme with the offsets child index so root exclusion rules apply.
506        let offset_ctx =
507            compress_ctx.descend_with_scheme(ROOT_SCHEME_ID, root_list_children::OFFSETS);
508        let list_offsets_primitive = list_array
509            .offsets()
510            .clone()
511            .execute::<PrimitiveArray>(exec_ctx)?
512            .narrow(exec_ctx)?;
513        let compressed_offsets = self.compress_canonical(
514            Canonical::Primitive(list_offsets_primitive),
515            offset_ctx,
516            exec_ctx,
517        )?;
518
519        Ok(
520            ListArray::try_new(compressed_elems, compressed_offsets, list_array.validity()?)?
521                .into_array(),
522        )
523    }
524
525    /// Compresses a [`ListViewArray`] by narrowing offsets/sizes and recursively compressing
526    /// elements.
527    fn compress_list_view_array(
528        &self,
529        list_view: ListViewArray,
530        compress_ctx: CompressorContext,
531        exec_ctx: &mut ExecutionCtx,
532    ) -> VortexResult<ArrayRef> {
533        let compressed_elems = self.compress(list_view.elements(), exec_ctx)?;
534
535        let offset_ctx = compress_ctx
536            .clone()
537            .descend_with_scheme(ROOT_SCHEME_ID, root_list_children::OFFSETS);
538        let list_view_offsets_primitive = list_view
539            .offsets()
540            .clone()
541            .execute::<PrimitiveArray>(exec_ctx)?
542            .narrow(exec_ctx)?;
543        let compressed_offsets = self.compress_canonical(
544            Canonical::Primitive(list_view_offsets_primitive),
545            offset_ctx,
546            exec_ctx,
547        )?;
548
549        let sizes_ctx = compress_ctx.descend_with_scheme(ROOT_SCHEME_ID, root_list_children::SIZES);
550        let list_view_sizes_primitive = list_view
551            .sizes()
552            .clone()
553            .execute::<PrimitiveArray>(exec_ctx)?
554            .narrow(exec_ctx)?;
555        let compressed_sizes = self.compress_canonical(
556            Canonical::Primitive(list_view_sizes_primitive),
557            sizes_ctx,
558            exec_ctx,
559        )?;
560
561        Ok(ListViewArray::try_new(
562            compressed_elems,
563            compressed_offsets,
564            compressed_sizes,
565            list_view.validity()?,
566        )?
567        .into_array())
568    }
569
570    /// Compress very child slot of the array, then re-build it from them.
571    fn compress_physical_slots(
572        &self,
573        array: &ArrayRef,
574        exec_ctx: &mut ExecutionCtx,
575    ) -> VortexResult<ArrayRef> {
576        let slots = array
577            .slots()
578            .iter()
579            .map(|slot| {
580                slot.as_ref()
581                    .map(|child| self.compress(child, exec_ctx))
582                    .transpose()
583            })
584            .collect::<VortexResult<ArraySlots>>()?;
585
586        // SAFETY: compression rewrites each child slot to an equivalent physical representation,
587        // preserving the parent array's logical values and statistics.
588        unsafe { array.clone().with_slots(slots) }
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use std::sync::LazyLock;
595
596    use parking_lot::Mutex;
597    use vortex_array::ArrayRef;
598    use vortex_array::Canonical;
599    use vortex_array::VortexSessionExecute;
600    use vortex_array::arrays::BoolArray;
601    use vortex_array::arrays::Constant;
602    use vortex_array::arrays::NullArray;
603    use vortex_array::arrays::PrimitiveArray;
604    use vortex_array::validity::Validity;
605    use vortex_buffer::buffer;
606    use vortex_session::VortexSession;
607
608    use super::*;
609    use crate::builtins::FloatDictScheme;
610    use crate::builtins::IntDictScheme;
611    use crate::builtins::StringDictScheme;
612    use crate::ctx::CompressorContext;
613    use crate::estimate::CompressionEstimate;
614    use crate::estimate::DeferredEstimate;
615    use crate::estimate::EstimateScore;
616    use crate::estimate::EstimateVerdict;
617    use crate::estimate::WinnerEstimate;
618    use crate::scheme::SchemeExt;
619
620    static SESSION: LazyLock<VortexSession> = LazyLock::new(vortex_array::array_session);
621
622    fn compressor() -> CascadingCompressor {
623        CascadingCompressor::new(vec![&IntDictScheme, &FloatDictScheme, &StringDictScheme])
624    }
625
626    fn estimate_test_data() -> ArrayAndStats {
627        let array = PrimitiveArray::new(buffer![1i32, 2, 3, 4], Validity::NonNullable).into_array();
628        ArrayAndStats::new(array, GenerateStatsOptions::default())
629    }
630
631    fn matches_integer_primitive(canonical: &Canonical) -> bool {
632        matches!(canonical, Canonical::Primitive(primitive) if primitive.ptype().is_int())
633    }
634
635    #[derive(Debug)]
636    struct DirectRatioScheme;
637
638    impl Scheme for DirectRatioScheme {
639        fn scheme_name(&self) -> &'static str {
640            "test.direct_ratio"
641        }
642
643        fn matches(&self, canonical: &Canonical) -> bool {
644            matches_integer_primitive(canonical)
645        }
646
647        fn expected_compression_ratio(
648            &self,
649            _data: &ArrayAndStats,
650            _compress_ctx: CompressorContext,
651            _exec_ctx: &mut ExecutionCtx,
652        ) -> CompressionEstimate {
653            CompressionEstimate::Verdict(EstimateVerdict::Ratio(2.0))
654        }
655
656        fn compress(
657            &self,
658            _compressor: &CascadingCompressor,
659            _data: &ArrayAndStats,
660            _compress_ctx: CompressorContext,
661            _exec_ctx: &mut ExecutionCtx,
662        ) -> VortexResult<ArrayRef> {
663            unreachable!("test helper should never be selected for compression")
664        }
665    }
666
667    #[derive(Debug)]
668    struct ImmediateAlwaysUseScheme;
669
670    impl Scheme for ImmediateAlwaysUseScheme {
671        fn scheme_name(&self) -> &'static str {
672            "test.immediate_always_use"
673        }
674
675        fn matches(&self, canonical: &Canonical) -> bool {
676            matches_integer_primitive(canonical)
677        }
678
679        fn expected_compression_ratio(
680            &self,
681            _data: &ArrayAndStats,
682            _compress_ctx: CompressorContext,
683            _exec_ctx: &mut ExecutionCtx,
684        ) -> CompressionEstimate {
685            CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse)
686        }
687
688        fn compress(
689            &self,
690            _compressor: &CascadingCompressor,
691            _data: &ArrayAndStats,
692            _compress_ctx: CompressorContext,
693            _exec_ctx: &mut ExecutionCtx,
694        ) -> VortexResult<ArrayRef> {
695            unreachable!("test helper should never be selected for compression")
696        }
697    }
698
699    #[derive(Debug)]
700    struct CallbackAlwaysUseScheme;
701
702    impl Scheme for CallbackAlwaysUseScheme {
703        fn scheme_name(&self) -> &'static str {
704            "test.callback_always_use"
705        }
706
707        fn matches(&self, canonical: &Canonical) -> bool {
708            matches_integer_primitive(canonical)
709        }
710
711        fn expected_compression_ratio(
712            &self,
713            _data: &ArrayAndStats,
714            _compress_ctx: CompressorContext,
715            _exec_ctx: &mut ExecutionCtx,
716        ) -> CompressionEstimate {
717            CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new(
718                |_compressor, _data, _ctx, _exec_ctx, _best_so_far| Ok(EstimateVerdict::AlwaysUse),
719            )))
720        }
721
722        fn compress(
723            &self,
724            _compressor: &CascadingCompressor,
725            _data: &ArrayAndStats,
726            _compress_ctx: CompressorContext,
727            _exec_ctx: &mut ExecutionCtx,
728        ) -> VortexResult<ArrayRef> {
729            unreachable!("test helper should never be selected for compression")
730        }
731    }
732
733    #[derive(Debug)]
734    struct CallbackSkipScheme;
735
736    impl Scheme for CallbackSkipScheme {
737        fn scheme_name(&self) -> &'static str {
738            "test.callback_skip"
739        }
740
741        fn matches(&self, canonical: &Canonical) -> bool {
742            matches_integer_primitive(canonical)
743        }
744
745        fn expected_compression_ratio(
746            &self,
747            _data: &ArrayAndStats,
748            _compress_ctx: CompressorContext,
749            _exec_ctx: &mut ExecutionCtx,
750        ) -> CompressionEstimate {
751            CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new(
752                |_compressor, _data, _ctx, _exec_ctx, _best_so_far| Ok(EstimateVerdict::Skip),
753            )))
754        }
755
756        fn compress(
757            &self,
758            _compressor: &CascadingCompressor,
759            _data: &ArrayAndStats,
760            _compress_ctx: CompressorContext,
761            _exec_ctx: &mut ExecutionCtx,
762        ) -> VortexResult<ArrayRef> {
763            unreachable!("test helper should never be selected for compression")
764        }
765    }
766
767    #[derive(Debug)]
768    struct CallbackRatioScheme;
769
770    impl Scheme for CallbackRatioScheme {
771        fn scheme_name(&self) -> &'static str {
772            "test.callback_ratio"
773        }
774
775        fn matches(&self, canonical: &Canonical) -> bool {
776            matches_integer_primitive(canonical)
777        }
778
779        fn expected_compression_ratio(
780            &self,
781            _data: &ArrayAndStats,
782            _compress_ctx: CompressorContext,
783            _exec_ctx: &mut ExecutionCtx,
784        ) -> CompressionEstimate {
785            CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new(
786                |_compressor, _data, _ctx, _exec_ctx, _best_so_far| Ok(EstimateVerdict::Ratio(3.0)),
787            )))
788        }
789
790        fn compress(
791            &self,
792            _compressor: &CascadingCompressor,
793            _data: &ArrayAndStats,
794            _compress_ctx: CompressorContext,
795            _exec_ctx: &mut ExecutionCtx,
796        ) -> VortexResult<ArrayRef> {
797            unreachable!("test helper should never be selected for compression")
798        }
799    }
800
801    #[derive(Debug)]
802    struct HugeRatioScheme;
803
804    impl Scheme for HugeRatioScheme {
805        fn scheme_name(&self) -> &'static str {
806            "test.huge_ratio"
807        }
808
809        fn matches(&self, canonical: &Canonical) -> bool {
810            matches_integer_primitive(canonical)
811        }
812
813        fn expected_compression_ratio(
814            &self,
815            _data: &ArrayAndStats,
816            _compress_ctx: CompressorContext,
817            _exec_ctx: &mut ExecutionCtx,
818        ) -> CompressionEstimate {
819            CompressionEstimate::Verdict(EstimateVerdict::Ratio(100.0))
820        }
821
822        fn compress(
823            &self,
824            _compressor: &CascadingCompressor,
825            _data: &ArrayAndStats,
826            _compress_ctx: CompressorContext,
827            _exec_ctx: &mut ExecutionCtx,
828        ) -> VortexResult<ArrayRef> {
829            unreachable!("test helper should never be selected for compression")
830        }
831    }
832
833    #[derive(Debug)]
834    struct ZeroBytesSamplingScheme;
835
836    impl Scheme for ZeroBytesSamplingScheme {
837        fn scheme_name(&self) -> &'static str {
838            "test.zero_bytes_sampling"
839        }
840
841        fn matches(&self, canonical: &Canonical) -> bool {
842            matches_integer_primitive(canonical)
843        }
844
845        fn expected_compression_ratio(
846            &self,
847            _data: &ArrayAndStats,
848            _compress_ctx: CompressorContext,
849            _exec_ctx: &mut ExecutionCtx,
850        ) -> CompressionEstimate {
851            CompressionEstimate::Deferred(DeferredEstimate::Sample)
852        }
853
854        fn compress(
855            &self,
856            _compressor: &CascadingCompressor,
857            data: &ArrayAndStats,
858            _compress_ctx: CompressorContext,
859            _exec_ctx: &mut ExecutionCtx,
860        ) -> VortexResult<ArrayRef> {
861            Ok(NullArray::new(data.array().len()).into_array())
862        }
863    }
864
865    #[test]
866    fn test_self_exclusion() {
867        let c = compressor();
868        let ctx = CompressorContext::default().descend_with_scheme(IntDictScheme.id(), 0);
869
870        // IntDictScheme is in the history, so it should be excluded.
871        assert!(c.is_excluded(&IntDictScheme, &ctx));
872    }
873
874    #[test]
875    fn test_root_exclusion_list_offsets() {
876        let c = compressor();
877        let ctx = CompressorContext::default()
878            .descend_with_scheme(ROOT_SCHEME_ID, root_list_children::OFFSETS);
879
880        // IntDict should be excluded for list offsets.
881        assert!(c.is_excluded(&IntDictScheme, &ctx));
882    }
883
884    #[test]
885    fn test_push_rule_float_dict_excludes_int_dict_from_codes() {
886        let c = compressor();
887        // FloatDict cascading through codes (child 1).
888        let ctx = CompressorContext::default().descend_with_scheme(FloatDictScheme.id(), 1);
889
890        // IntDict should be excluded from FloatDict's codes child.
891        assert!(c.is_excluded(&IntDictScheme, &ctx));
892    }
893
894    #[test]
895    fn test_push_rule_float_dict_excludes_int_dict_from_values() {
896        let c = compressor();
897        // FloatDict cascading through values (child 0).
898        let ctx = CompressorContext::default().descend_with_scheme(FloatDictScheme.id(), 0);
899
900        // IntDict should also be excluded from FloatDict's values child (ALP propagation
901        // replacement).
902        assert!(c.is_excluded(&IntDictScheme, &ctx));
903    }
904
905    #[test]
906    fn test_no_exclusion_without_history() {
907        let c = compressor();
908        let ctx = CompressorContext::default();
909
910        // No history means no exclusions.
911        assert!(!c.is_excluded(&IntDictScheme, &ctx));
912    }
913
914    #[test]
915    fn immediate_always_use_wins_immediately() -> VortexResult<()> {
916        let compressor =
917            CascadingCompressor::new(vec![&DirectRatioScheme, &ImmediateAlwaysUseScheme]);
918        let schemes: [&'static dyn Scheme; 2] = [&DirectRatioScheme, &ImmediateAlwaysUseScheme];
919        let data = estimate_test_data();
920        let mut exec_ctx = SESSION.create_execution_ctx();
921
922        let winner = compressor.choose_best_scheme(
923            &schemes,
924            &data,
925            CompressorContext::new(),
926            &mut exec_ctx,
927        )?;
928
929        assert!(matches!(
930            winner,
931            Some((scheme, WinnerEstimate::AlwaysUse))
932                if scheme.id() == ImmediateAlwaysUseScheme.id()
933        ));
934        Ok(())
935    }
936
937    #[test]
938    fn callback_always_use_wins_immediately() -> VortexResult<()> {
939        let compressor =
940            CascadingCompressor::new(vec![&DirectRatioScheme, &CallbackAlwaysUseScheme]);
941        let schemes: [&'static dyn Scheme; 2] = [&DirectRatioScheme, &CallbackAlwaysUseScheme];
942        let data = estimate_test_data();
943        let mut exec_ctx = SESSION.create_execution_ctx();
944
945        let winner = compressor.choose_best_scheme(
946            &schemes,
947            &data,
948            CompressorContext::new(),
949            &mut exec_ctx,
950        )?;
951
952        assert!(matches!(
953            winner,
954            Some((scheme, WinnerEstimate::AlwaysUse))
955                if scheme.id() == CallbackAlwaysUseScheme.id()
956        ));
957        Ok(())
958    }
959
960    #[test]
961    fn callback_skip_is_ignored() -> VortexResult<()> {
962        let compressor = CascadingCompressor::new(vec![&CallbackSkipScheme, &DirectRatioScheme]);
963        let schemes: [&'static dyn Scheme; 2] = [&CallbackSkipScheme, &DirectRatioScheme];
964        let data = estimate_test_data();
965        let mut exec_ctx = SESSION.create_execution_ctx();
966
967        let winner = compressor.choose_best_scheme(
968            &schemes,
969            &data,
970            CompressorContext::new(),
971            &mut exec_ctx,
972        )?;
973
974        assert!(matches!(
975            winner,
976            Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(2.0))))
977                if scheme.id() == DirectRatioScheme.id()
978        ));
979        Ok(())
980    }
981
982    #[test]
983    fn callback_ratio_competes_numerically() -> VortexResult<()> {
984        let compressor = CascadingCompressor::new(vec![&DirectRatioScheme, &CallbackRatioScheme]);
985        let schemes: [&'static dyn Scheme; 2] = [&DirectRatioScheme, &CallbackRatioScheme];
986        let data = estimate_test_data();
987        let mut exec_ctx = SESSION.create_execution_ctx();
988
989        let winner = compressor.choose_best_scheme(
990            &schemes,
991            &data,
992            CompressorContext::new(),
993            &mut exec_ctx,
994        )?;
995
996        assert!(matches!(
997            winner,
998            Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(3.0))))
999                if scheme.id() == CallbackRatioScheme.id()
1000        ));
1001        Ok(())
1002    }
1003
1004    #[test]
1005    fn zero_byte_sample_loses_to_finite_ratio() -> VortexResult<()> {
1006        let compressor = CascadingCompressor::new(vec![&HugeRatioScheme, &ZeroBytesSamplingScheme]);
1007        let schemes: [&'static dyn Scheme; 2] = [&HugeRatioScheme, &ZeroBytesSamplingScheme];
1008        let data = estimate_test_data();
1009        let mut exec_ctx = SESSION.create_execution_ctx();
1010
1011        let winner = compressor.choose_best_scheme(
1012            &schemes,
1013            &data,
1014            CompressorContext::new(),
1015            &mut exec_ctx,
1016        )?;
1017
1018        assert!(matches!(
1019            winner,
1020            Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(100.0))))
1021                if scheme.id() == HugeRatioScheme.id()
1022        ));
1023        Ok(())
1024    }
1025
1026    #[test]
1027    fn finite_ratio_displaces_zero_byte_sample() -> VortexResult<()> {
1028        let compressor = CascadingCompressor::new(vec![&ZeroBytesSamplingScheme, &HugeRatioScheme]);
1029        let schemes: [&'static dyn Scheme; 2] = [&ZeroBytesSamplingScheme, &HugeRatioScheme];
1030        let data = estimate_test_data();
1031        let mut exec_ctx = SESSION.create_execution_ctx();
1032
1033        let winner = compressor.choose_best_scheme(
1034            &schemes,
1035            &data,
1036            CompressorContext::new(),
1037            &mut exec_ctx,
1038        )?;
1039
1040        assert!(matches!(
1041            winner,
1042            Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(100.0))))
1043                if scheme.id() == HugeRatioScheme.id()
1044        ));
1045        Ok(())
1046    }
1047
1048    #[test]
1049    fn zero_byte_sample_alone_selects_no_scheme() -> VortexResult<()> {
1050        let compressor = CascadingCompressor::new(vec![&ZeroBytesSamplingScheme]);
1051        let schemes: [&'static dyn Scheme; 1] = [&ZeroBytesSamplingScheme];
1052        let data = estimate_test_data();
1053        let mut exec_ctx = SESSION.create_execution_ctx();
1054
1055        let winner = compressor.choose_best_scheme(
1056            &schemes,
1057            &data,
1058            CompressorContext::new(),
1059            &mut exec_ctx,
1060        )?;
1061
1062        assert!(winner.is_none());
1063        Ok(())
1064    }
1065
1066    // Observer helper used by threshold-related tests. Captures the `best_so_far` value the
1067    // compressor passes to its deferred callback. `OBSERVER_LOCK` serializes tests that share
1068    // `OBSERVED_THRESHOLD` so they do not race.
1069    static OBSERVER_LOCK: Mutex<()> = Mutex::new(());
1070    static OBSERVED_THRESHOLD: Mutex<Option<Option<EstimateScore>>> = Mutex::new(None);
1071
1072    #[derive(Debug)]
1073    struct ThresholdObservingScheme;
1074
1075    impl Scheme for ThresholdObservingScheme {
1076        fn scheme_name(&self) -> &'static str {
1077            "test.threshold_observing"
1078        }
1079
1080        fn matches(&self, canonical: &Canonical) -> bool {
1081            matches_integer_primitive(canonical)
1082        }
1083
1084        fn expected_compression_ratio(
1085            &self,
1086            _data: &ArrayAndStats,
1087            _compress_ctx: CompressorContext,
1088            _exec_ctx: &mut ExecutionCtx,
1089        ) -> CompressionEstimate {
1090            CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new(
1091                |_compressor, _data, best_so_far, _ctx, _exec_ctx| {
1092                    *OBSERVED_THRESHOLD.lock() = Some(best_so_far);
1093                    Ok(EstimateVerdict::Skip)
1094                },
1095            )))
1096        }
1097
1098        fn compress(
1099            &self,
1100            _compressor: &CascadingCompressor,
1101            _data: &ArrayAndStats,
1102            _compress_ctx: CompressorContext,
1103            _exec_ctx: &mut ExecutionCtx,
1104        ) -> VortexResult<ArrayRef> {
1105            unreachable!("test helper should never be selected for compression")
1106        }
1107    }
1108
1109    #[derive(Debug)]
1110    struct CallbackMatchingRatioScheme;
1111
1112    impl Scheme for CallbackMatchingRatioScheme {
1113        fn scheme_name(&self) -> &'static str {
1114            "test.callback_matching_ratio"
1115        }
1116
1117        fn matches(&self, canonical: &Canonical) -> bool {
1118            matches_integer_primitive(canonical)
1119        }
1120
1121        fn expected_compression_ratio(
1122            &self,
1123            _data: &ArrayAndStats,
1124            _compress_ctx: CompressorContext,
1125            _exec_ctx: &mut ExecutionCtx,
1126        ) -> CompressionEstimate {
1127            CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new(
1128                |_compressor, _data, _ctx, _exec_ctx, _best_so_far| Ok(EstimateVerdict::Ratio(2.0)),
1129            )))
1130        }
1131
1132        fn compress(
1133            &self,
1134            _compressor: &CascadingCompressor,
1135            _data: &ArrayAndStats,
1136            _compress_ctx: CompressorContext,
1137            _exec_ctx: &mut ExecutionCtx,
1138        ) -> VortexResult<ArrayRef> {
1139            unreachable!("test helper should never be selected for compression")
1140        }
1141    }
1142
1143    #[test]
1144    fn callback_always_use_overrides_pass_one_best() -> VortexResult<()> {
1145        // `HugeRatioScheme` returns an immediate `Ratio(100.0)` in pass 1;
1146        // `CallbackAlwaysUseScheme` returns `AlwaysUse` from its deferred callback in pass 2.
1147        // The deferred `AlwaysUse` must still win.
1148        let compressor = CascadingCompressor::new(vec![&HugeRatioScheme, &CallbackAlwaysUseScheme]);
1149        let schemes: [&'static dyn Scheme; 2] = [&HugeRatioScheme, &CallbackAlwaysUseScheme];
1150        let data = estimate_test_data();
1151        let mut exec_ctx = SESSION.create_execution_ctx();
1152
1153        let winner = compressor.choose_best_scheme(
1154            &schemes,
1155            &data,
1156            CompressorContext::new(),
1157            &mut exec_ctx,
1158        )?;
1159
1160        assert!(matches!(
1161            winner,
1162            Some((scheme, WinnerEstimate::AlwaysUse))
1163                if scheme.id() == CallbackAlwaysUseScheme.id()
1164        ));
1165        Ok(())
1166    }
1167
1168    #[test]
1169    fn threshold_reflects_pass_one_best() -> VortexResult<()> {
1170        let _guard = OBSERVER_LOCK.lock();
1171        *OBSERVED_THRESHOLD.lock() = None;
1172
1173        let compressor =
1174            CascadingCompressor::new(vec![&DirectRatioScheme, &ThresholdObservingScheme]);
1175        let schemes: [&'static dyn Scheme; 2] = [&DirectRatioScheme, &ThresholdObservingScheme];
1176        let data = estimate_test_data();
1177        let mut exec_ctx = SESSION.create_execution_ctx();
1178
1179        compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?;
1180
1181        let observed = *OBSERVED_THRESHOLD.lock();
1182        assert!(matches!(
1183            observed,
1184            Some(Some(EstimateScore::FiniteCompression(r))) if r == 2.0
1185        ));
1186        Ok(())
1187    }
1188
1189    #[test]
1190    fn threshold_is_none_when_only_prior_is_zero_bytes() -> VortexResult<()> {
1191        let _guard = OBSERVER_LOCK.lock();
1192        *OBSERVED_THRESHOLD.lock() = None;
1193
1194        let compressor =
1195            CascadingCompressor::new(vec![&ZeroBytesSamplingScheme, &ThresholdObservingScheme]);
1196        let schemes: [&'static dyn Scheme; 2] =
1197            [&ZeroBytesSamplingScheme, &ThresholdObservingScheme];
1198        let data = estimate_test_data();
1199        let mut exec_ctx = SESSION.create_execution_ctx();
1200
1201        compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?;
1202
1203        // The observing callback was invoked (outer `Some`) and `best_so_far` was `None` (inner
1204        // `None`) because the zero-byte sample is never stored as the best.
1205        let observed = *OBSERVED_THRESHOLD.lock();
1206        assert_eq!(observed, Some(None));
1207        Ok(())
1208    }
1209
1210    #[test]
1211    fn threshold_is_none_when_no_prior_scheme() -> VortexResult<()> {
1212        let _guard = OBSERVER_LOCK.lock();
1213        *OBSERVED_THRESHOLD.lock() = None;
1214
1215        let compressor = CascadingCompressor::new(vec![&ThresholdObservingScheme]);
1216        let schemes: [&'static dyn Scheme; 1] = [&ThresholdObservingScheme];
1217        let data = estimate_test_data();
1218        let mut exec_ctx = SESSION.create_execution_ctx();
1219
1220        compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?;
1221
1222        let observed = *OBSERVED_THRESHOLD.lock();
1223        assert_eq!(observed, Some(None));
1224        Ok(())
1225    }
1226
1227    #[test]
1228    fn threshold_updates_from_earlier_deferred_callback() -> VortexResult<()> {
1229        let _guard = OBSERVER_LOCK.lock();
1230        *OBSERVED_THRESHOLD.lock() = None;
1231
1232        // Both schemes are deferred. The first callback registers `Ratio(3.0)`; the second
1233        // callback must observe it as its threshold.
1234        let compressor =
1235            CascadingCompressor::new(vec![&CallbackRatioScheme, &ThresholdObservingScheme]);
1236        let schemes: [&'static dyn Scheme; 2] = [&CallbackRatioScheme, &ThresholdObservingScheme];
1237        let data = estimate_test_data();
1238        let mut exec_ctx = SESSION.create_execution_ctx();
1239
1240        compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?;
1241
1242        let observed = *OBSERVED_THRESHOLD.lock();
1243        assert!(matches!(
1244            observed,
1245            Some(Some(EstimateScore::FiniteCompression(r))) if r == 3.0
1246        ));
1247        Ok(())
1248    }
1249
1250    #[test]
1251    fn ratio_tie_between_immediate_and_deferred_favors_immediate() -> VortexResult<()> {
1252        // Both schemes produce the same `Ratio(2.0)`, one from pass 1 (immediate) and one from
1253        // pass 2 (deferred callback). Pass 1 locks in first, and strict `>` tie-breaking means
1254        // the deferred callback's equal ratio cannot displace it.
1255        let compressor =
1256            CascadingCompressor::new(vec![&CallbackMatchingRatioScheme, &DirectRatioScheme]);
1257        let schemes: [&'static dyn Scheme; 2] = [&CallbackMatchingRatioScheme, &DirectRatioScheme];
1258        let data = estimate_test_data();
1259        let mut exec_ctx = SESSION.create_execution_ctx();
1260
1261        let winner = compressor.choose_best_scheme(
1262            &schemes,
1263            &data,
1264            CompressorContext::new(),
1265            &mut exec_ctx,
1266        )?;
1267
1268        assert!(matches!(
1269            winner,
1270            Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(r))))
1271                if scheme.id() == DirectRatioScheme.id() && r == 2.0
1272        ));
1273        Ok(())
1274    }
1275
1276    #[test]
1277    fn all_null_array_compresses_to_constant() -> VortexResult<()> {
1278        let array = PrimitiveArray::new(
1279            buffer![0i32, 0, 0, 0, 0],
1280            Validity::Array(BoolArray::from_iter([false, false, false, false, false]).into_array()),
1281        )
1282        .into_array();
1283
1284        // The compressor should produce a `ConstantArray` for an all-null array regardless of
1285        // which schemes are registered.
1286        let compressor = CascadingCompressor::new(vec![&IntDictScheme]);
1287        let mut exec_ctx = SESSION.create_execution_ctx();
1288        let compressed = compressor.compress(&array, &mut exec_ctx)?;
1289        assert!(compressed.is::<Constant>());
1290        Ok(())
1291    }
1292
1293    /// Regression test for <https://github.com/vortex-data/vortex/issues/7227>.
1294    ///
1295    /// `estimate_compression_ratio_with_sampling` must use the *scheme's* stats options
1296    /// (which request distinct-value counting) rather than the context's stats options
1297    /// (which may not). With the old code this panicked inside `dictionary_encode` because
1298    /// distinct values were never computed for the sample.
1299    #[test]
1300    fn sampling_uses_scheme_stats_options() -> VortexResult<()> {
1301        // Low-cardinality float array so FloatDictScheme considers it compressible.
1302        let array = PrimitiveArray::new(
1303            buffer![1.0f32, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0],
1304            Validity::NonNullable,
1305        )
1306        .into_array();
1307
1308        let compressor = CascadingCompressor::new(vec![&FloatDictScheme]);
1309
1310        // A context with default stats_options (count_distinct_values = false) and
1311        // marked as a sample so the function skips the sampling step and compresses
1312        // the array directly.
1313        let ctx = CompressorContext::new().with_sampling();
1314
1315        // Before the fix this panicked with:
1316        //   "this must be present since `DictScheme` declared that we need distinct values"
1317        let mut exec_ctx = SESSION.create_execution_ctx();
1318        let score = estimate_compression_ratio_with_sampling(
1319            &compressor,
1320            &FloatDictScheme,
1321            &array,
1322            ctx,
1323            &mut exec_ctx,
1324        )?;
1325        assert!(matches!(score, EstimateScore::FiniteCompression(ratio) if ratio.is_finite()));
1326        Ok(())
1327    }
1328}