Skip to content

Commit d7a3b3a

Browse files
committed
Replace EstimateFn's ratio threshold with the SkipThreshold handle
BREAKING (changelog/break): the EstimateFn callback signature changes — the Option<EstimateScore> best-so-far parameter becomes a SkipThreshold handle owned by the compressor. Migration is mechanical: replace best_so_far.and_then(EstimateScore::finite_ratio) + 'max_ratio <= t' with threshold.best_case_ratio_cannot_win(max_ratio); the handle also exposes the old ratio view via best_ratio(). The handle wraps the best cost so far, the compressor's cost model, and the selection-site facts. best_case_ratio_cannot_win prices the calling scheme's hypothetical best-case candidate under the model and compares it against the best cost, so scheme-side early exits now speak cost rather than hardcoding ratio comparisons. Under the default SizeCost it reduces to exactly the historical 'max_ratio <= best_ratio' skip (pinned by unit tests across tie and one-ulp boundaries, and through the compressor-built handle), so behavior is unchanged: zero golden snapshot churn across all three variants. Delta and Sequence — the two callback users — migrate to the handle with identical max-ratio expressions (full_width * DELTA_PENALTY and len / 2), and gain end-to-end selection tests on both sides of their thresholds: winning over a low incumbent and losing at the exact best-case tie, plus Delta's min_ratio skip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
1 parent f19c5e7 commit d7a3b3a

5 files changed

Lines changed: 483 additions & 46 deletions

File tree

