structured_zstd/dictionary/mod.rs
1//! Dictionary training.
2//!
3//! Effective dictionaries are up to 1% the size of the complete training body,
4//! and are trained on many examples of the original data. The trainers are
5//! those of the reference implementation, each taking the samples back to back
6//! with their sizes:
7//!
8//! - COVER ([`train_cover_dict`], [`optimize_cover_dict`]) indexes every dmer
9//! exactly and fills the dictionary with the segments whose dmers the most
10//! samples share.
11//! - FastCOVER ([`train_fastcover_dict`], [`optimize_fastcover_dict`]) does the
12//! same over a hashed frequency table, faster and with less memory; it is
13//! what `zstd --train` runs.
14//! - The legacy trainer ([`create_legacy_dict_from_slice`]) searches a suffix
15//! array for repeated segments.
16//!
17//! The `optimize_*` forms search segment and dmer sizes, scoring each candidate
18//! by the total size of the scoring samples compressed with it.
19//!
20//! [`create_raw_dict_from_slice`] and its reader and directory forms build raw
21//! content, with no entropy tables, from a corpus without sample sizes: the
22//! FastCOVER search `zstd --train` runs, over the corpus cut into samples.
23mod cover;
24mod fastcover;
25mod finalize;
26mod legacy;
27mod samples;
28mod selection;
29mod suffix_array;
30
31pub use legacy::DEFAULT_SELECTIVITY;
32pub use samples::TrainingError;
33use samples::refuse;
34use std::{
35 format,
36 fs::{self, File},
37 io::{self, Read},
38 path::{Path, PathBuf},
39 vec::Vec,
40};
41
42const MAX_TRAINING_PREALLOC_BYTES: usize = 8 * 1024 * 1024;
43
44/// Smallest `dict_size` a dictionary is trained or finalized into (upstream
45/// zstd `ZDICT_DICTSIZE_MIN`).
46///
47/// Use it to reject an impossible `dict_size` before spending the corpus. A
48/// size at or above it can still be too small once the entropy tables the
49/// samples produce are built, which only training can tell.
50pub const MIN_TRAINED_DICT_SIZE: usize = finalize::DICT_SIZE_MIN;
51
52/// Tuning for COVER training, the knobs of the reference's
53/// `ZDICT_cover_params_t`.
54///
55/// The defaults are those `zstd --train-cover` starts from: `d` of 8, a search
56/// over `k` in four steps, every sample both building and scoring.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct CoverOptions {
59 /// Segment size in bytes. Zero searches 50..=2000 when optimizing.
60 pub k: u32,
61 /// Dmer size in bytes, at most `k`. Zero searches 6 and 8 when optimizing.
62 pub d: u32,
63 /// How many values of `k` the search tries; zero is 40.
64 pub steps: u32,
65 /// Share of the samples dictionaries are built from, in `(0, 1]`; the rest
66 /// score them. At 1 every sample does both. Zero or below is the trainer's
67 /// default: 1 for COVER, 0.75 for FastCOVER.
68 pub split_point: f64,
69 /// Try the content's last 256, 512, 1024, ... bytes, each finalized into a
70 /// dictionary of its own, and keep the first whose scoring samples
71 /// compress to at most this many percent more than with the whole content
72 /// (upstream zstd `COVER_selectDict`). A dictionary so found is its header
73 /// plus that tail, so none is smaller than the header plus 256 bytes. A
74 /// search cuts its winner down this way; the parameters it chooses are
75 /// unchanged.
76 pub shrink: Option<u32>,
77}
78
79impl Default for CoverOptions {
80 fn default() -> Self {
81 Self {
82 k: 0,
83 d: 8,
84 steps: 4,
85 split_point: 1.0,
86 shrink: None,
87 }
88 }
89}
90
91/// Tuning for FastCOVER training, the knobs of the reference's
92/// `ZDICT_fastCover_params_t`.
93///
94/// The defaults are those `zstd --train` runs: `d` of 8, a table of 2^20
95/// counts, a search over `k` in four steps, three quarters of the samples
96/// building and the rest scoring.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct FastCoverOptions {
99 /// Segment size, dmer size, search, split and shrink, as for COVER. `d` is
100 /// at least 4 here; the reference takes 6 and 8.
101 pub cover: CoverOptions,
102 /// Width of the dmer frequency table in bits, `1..=31`; its memory grows
103 /// as `2^f`. Zero is 20.
104 pub f: u32,
105 /// Count every `accel`-th position, `1..=10`, and draw the entropy tables
106 /// from a matching share of the samples. Zero is 1.
107 pub accel: u32,
108}
109
110impl Default for FastCoverOptions {
111 fn default() -> Self {
112 Self {
113 cover: CoverOptions {
114 split_point: 0.75,
115 ..CoverOptions::default()
116 },
117 f: 20,
118 accel: 1,
119 }
120 }
121}
122
123/// How a dictionary is finalized and trained against, the reference's
124/// `ZDICT_params_t`.
125#[derive(Debug, Clone, Copy, Default)]
126pub struct FinalizeOptions {
127 /// The dictionary id; `None` derives one from the content.
128 pub dict_id: Option<u32>,
129 /// The compression level the dictionary is built for: the entropy tables
130 /// come from samples compressed at it, and the trainers score candidates
131 /// at it. Zero is the default level.
132 pub level: i32,
133}
134
135/// Create a "raw content" dictionary of at most `dict_size` bytes, with no
136/// entropy tables, from every file in this directory and its subdirectories,
137/// and write it to `output`.
138///
139/// Each file is one sample, and the content is what [`optimize_fastcover_dict`]
140/// picks at its defaults, as `zstd --train` does; too few files to search is
141/// trained as [`create_raw_dict_from_slice`] trains an undivided corpus.
142///
143/// # Errors
144/// Returns an error reading the directory or its files, writing to `output`,
145/// or allocating the trainer's tables.
146///
147/// # Examples
148/// ```no_run
149/// use std::fs::File;
150/// // Create a roughly 1mb dictionary, training off of file in `sample_files`
151/// let input_folder = "sample_files/";
152/// let mut output = File::create("output.dict").unwrap();
153/// structured_zstd::dictionary::create_raw_dict_from_dir(input_folder, &mut output, 1_000_000)
154/// .expect("dictionary training from sample_files should succeed");
155/// ```
156pub fn create_raw_dict_from_dir<P: AsRef<Path>, W: io::Write>(
157 path: P,
158 output: &mut W,
159 dict_size: usize,
160) -> Result<(), io::Error> {
161 // Collect a list of a path to every file in the directory into `file_paths`
162 let mut file_paths: Vec<PathBuf> = Vec::new();
163 let dir: fs::ReadDir = fs::read_dir(path)?;
164 fn recurse_read(dir: fs::ReadDir, file_paths: &mut Vec<PathBuf>) -> Result<(), io::Error> {
165 for entry in dir {
166 let entry = entry?;
167 if entry.file_type()?.is_dir() {
168 recurse_read(fs::read_dir(entry.path())?, file_paths)?;
169 } else {
170 file_paths.push(entry.path());
171 }
172 }
173 Ok(())
174 }
175 recurse_read(dir, &mut file_paths)?;
176
177 // Every file is one sample.
178 let mut corpus = Vec::new();
179 let mut sizes = Vec::with_capacity(file_paths.len());
180 for path in file_paths {
181 let before = corpus.len();
182 File::open(path)?.read_to_end(&mut corpus)?;
183 sizes.push(corpus.len() - before);
184 }
185 output.write_all(&fastcover_raw_content(&corpus, Some(&sizes), dict_size)?)
186}
187
188/// Read from `source` to create a "raw content" dictionary of `dict_size`.
189/// The completed dictionary is written to `output`.
190///
191/// - `source` will be used as training data for the entire dictionary.
192/// - `source_size` is used only as a preallocation hint before reading `source` and
193/// does not affect sampling once all data has been buffered.
194/// - `output` is where the completed dictionary will be written.
195/// - `dict_size` determines how large the complete dictionary should be. The completed
196/// dictionary will be this size or smaller.
197///
198/// This function reads the entire `source` into an in-memory `Vec<u8>` before building
199/// the dictionary. The provided reader need not be buffered, but callers should avoid
200/// sources too large to fit comfortably in memory.
201///
202/// A corpus already in memory trains without this copy through
203/// [`create_raw_dict_from_slice`], whose training this is.
204///
205/// # Errors
206/// Returns an error reading `source`, writing to `output`, or allocating the
207/// trainer's tables.
208pub fn create_raw_dict_from_source<R: io::Read, W: io::Write>(
209 mut source: R,
210 source_size: usize,
211 output: &mut W,
212 dict_size: usize,
213) -> io::Result<()> {
214 if dict_size == 0 {
215 return Ok(());
216 }
217 let prealloc = source_size.min(MAX_TRAINING_PREALLOC_BYTES);
218 let mut all = Vec::with_capacity(prealloc);
219 source.read_to_end(&mut all)?;
220 create_raw_dict_from_slice(&all, output, dict_size)
221}
222
223/// Create a "raw content" dictionary of at most `dict_size` bytes, with no
224/// entropy tables, from a corpus already in memory, writing it to `output`.
225///
226/// The corpus has no sample sizes, so it is cut into at least sixteen samples
227/// of at most 128 KiB, and the content is what [`optimize_fastcover_dict`]
228/// picks over them at its defaults, as `zstd --train` does. A corpus no larger
229/// than `dict_size` is its own content; one the trainer refuses (a
230/// `dict_size` under [`MIN_TRAINED_DICT_SIZE`], too little to search) gives
231/// its last `dict_size` bytes.
232///
233/// # Errors
234/// Returns an error writing to `output` or allocating the trainer's tables.
235///
236/// # Examples
237/// ```
238/// use structured_zstd::dictionary::create_raw_dict_from_slice;
239///
240/// let corpus: Vec<u8> = (0..20_000u32)
241/// .flat_map(|i| format!("record {} value {}\n", i % 100, i % 7).into_bytes())
242/// .collect();
243/// let mut dict = Vec::new();
244/// create_raw_dict_from_slice(&corpus, &mut dict, 4096).unwrap();
245/// assert!(!dict.is_empty() && dict.len() <= 4096);
246/// ```
247pub fn create_raw_dict_from_slice<W: io::Write>(
248 all: &[u8],
249 output: &mut W,
250 dict_size: usize,
251) -> io::Result<()> {
252 output.write_all(&fastcover_raw_content(all, None, dict_size)?)
253}
254
255/// Largest sample an undivided corpus is cut into: upstream zstd's largest
256/// block, the most of a sample a dictionary's statistics read.
257const RAW_SAMPLE_MAX: usize = 128 << 10;
258/// Fewest samples an undivided corpus is cut into, so the search has samples
259/// to build from and samples to score on.
260const RAW_SAMPLES_MIN: usize = 16;
261
262/// Raw content of at most `dict_size` bytes from `corpus`, as `zstd --train`
263/// picks it: FastCOVER searching `k` over the samples `sizes` cuts, or over
264/// even cuts of the corpus when there are none or too few to search.
265fn fastcover_raw_content(
266 corpus: &[u8],
267 sizes: Option<&[usize]>,
268 dict_size: usize,
269) -> io::Result<Vec<u8>> {
270 if corpus.len() <= dict_size {
271 return Ok(corpus.to_vec());
272 }
273 let refused =
274 |result: &io::Result<Vec<u8>>| result.as_ref().err().and_then(TrainingError::of).is_some();
275 if let Some(sizes) = sizes {
276 let trained = search_raw_content(corpus, sizes, dict_size);
277 if !refused(&trained) {
278 return trained;
279 }
280 }
281 let cut = corpus.len().div_ceil(RAW_SAMPLES_MIN).min(RAW_SAMPLE_MAX);
282 let even: Vec<usize> = corpus.chunks(cut).map(<[u8]>::len).collect();
283 let trained = search_raw_content(corpus, &even, dict_size);
284 if refused(&trained) {
285 return Ok(corpus[corpus.len() - dict_size..].to_vec());
286 }
287 trained
288}
289
290/// The content of the dictionary [`optimize_fastcover_dict`] picks at its
291/// defaults: the search keeps its winner's content and hands that back.
292fn search_raw_content(corpus: &[u8], sizes: &[usize], dict_size: usize) -> io::Result<Vec<u8>> {
293 let options = FastCoverOptions::default();
294 let space = SearchSpace::optimizing(&options.cover, 0.75)?;
295 let (content, _) = run_fastcover(
296 corpus,
297 sizes,
298 dict_size,
299 &options,
300 FinalizeOptions::default(),
301 &space,
302 selection::Keep::Content,
303 )?;
304 Ok(content)
305}
306
307/// Finalize raw dictionary content into a dictionary of at most `dict_size`
308/// bytes: magic, id, entropy tables, repeat offsets and the content (the
309/// reference's `ZDICT_finalizeDictionary`).
310///
311/// The entropy tables are measured, not guessed: the first block of each
312/// sample is compressed with `raw_content` as a raw dictionary at
313/// [`FinalizeOptions::level`], and the tables describe the literals and
314/// sequences those blocks produced. `samples` is every sample back to back and
315/// `sample_sizes` their lengths. Content that does not fit after the header is
316/// cut from the front: trainers place their best segments last.
317///
318/// # Errors
319///
320/// `InvalidInput` when `raw_content` is empty, when `dict_size` is below
321/// [`MIN_TRAINED_DICT_SIZE`] or leaves less than eight bytes of content after
322/// the header, when the id is zero, or when `sample_sizes` does not add up to
323/// `samples.len()`; a size too small carries
324/// [`TrainingError::DictionaryTooSmall`].
325///
326/// # Examples
327///
328/// ```
329/// use structured_zstd::dictionary::{FinalizeOptions, finalize_raw_dict};
330///
331/// let mut samples = Vec::new();
332/// let mut sizes = Vec::new();
333/// for i in 0..50u32 {
334/// let line = format!("tenant=demo table=orders key={i} status=shipped\n");
335/// sizes.push(line.len());
336/// samples.extend_from_slice(line.as_bytes());
337/// }
338/// let content = b"tenant=demo table=orders key= status=shipped\n";
339/// let dict = finalize_raw_dict(content, &samples, &sizes, 1024, FinalizeOptions::default())
340/// .unwrap();
341/// assert!(dict.ends_with(content));
342/// ```
343pub fn finalize_raw_dict(
344 raw_content: &[u8],
345 samples: &[u8],
346 sample_sizes: &[usize],
347 dict_size: usize,
348 options: FinalizeOptions,
349) -> io::Result<Vec<u8>> {
350 check_finalize_dict_size(dict_size)?;
351 let set = samples::SampleSet::new(samples, sample_sizes)?;
352 finalize::finalize(raw_content, &set, set.count(), dict_size, options)
353}
354
355/// Refuse a `dict_size` [`finalize_raw_dict`] cannot fill, from the size
356/// alone: below [`MIN_TRAINED_DICT_SIZE`]. Finalizing checks it first, before
357/// the samples; a caller holding the samples somewhere costly to walk can run
358/// it before that.
359///
360/// # Errors
361///
362/// `InvalidInput` carrying [`TrainingError::DictionaryTooSmall`].
363///
364/// # Examples
365///
366/// ```
367/// use structured_zstd::dictionary::{
368/// MIN_TRAINED_DICT_SIZE, TrainingError, check_finalize_dict_size,
369/// };
370///
371/// assert!(check_finalize_dict_size(MIN_TRAINED_DICT_SIZE).is_ok());
372/// let err = check_finalize_dict_size(MIN_TRAINED_DICT_SIZE - 1).unwrap_err();
373/// assert_eq!(TrainingError::of(&err), Some(TrainingError::DictionaryTooSmall));
374/// ```
375pub fn check_finalize_dict_size(dict_size: usize) -> io::Result<()> {
376 finalize::check_size(dict_size)
377}
378
379/// The `k` and `d` values a training run tries, resolved from the options the
380/// way the reference's optimizers resolve theirs.
381struct SearchSpace {
382 d: core::ops::RangeInclusive<u32>,
383 k: core::ops::RangeInclusive<u32>,
384 k_step: usize,
385 steps: u32,
386 split_point: f64,
387}
388
389impl SearchSpace {
390 /// Upstream zstd `ZDICT_optimizeTrainFromBuffer_cover`: zero `d` tries 6
391 /// and 8, zero `k` tries 50..=2000 in `steps` strides.
392 fn optimizing(options: &CoverOptions, default_split: f64) -> io::Result<Self> {
393 // NaN fails every comparison below, so it is refused on its own.
394 if !options.split_point.is_finite() {
395 return Err(refuse(
396 TrainingError::Parameter,
397 "the split point must lie in (0, 1]",
398 ));
399 }
400 let split_point = if options.split_point <= 0.0 {
401 default_split
402 } else {
403 options.split_point
404 };
405 if split_point > 1.0 {
406 return Err(refuse(
407 TrainingError::Parameter,
408 "the split point must lie in (0, 1]",
409 ));
410 }
411 let d = if options.d == 0 {
412 6..=8
413 } else {
414 options.d..=options.d
415 };
416 let k = if options.k == 0 {
417 50..=2000
418 } else {
419 options.k..=options.k
420 };
421 if *k.start() < *d.end() {
422 return Err(refuse(TrainingError::Parameter, "k must be at least d"));
423 }
424 let steps = if options.steps == 0 {
425 40
426 } else {
427 options.steps
428 };
429 let k_step = ((k.end() - k.start()) / steps).max(1) as usize;
430 Ok(Self {
431 d,
432 k,
433 k_step,
434 steps,
435 split_point,
436 })
437 }
438
439 /// The single `k` and `d` a plain training run is given, with every sample
440 /// building (upstream zstd `ZDICT_trainFromBuffer_cover`).
441 fn fixed(options: &CoverOptions) -> io::Result<Self> {
442 if options.k == 0 || options.d == 0 {
443 return Err(refuse(
444 TrainingError::Parameter,
445 "k and d are required; zero asks for a search, which the optimize_* trainers run",
446 ));
447 }
448 Ok(Self {
449 d: options.d..=options.d,
450 k: options.k..=options.k,
451 k_step: 1,
452 steps: options.steps,
453 split_point: 1.0,
454 })
455 }
456
457 fn pairs(&self) -> impl Iterator<Item = (usize, usize)> + '_ {
458 self.d.clone().step_by(2).flat_map(move |d| {
459 self.k
460 .clone()
461 .step_by(self.k_step)
462 .map(move |k| (d as usize, k as usize))
463 })
464 }
465
466 /// Refuse a space with no `k` and `d` a `dict_size` dictionary can hold,
467 /// before any sample is indexed (upstream zstd `COVER_checkParameters`,
468 /// which checks the parameters ahead of the samples).
469 fn check_fits(&self, dict_size: usize) -> io::Result<()> {
470 if self.pairs().any(|(d, k)| segment_fits(k, d, dict_size)) {
471 return Ok(());
472 }
473 Err(refuse(
474 TrainingError::Parameter,
475 "no parameter combination is valid for this dictionary size",
476 ))
477 }
478
479 /// The trainer `options` select, the way the CLI and the C ABI choose it:
480 /// both `k` and `d` given train with them, anything less searches.
481 fn selected(options: &CoverOptions, default_split: f64) -> io::Result<Self> {
482 if options.k != 0 && options.d != 0 {
483 Self::fixed(options)
484 } else {
485 Self::optimizing(options, default_split)
486 }
487 }
488}
489
490/// Refuse COVER tuning no training run can use for a `dict_size` dictionary,
491/// from the options alone: what [`train_cover_dict`] (both `k` and `d` given)
492/// or [`optimize_cover_dict`] (either left zero) would refuse before reading a
493/// sample. Lets a caller holding a large corpus fail before loading it.
494///
495/// # Errors
496///
497/// `InvalidInput` for tuning out of range or for a `k` and `d` that no
498/// dictionary of `dict_size` bytes holds, where [`TrainingError::of`] reports
499/// [`TrainingError::Parameter`], and for a `dict_size` under
500/// [`MIN_TRAINED_DICT_SIZE`], where it reports
501/// [`TrainingError::DictionaryTooSmall`].
502///
503/// # Examples
504///
505/// ```
506/// use structured_zstd::dictionary::{CoverOptions, check_cover_options};
507///
508/// let fits = CoverOptions { k: 256, d: 8, ..CoverOptions::default() };
509/// assert!(check_cover_options(&fits, 4096).is_ok());
510/// let too_long = CoverOptions { k: 8192, d: 8, ..CoverOptions::default() };
511/// assert!(check_cover_options(&too_long, 4096).is_err());
512/// ```
513pub fn check_cover_options(options: &CoverOptions, dict_size: usize) -> io::Result<()> {
514 // The trainers' order, and upstream zstd's: `COVER_checkParameters` before
515 // the `ZDICT_DICTSIZE_MIN` check, so both name the same cause.
516 let space = SearchSpace::selected(options, 1.0)?;
517 space.check_fits(dict_size)?;
518 check_dict_size(dict_size)
519}
520
521/// [`check_cover_options`] for FastCOVER: also refuses `f`, `accel` and a `d`
522/// below 4 as [`train_fastcover_dict`] and [`optimize_fastcover_dict`] do.
523///
524/// # Errors
525///
526/// As [`check_cover_options`].
527///
528/// # Examples
529///
530/// ```
531/// use structured_zstd::dictionary::{CoverOptions, FastCoverOptions, check_fastcover_options};
532///
533/// let options = FastCoverOptions {
534/// cover: CoverOptions { k: 256, d: 8, ..FastCoverOptions::default().cover },
535/// ..FastCoverOptions::default()
536/// };
537/// assert!(check_fastcover_options(&options, 4096).is_ok());
538/// assert!(check_fastcover_options(&options, 128).is_err());
539/// ```
540pub fn check_fastcover_options(options: &FastCoverOptions, dict_size: usize) -> io::Result<()> {
541 let space = SearchSpace::selected(&options.cover, 0.75)?;
542 fastcover_knobs(options, &space)?;
543 space.check_fits(dict_size)?;
544 check_dict_size(dict_size)
545}
546
547/// Refuse `sample_count` samples the COVER training `options` select cannot
548/// build from: too few once the split takes its scoring share, or none left to
549/// score on. Needs only the count, so a caller can ask before reading a sample.
550///
551/// # Errors
552///
553/// `InvalidInput` for tuning out of range, where [`TrainingError::of`]
554/// reports [`TrainingError::Parameter`], and for too few samples, where it
555/// reports [`TrainingError::Samples`].
556///
557/// # Examples
558///
559/// ```
560/// use structured_zstd::dictionary::{CoverOptions, check_cover_sample_count};
561///
562/// // The search builds from the leading three quarters of the samples.
563/// let search = CoverOptions { split_point: 0.75, ..CoverOptions::default() };
564/// assert!(check_cover_sample_count(&search, 6).is_err());
565/// assert!(check_cover_sample_count(&search, 8).is_ok());
566/// ```
567pub fn check_cover_sample_count(options: &CoverOptions, sample_count: usize) -> io::Result<()> {
568 let space = SearchSpace::selected(options, 1.0)?;
569 samples::split_count(sample_count, space.split_point).map(drop)
570}
571
572/// [`check_cover_sample_count`] for FastCOVER, whose search scores on a
573/// quarter of the samples unless `split_point` says otherwise.
574///
575/// # Errors
576///
577/// As [`check_cover_sample_count`].
578///
579/// # Examples
580///
581/// ```
582/// use structured_zstd::dictionary::{FastCoverOptions, check_fastcover_sample_count};
583///
584/// let search = FastCoverOptions::default();
585/// assert!(check_fastcover_sample_count(&search, 6).is_err());
586/// assert!(check_fastcover_sample_count(&search, 8).is_ok());
587/// ```
588pub fn check_fastcover_sample_count(
589 options: &FastCoverOptions,
590 sample_count: usize,
591) -> io::Result<()> {
592 let space = SearchSpace::selected(&options.cover, 0.75)?;
593 samples::split_count(sample_count, space.split_point).map(drop)
594}
595
596/// The table width and acceleration `options` run with, zero meaning upstream
597/// zstd's defaults, refused where FastCOVER cannot run them.
598fn fastcover_knobs(options: &FastCoverOptions, space: &SearchSpace) -> io::Result<(u32, u32)> {
599 let f = if options.f == 0 { 20 } else { options.f };
600 let accel = if options.accel == 0 { 1 } else { options.accel };
601 if f > fastcover::MAX_F {
602 return Err(refuse(
603 TrainingError::Parameter,
604 &format!("f must be in 1..={}, got {f}", fastcover::MAX_F),
605 ));
606 }
607 if accel > fastcover::MAX_ACCEL {
608 return Err(refuse(
609 TrainingError::Parameter,
610 &format!("accel must be in 1..={}, got {accel}", fastcover::MAX_ACCEL),
611 ));
612 }
613 if *space.d.start() < 4 {
614 return Err(refuse(
615 TrainingError::Parameter,
616 "FastCOVER needs d of at least 4",
617 ));
618 }
619 Ok((f, accel))
620}
621
622/// Upstream zstd `COVER_checkParameters`: a segment fits the dictionary and
623/// holds at least one dmer.
624fn segment_fits(k: usize, d: usize, dict_size: usize) -> bool {
625 d > 0 && d <= k && k <= dict_size
626}
627
628/// The samples and the dictionary size, checked in upstream zstd's order
629/// (`ZDICT_trainFromBuffer_cover`): no samples at all, then a dictionary too
630/// small; the samples are walked, and the remaining checks run, after both.
631fn check_samples_and_dict_size<'s>(
632 samples: &'s [u8],
633 sample_sizes: &[usize],
634 dict_size: usize,
635) -> io::Result<samples::SampleSet<'s>> {
636 if sample_sizes.is_empty() {
637 return Err(refuse(TrainingError::Samples, "there are no samples"));
638 }
639 check_dict_size(dict_size)?;
640 samples::SampleSet::new(samples, sample_sizes)
641}
642
643/// Refuse a dictionary smaller than any trainer builds.
644fn check_dict_size(dict_size: usize) -> io::Result<()> {
645 if dict_size < MIN_TRAINED_DICT_SIZE {
646 return Err(refuse(
647 TrainingError::DictionaryTooSmall,
648 &format!("a dictionary must be at least {MIN_TRAINED_DICT_SIZE} bytes"),
649 ));
650 }
651 Ok(())
652}
653
654/// Train a COVER dictionary of at most `dict_size` bytes with the `k` and `d`
655/// given (the reference's `ZDICT_trainFromBuffer_cover`).
656///
657/// `samples` is every sample back to back and `sample_sizes` their lengths.
658/// Every sample builds, and the entropy tables are drawn from all of them. With
659/// [`CoverOptions::shrink`] the samples also score the shrinking search;
660/// `steps` and `split_point` are ignored.
661///
662/// # Errors
663///
664/// `InvalidInput` when `k` or `d` is zero or `d > k`, when `k` exceeds
665/// `dict_size`, when `dict_size` is under [`MIN_TRAINED_DICT_SIZE`], when there
666/// are fewer than five samples or they do not add up to `samples.len()`;
667/// [`TrainingError::of`] tells these causes apart.
668///
669/// # Examples
670///
671/// ```
672/// use structured_zstd::dictionary::{CoverOptions, FinalizeOptions, train_cover_dict};
673///
674/// let mut samples = Vec::new();
675/// let mut sizes = Vec::new();
676/// for i in 0..200u32 {
677/// let line = format!("tenant=demo table=orders key={i} region=eu status=shipped\n");
678/// sizes.push(line.len());
679/// samples.extend_from_slice(line.as_bytes());
680/// }
681/// let options = CoverOptions { k: 64, ..CoverOptions::default() };
682/// let dict = train_cover_dict(&samples, &sizes, 4096, &options, FinalizeOptions::default())
683/// .unwrap();
684/// assert!(dict.starts_with(&[0x37, 0xA4, 0x30, 0xEC]) && dict.len() <= 4096);
685/// ```
686pub fn train_cover_dict(
687 samples: &[u8],
688 sample_sizes: &[usize],
689 dict_size: usize,
690 options: &CoverOptions,
691 finalize: FinalizeOptions,
692) -> io::Result<Vec<u8>> {
693 let space = SearchSpace::fixed(options)?;
694 let (dict, _) = run_cover(samples, sample_sizes, dict_size, options, finalize, &space)?;
695 Ok(dict)
696}
697
698/// Train COVER dictionaries over a range of `k` and `d` and keep the one the
699/// scoring samples compress best with (the reference's
700/// `ZDICT_optimizeTrainFromBuffer_cover`). Returns it with the options that
701/// built it.
702///
703/// A zero `k` searches 50..=2000 in `steps` strides (zero `steps` is 40), a
704/// zero `d` tries 6 and 8. The leading `split_point` of the samples build and
705/// the rest score; at 1 every sample does both. [`CoverOptions::shrink`] takes
706/// effect here, which the reference's optimizer does not honour.
707///
708/// # Errors
709///
710/// As [`train_cover_dict`], and when `split_point` exceeds 1, when `k` is
711/// below `d`, or when the split leaves fewer than five samples to build or
712/// none to score.
713///
714/// # Examples
715///
716/// ```
717/// use structured_zstd::dictionary::{CoverOptions, FinalizeOptions, optimize_cover_dict};
718///
719/// let mut samples = Vec::new();
720/// let mut sizes = Vec::new();
721/// for i in 0..200u32 {
722/// let line = format!("tenant=demo table=orders key={i} region=eu status=shipped\n");
723/// sizes.push(line.len());
724/// samples.extend_from_slice(line.as_bytes());
725/// }
726/// let (dict, chosen) = optimize_cover_dict(
727/// &samples,
728/// &sizes,
729/// 4096,
730/// &CoverOptions::default(),
731/// FinalizeOptions::default(),
732/// )
733/// .unwrap();
734/// assert!(dict.len() <= 4096 && chosen.k >= 50);
735/// ```
736pub fn optimize_cover_dict(
737 samples: &[u8],
738 sample_sizes: &[usize],
739 dict_size: usize,
740 options: &CoverOptions,
741 finalize: FinalizeOptions,
742) -> io::Result<(Vec<u8>, CoverOptions)> {
743 let space = SearchSpace::optimizing(options, 1.0)?;
744 run_cover(samples, sample_sizes, dict_size, options, finalize, &space)
745}
746
747fn run_cover(
748 samples: &[u8],
749 sample_sizes: &[usize],
750 dict_size: usize,
751 options: &CoverOptions,
752 finalize: FinalizeOptions,
753 space: &SearchSpace,
754) -> io::Result<(Vec<u8>, CoverOptions)> {
755 space.check_fits(dict_size)?;
756 let set = check_samples_and_dict_size(samples, sample_sizes, dict_size)?;
757 let split = set.split(space.split_point)?;
758 let plain = space.k.start() == space.k.end() && space.d.start() == space.d.end();
759 // A plain run with no shrinking prices nothing: its one dictionary is the
760 // answer, as the reference's plain trainer returns it unscored.
761 let scored = !plain || options.shrink.is_some() || space.split_point < 1.0;
762 let mut evaluator =
763 selection::Evaluator::new(&set, split.train, split.test.clone(), dict_size, finalize);
764 let mut best = selection::Best::new(selection::Keep::Dictionary {
765 shrink: options.shrink,
766 });
767 let mut state = Vec::new();
768 let mut content_scratch = Vec::new();
769 // `None` beside a `d` is a dmer size these samples cannot index.
770 let mut context: Option<(usize, Option<cover::CoverContext<'_>>)> = None;
771 for (d, k) in space.pairs() {
772 if !segment_fits(k, d, dict_size) {
773 continue;
774 }
775 let chosen = CoverOptions {
776 k: k as u32,
777 d: d as u32,
778 steps: space.steps,
779 split_point: space.split_point,
780 ..*options
781 };
782 if context
783 .as_ref()
784 .is_none_or(|(built_for, _)| *built_for != d)
785 {
786 // The previous index and its scratch are corpus-sized: released
787 // before the next is built, so the two never coexist.
788 drop(context.take());
789 state = Vec::new();
790 // A size the samples cannot index is one failed candidate: the
791 // search goes on with the other sizes, and its error is what is
792 // returned only if no size builds anything.
793 let built = match cover::CoverContext::new(&set, split.train, d) {
794 Ok(ctx) => Some(ctx),
795 Err(err) => {
796 best.offer(Err(err), &[], chosen);
797 None
798 }
799 };
800 context = Some((d, built));
801 }
802 let Some(ctx) = context.as_ref().and_then(|(_, built)| built.as_ref()) else {
803 continue;
804 };
805 let content = ctx.build(&mut state, &mut content_scratch, dict_size, k);
806 if !scored {
807 return Ok((evaluator.finalize(content)?, chosen));
808 }
809 // Ranked at full size; `finish` shrinks only the winner, as
810 // `CoverOptions::shrink` documents. Upstream's optimizer never applies
811 // its shrink at all, and cutting every candidate would multiply the
812 // search by the number of sizes tried.
813 best.offer(evaluator.score(content), content, chosen);
814 }
815 best.finish(&mut evaluator)
816}
817
818/// Train a FastCOVER dictionary of at most `dict_size` bytes with the `k` and
819/// `d` given (the reference's `ZDICT_trainFromBuffer_fastCover`).
820///
821/// As [`train_cover_dict`], over a hashed frequency table of `2^f` counts; the
822/// entropy tables are drawn from the share of samples `accel` sets.
823///
824/// # Errors
825///
826/// As [`train_cover_dict`], and when `d` is below 4, `f` exceeds 31 or `accel`
827/// exceeds 10. A table too large for memory is an `OutOfMemory` error.
828///
829/// # Examples
830///
831/// ```
832/// use structured_zstd::dictionary::{
833/// CoverOptions, FastCoverOptions, FinalizeOptions, train_fastcover_dict,
834/// };
835///
836/// let mut samples = Vec::new();
837/// let mut sizes = Vec::new();
838/// for i in 0..200u32 {
839/// let line = format!("tenant=demo table=orders key={i} region=eu status=shipped\n");
840/// sizes.push(line.len());
841/// samples.extend_from_slice(line.as_bytes());
842/// }
843/// let options = FastCoverOptions {
844/// cover: CoverOptions { k: 64, ..FastCoverOptions::default().cover },
845/// ..FastCoverOptions::default()
846/// };
847/// let dict = train_fastcover_dict(&samples, &sizes, 4096, &options, FinalizeOptions::default())
848/// .unwrap();
849/// assert!(dict.len() <= 4096);
850/// ```
851pub fn train_fastcover_dict(
852 samples: &[u8],
853 sample_sizes: &[usize],
854 dict_size: usize,
855 options: &FastCoverOptions,
856 finalize: FinalizeOptions,
857) -> io::Result<Vec<u8>> {
858 let space = SearchSpace::fixed(&options.cover)?;
859 let keep = selection::Keep::Dictionary {
860 shrink: options.cover.shrink,
861 };
862 let (dict, _) = run_fastcover(
863 samples,
864 sample_sizes,
865 dict_size,
866 options,
867 finalize,
868 &space,
869 keep,
870 )?;
871 Ok(dict)
872}
873
874/// Train FastCOVER dictionaries over a range of `k` and `d` and keep the one
875/// the scoring samples compress best with (the reference's
876/// `ZDICT_optimizeTrainFromBuffer_fastCover`, which `zstd --train` and
877/// `ZDICT_trainFromBuffer` run). Returns it with the options that built it.
878///
879/// The search is [`optimize_cover_dict`]'s; a non-positive `split_point` is
880/// 0.75 here.
881///
882/// # Errors
883///
884/// As [`optimize_cover_dict`] and [`train_fastcover_dict`].
885///
886/// # Examples
887///
888/// ```
889/// use structured_zstd::dictionary::{FastCoverOptions, FinalizeOptions, optimize_fastcover_dict};
890///
891/// let mut samples = Vec::new();
892/// let mut sizes = Vec::new();
893/// for i in 0..200u32 {
894/// let line = format!("tenant=demo table=orders key={i} region=eu status=shipped\n");
895/// sizes.push(line.len());
896/// samples.extend_from_slice(line.as_bytes());
897/// }
898/// let (dict, chosen) = optimize_fastcover_dict(
899/// &samples,
900/// &sizes,
901/// 4096,
902/// &FastCoverOptions::default(),
903/// FinalizeOptions::default(),
904/// )
905/// .unwrap();
906/// assert!(dict.len() <= 4096 && chosen.cover.d == 8);
907/// ```
908pub fn optimize_fastcover_dict(
909 samples: &[u8],
910 sample_sizes: &[usize],
911 dict_size: usize,
912 options: &FastCoverOptions,
913 finalize: FinalizeOptions,
914) -> io::Result<(Vec<u8>, FastCoverOptions)> {
915 let space = SearchSpace::optimizing(&options.cover, 0.75)?;
916 let keep = selection::Keep::Dictionary {
917 shrink: options.cover.shrink,
918 };
919 run_fastcover(
920 samples,
921 sample_sizes,
922 dict_size,
923 options,
924 finalize,
925 &space,
926 keep,
927 )
928}
929
930fn run_fastcover(
931 samples: &[u8],
932 sample_sizes: &[usize],
933 dict_size: usize,
934 options: &FastCoverOptions,
935 finalize: FinalizeOptions,
936 space: &SearchSpace,
937 keep: selection::Keep,
938) -> io::Result<(Vec<u8>, FastCoverOptions)> {
939 let (f, accel) = fastcover_knobs(options, space)?;
940 space.check_fits(dict_size)?;
941 let set = check_samples_and_dict_size(samples, sample_sizes, dict_size)?;
942 let split = set.split(space.split_point)?;
943 let plain = space.k.start() == space.k.end() && space.d.start() == space.d.end();
944 let scored = !plain || options.cover.shrink.is_some() || space.split_point < 1.0;
945 let mut window = fastcover::WindowCounts::default();
946 let mut freqs = Vec::new();
947 let mut content_scratch = Vec::new();
948 let mut best = selection::Best::new(keep);
949 // `None` beside a `d` is a dmer size these samples cannot count.
950 let mut context: Option<(usize, Option<fastcover::FastCoverContext<'_>>)> = None;
951 let mut evaluator = selection::Evaluator::new(
952 &set,
953 fastcover::finalize_samples(split.train, accel),
954 split.test.clone(),
955 dict_size,
956 finalize,
957 );
958 for (d, k) in space.pairs() {
959 if !segment_fits(k, d, dict_size) {
960 continue;
961 }
962 let chosen = FastCoverOptions {
963 cover: CoverOptions {
964 k: k as u32,
965 d: d as u32,
966 steps: space.steps,
967 split_point: space.split_point,
968 ..options.cover
969 },
970 f,
971 accel,
972 };
973 if context
974 .as_ref()
975 .is_none_or(|(built_for, _)| *built_for != d)
976 {
977 // The previous count table is released before the next is built.
978 drop(context.take());
979 // As in the COVER search: a size the samples cannot count is one
980 // failed candidate, returned only if no size builds anything.
981 let built = match fastcover::FastCoverContext::new(&set, split.train, d, f, accel) {
982 Ok(ctx) => Some(ctx),
983 Err(err) => {
984 best.offer(Err(err), &[], chosen);
985 None
986 }
987 };
988 context = Some((d, built));
989 }
990 let Some(ctx) = context.as_ref().and_then(|(_, built)| built.as_ref()) else {
991 continue;
992 };
993 let content = ctx.build(&mut freqs, &mut window, &mut content_scratch, dict_size, k)?;
994 if !scored {
995 return match keep {
996 selection::Keep::Content => Ok((content.to_vec(), chosen)),
997 selection::Keep::Dictionary { .. } => Ok((evaluator.finalize(content)?, chosen)),
998 };
999 }
1000 // Ranked at full size, the winner alone shrunk, as in the COVER search.
1001 best.offer(evaluator.score(content), content, chosen);
1002 }
1003 best.finish(&mut evaluator)
1004}
1005
1006/// Train and finalize a dictionary with the reference's original trainer, the
1007/// one `zstd --train-legacy` runs (`ZDICT_trainFromBuffer_legacy`).
1008///
1009/// `samples` is every sample back to back and `sample_sizes` their lengths:
1010/// the trainer searches the corpus as a whole, and the number of samples sets
1011/// how often a segment has to repeat to be kept, `samples >> selectivity`
1012/// times and at least 4. A higher `selectivity` keeps more, rarer segments;
1013/// zero is [`DEFAULT_SELECTIVITY`]. Corpus past 2000 MiB is dropped a whole
1014/// sample at a time from the end.
1015///
1016/// # Errors
1017///
1018/// `InvalidInput` when the corpus is under 512 bytes (carrying
1019/// [`TrainingError::Samples`]), when `dict_size` is below 256 bytes (carrying
1020/// [`TrainingError::DictionaryTooSmall`]), when it repeats too little to yield
1021/// 128 bytes of content, or when `sample_sizes` does not add up to
1022/// `samples.len()`.
1023///
1024/// # Examples
1025///
1026/// ```
1027/// use structured_zstd::dictionary::{create_legacy_dict_from_slice, FinalizeOptions};
1028///
1029/// let mut samples = Vec::new();
1030/// let mut sizes = Vec::new();
1031/// for i in 0..200u32 {
1032/// let line = format!("tenant=demo table=orders key={i} region=eu status=shipped\n");
1033/// sizes.push(line.len());
1034/// samples.extend_from_slice(line.as_bytes());
1035/// }
1036/// let mut dict = Vec::new();
1037/// create_legacy_dict_from_slice(&samples, &sizes, &mut dict, 4096, 0, FinalizeOptions::default())
1038/// .unwrap();
1039/// assert!(dict.starts_with(&[0x37, 0xA4, 0x30, 0xEC]));
1040/// ```
1041pub fn create_legacy_dict_from_slice<W: io::Write>(
1042 samples: &[u8],
1043 sample_sizes: &[usize],
1044 output: &mut W,
1045 dict_size: usize,
1046 selectivity: u32,
1047 finalize: FinalizeOptions,
1048) -> io::Result<()> {
1049 let described = sample_sizes
1050 .iter()
1051 .try_fold(0usize, |total, &size| total.checked_add(size));
1052 if described != Some(samples.len()) {
1053 return Err(io::Error::new(
1054 io::ErrorKind::InvalidInput,
1055 "the sample sizes do not add up to the corpus",
1056 ));
1057 }
1058 let content =
1059 legacy::train_legacy_raw(samples, sample_sizes, dict_size, selectivity).map_err(|err| {
1060 match err {
1061 legacy::TooSmall::Dictionary => refuse(
1062 TrainingError::DictionaryTooSmall,
1063 &format!(
1064 "a legacy dictionary must be at least {} bytes",
1065 legacy::DICT_SIZE_MIN
1066 ),
1067 ),
1068 legacy::TooSmall::Corpus => refuse(
1069 TrainingError::Samples,
1070 &format!(
1071 "the samples total {} bytes; the legacy trainer needs at least {}",
1072 samples.len(),
1073 legacy::MIN_SAMPLES_SIZE
1074 ),
1075 ),
1076 legacy::TooSmall::Content => io::Error::new(
1077 io::ErrorKind::InvalidInput,
1078 "the samples repeat too little to yield dictionary content",
1079 ),
1080 }
1081 })?;
1082 // Every sample, not only those the content search kept: the reference cuts
1083 // the corpus to its size limit inside the search alone, for its suffix
1084 // sort (zdict.c, `ZDICT_trainBuffer_legacy`), and builds the entropy tables
1085 // from all of them (`ZDICT_trainFromBuffer_unsafe_legacy`).
1086 let finalized = finalize_raw_dict(
1087 content.as_slice(),
1088 samples,
1089 sample_sizes,
1090 dict_size,
1091 finalize,
1092 )?;
1093 output.write_all(finalized.as_slice())
1094}
1095
1096/// The legacy trainer's content alone, for the reference comparison in
1097/// `ffi-bench`.
1098#[cfg(feature = "bench-internals")]
1099pub(crate) fn legacy_dict_content(
1100 samples: &[u8],
1101 sample_sizes: &[usize],
1102 dict_size: usize,
1103 selectivity: u32,
1104) -> Option<Vec<u8>> {
1105 legacy::train_legacy_raw(samples, sample_sizes, dict_size, selectivity).ok()
1106}
1107
1108/// Build a finalized FastCOVER dictionary, attach it to a fastest-level
1109/// frame compressor, and compress a fresh payload. Returns
1110/// `(finalized_dictionary, compressed_frame, original_payload)` so a
1111/// roundtrip check can decode `compressed_frame` against
1112/// `finalized_dictionary` and compare to `original_payload`. The
1113/// C-decoder roundtrip that consumes this lives in the `ffi-bench` crate;
1114/// this side stays pure Rust.
1115#[cfg(feature = "bench-internals")]
1116pub(crate) fn dict_roundtrip_fixture() -> (
1117 alloc::vec::Vec<u8>,
1118 alloc::vec::Vec<u8>,
1119 alloc::vec::Vec<u8>,
1120) {
1121 use crate::decoding::Dictionary;
1122 use crate::encoding::{CompressionLevel, FrameCompressor};
1123
1124 let mut sample = alloc::vec::Vec::new();
1125 let mut sizes = alloc::vec::Vec::new();
1126 for i in 0..512u32 {
1127 let line = alloc::format!(
1128 "tenant=demo table=orders key={i} region=eu payload=aaaaabbbbbcccccdddddeeeee\n"
1129 );
1130 sizes.push(line.len());
1131 sample.extend_from_slice(line.as_bytes());
1132 }
1133
1134 let options = FastCoverOptions {
1135 cover: CoverOptions {
1136 k: 256,
1137 ..FastCoverOptions::default().cover
1138 },
1139 ..FastCoverOptions::default()
1140 };
1141 let finalized = train_fastcover_dict(
1142 sample.as_slice(),
1143 &sizes,
1144 4096,
1145 &options,
1146 FinalizeOptions::default(),
1147 )
1148 .expect("training should succeed");
1149 let parsed =
1150 Dictionary::decode_dict(finalized.as_slice()).expect("finalized dictionary should parse");
1151 assert!(!parsed.dict_content.is_empty());
1152
1153 let mut payload = alloc::vec::Vec::new();
1154 for idx in 0..96u32 {
1155 payload.extend_from_slice(
1156 alloc::format!("tenant=demo op=put key={idx} value=aaaaabbbbbcccccdddddeeeee\n")
1157 .as_bytes(),
1158 );
1159 }
1160
1161 let mut compressed = alloc::vec::Vec::new();
1162 let mut compressor = FrameCompressor::new(CompressionLevel::Fastest);
1163 compressor
1164 .set_dictionary(parsed)
1165 .expect("dictionary should attach");
1166 compressor.set_source(payload.as_slice());
1167 compressor.set_drain(&mut compressed);
1168 compressor.compress();
1169
1170 (finalized, compressed, payload)
1171}
1172
1173#[cfg(test)]
1174mod tests;