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