vortex-btrblocks/src/schemes/integer/delta.rs

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ use vortex_compressor::builtins::IntDictScheme;
1414
use vortex_compressor::builtins::StringDictScheme;
1515
use vortex_compressor::estimate::CompressionEstimate;
1616
use vortex_compressor::estimate::DeferredEstimate;
17-
use vortex_compressor::estimate::EstimateScore;
1817
use vortex_compressor::estimate::EstimateVerdict;
1918
use vortex_compressor::scheme::AncestorExclusion;
2019
use vortex_compressor::scheme::ChildSelection;
@@ -133,14 +132,13 @@ impl Scheme for DeltaScheme {
133132
// delta-encodes the array and measures the residual range.
134133
let min_ratio = self.min_ratio;
135134
CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new(
136-
move |_compressor, data, best_so_far, _ctx, exec_ctx| {
135+
move |_compressor, data, threshold, _ctx, exec_ctx| {
137136
let primitive = data.array().clone().execute::<PrimitiveArray>(exec_ctx)?;
138137
let full_width = primitive.ptype().bit_width() as f64;
139138

140139
// Delta's best case is residuals collapsing to a single bit. If even that, after
141140
// the penalty, can't beat the incumbent, skip before doing the encode work.
142-
let threshold = best_so_far.and_then(EstimateScore::finite_ratio);
143-
if threshold.is_some_and(|t| full_width * DELTA_PENALTY <= t) {
141+
if threshold.best_case_ratio_cannot_win(full_width * DELTA_PENALTY) {
144142
return Ok(EstimateVerdict::Skip);
145143
}
146144

@@ -197,3 +195,130 @@ impl Scheme for DeltaScheme {
197195
Delta::try_new(compressed_bases, compressed_deltas, 0, len).map(IntoArray::into_array)
198196
}
199197
}
198+
199+
#[cfg(test)]
200+
mod tests {
201+
use std::sync::LazyLock;
202+
203+
use rand::RngExt;
204+
use rand::SeedableRng;
205+
use rand::rngs::StdRng;
206+
use vortex_array::IntoArray;
207+
use vortex_array::VortexSessionExecute;
208+
use vortex_array::arrays::Constant;
209+
use vortex_array::arrays::ConstantArray;
210+
use vortex_array::dtype::Nullability;
211+
use vortex_array::scalar::Scalar;
212+
use vortex_array::validity::Validity;
213+
use vortex_buffer::Buffer;
214+
use vortex_session::VortexSession;
215+
216+
use super::*;
217+
218+
static SESSION: LazyLock<VortexSession> = LazyLock::new(vortex_array::array_session);
219+
220+
/// Immediate fixed-ratio competitor; its `compress` emits a tiny constant array so the
221+
/// winner is observable from the output encoding.
222+
#[derive(Debug)]
223+
struct FixedRatioScheme {
224+
ratio: f64,
225+
}
226+
227+
impl Scheme for FixedRatioScheme {
228+
fn scheme_name(&self) -> &'static str {
229+
"test.fixed_ratio"
230+
}
231+
232+
fn matches(&self, canonical: &Canonical) -> bool {
233+
canonical.dtype().is_int()
234+
}
235+
236+
fn expected_compression_ratio(
237+
&self,
238+
_data: &ArrayAndStats,
239+
_compress_ctx: CompressorContext,
240+
_exec_ctx: &mut ExecutionCtx,
241+
) -> CompressionEstimate {
242+
CompressionEstimate::Verdict(EstimateVerdict::Ratio(self.ratio))
243+
}
244+
245+
fn compress(
246+
&self,
247+
_compressor: &CascadingCompressor,
248+
data: &ArrayAndStats,
249+
_compress_ctx: CompressorContext,
250+
_exec_ctx: &mut ExecutionCtx,
251+
) -> VortexResult<ArrayRef> {
252+
Ok(ConstantArray::new(
253+
Scalar::primitive(0u64, Nullability::NonNullable),
254+
data.array_len(),
255+
)
256+
.into_array())
257+
}
258+
}
259+
260+
/// Near-monotone u64 data: Delta's residual span is a few bits, so its real penalized
261+
/// ratio is comfortably above 2.0 but nowhere near its 64-bit best case.
262+
fn monotone_jitter_u64() -> ArrayRef {
263+
let mut rng = StdRng::seed_from_u64(42);
264+
let mut value = 1_700_000_000_000u64;
265+
let values: Buffer<u64> = (0..4096)
266+
.map(|_| {
267+
value += 900 + rng.random_range(0..200);
268+
value
269+
})
270+
.collect();
271+
PrimitiveArray::new(values, Validity::NonNullable).into_array()
272+
}
273+
274+
/// With the incumbent below Delta's achievable ratio, the callback must proceed past
275+
/// the best-case threshold check and win with its measured estimate.
276+
#[test]
277+
fn delta_wins_over_low_threshold() -> VortexResult<()> {
278+
static COMPETITOR: FixedRatioScheme = FixedRatioScheme { ratio: 2.0 };
279+
static DELTA: DeltaScheme = DeltaScheme::new(1.25);
280+
let compressor = CascadingCompressor::new(vec![&COMPETITOR, &DELTA]);
281+
282+
let mut exec_ctx = SESSION.create_execution_ctx();
283+
let compressed = compressor.compress(&monotone_jitter_u64(), &mut exec_ctx)?;
284+
285+
assert!(compressed.is::<Delta>());
286+
Ok(())
287+
}
288+
289+
/// With the incumbent at exactly Delta's best case (`full_width * DELTA_PENALTY`, the
290+
/// same expression the callback computes), Delta must not be chosen — the same decision
291+
/// the pre-threshold-handle `max_ratio <= best` skip produced on this input.
292+
#[test]
293+
fn delta_loses_at_best_case_tie() -> VortexResult<()> {
294+
static COMPETITOR: FixedRatioScheme = FixedRatioScheme {
295+
ratio: 64.0 * DELTA_PENALTY,
296+
};
297+
static DELTA: DeltaScheme = DeltaScheme::new(1.25);
298+
let compressor = CascadingCompressor::new(vec![&COMPETITOR, &DELTA]);
299+
300+
let mut exec_ctx = SESSION.create_execution_ctx();
301+
let compressed = compressor.compress(&monotone_jitter_u64(), &mut exec_ctx)?;
302+
303+
assert!(compressed.is::<Constant>());
304+
Ok(())
305+
}
306+
307+
/// Wide random residuals put Delta's penalized ratio below its `min_ratio`, so with no
308+
/// competitor the array must stay canonical.
309+
#[test]
310+
fn delta_skips_below_min_ratio() -> VortexResult<()> {
311+
static DELTA: DeltaScheme = DeltaScheme::new(1.25);
312+
let compressor = CascadingCompressor::new(vec![&DELTA]);
313+
314+
let mut rng = StdRng::seed_from_u64(43);
315+
let values: Buffer<u64> = (0..4096).map(|_| rng.random::<u64>()).collect();
316+
let array = PrimitiveArray::new(values, Validity::NonNullable).into_array();
317+
318+
let mut exec_ctx = SESSION.create_execution_ctx();
319+
let compressed = compressor.compress(&array, &mut exec_ctx)?;
320+
321+
assert!(!compressed.is::<Delta>());
322+
Ok(())
323+
}
324+
}

vortex-btrblocks/src/schemes/integer/sequence.rs

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ use vortex_compressor::builtins::IntDictScheme;
1212
use vortex_compressor::builtins::StringDictScheme;
1313
use vortex_compressor::estimate::CompressionEstimate;
1414
use vortex_compressor::estimate::DeferredEstimate;
15-
use vortex_compressor::estimate::EstimateScore;
1615
use vortex_compressor::estimate::EstimateVerdict;
1716
use vortex_compressor::scheme::AncestorExclusion;
1817
use vortex_compressor::scheme::ChildSelection;
@@ -94,16 +93,15 @@ impl Scheme for SequenceScheme {
9493
// TODO(connor): `sequence_encode` allocates the encoded array just to confirm feasibility.
9594
// A cheaper `is_sequence` probe would let us skip the allocation entirely.
9695
CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new(
97-
|_compressor, data, best_so_far, _ctx, exec_ctx| {
96+
|_compressor, data, threshold, _ctx, exec_ctx| {
9897
// `SequenceArray` stores exactly two scalars (base and multiplier), so the best
9998
// achievable compression ratio is `array_len / 2`.
10099
let compressed_size = 2usize;
101100
let max_ratio = data.array_len() as f64 / compressed_size as f64;
102101

103102
// If we cannot beat the best so far, then we do not want to even try sequence
104103
// encoding the data.
105-
let threshold = best_so_far.and_then(EstimateScore::finite_ratio);
106-
if threshold.is_some_and(|t| max_ratio <= t) {
104+
if threshold.best_case_ratio_cannot_win(max_ratio) {
107105
return Ok(EstimateVerdict::Skip);
108106
}
109107

@@ -134,3 +132,106 @@ impl Scheme for SequenceScheme {
134132
.ok_or_else(|| vortex_err!("cannot sequence encode array"))
135133
}
136134
}
135+
136+
#[cfg(test)]
137+
mod tests {
138+
use std::sync::LazyLock;
139+
140+
use vortex_array::ExecutionCtx;
141+
use vortex_array::IntoArray;
142+
use vortex_array::VortexSessionExecute;
143+
use vortex_array::arrays::Constant;
144+
use vortex_array::arrays::ConstantArray;
145+
use vortex_array::arrays::PrimitiveArray;
146+
use vortex_array::dtype::Nullability;
147+
use vortex_array::scalar::Scalar;
148+
use vortex_array::validity::Validity;
149+
use vortex_buffer::Buffer;
150+
use vortex_compressor::estimate::EstimateVerdict;
151+
use vortex_sequence::Sequence;
152+
use vortex_session::VortexSession;
153+
154+
use super::*;
155+
156+
static SESSION: LazyLock<VortexSession> = LazyLock::new(vortex_array::array_session);
157+
158+
/// Number of values in the test arrays; Sequence's best case is `LEN / 2`.
159+
const LEN: usize = 2048;
160+
161+
/// Immediate fixed-ratio competitor; its `compress` emits a tiny constant array so the
162+
/// winner is observable from the output encoding.
163+
#[derive(Debug)]
164+
struct FixedRatioScheme {
165+
ratio: f64,
166+
}
167+
168+
impl Scheme for FixedRatioScheme {
169+
fn scheme_name(&self) -> &'static str {
170+
"test.fixed_ratio"
171+
}
172+
173+
fn matches(&self, canonical: &Canonical) -> bool {
174+
canonical.dtype().is_int()
175+
}
176+
177+
fn expected_compression_ratio(
178+
&self,
179+
_data: &ArrayAndStats,
180+
_compress_ctx: CompressorContext,
181+
_exec_ctx: &mut ExecutionCtx,
182+
) -> CompressionEstimate {
183+
CompressionEstimate::Verdict(EstimateVerdict::Ratio(self.ratio))
184+
}
185+
186+
fn compress(
187+
&self,
188+
_compressor: &CascadingCompressor,
189+
data: &ArrayAndStats,
190+
_compress_ctx: CompressorContext,
191+
_exec_ctx: &mut ExecutionCtx,
192+
) -> VortexResult<ArrayRef> {
193+
Ok(ConstantArray::new(
194+
Scalar::primitive(0i64, Nullability::NonNullable),
195+
data.array_len(),
196+
)
197+
.into_array())
198+
}
199+
}
200+
201+
/// An exact arithmetic sequence, encodable by [`SequenceScheme`].
202+
fn arithmetic_sequence() -> ArrayRef {
203+
let values: Buffer<i64> = (0..LEN as i64).map(|i| 10_000 + 7 * i).collect();
204+
PrimitiveArray::new(values, Validity::NonNullable).into_array()
205+
}
206+
207+
/// With the incumbent below Sequence's best case, the callback must proceed past the
208+
/// threshold check, trial-encode, and win.
209+
#[test]
210+
fn sequence_wins_over_low_threshold() -> VortexResult<()> {
211+
static COMPETITOR: FixedRatioScheme = FixedRatioScheme { ratio: 2.0 };
212+
let compressor = CascadingCompressor::new(vec![&COMPETITOR, &SequenceScheme]);
213+
214+
let mut exec_ctx = SESSION.create_execution_ctx();
215+
let compressed = compressor.compress(&arithmetic_sequence(), &mut exec_ctx)?;
216+
217+
assert!(compressed.is::<Sequence>());
218+
Ok(())
219+
}
220+
221+
/// With the incumbent at exactly Sequence's best case (`LEN / 2`), Sequence must not be
222+
/// chosen — the same decision the pre-threshold-handle `max_ratio <= best` skip produced
223+
/// on this input.
224+
#[test]
225+
fn sequence_loses_at_best_case_tie() -> VortexResult<()> {
226+
static COMPETITOR: FixedRatioScheme = FixedRatioScheme {
227+
ratio: LEN as f64 / 2.0,
228+
};
229+
let compressor = CascadingCompressor::new(vec![&COMPETITOR, &SequenceScheme]);
230+
231+
let mut exec_ctx = SESSION.create_execution_ctx();
232+
let compressed = compressor.compress(&arithmetic_sequence(), &mut exec_ctx)?;
233+
234+
assert!(compressed.is::<Constant>());
235+
Ok(())
236+
}
237+
}

0 commit comments

Comments
 (0)