rudb_encoding/chooser.rs
1//! How the encoder decides which candidate to keep.
2//!
3//! The encoders in [`crate::string`] and [`crate::integer`] are the work. This is the search. They
4//! are separate things and until now they were the same thing, because `encode` both offered every
5//! candidate and encoded every candidate it offered, and there was no way to have one without the
6//! other.
7//!
8//! # Why it is worth separating
9//!
10//! `cargo xtask encode` over a million rows of ClickBench `hits` says where the encoder's seconds
11//! go. String `FRONT` is 32.2 percent of them and is kept once in 142 chunks. String `FSST` is 11.2
12//! percent and is kept twice in 223. Integer `DELTA` is 10.8 percent and is kept never in 607.
13//! String `PLAIN` is 8.7 percent and is kept four times in 223. Those four are 62.9 percent of the
14//! encoder's time and they were kept seven times out of 1,195 offers.
15//!
16//! That is not a bug in any encoder. It is what an exhaustive search costs, and the search is worth
17//! something: the shapes it arrives at are five to one on `hits` and nobody wrote them down in
18//! advance. The question is how much of the search is needed, which is a question about the data
19//! and therefore a question to measure rather than argue about. F2 asks for exactly this, as "the
20//! encoder chooser as a seam, with exhaustive and sampled implementations", with the ablation being
21//! how much size the sampled one gives up.
22//!
23//! # What a chooser sees and what it does not
24//!
25//! A chooser is asked once per chunk per level of the cascade, never once per value. It is handed
26//! the values and the candidates that apply and it returns the ones worth encoding in full. It
27//! cannot invent a candidate that does not apply, so nothing it does can produce a chunk that will
28//! not decode, and the worst a bad chooser can do is pick a bigger encoding than another one would
29//! have. That is the property that makes this safe to swap.
30//!
31//! # Not a `rudb-seam` seam yet, and why
32//!
33//! `SeamId::StorageEncoder` exists and says "how a block of values is encoded on the way to disk",
34//! and this is what belongs behind it. It cannot be registered here: `rudb-seam` is rank 2 and so is
35//! this crate, so the `Strategy` supertrait every seam trait needs is not visible from here. The
36//! registry goes in `rudb-storage` at rank 5, next to the write path, and there is no write path
37//! yet. Until there is, this is a plain trait with two implementations and an ablation, which is
38//! the part that can be measured today.
39
40use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
41
42use crate::{integer, string};
43
44/// Which of the candidates that apply are worth encoding in full.
45///
46/// Crossed once per chunk per level of the cascade. No method here sees a single value on its own,
47/// which is the rule that lets the decision be indirect at all.
48pub trait Chooser: std::fmt::Debug + Sync {
49 /// The name that goes in a report.
50 fn name(&self) -> &'static str;
51
52 /// Which of `offered` to encode in full, for a chunk of strings at `depth`.
53 ///
54 /// `offered` is what applies, in the order the exhaustive chooser would try them. The return
55 /// has to be a subset of it and has to be non empty, because a chunk with no candidate is a
56 /// chunk that cannot be written.
57 fn narrow_strings(
58 &self,
59 values: &[&[u8]],
60 offered: &[string::Kind],
61 depth: u8,
62 ) -> Vec<string::Kind>;
63
64 /// Which of `offered` to encode in full, for a chunk of integers at `depth`.
65 fn narrow_integers(
66 &self,
67 values: &[i64],
68 offered: &[integer::Kind],
69 depth: u8,
70 ) -> Vec<integer::Kind>;
71
72 /// Whether `kind` can ever be in what [`Chooser::narrow_integers`] returns at `depth`.
73 ///
74 /// Asked before the candidates are worked out, so a kind this rules out is never tested for.
75 /// That matters because the test is not free: finding out whether a dictionary or a sparse
76 /// encoding applies used to sort a copy of the chunk, at every level of the cascade, for a
77 /// chooser that was going to throw both away. Saying yes to a kind that is then dropped only
78 /// costs the test. Saying no to a kind the narrowing would have kept changes what gets written,
79 /// so the default is yes and an implementation only says no where its narrowing always would.
80 fn considers_integer(&self, kind: integer::Kind, depth: u8) -> bool {
81 let _ = (kind, depth);
82 true
83 }
84}
85
86/// Encode every candidate that applies and keep the smallest.
87///
88/// The reference, and what `encode` has always done. It is the thing to beat rather than the thing
89/// to ship: every size this crate has ever reported came out of it, so an alternative's ablation is
90/// against this and a build that wants the old bytes exactly asks for this.
91#[derive(Debug, Clone, Copy, Default)]
92pub struct Exhaustive;
93
94/// The one of these that does not have to be constructed, since it holds nothing.
95pub const EXHAUSTIVE: Exhaustive = Exhaustive;
96
97impl Chooser for Exhaustive {
98 fn name(&self) -> &'static str {
99 "exhaustive"
100 }
101
102 fn narrow_strings(
103 &self,
104 _values: &[&[u8]],
105 offered: &[string::Kind],
106 _depth: u8,
107 ) -> Vec<string::Kind> {
108 offered.to_vec()
109 }
110
111 fn narrow_integers(
112 &self,
113 _values: &[i64],
114 offered: &[integer::Kind],
115 _depth: u8,
116 ) -> Vec<integer::Kind> {
117 offered.to_vec()
118 }
119}
120
121/// Encode every candidate on a sample, then encode only the winner on the whole chunk.
122///
123/// The bet is that a chunk of 122,880 values and a sample of 8,192 drawn from it agree about which
124/// encoding suits them, which is a bet about the data and is what the ablation settles. Where it is
125/// wrong the cost is size and never correctness, because the winner still has to apply to the whole
126/// chunk and is still encoded over all of it.
127///
128/// The sample is windows of consecutive values rather than values picked one at a time, because
129/// three of the candidates are about what a value has in common with the value before it. A sample
130/// of scattered singletons would show `FRONT` and `RLE` nothing to find and would rule them out on
131/// every column, which is the wrong answer arrived at quickly.
132///
133/// There are two guards on whether to sample at all and both of them are there because a measurement
134/// said so. A chunk with fewer values than the sample is not sampled, because encoding every
135/// candidate on something the size of the chunk and then encoding the winner on the chunk is more
136/// work than the exhaustive chooser for the same answer. A chunk holding less than a page of bytes is
137/// not sampled either, because the cost of the search scales with the bytes in the chunk and not
138/// with how many values they are spread over, so on a narrow column there is nothing to save and a
139/// sample that misses the structure gives up real size for it.
140#[derive(Debug, Clone, Copy)]
141pub struct Sampled {
142 window: usize,
143 regions: usize,
144}
145
146/// How many consecutive values one window of the sample holds.
147///
148/// The tile, which is what a bit packing kernel works in and is the smallest run of a column that
149/// has the column's local structure in it rather than one value's worth of accident.
150const WINDOW: usize = 1024;
151
152/// How many windows the sample is drawn from.
153///
154/// Eight windows of a tile each is 8,192 values, a fifteenth of a chunk. Spread across the chunk
155/// rather than taken off the front, because the front of a sorted column is one value repeated and
156/// a chooser that saw only that would pick `CONSTANT` for everything.
157const REGIONS: usize = 8;
158
159/// How few bytes a chunk can hold before sampling it is not worth the risk.
160///
161/// The ablation in #559 found `Params` at a million rows encoding to 21,782 bytes exhaustively and
162/// 128,455 bytes sampled, which is 490 percent for a column that is almost entirely empty strings.
163/// It passed the value count guard because it has a million values, and then the sample missed what
164/// little structure it had. The exhaustive search over a column that small costs almost nothing,
165/// which is the same fact from the other side, so a floor on bytes takes the whole class of column
166/// out of the sampler's hands and gives up nothing to do it.
167///
168/// 256 KiB is one page, which is the smallest unit the format moves. Below that the search is not
169/// where the time is.
170const FLOOR: usize = 256 * 1024;
171
172impl Default for Sampled {
173 fn default() -> Self {
174 Self { window: WINDOW, regions: REGIONS }
175 }
176}
177
178impl Sampled {
179 /// The default sample, which is eight windows of 1,024 values.
180 #[must_use]
181 pub fn new() -> Self {
182 Self::default()
183 }
184
185 /// A sample of a size somebody else picked, which is what the ablation sweeps.
186 #[must_use]
187 pub fn over(window: usize, regions: usize) -> Self {
188 Self { window: window.max(1), regions: regions.max(1) }
189 }
190
191 /// How many values the sample holds, which is one of the two things that decide whether
192 /// sampling is worth doing.
193 #[must_use]
194 pub fn size(self) -> usize {
195 self.window * self.regions
196 }
197
198 /// Whether a chunk of `count` values holding `bytes` bytes is worth sampling.
199 fn worth_it(self, count: usize, bytes: usize) -> bool {
200 count > self.size() && bytes >= FLOOR
201 }
202}
203
204impl Chooser for Sampled {
205 fn name(&self) -> &'static str {
206 "sampled"
207 }
208
209 fn narrow_strings(
210 &self,
211 values: &[&[u8]],
212 offered: &[string::Kind],
213 depth: u8,
214 ) -> Vec<string::Kind> {
215 let bytes = values.iter().map(|value| value.len()).sum();
216 if offered.len() < 2 || !self.worth_it(values.len(), bytes) {
217 return offered.to_vec();
218 }
219 let sample = sample(values, self.window, self.regions);
220 let mut best: Option<(string::Kind, usize)> = None;
221 for &kind in offered {
222 let Ok(Some(size)) = string::size_as(kind, &sample, depth) else {
223 continue;
224 };
225 if best.is_none_or(|(_, smallest)| size < smallest) {
226 best = Some((kind, size));
227 }
228 }
229 // Nothing applied to the sample, which should not happen and is not worth a wrong answer
230 // if it does. Hand back everything and let the exhaustive path sort it out.
231 best.map_or_else(|| offered.to_vec(), |(kind, _)| vec![kind])
232 }
233
234 fn narrow_integers(
235 &self,
236 values: &[i64],
237 offered: &[integer::Kind],
238 depth: u8,
239 ) -> Vec<integer::Kind> {
240 if offered.len() < 2 || !self.worth_it(values.len(), values.len() * 8) {
241 return offered.to_vec();
242 }
243 let sample = sample(values, self.window, self.regions);
244 let mut best: Option<(integer::Kind, usize)> = None;
245 for &kind in offered {
246 let Ok(Some(size)) = integer::size_as(kind, &sample, depth) else {
247 continue;
248 };
249 if best.is_none_or(|(_, smallest)| size < smallest) {
250 best = Some((kind, size));
251 }
252 }
253 best.map_or_else(|| offered.to_vec(), |(kind, _)| vec![kind])
254 }
255}
256
257/// Encode one shape that somebody else settled on, and do not search at all.
258///
259/// [`Sampled`] decides per chunk, which is right when a chunk is big enough to pay for the sample
260/// and when neighbouring chunks are different from each other. Neither holds for a caller that has
261/// thousands of small chunks cut out of one column, because the sample would cost as much as the
262/// encode and because the answer would come out the same thousands of times. Such a caller decides
263/// once, over as much of the column as it likes, and hands the answer here.
264///
265/// A shape is one kind per level of the cascade, which is a simplification of a real one: `FRONT`
266/// produces an integer chunk of prefixes and a string chunk of suffixes at the next level, and both
267/// are narrowed to the same entry. That is enough on real data because the tree is narrow and
268/// because the levels below the second are small. Any level the shape does not reach is searched
269/// exhaustively, which is what makes the shape a hint about the expensive part rather than a
270/// decision about all of it.
271///
272/// An entry that does not apply to a chunk is ignored and the chunk is searched instead. The kinds
273/// that apply are a property of the values, and this is a chooser rather than a way round the
274/// filter, so a shape can never produce something that will not decode.
275#[derive(Debug, Clone)]
276pub struct Settled {
277 strings: Vec<string::Kind>,
278 integers: Vec<integer::Kind>,
279}
280
281impl Settled {
282 /// A shape, outermost level first, for the string levels and the integer levels.
283 #[must_use]
284 pub fn new(strings: Vec<string::Kind>, integers: Vec<integer::Kind>) -> Self {
285 Self { strings, integers }
286 }
287
288 /// The string kinds of the shape, outermost first, which is what a report prints.
289 #[must_use]
290 pub fn strings(&self) -> &[string::Kind] {
291 &self.strings
292 }
293}
294
295impl Chooser for Settled {
296 fn name(&self) -> &'static str {
297 "settled"
298 }
299
300 fn narrow_strings(
301 &self,
302 _values: &[&[u8]],
303 offered: &[string::Kind],
304 depth: u8,
305 ) -> Vec<string::Kind> {
306 match self.strings.get(depth as usize) {
307 Some(kind) if offered.contains(kind) => vec![*kind],
308 _ => offered.to_vec(),
309 }
310 }
311
312 fn narrow_integers(
313 &self,
314 _values: &[i64],
315 offered: &[integer::Kind],
316 depth: u8,
317 ) -> Vec<integer::Kind> {
318 match self.integers.get(depth as usize) {
319 Some(kind) if offered.contains(kind) => vec![*kind],
320 _ => offered.to_vec(),
321 }
322 }
323}
324
325/// Encode an integer chunk the way an earlier one came out, and search only where it stops fitting.
326///
327/// [`Settled`] holds one kind per level, which is too coarse for a cascade that branches: an `RLE`
328/// wants its run values packed and its run lengths constant, and a shape of one kind per level
329/// cannot say both. This holds every level's kind in the order the encoder asks for them, which is
330/// what [`integer::shape`] reads back out of an encoded chunk, and hands them back one per question.
331///
332/// The first question whose answer is not among the kinds offered ends the replay, and from there
333/// on every question goes to `fallback`. A chunk only offers kinds that apply to it, so a shape
334/// that stops fitting costs a search and never a chunk that will not decode. The order of the
335/// questions is the order of the kinds only while every answer is a single kind, which is why the
336/// replay does not pick back up after a search.
337///
338/// A shape that still fits can still be the wrong one. Bit packing applies to everything, so a
339/// shape settled on a stretch of noise replays happily over a column that has since become one
340/// value with exceptions, at forty times the size. What does change when the column does is the set
341/// of kinds the top level offers, so a replay can be told the set its shape was searched under with
342/// [`Replay::expecting`], and searches from the top when the chunk offers anything else. The set is
343/// worked out for the chunk whatever the chooser, so the check costs nothing.
344///
345/// One of these is for one chunk. The position is kept in atomics because a chooser is shared
346/// between threads by contract, not because a chunk's encode is ever split between them.
347#[derive(Debug)]
348pub struct Replay<'a> {
349 kinds: &'a [integer::Kind],
350 next: AtomicUsize,
351 lost: AtomicBool,
352 /// The kinds the top level offered, one bit per tag, once it has been asked.
353 first: AtomicU8,
354 /// The set the top level has to offer for the replay to go ahead, when there is one.
355 expected: Option<u8>,
356 fallback: &'a dyn Chooser,
357}
358
359impl<'a> Replay<'a> {
360 /// A replay of `kinds`, with `fallback` answering once they stop fitting.
361 #[must_use]
362 pub fn new(kinds: &'a [integer::Kind], fallback: &'a dyn Chooser) -> Self {
363 Self {
364 kinds,
365 next: AtomicUsize::new(0),
366 lost: AtomicBool::new(false),
367 first: AtomicU8::new(0),
368 expected: None,
369 fallback,
370 }
371 }
372
373 /// The same replay, going ahead only on a chunk whose top level offers exactly `offered`.
374 #[must_use]
375 pub fn expecting(mut self, offered: &[integer::Kind]) -> Self {
376 self.expected = Some(bits(offered));
377 self
378 }
379
380 /// What the top level of the chunk offered, in tag order, or nothing before it was asked.
381 #[must_use]
382 pub fn first_offered(&self) -> Vec<integer::Kind> {
383 let first = self.first.load(Ordering::Relaxed);
384 integer::Kind::ALL.into_iter().filter(|kind| first & (1 << *kind as u8) != 0).collect()
385 }
386
387 /// Whether every question was answered from the shape, which is whether the chunk came out
388 /// the shape it was given.
389 #[must_use]
390 pub fn held(&self) -> bool {
391 !self.lost.load(Ordering::Relaxed) && self.next.load(Ordering::Relaxed) == self.kinds.len()
392 }
393}
394
395impl Chooser for Replay<'_> {
396 fn name(&self) -> &'static str {
397 "replay"
398 }
399
400 fn narrow_strings(
401 &self,
402 values: &[&[u8]],
403 offered: &[string::Kind],
404 depth: u8,
405 ) -> Vec<string::Kind> {
406 self.fallback.narrow_strings(values, offered, depth)
407 }
408
409 fn narrow_integers(
410 &self,
411 values: &[i64],
412 offered: &[integer::Kind],
413 depth: u8,
414 ) -> Vec<integer::Kind> {
415 if depth == 0 {
416 self.first.store(bits(offered), Ordering::Relaxed);
417 if self.expected.is_some_and(|expected| expected != bits(offered)) {
418 self.lost.store(true, Ordering::Relaxed);
419 }
420 }
421 if !self.lost.load(Ordering::Relaxed) {
422 let at = self.next.fetch_add(1, Ordering::Relaxed);
423 match self.kinds.get(at) {
424 Some(kind) if offered.contains(kind) => return vec![*kind],
425 _ => self.lost.store(true, Ordering::Relaxed),
426 }
427 }
428 self.fallback.narrow_integers(values, offered, depth)
429 }
430
431 fn considers_integer(&self, kind: integer::Kind, depth: u8) -> bool {
432 // Asked before the question the replay answers, so it has to say yes to the kind the shape
433 // is about to hand back as well as to anything the fallback might keep.
434 self.kinds.contains(&kind) || self.fallback.considers_integer(kind, depth)
435 }
436}
437
438/// A set of integer kinds as one bit per tag.
439fn bits(kinds: &[integer::Kind]) -> u8 {
440 kinds.iter().fold(0, |set, kind| set | 1 << *kind as u8)
441}
442
443/// `regions` windows of `window` consecutive values each, spread evenly across the input.
444///
445/// The starts are spread over the whole range a window can start at, so the first window begins at
446/// the first value and the last one ends at the last value. A chunk of 122,880 values sampled at
447/// eight windows of 1,024 gives windows starting at 0, 17,408, 34,816 and so on up to 121,856, which
448/// crosses every part of the chunk including both ends of it.
449///
450/// Spreading to the end rather than striding by `len / regions` matters on the columns this is for.
451/// A stride would leave the last stride minus one window of the chunk unsampled, and the tail of a
452/// chunk is exactly where a column that is sorted or clustered stops looking like its front.
453pub(crate) fn sample<T: Copy>(values: &[T], window: usize, regions: usize) -> Vec<T> {
454 let wanted = window * regions;
455 if values.len() <= wanted {
456 return values.to_vec();
457 }
458 let last = values.len() - window;
459 let mut out = Vec::with_capacity(wanted);
460 for region in 0..regions {
461 let from = if regions == 1 { 0 } else { region * last / (regions - 1) };
462 out.extend_from_slice(&values[from..from + window]);
463 }
464 out
465}
466
467#[cfg(test)]
468mod tests {
469 use super::{Chooser, EXHAUSTIVE, Replay, Sampled, sample};
470 use crate::{integer, string};
471
472 /// Columns of the shapes a writer meets: a climbing timestamp, runs, one value with exceptions,
473 /// a stride, noise, and a short tail.
474 fn shaped_columns() -> Vec<Vec<i64>> {
475 let mut state = 0x9e37_79b9_7f4a_7c15_u64;
476 let mut noise = || {
477 state ^= state << 13;
478 state ^= state >> 7;
479 state ^= state << 17;
480 (state % 1_000_000) as i64
481 };
482 vec![
483 (0..2048).map(|row| 1_600_000_000_000_000 + row * 1_000_000 + row % 7).collect(),
484 (0..2048).map(|row| row / 300).collect(),
485 (0..2048).map(|row| if row % 97 == 0 { row } else { 42 }).collect(),
486 (0..2048).map(|row| 5 + row * 1_000_000).collect(),
487 (0..2048).map(|_| noise()).collect(),
488 (0..37).map(|row| row * row).collect(),
489 ]
490 }
491
492 /// Replaying the shape a chunk came out as gives the same bytes, and asks no question the shape
493 /// did not answer, which is the whole of what a writer is relying on when it stops searching.
494 #[test]
495 fn a_chunk_replayed_through_its_own_shape_comes_out_the_same() {
496 for values in shaped_columns() {
497 let searched = integer::encode_with(&values, &EXHAUSTIVE).unwrap();
498 let kinds = integer::shape(&searched).unwrap();
499 let replay = Replay::new(&kinds, &EXHAUSTIVE);
500 let replayed = integer::encode_with(&values, &replay).unwrap();
501 assert_eq!(replayed, searched, "{}", integer::describe(&searched).unwrap());
502 assert!(replay.held(), "{}", integer::describe(&searched).unwrap());
503 }
504 }
505
506 /// A shape that fits but was searched under a different offer is not replayed. Bit packing
507 /// fits everything, so without the check a shape settled on noise would pack a column of one
508 /// value with exceptions, which the search writes in a fraction of the bytes.
509 #[test]
510 fn a_shape_searched_under_another_offer_searches_again() {
511 let columns = shaped_columns();
512 let (noise, sparse) = (&columns[4], &columns[2]);
513 let first = Replay::new(&[], &EXHAUSTIVE);
514 let searched = integer::encode_with(noise, &first).unwrap();
515 let kinds = integer::shape(&searched).unwrap();
516 let offered = first.first_offered();
517 assert_eq!(offered, integer::offered(noise));
518
519 let blind = Replay::new(&kinds, &EXHAUSTIVE);
520 let packed = integer::encode_with(sparse, &blind).unwrap();
521 assert!(blind.held(), "packing fits any column, which is the trouble");
522
523 let checked = Replay::new(&kinds, &EXHAUSTIVE).expecting(&offered);
524 let written = integer::encode_with(sparse, &checked).unwrap();
525 assert!(!checked.held());
526 assert_eq!(written, integer::encode_with(sparse, &EXHAUSTIVE).unwrap());
527 assert!(written.len() * 4 < packed.len(), "{} against {}", written.len(), packed.len());
528 }
529
530 /// A shape from one column on another column it does not fit still writes that column, because
531 /// the replay stops at the first kind that is not offered and searches from there.
532 #[test]
533 fn a_shape_that_does_not_fit_still_writes_values_that_read_back() {
534 let columns = shaped_columns();
535 for from in &columns {
536 let kinds = integer::shape(&integer::encode_with(from, &EXHAUSTIVE).unwrap()).unwrap();
537 for values in &columns {
538 let replay = Replay::new(&kinds, &EXHAUSTIVE);
539 let bytes = integer::encode_with(values, &replay).unwrap();
540 assert_eq!(&integer::decode(&bytes).unwrap(), values);
541 }
542 }
543 }
544
545 #[test]
546 fn a_sample_covers_the_whole_input_and_not_one_end_of_it() {
547 let values: Vec<i64> = (0..8000).collect();
548 let taken = sample(&values, 10, 4);
549 assert_eq!(taken.len(), 40);
550 assert_eq!(taken[0], 0);
551 assert_eq!(taken[10], 2663);
552 assert_eq!(taken[20], 5326);
553 assert_eq!(taken[30], 7990);
554 assert_eq!(taken[39], 7999);
555 }
556
557 #[test]
558 fn an_input_no_bigger_than_the_sample_is_the_sample() {
559 let values: Vec<i64> = (0..30).collect();
560 assert_eq!(sample(&values, 10, 4), values);
561 }
562
563 #[test]
564 fn the_last_window_does_not_run_off_the_end() {
565 // Two windows of 40 over 100 values puts the second one at 60, which is the last start that
566 // fits. Windows that overlap because there are more of them than the input has room for is
567 // fine and double counts a few values. Reading past the end is not.
568 let values: Vec<i64> = (0..100).collect();
569 let taken = sample(&values, 40, 2);
570 assert_eq!(taken.len(), 80);
571 assert_eq!(*taken.last().expect("the sample is not empty"), 99);
572 }
573
574 #[test]
575 fn the_exhaustive_chooser_hands_back_exactly_what_it_was_offered() {
576 let offered = [string::Kind::Plain, string::Kind::Fsst, string::Kind::Dict];
577 assert_eq!(EXHAUSTIVE.narrow_strings(&[b"a".as_slice()], &offered, 0), offered);
578 let offered = [integer::Kind::Packed, integer::Kind::Delta];
579 assert_eq!(EXHAUSTIVE.narrow_integers(&[1, 2], &offered, 0), offered);
580 }
581
582 #[test]
583 fn a_chunk_no_bigger_than_the_sample_is_not_narrowed_at_all() {
584 // Sampling a chunk that is smaller than the sample would encode every candidate on
585 // something the size of the chunk and then encode the winner on the chunk, which is more
586 // work than the exhaustive chooser for the same answer.
587 let sampled = Sampled::over(4, 2);
588 let values: Vec<i64> = (0..8).collect();
589 let offered = [integer::Kind::Packed, integer::Kind::Delta];
590 assert_eq!(sampled.narrow_integers(&values, &offered, 0), offered);
591 }
592
593 #[test]
594 fn a_sampled_chooser_returns_one_of_what_it_was_offered() {
595 let sampled = Sampled::over(16, 2);
596 let values: Vec<i64> = (0..40_000).map(|index| index / 200).collect();
597 let offered = [integer::Kind::Packed, integer::Kind::Rle, integer::Kind::Dict];
598 let narrowed = sampled.narrow_integers(&values, &offered, 0);
599 assert_eq!(narrowed.len(), 1);
600 assert!(offered.contains(&narrowed[0]), "{narrowed:?}");
601 }
602
603 #[test]
604 fn a_chunk_with_plenty_of_values_and_hardly_any_bytes_is_not_sampled() {
605 // ClickBench Params at a million rows: a value per row and almost all of them empty. It
606 // passes the value count guard and the exhaustive chooser encodes it in 21,782 bytes while
607 // the sampler took 128,455, so the byte floor is what keeps it out of the sampler's hands.
608 let sampled = Sampled::over(16, 2);
609 let empty = Vec::new();
610 let values: Vec<&[u8]> = vec![empty.as_slice(); 40_000];
611 let offered = [string::Kind::Plain, string::Kind::Fsst, string::Kind::Dict];
612 assert_eq!(sampled.narrow_strings(&values, &offered, 0), offered);
613 }
614}