Skip to main content

chronix_encoding/
adaptive.rs

1//! Adaptive encoding selection — samples data to choose the optimal encoding.
2//!
3//! [`AdaptiveSelector`] examines the first N values (default: 1024) of a
4//! column to estimate which encoder will achieve the best compression ratio,
5//! then encodes the full column with that encoder.
6//!
7//! ## Decision matrix
8//!
9//! | Pattern                  | Selected encoding              |
10//! |--------------------------|--------------------------------|
11//! | All values identical     | RLE                            |
12//! | Periodic floats          | Chimp128                       |
13//! | Regular intervals (i64)  | Delta-of-delta                 |
14//! | Narrow-range integers    | Frame-of-Reference (FOR)       |
15//! | Slowly varying floats    | Chimp128 / Chimp               |
16//! | Random floats            | Gorilla → Plain fallback       |
17//! | Low-cardinality strings  | Dictionary                     |
18//! | High-cardinality strings | Plain                          |
19//! | Booleans                 | Bitmap (always)                |
20
21use std::collections::HashSet;
22
23use crate::error::Result;
24use crate::unified::{ColumnEncoder, EncodedBlock};
25
26/// Default sample size for adaptive selection.
27const DEFAULT_SAMPLE_SIZE: usize = 1024;
28
29/// Number of strata for stratified sampling.
30const NUM_STRATA: usize = 4;
31
32/// Adaptive encoding selector.
33///
34/// Uses stratified sampling: divides the column into `NUM_STRATA`
35/// equal segments and samples `sample_size / NUM_STRATA` values from the
36/// start of each segment. This captures data characteristics across the
37/// entire column, not just the leading values.
38#[derive(Debug, Clone)]
39pub struct AdaptiveSelector {
40    /// Total number of values to sample (spread across strata).
41    pub sample_size: usize,
42}
43
44impl Default for AdaptiveSelector {
45    fn default() -> Self {
46        Self {
47            sample_size: DEFAULT_SAMPLE_SIZE,
48        }
49    }
50}
51
52/// Result of analyzing a float sample.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum FloatPattern {
55    /// All values are identical — constant column.
56    Constant,
57    /// Highly periodic data with repeating values — Chimp128 excels.
58    Periodic,
59    /// Values change slowly (small XOR leading zeros) — Chimp excels.
60    SlowlyVarying,
61    /// Values are mostly random — Gorilla or Plain.
62    Random,
63}
64
65/// Result of analyzing a string sample.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum StringPattern {
68    /// Few distinct values — dictionary encoding.
69    LowCardinality,
70    /// Many distinct values — plain encoding.
71    HighCardinality,
72}
73
74/// Result of analyzing an integer sample.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum IntegerPattern {
77    /// All values identical.
78    Constant,
79    /// Regular intervals — ideal for delta-of-delta.
80    RegularInterval,
81    /// Values cluster in a narrow range — ideal for FOR encoding.
82    NarrowRange,
83    /// Irregular — delta with zigzag.
84    Irregular,
85}
86
87impl AdaptiveSelector {
88    /// Create a new adaptive selector with the default sample size.
89    #[must_use]
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Stratified-sample indices: divide `len` into [`NUM_STRATA`]
95    /// segments and take up to `per_stratum` contiguous values from the
96    /// start of each segment.
97    fn stratified_sample<T: Copy>(values: &[T], budget: usize) -> Vec<T> {
98        let n = values.len();
99        if n <= budget {
100            return values.to_vec();
101        }
102        let per_stratum = budget / NUM_STRATA;
103        let stride = n / NUM_STRATA;
104        let mut out = Vec::with_capacity(budget);
105        for s in 0..NUM_STRATA {
106            let start = s * stride;
107            let end = (start + per_stratum).min(n);
108            out.extend_from_slice(&values[start..end]);
109        }
110        out
111    }
112
113    /// Analyze a float column sample and determine the best encoding.
114    #[must_use]
115    pub fn analyze_floats(&self, values: &[f64]) -> FloatPattern {
116        if values.is_empty() {
117            return FloatPattern::Random;
118        }
119
120        let sample = Self::stratified_sample(values, self.sample_size);
121
122        // Check for constant
123        let first = sample[0].to_bits();
124        if sample.iter().all(|v| v.to_bits() == first) {
125            return FloatPattern::Constant;
126        }
127
128        // Measure XOR leading zeros — high average means slowly varying
129        let mut total_leading: u64 = 0;
130        let mut prev = first;
131        for &v in &sample[1..] {
132            let bits = v.to_bits();
133            let xor = prev ^ bits;
134            total_leading += if xor == 0 {
135                64
136            } else {
137                xor.leading_zeros() as u64
138            };
139            prev = bits;
140        }
141
142        #[allow(clippy::cast_precision_loss)]
143        let avg_leading = total_leading as f64 / (sample.len() - 1) as f64;
144
145        // Detect periodic data: if >30% of values in the sample appear
146        // among the previous 128 values, the ring-buffer optimisation
147        // in Chimp128 will find near-exact matches.
148        if sample.len() > 128 {
149            let mut hits: usize = 0;
150            for i in 128..sample.len() {
151                let bits = sample[i].to_bits();
152                let window = &sample[i.saturating_sub(128)..i];
153                if window.iter().any(|v| v.to_bits() == bits) {
154                    hits += 1;
155                }
156            }
157            #[allow(clippy::cast_precision_loss)]
158            let hit_rate = hits as f64 / (sample.len() - 128) as f64;
159            if hit_rate > 0.3 {
160                return FloatPattern::Periodic;
161            }
162        }
163
164        if avg_leading >= 16.0 {
165            FloatPattern::SlowlyVarying
166        } else {
167            FloatPattern::Random
168        }
169    }
170
171    /// Analyze a string column sample and determine the best encoding.
172    #[must_use]
173    pub fn analyze_strings(&self, values: &[&str]) -> StringPattern {
174        if values.is_empty() {
175            return StringPattern::HighCardinality;
176        }
177
178        let sample = Self::stratified_sample(values, self.sample_size);
179        let distinct: HashSet<&str> = sample.iter().copied().collect();
180
181        // Low cardinality: fewer unique values than 10% of sample size
182        if distinct.len() <= (sample.len() / 10).max(1) {
183            StringPattern::LowCardinality
184        } else {
185            StringPattern::HighCardinality
186        }
187    }
188
189    /// Analyze an integer column sample and determine the best encoding.
190    #[must_use]
191    pub fn analyze_integers(&self, values: &[i64]) -> IntegerPattern {
192        if values.is_empty() {
193            return IntegerPattern::Irregular;
194        }
195
196        let sample = Self::stratified_sample(values, self.sample_size);
197
198        // Check for constant
199        let first = sample[0];
200        if sample.iter().all(|&v| v == first) {
201            return IntegerPattern::Constant;
202        }
203
204        // Check for regular interval (use wrapping subtraction to avoid panic)
205        if sample.len() >= 3 {
206            let interval = sample[1].wrapping_sub(sample[0]);
207            let is_regular = sample
208                .windows(2)
209                .all(|w| w[1].wrapping_sub(w[0]) == interval);
210            if is_regular {
211                return IntegerPattern::RegularInterval;
212            }
213        }
214
215        // Check for narrow range: if the value range fits in ≤16 bits
216        // (65 535), FOR encoding will pack at most 16 bits per value
217        // vs 64 bits plain — a guaranteed ≥4× win.  This heuristic is
218        // cheap (one pass to find min/max of the sample, which we already
219        // have), and conservatively covers the sweet spot for FOR.
220        let min = sample.iter().copied().min().unwrap_or(0);
221        let max = sample.iter().copied().max().unwrap_or(0);
222        let range = (max as u128).wrapping_sub(min as u128);
223        if range <= u128::from(u16::MAX) {
224            return IntegerPattern::NarrowRange;
225        }
226
227        IntegerPattern::Irregular
228    }
229
230    /// Encode a float column with adaptive selection.
231    ///
232    /// Uses sample-based trial encoding: trial-encodes only a
233    /// stratified sample with each candidate encoder to pick the winner,
234    /// then encodes the full column exactly once with the best encoder.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if encoding fails or the input is empty.
239    pub fn encode_f64_adaptive(&self, values: &[f64]) -> Result<EncodedBlock> {
240        use crate::alp::AlpEncoder;
241        use crate::chimp::{Chimp128Encoder, ChimpEncoder};
242        use crate::gorilla::GorillaEncoder;
243        use crate::patas::PatasEncoder;
244        use crate::plain::PlainEncoder;
245        use crate::unified::{EncodingType, should_use_specialized};
246
247        let pattern = self.analyze_floats(values);
248
249        // For deterministic patterns, skip trial encoding entirely.
250        // Constant data always compresses best with RLE; Random has only one
251        // candidate (Gorilla), so trial-encoding is redundant.
252        if matches!(pattern, FloatPattern::Constant) {
253            return self.encode_f64_direct(values, pattern);
254        }
255
256        // For small columns, skip sampling and encode directly.
257        if values.len() <= self.sample_size {
258            return self.encode_f64_direct(values, pattern);
259        }
260
261        // Trial-encode only the sample to pick the winner.
262        let sample = Self::stratified_sample(values, self.sample_size);
263        let sample_raw_size = sample.len() * 8;
264
265        // Build candidate list ordered by expected quality for this pattern.
266        let candidates: &[EncodingType] = match pattern {
267            FloatPattern::Constant => &[
268                EncodingType::Rle,
269                EncodingType::Chimp,
270                EncodingType::Gorilla,
271            ],
272            // pco leads every non-constant list, ALP second: both recover
273            // the decimal most metric data started life as, and pco then
274            // entropy-codes the deltas, which is 3–20× smaller again on the
275            // measured workloads (`tests/codec_field_check.rs`). A candidate
276            // that loses the trial costs one sample encode and nothing else.
277            FloatPattern::Periodic => &[
278                EncodingType::Pco,
279                EncodingType::Alp,
280                EncodingType::Chimp128,
281                EncodingType::Patas,
282                EncodingType::Chimp,
283                EncodingType::Gorilla,
284            ],
285            FloatPattern::SlowlyVarying => &[
286                EncodingType::Pco,
287                EncodingType::Alp,
288                EncodingType::Patas,
289                EncodingType::Chimp128,
290                EncodingType::Chimp,
291                EncodingType::Gorilla,
292            ],
293            FloatPattern::Random => &[
294                EncodingType::Pco,
295                EncodingType::Alp,
296                EncodingType::Gorilla,
297                EncodingType::Patas,
298            ],
299        };
300
301        // Trial-encode the sample with each candidate; pick the smallest
302        // that passes the compression ratio threshold.
303        let mut best: Option<(EncodingType, usize)> = None;
304        let mut best_any: Option<(EncodingType, usize)> = None;
305        for &enc in candidates {
306            let trial = match enc {
307                EncodingType::Rle => crate::rle::RleEncoder::encode_f64(&sample),
308                EncodingType::Pco => crate::pco::PcoEncoder::encode_f64(&sample),
309                EncodingType::Alp => AlpEncoder::encode(&sample),
310                EncodingType::Chimp128 => Chimp128Encoder::encode(&sample),
311                EncodingType::Patas => PatasEncoder::encode(&sample),
312                EncodingType::Chimp => ChimpEncoder::encode(&sample),
313                EncodingType::Gorilla => GorillaEncoder::encode(&sample),
314                _ => continue,
315            };
316            if let Ok(payload) = trial {
317                let len = payload.len();
318                // Track smallest candidate regardless of threshold
319                if best_any.as_ref().is_none_or(|(_, bl)| len < *bl) {
320                    best_any = Some((enc, len));
321                }
322                if should_use_specialized(&payload, sample_raw_size)
323                    && best.as_ref().is_none_or(|(_, best_len)| len < *best_len)
324                {
325                    best = Some((enc, len));
326                }
327            }
328        }
329
330        // If no candidate passed the sample threshold, fall back to the
331        // best-any candidate so pattern-specific encoders (e.g. Chimp128
332        // for Periodic) still get tried on the full data where they often
333        // achieve the required compression ratio.
334        let winner = best
335            .or(best_any)
336            .map_or(EncodingType::PlainF64, |(enc, _)| enc);
337        let payload = match winner {
338            EncodingType::Rle => crate::rle::RleEncoder::encode_f64(values)?,
339            EncodingType::Pco => crate::pco::PcoEncoder::encode_f64(values)?,
340            EncodingType::Alp => AlpEncoder::encode(values)?,
341            EncodingType::Chimp128 => Chimp128Encoder::encode(values)?,
342            EncodingType::Patas => PatasEncoder::encode(values)?,
343            EncodingType::Chimp => ChimpEncoder::encode(values)?,
344            EncodingType::Gorilla => GorillaEncoder::encode(values)?,
345            _ => PlainEncoder::encode_f64(values)?,
346        };
347
348        // Verify the winner still beats plain on the full data; if not, fall
349        // back to plain. This guards against sample-vs-full divergence.
350        let full_raw_size = values.len() * 8;
351        if winner != EncodingType::PlainF64 && !should_use_specialized(&payload, full_raw_size) {
352            return Ok(EncodedBlock {
353                encoding: EncodingType::PlainF64,
354                payload: PlainEncoder::encode_f64(values)?,
355            });
356        }
357
358        Ok(EncodedBlock {
359            encoding: winner,
360            payload,
361        })
362    }
363
364    /// Direct encode without sample-based selection (for small columns).
365    fn encode_f64_direct(&self, values: &[f64], pattern: FloatPattern) -> Result<EncodedBlock> {
366        use crate::alp::AlpEncoder;
367        use crate::chimp::{Chimp128Encoder, ChimpEncoder};
368        use crate::gorilla::GorillaEncoder;
369        use crate::patas::PatasEncoder;
370        use crate::plain::PlainEncoder;
371        use crate::unified::{EncodedBlock, EncodingType, should_use_specialized};
372
373        let raw_size = values.len() * 8;
374        match pattern {
375            FloatPattern::Constant => {
376                let rle_payload = crate::rle::RleEncoder::encode_f64(values)?;
377                if should_use_specialized(&rle_payload, raw_size) {
378                    return Ok(EncodedBlock {
379                        encoding: EncodingType::Rle,
380                        payload: rle_payload,
381                    });
382                }
383            }
384            FloatPattern::Periodic | FloatPattern::SlowlyVarying => {
385                let pco_payload = crate::pco::PcoEncoder::encode_f64(values)?;
386                if should_use_specialized(&pco_payload, raw_size) {
387                    return Ok(EncodedBlock {
388                        encoding: EncodingType::Pco,
389                        payload: pco_payload,
390                    });
391                }
392                let chimp128_payload = Chimp128Encoder::encode(values)?;
393                if should_use_specialized(&chimp128_payload, raw_size) {
394                    return Ok(EncodedBlock {
395                        encoding: EncodingType::Chimp128,
396                        payload: chimp128_payload,
397                    });
398                }
399                // Try Patas as a byte-aligned alternative
400                let patas_payload = PatasEncoder::encode(values)?;
401                if should_use_specialized(&patas_payload, raw_size) {
402                    return Ok(EncodedBlock {
403                        encoding: EncodingType::Patas,
404                        payload: patas_payload,
405                    });
406                }
407            }
408            FloatPattern::Random => {}
409        }
410
411        // Common fallback chain: pco → ALP → Chimp → Patas → Gorilla → Plain,
412        // in the order the sampled candidate lists above use.
413        let pco_payload = crate::pco::PcoEncoder::encode_f64(values)?;
414        if should_use_specialized(&pco_payload, raw_size) {
415            return Ok(EncodedBlock {
416                encoding: EncodingType::Pco,
417                payload: pco_payload,
418            });
419        }
420        let alp_payload = AlpEncoder::encode(values)?;
421        if should_use_specialized(&alp_payload, raw_size) {
422            return Ok(EncodedBlock {
423                encoding: EncodingType::Alp,
424                payload: alp_payload,
425            });
426        }
427        let chimp_payload = ChimpEncoder::encode(values)?;
428        if should_use_specialized(&chimp_payload, raw_size) {
429            return Ok(EncodedBlock {
430                encoding: EncodingType::Chimp,
431                payload: chimp_payload,
432            });
433        }
434        let patas_payload = PatasEncoder::encode(values)?;
435        if should_use_specialized(&patas_payload, raw_size) {
436            return Ok(EncodedBlock {
437                encoding: EncodingType::Patas,
438                payload: patas_payload,
439            });
440        }
441        let gorilla_payload = GorillaEncoder::encode(values)?;
442        if should_use_specialized(&gorilla_payload, raw_size) {
443            return Ok(EncodedBlock {
444                encoding: EncodingType::Gorilla,
445                payload: gorilla_payload,
446            });
447        }
448        Ok(EncodedBlock {
449            encoding: EncodingType::PlainF64,
450            payload: PlainEncoder::encode_f64(values)?,
451        })
452    }
453
454    /// Encode a string column with adaptive selection.
455    ///
456    /// # Errors
457    ///
458    /// Returns an error if encoding fails or the input is empty.
459    pub fn encode_string_adaptive(&self, values: &[&str]) -> Result<EncodedBlock> {
460        // The unified encoder already handles this, but we can shortcut
461        // if we know it's high cardinality
462        let pattern = self.analyze_strings(values);
463        match pattern {
464            StringPattern::LowCardinality => ColumnEncoder::encode_string(values),
465            StringPattern::HighCardinality => {
466                // Try dictionary anyway (unified encoder checks ratio)
467                ColumnEncoder::encode_string(values)
468            }
469        }
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use crate::unified::EncodingType;
477
478    #[test]
479    fn detects_constant_floats() {
480        let selector = AdaptiveSelector::new();
481        let values = vec![42.0_f64; 500];
482        assert_eq!(selector.analyze_floats(&values), FloatPattern::Constant);
483    }
484
485    #[test]
486    fn detects_slowly_varying_floats() {
487        let selector = AdaptiveSelector::new();
488        let values: Vec<f64> = (0..500).map(|i| 50.0 + (i as f64) * 0.01).collect();
489        assert_eq!(
490            selector.analyze_floats(&values),
491            FloatPattern::SlowlyVarying
492        );
493    }
494
495    #[test]
496    fn detects_random_floats() {
497        let selector = AdaptiveSelector::new();
498        // Highly varied values: sin(large) produces pseudo-random XOR patterns
499        let values: Vec<f64> = (0..500)
500            .map(|i| (i as f64 * 123.456).sin() * 1e15)
501            .collect();
502        assert_eq!(selector.analyze_floats(&values), FloatPattern::Random);
503    }
504
505    #[test]
506    fn encode_f64_adaptive_selects_pco_for_slowly_varying_decimals() {
507        let selector = AdaptiveSelector::new();
508        // Two-decimal values — what a meter or sensor actually emits, and
509        // what ALP reconstructs as scaled integers.
510        let values: Vec<f64> = (0..500).map(|i| 50.0 + (i as f64) * 0.01).collect();
511        let block = selector.encode_f64_adaptive(&values).unwrap();
512        assert_eq!(
513            block.encoding,
514            EncodingType::Pco,
515            "decimal data should select pco over ALP and the XOR codecs, got {}",
516            block.encoding,
517        );
518        // Verify correctness regardless of which encoding was chosen
519        let decoded = crate::unified::ColumnDecoder::decode(&block).unwrap();
520        if let crate::unified::DecodedColumn::F64(vals) = decoded {
521            assert_eq!(vals.len(), values.len());
522            for (a, b) in values.iter().zip(vals.iter()) {
523                assert_eq!(a.to_bits(), b.to_bits());
524            }
525        } else {
526            panic!("Expected F64 decoded column");
527        }
528    }
529
530    #[test]
531    fn detects_low_cardinality_strings() {
532        let selector = AdaptiveSelector::new();
533        let values = vec!["us-east-1"; 100];
534        assert_eq!(
535            selector.analyze_strings(&values),
536            StringPattern::LowCardinality
537        );
538    }
539
540    #[test]
541    fn detects_high_cardinality_strings() {
542        let selector = AdaptiveSelector::new();
543        let owned: Vec<String> = (0..100).map(|i| format!("uuid-{i}")).collect();
544        let refs: Vec<&str> = owned.iter().map(String::as_str).collect();
545        assert_eq!(
546            selector.analyze_strings(&refs),
547            StringPattern::HighCardinality
548        );
549    }
550
551    #[test]
552    fn detects_constant_integers() {
553        let selector = AdaptiveSelector::new();
554        let values = vec![42_i64; 500];
555        assert_eq!(selector.analyze_integers(&values), IntegerPattern::Constant);
556    }
557
558    #[test]
559    fn detects_regular_interval_integers() {
560        let selector = AdaptiveSelector::new();
561        let values: Vec<i64> = (0..500).map(|i| 1000 + i * 10).collect();
562        assert_eq!(
563            selector.analyze_integers(&values),
564            IntegerPattern::RegularInterval
565        );
566    }
567
568    #[test]
569    fn detects_irregular_integers() {
570        let selector = AdaptiveSelector::new();
571        // Range > 65535 so NarrowRange does not trigger.
572        let values = vec![1, 100, 3, 999, 42, 7, 88, 100_000];
573        assert_eq!(
574            selector.analyze_integers(&values),
575            IntegerPattern::Irregular
576        );
577    }
578
579    #[test]
580    fn detects_narrow_range_integers() {
581        let selector = AdaptiveSelector::new();
582        // HTTP status codes: values clustered in range [200, 503] → NarrowRange.
583        let values = vec![200, 201, 204, 301, 302, 400, 404, 500, 502, 503];
584        assert_eq!(
585            selector.analyze_integers(&values),
586            IntegerPattern::NarrowRange
587        );
588    }
589
590    #[test]
591    fn periodic_data_dispatches_pco() {
592        let selector = AdaptiveSelector::new();
593        // Periodic sensor data: 64 distinct values cycling many times (>1024 to
594        // exercise the sample path, and >30% ring-buffer hits).
595        let base: Vec<f64> = (0..64).map(|i| 20.0 + (i as f64) * 0.5).collect();
596        let values: Vec<f64> = base.iter().cycle().take(5000).copied().collect();
597
598        assert_eq!(selector.analyze_floats(&values), FloatPattern::Periodic);
599
600        let block = selector.encode_f64_adaptive(&values).unwrap();
601        assert_eq!(
602            block.encoding,
603            EncodingType::Pco,
604            "Periodic data should use pco, got {}",
605            block.encoding,
606        );
607
608        // Verify lossless round-trip.
609        let decoded = crate::unified::ColumnDecoder::decode(&block).unwrap();
610        if let crate::unified::DecodedColumn::F64(vals) = decoded {
611            assert_eq!(vals.len(), values.len());
612            for (a, b) in values.iter().zip(vals.iter()) {
613                assert_eq!(a.to_bits(), b.to_bits());
614            }
615        } else {
616            panic!("Expected F64 decoded column");
617        }
618    }
619
620    #[test]
621    fn slowly_varying_picks_a_specialised_float_codec() {
622        let selector = AdaptiveSelector::new();
623        // Slowly varying data. Whichever candidate wins the trial, the
624        // selector must not fall back to plain on data this compressible.
625        let values: Vec<f64> = (0..5000).map(|i| 50.0 + (i as f64) * 0.001).collect();
626
627        let block = selector.encode_f64_adaptive(&values).unwrap();
628        assert!(
629            block.encoding == EncodingType::Pco
630                || block.encoding == EncodingType::Alp
631                || block.encoding == EncodingType::Chimp128
632                || block.encoding == EncodingType::Chimp
633                || block.encoding == EncodingType::Gorilla,
634            "Unexpected encoding: {}",
635            block.encoding,
636        );
637
638        // Verify correctness regardless of chosen encoding.
639        let decoded = crate::unified::ColumnDecoder::decode(&block).unwrap();
640        if let crate::unified::DecodedColumn::F64(vals) = decoded {
641            assert_eq!(vals.len(), values.len());
642        } else {
643            panic!("Expected F64 decoded column");
644        }
645    }
646}
647
648#[cfg(test)]
649mod proptests {
650    use super::*;
651    use crate::unified::{ColumnDecoder, DecodedColumn};
652    use proptest::prelude::*;
653
654    proptest! {
655        #[test]
656        fn roundtrip_f64_adaptive(values in proptest::collection::vec(any::<f64>(), 50..300)) {
657            let selector = AdaptiveSelector::new();
658            let block = selector.encode_f64_adaptive(&values).unwrap();
659            let decoded = ColumnDecoder::decode(&block).unwrap();
660            match decoded {
661                DecodedColumn::F64(vals) => {
662                    prop_assert_eq!(values.len(), vals.len());
663                    for (a, b) in values.iter().zip(vals.iter()) {
664                        prop_assert_eq!(a.to_bits(), b.to_bits());
665                    }
666                }
667                other => prop_assert!(false, "Expected F64, got {other:?}"),
668            }
669        }
670
671        #[test]
672        fn roundtrip_string_adaptive(values in proptest::collection::vec("[a-z]{0,8}", 1..100)) {
673            let selector = AdaptiveSelector::new();
674            let refs: Vec<&str> = values.iter().map(String::as_str).collect();
675            let block = selector.encode_string_adaptive(&refs).unwrap();
676            let decoded = ColumnDecoder::decode(&block).unwrap();
677            match decoded {
678                DecodedColumn::String(vals) => {
679                    prop_assert_eq!(&values, &vals);
680                }
681                other => prop_assert!(false, "Expected String, got {other:?}"),
682            }
683        }
684    }
685}