vortex_compressor/scheme.rs
1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Unified compression scheme trait and exclusion rules.
5
6use std::fmt;
7use std::fmt::Debug;
8use std::hash::Hash;
9use std::hash::Hasher;
10
11use vortex_array::ArrayRef;
12use vortex_array::Canonical;
13use vortex_array::ExecutionCtx;
14use vortex_error::VortexResult;
15
16use crate::CascadingCompressor;
17use crate::ctx::CompressorContext;
18use crate::estimate::CompressionEstimate;
19use crate::stats::ArrayAndStats;
20use crate::stats::GenerateStatsOptions;
21
22/// Unique identifier for a compression scheme.
23///
24/// The only way to obtain a [`SchemeId`] is through [`SchemeExt::id()`], which is auto-implemented
25/// for all [`Scheme`] types. There is no public constructor.
26///
27/// The only exception to this is for the compressor's synthetic `ROOT_SCHEME_ID`.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub struct SchemeId {
30 /// Only constructable within `vortex-compressor`.
31 ///
32 /// The only public way to obtain a [`SchemeId`] is through [`SchemeExt::id()`].
33 pub(super) name: &'static str,
34}
35
36impl fmt::Display for SchemeId {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 f.write_str(self.name)
39 }
40}
41
42/// Selects which children of a cascading scheme a rule applies to.
43#[derive(Debug, Clone, Copy)]
44pub enum ChildSelection {
45 /// Rule applies to all children.
46 All,
47 /// Rule applies to a single child.
48 One(usize),
49 /// Rule applies to multiple specific children.
50 Many(&'static [usize]),
51}
52
53impl ChildSelection {
54 /// Returns `true` if this selection includes the given child index.
55 pub fn contains(&self, child_index: usize) -> bool {
56 match self {
57 ChildSelection::All => true,
58 ChildSelection::One(idx) => *idx == child_index,
59 ChildSelection::Many(indices) => indices.contains(&child_index),
60 }
61 }
62}
63
64/// Push rule: declared by a cascading scheme to exclude another scheme from the subtree
65/// rooted at the specified children.
66///
67/// Use this when the declaring scheme (the ancestor) knows about the excluded scheme. For example,
68/// `ZigZag` excludes `Dict` from all its children.
69#[derive(Debug, Clone, Copy)]
70pub struct DescendantExclusion {
71 /// The scheme to exclude from descendants.
72 pub excluded: SchemeId,
73 /// Which children of the declaring scheme this rule applies to.
74 pub children: ChildSelection,
75}
76
77/// Pull rule: declared by a scheme to exclude itself when the specified ancestor is in the
78/// cascade chain.
79///
80/// Use this when the excluded scheme (the descendant) knows about the ancestor. For example,
81/// `Sequence` excludes itself when `IntDict` is an ancestor on its codes child.
82#[derive(Debug, Clone, Copy)]
83pub struct AncestorExclusion {
84 /// The ancestor scheme that makes the declaring scheme ineligible.
85 pub ancestor: SchemeId,
86 /// Which children of the ancestor this rule applies to.
87 pub children: ChildSelection,
88}
89
90// TODO(connor): Remove all default implemented methods.
91/// A single compression encoding that the [`CascadingCompressor`] can select from.
92///
93/// The compressor evaluates every registered scheme whose [`matches`] returns `true` for a given
94/// array, picks the one with the highest [`expected_compression_ratio`], and calls [`compress`] on
95/// the winner.
96///
97/// One of the key features of the compressor in this crate is that schemes may "cascade". A
98/// scheme's [`compress`] can call back into the compressor via
99/// [`CascadingCompressor::compress_child`] to compress child or transformed arrays, building up
100/// multiple encoding layers (e.g. frame-of-reference and then bit-packing).
101///
102/// # Scheme IDs
103///
104/// Every scheme has a globally unique name returned by [`scheme_name`]. The [`SchemeExt::id`]
105/// method (auto-implemented, cannot be overridden) wraps that name in an opaque [`SchemeId`] used
106/// for equality, hashing, and exclusion rules (see below).
107///
108/// # Cascading and children
109///
110/// Schemes that produce child arrays for further compression must declare [`num_children`] > 0.
111/// Each child should be identified by a stable index. Cascading schemes should use
112/// [`CascadingCompressor::compress_child`] to compress each child array, which handles cascade
113/// level / budget tracking and context management automatically.
114///
115/// No scheme may appear twice in a cascade (descendant) chain (enforced by the compressor). This
116/// keeps the search space a tree.
117///
118/// # Exclusion rules
119///
120/// Schemes declare exclusion rules to prevent incompatible scheme combinations in the cascade
121/// chain:
122///
123/// - [`descendant_exclusions`] (push): "exclude scheme X from my child Y's subtree." Used when the
124/// declaring scheme knows about the excluded scheme.
125/// - [`ancestor_exclusions`] (pull): "exclude me if ancestor X's child Y is above me." Used when
126/// the declaring scheme knows about the ancestor.
127///
128/// We do this because different schemes will live in different crates, and we cannot know the
129/// dependency direction ahead of time.
130///
131/// # Implementing a scheme
132///
133/// [`expected_compression_ratio`] should return
134/// `CompressionEstimate::Deferred(DeferredEstimate::Sample)` when a cheap heuristic is not
135/// available, asking the compressor to estimate via sampling. Implementors should return an
136/// immediate [`CompressionEstimate::Verdict`] when possible.
137///
138/// Schemes that need statistics that may be expensive to compute should override [`stats_options`]
139/// to declare what they require. The compressor merges all eligible schemes' options before
140/// generating stats, so each stat is always computed at most once for a given array.
141///
142/// A scheme implementation should be deterministic for a fixed input array and context. The
143/// compressor uses scheme order for deterministic tie-breaking, so non-deterministic estimates make
144/// compressed output harder to reproduce and compare.
145///
146/// [`scheme_name`]: Scheme::scheme_name
147/// [`matches`]: Scheme::matches
148/// [`compress`]: Scheme::compress
149/// [`expected_compression_ratio`]: Scheme::expected_compression_ratio
150/// [`stats_options`]: Scheme::stats_options
151/// [`num_children`]: Scheme::num_children
152/// [`descendant_exclusions`]: Scheme::descendant_exclusions
153/// [`ancestor_exclusions`]: Scheme::ancestor_exclusions
154pub trait Scheme: Debug + Send + Sync {
155 /// The globally unique name for this scheme (e.g. `"vortex.int.bitpacking"`).
156 fn scheme_name(&self) -> &'static str;
157
158 /// Whether this scheme can compress the given canonical array.
159 fn matches(&self, canonical: &Canonical) -> bool;
160
161 /// Returns the stats generation options this scheme requires. The compressor merges all
162 /// eligible schemes' options before generating stats so that a single stats pass satisfies
163 /// every scheme.
164 fn stats_options(&self) -> GenerateStatsOptions {
165 GenerateStatsOptions::default()
166 }
167
168 /// The number of child arrays this scheme produces when cascading. Returns 0 for leaf
169 /// schemes that produce a final encoded array.
170 fn num_children(&self) -> usize {
171 0
172 }
173
174 /// Schemes to exclude from specific children's subtrees (push direction).
175 ///
176 /// Each rule says: "when I cascade through child Y, do not use scheme X anywhere in that
177 /// subtree." Only meaningful when [`num_children`](Scheme::num_children) > 0.
178 fn descendant_exclusions(&self) -> Vec<DescendantExclusion> {
179 Vec::new()
180 }
181
182 /// Ancestors that make this scheme ineligible (pull direction).
183 ///
184 /// Each rule says: "if ancestor X cascaded through child Y somewhere above me in the chain, do
185 /// not try me."
186 fn ancestor_exclusions(&self) -> Vec<AncestorExclusion> {
187 Vec::new()
188 }
189
190 /// Cheaply estimate the compression ratio for this scheme on the given array.
191 ///
192 /// This method should be fast and infallible. Any expensive or fallible work should be
193 /// deferred to the compressor by returning
194 /// `CompressionEstimate::Deferred(DeferredEstimate::Sample)` or
195 /// `CompressionEstimate::Deferred(DeferredEstimate::Callback(...))`.
196 ///
197 /// The compressor will ask all schemes what their expected compression ratio is given the array
198 /// and statistics. The scheme with the highest estimated ratio will then be applied to the
199 /// entire array.
200 ///
201 /// [`CompressionEstimate::Verdict`] means the scheme already knows the terminal
202 /// [`crate::estimate::EstimateVerdict`]. `CompressionEstimate::Deferred(DeferredEstimate::Sample)`
203 /// asks the compressor to sample. `CompressionEstimate::Deferred(DeferredEstimate::Callback(...))`
204 /// asks the compressor to run custom deferred work. Deferred callbacks must return a
205 /// [`crate::estimate::EstimateVerdict`] directly, never another deferred request.
206 ///
207 /// Note that the compressor will also use this method when compressing samples, so some
208 /// statistics that might hold for the samples may not hold for the entire array (e.g.,
209 /// constancy). Implementations should check `ctx.is_sample` to make sure that they are
210 /// returning the correct information.
211 ///
212 /// The compressor guarantees that empty and all-null arrays are handled before this method is
213 /// called, so implementations may assume the array has at least one valid element. Outside of
214 /// sample compression, the compressor also encodes constant arrays itself before evaluating
215 /// schemes, so implementations only see constant arrays when `ctx.is_sample()` is `true`.
216 fn expected_compression_ratio(
217 &self,
218 _data: &ArrayAndStats,
219 _compress_ctx: CompressorContext,
220 _exec_ctx: &mut ExecutionCtx,
221 ) -> CompressionEstimate;
222
223 /// Compress the array using this scheme.
224 ///
225 /// # Errors
226 ///
227 /// Returns an error if compression fails.
228 fn compress(
229 &self,
230 compressor: &CascadingCompressor,
231 data: &ArrayAndStats,
232 compress_ctx: CompressorContext,
233 exec_ctx: &mut ExecutionCtx,
234 ) -> VortexResult<ArrayRef>;
235}
236
237impl PartialEq for dyn Scheme {
238 fn eq(&self, other: &Self) -> bool {
239 self.id() == other.id()
240 }
241}
242
243impl Eq for dyn Scheme {}
244
245impl Hash for dyn Scheme {
246 fn hash<H: Hasher>(&self, state: &mut H) {
247 self.id().hash(state);
248 }
249}
250
251/// Extension trait providing [`id`](SchemeExt::id) for all [`Scheme`] implementors.
252///
253/// This trait is automatically implemented for every type that implements [`Scheme`]. Because the
254/// blanket implementation covers all types, external crates cannot override `id()`.
255pub trait SchemeExt: Scheme {
256 /// Unique identifier derived from [`scheme_name`](Scheme::scheme_name).
257 fn id(&self) -> SchemeId {
258 SchemeId {
259 name: self.scheme_name(),
260 }
261 }
262}
263
264impl<T: Scheme + ?Sized> SchemeExt for T {}