Skip to main content

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 `dict_size` is below 256 bytes, when the corpus is under
1019/// 512 bytes, when it repeats too little to yield 128 bytes of content, or when
1020/// `sample_sizes` does not add up to `samples.len()`.
1021///
1022/// # Examples
1023///
1024/// ```
1025/// use structured_zstd::dictionary::{create_legacy_dict_from_slice, FinalizeOptions};
1026///
1027/// let mut samples = Vec::new();
1028/// let mut sizes = Vec::new();
1029/// for i in 0..200u32 {
1030///     let line = format!("tenant=demo table=orders key={i} region=eu status=shipped\n");
1031///     sizes.push(line.len());
1032///     samples.extend_from_slice(line.as_bytes());
1033/// }
1034/// let mut dict = Vec::new();
1035/// create_legacy_dict_from_slice(&samples, &sizes, &mut dict, 4096, 0, FinalizeOptions::default())
1036///     .unwrap();
1037/// assert!(dict.starts_with(&[0x37, 0xA4, 0x30, 0xEC]));
1038/// ```
1039pub fn create_legacy_dict_from_slice<W: io::Write>(
1040    samples: &[u8],
1041    sample_sizes: &[usize],
1042    output: &mut W,
1043    dict_size: usize,
1044    selectivity: u32,
1045    finalize: FinalizeOptions,
1046) -> io::Result<()> {
1047    let described = sample_sizes
1048        .iter()
1049        .try_fold(0usize, |total, &size| total.checked_add(size));
1050    if described != Some(samples.len()) {
1051        return Err(io::Error::new(
1052            io::ErrorKind::InvalidInput,
1053            "the sample sizes do not add up to the corpus",
1054        ));
1055    }
1056    let content =
1057        legacy::train_legacy_raw(samples, sample_sizes, dict_size, selectivity).map_err(|err| {
1058            let reason = match err {
1059                legacy::TooSmall::Dictionary => format!(
1060                    "a legacy dictionary must be at least {} bytes",
1061                    legacy::DICT_SIZE_MIN
1062                ),
1063                legacy::TooSmall::Corpus => format!(
1064                    "the samples total {} bytes; the legacy trainer needs at least {}",
1065                    samples.len(),
1066                    legacy::MIN_SAMPLES_SIZE
1067                ),
1068                legacy::TooSmall::Content => {
1069                    "the samples repeat too little to yield dictionary content".into()
1070                }
1071            };
1072            io::Error::new(io::ErrorKind::InvalidInput, reason)
1073        })?;
1074    // Every sample, not only those the content search kept: the reference cuts
1075    // the corpus to its size limit inside the search alone, for its suffix
1076    // sort (zdict.c, `ZDICT_trainBuffer_legacy`), and builds the entropy tables
1077    // from all of them (`ZDICT_trainFromBuffer_unsafe_legacy`).
1078    let finalized = finalize_raw_dict(
1079        content.as_slice(),
1080        samples,
1081        sample_sizes,
1082        dict_size,
1083        finalize,
1084    )?;
1085    output.write_all(finalized.as_slice())
1086}
1087
1088/// The legacy trainer's content alone, for the reference comparison in
1089/// `ffi-bench`.
1090#[cfg(feature = "bench-internals")]
1091pub(crate) fn legacy_dict_content(
1092    samples: &[u8],
1093    sample_sizes: &[usize],
1094    dict_size: usize,
1095    selectivity: u32,
1096) -> Option<Vec<u8>> {
1097    legacy::train_legacy_raw(samples, sample_sizes, dict_size, selectivity).ok()
1098}
1099
1100/// Build a finalized FastCOVER dictionary, attach it to a fastest-level
1101/// frame compressor, and compress a fresh payload. Returns
1102/// `(finalized_dictionary, compressed_frame, original_payload)` so a
1103/// roundtrip check can decode `compressed_frame` against
1104/// `finalized_dictionary` and compare to `original_payload`. The
1105/// C-decoder roundtrip that consumes this lives in the `ffi-bench` crate;
1106/// this side stays pure Rust.
1107#[cfg(feature = "bench-internals")]
1108pub(crate) fn dict_roundtrip_fixture() -> (
1109    alloc::vec::Vec<u8>,
1110    alloc::vec::Vec<u8>,
1111    alloc::vec::Vec<u8>,
1112) {
1113    use crate::decoding::Dictionary;
1114    use crate::encoding::{CompressionLevel, FrameCompressor};
1115
1116    let mut sample = alloc::vec::Vec::new();
1117    let mut sizes = alloc::vec::Vec::new();
1118    for i in 0..512u32 {
1119        let line = alloc::format!(
1120            "tenant=demo table=orders key={i} region=eu payload=aaaaabbbbbcccccdddddeeeee\n"
1121        );
1122        sizes.push(line.len());
1123        sample.extend_from_slice(line.as_bytes());
1124    }
1125
1126    let options = FastCoverOptions {
1127        cover: CoverOptions {
1128            k: 256,
1129            ..FastCoverOptions::default().cover
1130        },
1131        ..FastCoverOptions::default()
1132    };
1133    let finalized = train_fastcover_dict(
1134        sample.as_slice(),
1135        &sizes,
1136        4096,
1137        &options,
1138        FinalizeOptions::default(),
1139    )
1140    .expect("training should succeed");
1141    let parsed =
1142        Dictionary::decode_dict(finalized.as_slice()).expect("finalized dictionary should parse");
1143    assert!(!parsed.dict_content.is_empty());
1144
1145    let mut payload = alloc::vec::Vec::new();
1146    for idx in 0..96u32 {
1147        payload.extend_from_slice(
1148            alloc::format!("tenant=demo op=put key={idx} value=aaaaabbbbbcccccdddddeeeee\n")
1149                .as_bytes(),
1150        );
1151    }
1152
1153    let mut compressed = alloc::vec::Vec::new();
1154    let mut compressor = FrameCompressor::new(CompressionLevel::Fastest);
1155    compressor
1156        .set_dictionary(parsed)
1157        .expect("dictionary should attach");
1158    compressor.set_source(payload.as_slice());
1159    compressor.set_drain(&mut compressed);
1160    compressor.compress();
1161
1162    (finalized, compressed, payload)
1163}
1164
1165#[cfg(test)]
1166mod tests;