Skip to main content

structured_zstd/dictionary/
mod.rs

1//! Code for creating a separate content dictionary.
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.
5//!
6//! Implemented following the paper "Effective construction of
7//! Relative Lempel-Ziv Dictionaries", by Kewen Liao, Matthias Petri,
8//! Alistair Moffat, and Anthony Wirth
9
10// The algorithm is summarized here
11// 1. The text is split into "epochs", or chunks from the original source
12// 2. From within each epoch, we select the "segment", or 1 KiB contiguous section
13//    that's predicted to be the best option to include in the dictionary. Concatenated,
14//    these segments form the dictionary.
15//
16// This segment scoring algorithm operates as follows:
17// For a given epoch:
18//  - Run a reservoir sampler over the entire epoch, creating a
19//    reservoir of n/t, where `t` is the desired number of occurrences
20//    we want the most common k-mers to have
21//  - Have the ability to estimate
22//    the frequency of a given k-mer: `f(w: k-mer)` calculates
23//    the frequency of w in the reservoir using a rolling karp-rabin hash
24//  - The score of a segment is the sum of `f(w)` called on every kmer within the segment
25mod cover;
26mod fastcover;
27mod frequency;
28mod legacy;
29mod reservoir;
30mod suffix_array;
31
32use crate::bit_io::BitWriter;
33use crate::blocks::sequence_section::{
34    MAX_LITERAL_LENGTH_CODE, MAX_MATCH_LENGTH_CODE, MAX_OFFSET_CODE,
35};
36use crate::decoding::dictionary::MAGIC_NUM as DICT_MAGIC_NUM;
37use crate::decoding::sequence_section_decoder::{LL_MAX_LOG, ML_MAX_LOG, OF_MAX_LOG};
38use crate::dictionary::reservoir::create_sample;
39use crate::fse::fse_encoder::{self, build_table_from_symbol_counts};
40use crate::huff0::HuffmanTable as HuffmanDecoderTable;
41use crate::huff0::huff0_encoder::{HuffmanEncoder, HuffmanTable as HuffmanEncoderTable};
42use core::cmp::Reverse;
43use cover::*;
44pub use fastcover::{
45    DEFAULT_D_CANDIDATES, DEFAULT_F_CANDIDATES, DEFAULT_K_CANDIDATES, FastCoverParams,
46    FastCoverTuned,
47};
48pub use legacy::DEFAULT_SELECTIVITY;
49use std::{
50    boxed::Box,
51    collections::{BinaryHeap, HashMap},
52    format,
53    fs::{self, File},
54    io::{self, Read},
55    path::{Path, PathBuf},
56    // `vec` import covers the `vec![..]` macro used below: this crate is
57    // no_std-with-std-feature, so the std prelude isn't pulled in implicitly
58    // for top-level items in this module. Removing this import fails the
59    // build with `cannot find macro 'vec' in this scope` — verified.
60    vec,
61    vec::Vec,
62};
63
64const MAX_TRAINING_PREALLOC_BYTES: usize = 8 * 1024 * 1024;
65const MAX_HUFFMAN_STATS_BYTES: usize = 64 * 1024;
66
67/// Smallest size a trained dictionary can occupy, whatever it was trained on.
68///
69/// The magic number, the dictionary ID, the three repeat offsets and the
70/// shortest content the writers emit are unconditional; a real dictionary is
71/// larger still, since the entropy tables between them are never empty. Use it
72/// to reject an impossible `dict_size` before spending the corpus: the training
73/// entry points can only discover the true bound once those tables are built.
74pub const MIN_TRAINED_DICT_SIZE: usize = DICT_MAGIC_NUM.len() + 4 + 12 + 8;
75
76/// Tuning knobs for pure-Rust FastCOVER training.
77#[derive(Debug, Clone)]
78pub struct FastCoverOptions {
79    pub optimize: bool,
80    pub split_point: f64,
81    pub accel: usize,
82    pub k: usize,
83    pub d: usize,
84    /// Width of the dmer frequency table in bits, `1..=31`; its memory grows
85    /// as `2^f`. A value outside the range is brought to the nearest end.
86    pub f: u32,
87    pub k_candidates: Vec<usize>,
88    pub d_candidates: Vec<usize>,
89    pub f_candidates: Vec<u32>,
90}
91
92impl Default for FastCoverOptions {
93    fn default() -> Self {
94        Self {
95            optimize: true,
96            split_point: 0.75,
97            accel: 1,
98            k: 256,
99            d: 8,
100            f: 20,
101            k_candidates: DEFAULT_K_CANDIDATES.to_vec(),
102            d_candidates: DEFAULT_D_CANDIDATES.to_vec(),
103            f_candidates: DEFAULT_F_CANDIDATES.to_vec(),
104        }
105    }
106}
107
108#[derive(Debug, Clone, Copy, Default)]
109pub struct FinalizeOptions {
110    pub dict_id: Option<u32>,
111}
112
113/// A set of values that are used during dictionary construction.
114///
115/// Changing these values can improve the resulting dictionary size for certain datasets.
116// TODO: move `k` here.
117pub(super) struct DictParams {
118    /// Segment size.
119    ///
120    /// As found under "4. Experiments - Varying Segment Size" in the original paper, a
121    /// segment size of 2 kiB was effective.
122    ///
123    /// "We explored a range of \[`segment_size`\] values and found the performance of LMC is insensitive
124    /// to \[`segment_size`\]. We fix \[`segment_size`\] to 2kiB
125    ///
126    /// Reasonable range: [16, 2048+]
127    pub segment_size: u32,
128}
129
130/// Creates a "raw content" dictionary, training off of every file in this directory and all
131/// sub-directories.
132///
133/// The resulting dictionary will be approximately `dict_size` or less, and written to `output`.
134///
135/// # Errors
136/// This function returns `Ok(())` if the dictionary was created successfully, and an
137/// `Err(io::Error)` if an error was encountered reading the input directory or
138/// writing dictionary bytes to `output`.
139///
140/// # Examples
141/// ```no_run
142/// use std::fs::File;
143/// // Create a roughly 1mb dictionary, training off of file in `sample_files`
144/// let input_folder = "sample_files/";
145/// let mut output = File::create("output.dict").unwrap();
146/// structured_zstd::dictionary::create_raw_dict_from_dir(input_folder, &mut output, 1_000_000)
147///     .expect("dictionary training from sample_files should succeed");
148/// ```
149pub fn create_raw_dict_from_dir<P: AsRef<Path>, W: io::Write>(
150    path: P,
151    output: &mut W,
152    dict_size: usize,
153) -> Result<(), io::Error> {
154    // Collect a list of a path to every file in the directory into `file_paths`
155    let mut file_paths: Vec<PathBuf> = Vec::new();
156    let dir: fs::ReadDir = fs::read_dir(path)?;
157    fn recurse_read(dir: fs::ReadDir, file_paths: &mut Vec<PathBuf>) -> Result<(), io::Error> {
158        for entry in dir {
159            let entry = entry?;
160            if entry.file_type()?.is_dir() {
161                recurse_read(fs::read_dir(entry.path())?, file_paths)?;
162            } else {
163                file_paths.push(entry.path());
164            }
165        }
166        Ok(())
167    }
168    recurse_read(dir, &mut file_paths)?;
169
170    // Open each file and chain the readers together
171    let mut total_file_len: u64 = 0;
172    let mut file_handles: Vec<fs::File> = Vec::new();
173    for path in file_paths {
174        let handle = File::open(path)?;
175        total_file_len += handle.metadata()?.len();
176        file_handles.push(handle);
177    }
178    let empty_reader: Box<dyn Read> = Box::new(io::empty());
179    let chained_files = file_handles
180        .iter()
181        .fold(empty_reader, |acc, reader| Box::new(acc.chain(reader)));
182
183    // Create a dict using the new reader
184    create_raw_dict_from_source(chained_files, total_file_len as usize, output, dict_size)?;
185    Ok(())
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`].
204///
205/// # API note
206/// This public API returns `io::Result<()>` and propagates source/output I/O failures.
207pub fn create_raw_dict_from_source<R: io::Read, W: io::Write>(
208    mut source: R,
209    source_size: usize,
210    output: &mut W,
211    dict_size: usize,
212) -> io::Result<()> {
213    if dict_size == 0 {
214        return Ok(());
215    }
216    let prealloc = source_size.min(MAX_TRAINING_PREALLOC_BYTES);
217    let mut all = Vec::with_capacity(prealloc);
218    source.read_to_end(&mut all)?;
219    create_raw_dict_from_slice(&all, output, dict_size)
220}
221
222/// Create a "raw content" dictionary of at most `dict_size` bytes from a
223/// corpus already in memory, writing it to `output`.
224///
225/// The same training as [`create_raw_dict_from_source`], reading `source` in
226/// place: a caller that holds the samples anyway does not pay for a second
227/// copy of them.
228///
229/// # Errors
230/// Returns the error `output` reports while the dictionary is written.
231///
232/// # Examples
233/// ```
234/// use structured_zstd::dictionary::create_raw_dict_from_slice;
235///
236/// let corpus: Vec<u8> = (0..20_000u32)
237///     .flat_map(|i| format!("record {} value {}\n", i % 100, i % 7).into_bytes())
238///     .collect();
239/// let mut dict = Vec::new();
240/// create_raw_dict_from_slice(&corpus, &mut dict, 4096).unwrap();
241/// assert!(!dict.is_empty() && dict.len() <= 4096);
242/// ```
243pub fn create_raw_dict_from_slice<W: io::Write>(
244    all: &[u8],
245    output: &mut W,
246    dict_size: usize,
247) -> io::Result<()> {
248    if dict_size == 0 || all.is_empty() {
249        return Ok(());
250    }
251
252    if all.len() < K {
253        let keep = usize::min(all.len(), dict_size);
254        output.write_all(&all[all.len() - keep..])?;
255        return Ok(());
256    }
257
258    let source_size = all.len();
259    vprintln!("create_dict: creating {dict_size} byte dict from {source_size} byte source");
260
261    let params = DictParams { segment_size: 2048 };
262    let num_segments = usize::max(1, source_size / params.segment_size as usize);
263    // According to 4. Experiments - Varying Reservoir Sampler Thresholds,
264    // setting reservoir size to collection size / min{collection size / (2 * number of segments),
265    // 256} was effective
266    let denom = usize::max(1, source_size / (2 * num_segments));
267    let sample_scale = usize::max(1, usize::min(denom, 256));
268    let mut sample_size = source_size / sample_scale;
269    sample_size = usize::max(sample_size, usize::min(source_size, 16));
270    vprintln!("create_dict: creating {sample_size} byte sample of collection");
271    let mut sample_reader = all;
272    let collection_sample = create_sample(&mut sample_reader, sample_size);
273
274    // A collection of segments to be used in the final dictionary.
275    //
276    // Contains the best segment from every epoch.
277    // Reverse is used because we want a min heap, where
278    // the lowest scoring items come first
279    let mut pool: BinaryHeap<Reverse<Segment>> = BinaryHeap::new();
280    let (num_epochs, epoch_size_kmers) = compute_epoch_info(&params, dict_size, source_size / K);
281    // Plain `*`/`+` throughout the epoch walk below: epochs partition the
282    // training source, so `epoch_size_kmers * K`, `epoch_idx * epoch_size`, and
283    // `start + epoch_size` are all bounded by the source length (<= isize::MAX)
284    // and cannot overflow usize.
285    let epoch_size = usize::max(K, epoch_size_kmers * K);
286    vprintln!("create_dict: computed epoch info, using {num_epochs} epochs of {epoch_size} bytes");
287    let mut epoch_counter = 0;
288    let mut ctx = Context {
289        frequencies: HashMap::with_capacity(epoch_size / K),
290    };
291    // Score each segment in each planned epoch and select the highest-scoring
292    // segment for the pool. Keep exactly `num_epochs` windows to avoid
293    // emitting more segments than the requested dictionary budget allows.
294    for epoch_idx in 0..num_epochs {
295        let start = epoch_idx * epoch_size;
296        if start >= all.len() {
297            break;
298        }
299        let end = if epoch_idx + 1 == num_epochs {
300            all.len()
301        } else {
302            usize::min(start + epoch_size, all.len())
303        };
304        let epoch = &all[start..end];
305        epoch_counter += 1;
306        let best_segment = pick_best_segment(&params, &mut ctx, epoch, &collection_sample);
307        vprintln!(
308            "\tcreate_dict: epoch {epoch_counter}/{num_epochs} has best segment score {}",
309            best_segment.score
310        );
311        pool.push(Reverse(best_segment));
312        // Wipe frequency list for next epoch
313        ctx.frequencies.clear();
314    }
315    vprintln!(
316        "create_dict: {epoch_counter} epochs written, writing {} segments",
317        pool.len()
318    );
319    // Write the dictionary with the highest scoring segment last because
320    // closer items can be represented with a smaller offset
321    while let Some(segment) = pool.pop() {
322        output.write_all(&segment.0.raw)?;
323    }
324    Ok(())
325}
326
327/// The `i`th of [`MAX_HUFFMAN_STATS_BYTES`] samples spread evenly over `len`
328/// bytes.
329///
330/// Computed in 64 bits: `i * len` reaches 2^48 for an addressable corpus, which
331/// a 32-bit `usize` cannot hold — the product overflows for any corpus past
332/// 64 KiB there, and the multiply panics rather than sampling. The quotient is
333/// always below `len`, so the narrowing back is exact.
334fn strided_index(i: usize, len: usize) -> usize {
335    ((i as u64 * len as u64) / MAX_HUFFMAN_STATS_BYTES as u64) as usize
336}
337
338fn serialize_huffman_table(sample_data: &[u8], raw_content: &[u8]) -> io::Result<Vec<u8>> {
339    fn bounded_huffman_stats(data: &[u8]) -> Vec<u8> {
340        if data.len() <= MAX_HUFFMAN_STATS_BYTES {
341            return data.to_vec();
342        }
343
344        let mut stats = Vec::with_capacity(MAX_HUFFMAN_STATS_BYTES);
345        for i in 0..MAX_HUFFMAN_STATS_BYTES {
346            stats.push(data[strided_index(i, data.len())]);
347        }
348        stats
349    }
350
351    let source = if sample_data.len() >= 2 {
352        sample_data
353    } else {
354        raw_content
355    };
356    let mut stats = bounded_huffman_stats(source);
357    if stats.len() < 2 || stats.iter().all(|b| *b == stats[0]) {
358        // A corpus with no distribution to measure gets a synthetic one. It
359        // stops at 128 symbols because a perfectly flat alphabet gives every
360        // symbol the same weight: FSE cannot encode that (an RLE weight
361        // stream), and the direct nibble form addresses at most 128 symbols, so
362        // a full 0..=255 alphabet would have no description at all.
363        stats = (0u8..128).collect();
364    }
365
366    let mut table = HuffmanEncoderTable::build_from_data(stats.as_slice());
367    if table
368        .writeable_table_description_size(&mut crate::fse::fse_encoder::FSETable::blank())
369        .is_none()
370    {
371        // Sampled real data can land on the same shape: a flat alphabet wider
372        // than 128 symbols. Fall back to the synthetic narrow one, which always
373        // has a description.
374        stats = (0u8..128).collect();
375        table = HuffmanEncoderTable::build_from_data(stats.as_slice());
376    }
377    let mut writer = BitWriter::new();
378    let mut encoder = HuffmanEncoder::new(&table, &mut writer);
379    encoder.encode(&[stats[0]], true);
380    let encoded = writer.dump();
381
382    let mut decoder = HuffmanDecoderTable::new();
383    let table_size = decoder
384        .build_decoder(encoded.as_slice())
385        .map_err(|e| io::Error::other(format!("failed to decode generated huffman table: {e}")))?;
386    Ok(encoded[..table_size as usize].to_vec())
387}
388
389fn serialize_fse_table(table: &fse_encoder::FSETable) -> Vec<u8> {
390    let mut writer = BitWriter::new();
391    table.write_table(&mut writer);
392    writer.dump()
393}
394
395fn bounded_fse_symbols(data: &[u8], max_symbol: u8) -> Vec<u8> {
396    let modulo = u16::from(max_symbol) + 1;
397    if data.is_empty() {
398        return Vec::from([0u8]);
399    }
400    if data.len() <= MAX_HUFFMAN_STATS_BYTES {
401        return data
402            .iter()
403            .map(|b| (u16::from(*b) % modulo) as u8)
404            .collect();
405    }
406
407    let mut out = Vec::with_capacity(MAX_HUFFMAN_STATS_BYTES);
408    for i in 0..MAX_HUFFMAN_STATS_BYTES {
409        let idx = strided_index(i, data.len());
410        out.push((u16::from(data[idx]) % modulo) as u8);
411    }
412    out
413}
414
415fn serialize_fse_table_from_corpus(
416    sample_data: &[u8],
417    raw_content: &[u8],
418    max_symbol: u8,
419    max_log: u8,
420) -> io::Result<Vec<u8>> {
421    fn counts_total_for_source(source: &[u8], max_symbol: u8, counts: &mut [usize]) -> usize {
422        counts.fill(0);
423        for symbol in bounded_fse_symbols(source, max_symbol) {
424            counts[usize::from(symbol)] += 1;
425        }
426        counts.iter().sum::<usize>()
427    }
428
429    let mut counts = vec![0usize; usize::from(max_symbol) + 1];
430    let using_sample = !sample_data.is_empty();
431    let mut total = counts_total_for_source(
432        if using_sample {
433            sample_data
434        } else {
435            raw_content
436        },
437        max_symbol,
438        &mut counts,
439    );
440    if total <= 1 && using_sample && !raw_content.is_empty() {
441        total = counts_total_for_source(raw_content, max_symbol, &mut counts);
442    }
443    if total <= 1 {
444        return Err(io::Error::new(
445            io::ErrorKind::InvalidInput,
446            "insufficient symbol statistics for FSE table",
447        ));
448    }
449    let table = build_table_from_symbol_counts(&counts, max_log, false);
450    Ok(serialize_fse_table(&table))
451}
452
453fn finalized_content_budget(
454    sample_data: &[u8],
455    raw_fallback: &[u8],
456    dict_size: usize,
457) -> io::Result<usize> {
458    let min_content_size = 8usize;
459    let huf_len = serialize_huffman_table(sample_data, raw_fallback)?.len();
460    let of_len =
461        serialize_fse_table_from_corpus(sample_data, raw_fallback, MAX_OFFSET_CODE, OF_MAX_LOG)?
462            .len();
463    let ml_len = serialize_fse_table_from_corpus(
464        sample_data,
465        raw_fallback,
466        MAX_MATCH_LENGTH_CODE,
467        ML_MAX_LOG,
468    )?
469    .len();
470    let ll_len = serialize_fse_table_from_corpus(
471        sample_data,
472        raw_fallback,
473        MAX_LITERAL_LENGTH_CODE,
474        LL_MAX_LOG,
475    )?
476    .len();
477
478    let header_len = DICT_MAGIC_NUM.len() + 4 + huf_len + of_len + ml_len + ll_len + 12;
479    let max_content_budget = dict_size.saturating_sub(header_len);
480    if max_content_budget < min_content_size {
481        return Err(io::Error::new(
482            io::ErrorKind::InvalidInput,
483            "dictionary size too small to fit header and offset history",
484        ));
485    }
486    Ok(max_content_budget)
487}
488
489fn derive_dict_id(raw_content: &[u8]) -> u32 {
490    let mut h = 0xcbf29ce484222325u64;
491    for &b in raw_content {
492        h ^= u64::from(b);
493        h = h.wrapping_mul(0x100000001b3);
494    }
495    let compliant = (h % ((1u64 << 31) - 32768)) + 32768;
496    compliant as u32
497}
498
499/// Finalize raw dictionary content into a full zstd dictionary binary
500/// (`magic + dict_id + entropy tables + offset history + content`).
501pub fn finalize_raw_dict(
502    raw_content: &[u8],
503    sample_data: &[u8],
504    dict_size: usize,
505    options: FinalizeOptions,
506) -> io::Result<Vec<u8>> {
507    if raw_content.is_empty() {
508        return Err(io::Error::new(
509            io::ErrorKind::InvalidInput,
510            "raw dictionary content must not be empty",
511        ));
512    }
513    let mut out = Vec::with_capacity(dict_size.max(256));
514    out.extend_from_slice(&DICT_MAGIC_NUM);
515    let dict_id = options
516        .dict_id
517        .unwrap_or_else(|| derive_dict_id(raw_content));
518    if dict_id == 0 {
519        return Err(io::Error::new(
520            io::ErrorKind::InvalidInput,
521            "dictionary id must be non-zero",
522        ));
523    }
524    out.extend_from_slice(&dict_id.to_le_bytes());
525    out.extend_from_slice(serialize_huffman_table(sample_data, raw_content)?.as_slice());
526    out.extend_from_slice(
527        serialize_fse_table_from_corpus(sample_data, raw_content, MAX_OFFSET_CODE, OF_MAX_LOG)?
528            .as_slice(),
529    );
530    out.extend_from_slice(
531        serialize_fse_table_from_corpus(
532            sample_data,
533            raw_content,
534            MAX_MATCH_LENGTH_CODE,
535            ML_MAX_LOG,
536        )?
537        .as_slice(),
538    );
539    out.extend_from_slice(
540        serialize_fse_table_from_corpus(
541            sample_data,
542            raw_content,
543            MAX_LITERAL_LENGTH_CODE,
544            LL_MAX_LOG,
545        )?
546        .as_slice(),
547    );
548
549    // Repeat offsets: keep default bootstrap history.
550    out.extend_from_slice(&1u32.to_le_bytes());
551    out.extend_from_slice(&4u32.to_le_bytes());
552    out.extend_from_slice(&8u32.to_le_bytes());
553
554    let min_content_size = 8usize;
555    let max_content_budget = dict_size.saturating_sub(out.len());
556    if max_content_budget < min_content_size {
557        return Err(io::Error::new(
558            io::ErrorKind::InvalidInput,
559            "dictionary size too small to fit header and offset history",
560        ));
561    }
562
563    let content = if raw_content.len() > max_content_budget {
564        &raw_content[raw_content.len() - max_content_budget..]
565    } else {
566        raw_content
567    };
568    if content.len() < min_content_size {
569        out.resize(out.len() + (min_content_size - content.len()), 0);
570    }
571    out.extend_from_slice(content);
572    Ok(out)
573}
574
575/// Train a raw FastCOVER dictionary from a source stream. A frequency table
576/// wider than memory allows is an `OutOfMemory` error.
577fn train_fastcover_internal(
578    sample: &[u8],
579    dict_size: usize,
580    options: &FastCoverOptions,
581) -> io::Result<(Vec<u8>, FastCoverTuned)> {
582    let trained = if options.optimize {
583        fastcover::optimize_fastcover_raw(
584            sample,
585            dict_size,
586            options.split_point,
587            options.accel,
588            options.d_candidates.as_slice(),
589            options.f_candidates.as_slice(),
590            options.k_candidates.as_slice(),
591        )
592    } else {
593        let params = fastcover::normalize_fastcover_params(FastCoverParams {
594            k: options.k,
595            d: options.d,
596            f: options.f,
597            accel: options.accel,
598        });
599        fastcover::train_fastcover_raw(sample, dict_size, params).map(|dict| {
600            (
601                dict,
602                FastCoverTuned {
603                    k: params.k,
604                    d: params.d,
605                    f: params.f,
606                    accel: params.accel,
607                    score: 0,
608                },
609            )
610        })
611    };
612    trained.map_err(io::Error::from)
613}
614
615impl From<fastcover::TableTooLarge> for io::Error {
616    fn from(table: fastcover::TableTooLarge) -> Self {
617        io::Error::new(
618            io::ErrorKind::OutOfMemory,
619            format!(
620                "a FastCOVER table of {} entries does not fit in memory; use a smaller f",
621                table.entries
622            ),
623        )
624    }
625}
626
627/// Train a raw FastCOVER dictionary directly from an in-memory sample.
628pub fn train_fastcover_raw_from_slice(
629    sample: &[u8],
630    dict_size: usize,
631    options: &FastCoverOptions,
632) -> io::Result<(Vec<u8>, FastCoverTuned)> {
633    if sample.is_empty() {
634        return Err(io::Error::new(
635            io::ErrorKind::InvalidInput,
636            "source stream is empty",
637        ));
638    }
639    let (dict, tuned) = train_fastcover_internal(sample, dict_size, options)?;
640    if dict.is_empty() && dict_size > 0 {
641        return Err(io::Error::new(
642            io::ErrorKind::InvalidInput,
643            "training sample is too small for FastCOVER",
644        ));
645    }
646    Ok((dict, tuned))
647}
648
649/// Train a raw FastCOVER dictionary from a source stream.
650///
651/// This function fully buffers the entire training corpus into memory via
652/// `read_to_end`, which can consume significant RAM for large inputs.
653pub fn create_fastcover_raw_dict_from_source<R: io::Read, W: io::Write>(
654    mut source: R,
655    output: &mut W,
656    dict_size: usize,
657    options: &FastCoverOptions,
658) -> io::Result<FastCoverTuned> {
659    let mut sample = Vec::new();
660    source.read_to_end(&mut sample)?;
661    let (dict, tuned) = train_fastcover_raw_from_slice(sample.as_slice(), dict_size, options)?;
662    output.write_all(dict.as_slice())?;
663    Ok(tuned)
664}
665
666/// Train and finalize a FastCOVER dictionary in pure Rust.
667///
668/// This function fully buffers the entire training corpus into memory via
669/// `read_to_end`, which can consume significant RAM for large inputs.
670pub fn create_fastcover_dict_from_source<R: io::Read, W: io::Write>(
671    mut source: R,
672    output: &mut W,
673    dict_size: usize,
674    fastcover: &FastCoverOptions,
675    finalize: FinalizeOptions,
676) -> io::Result<FastCoverTuned> {
677    let mut sample = Vec::new();
678    source.read_to_end(&mut sample)?;
679    create_fastcover_dict_from_slice(sample.as_slice(), output, dict_size, fastcover, finalize)
680}
681
682/// Train and finalize a FastCOVER dictionary from a corpus already in memory.
683///
684/// The same work as [`create_fastcover_dict_from_source`] for a caller that
685/// holds the bytes: the corpus is the largest allocation training makes, and
686/// handing it over as a slice keeps it to one copy rather than buffering it a
687/// second time inside.
688pub fn create_fastcover_dict_from_slice<W: io::Write>(
689    sample: &[u8],
690    output: &mut W,
691    dict_size: usize,
692    fastcover: &FastCoverOptions,
693    finalize: FinalizeOptions,
694) -> io::Result<FastCoverTuned> {
695    if sample.is_empty() {
696        return Err(io::Error::new(
697            io::ErrorKind::InvalidInput,
698            "source stream is empty",
699        ));
700    }
701    let content_budget = finalized_content_budget(sample, sample, dict_size)?;
702    let (raw_dict, tuned) = train_fastcover_raw_from_slice(sample, content_budget, fastcover)?;
703
704    let finalized = finalize_raw_dict(raw_dict.as_slice(), sample, dict_size, finalize)?;
705    output.write_all(finalized.as_slice())?;
706    Ok(tuned)
707}
708
709/// Train and finalize a dictionary with the reference's original trainer, the
710/// one `zstd --train-legacy` runs (`ZDICT_trainFromBuffer_legacy`).
711///
712/// `samples` is every sample back to back and `sample_sizes` their lengths:
713/// the trainer searches the corpus as a whole, and the number of samples sets
714/// how often a segment has to repeat to be kept, `samples >> selectivity`
715/// times and at least 4. A higher `selectivity` keeps more, rarer segments;
716/// zero is [`DEFAULT_SELECTIVITY`]. Corpus past 2000 MiB is dropped a whole
717/// sample at a time from the end.
718///
719/// # Errors
720///
721/// `InvalidInput` when `dict_size` is below 256 bytes, when the corpus is under
722/// 512 bytes, when it repeats too little to yield 128 bytes of content, or when
723/// `sample_sizes` does not add up to `samples.len()`.
724///
725/// # Examples
726///
727/// ```
728/// use structured_zstd::dictionary::{create_legacy_dict_from_slice, FinalizeOptions};
729///
730/// let mut samples = Vec::new();
731/// let mut sizes = Vec::new();
732/// for i in 0..200u32 {
733///     let line = format!("tenant=demo table=orders key={i} region=eu status=shipped\n");
734///     sizes.push(line.len());
735///     samples.extend_from_slice(line.as_bytes());
736/// }
737/// let mut dict = Vec::new();
738/// create_legacy_dict_from_slice(&samples, &sizes, &mut dict, 4096, 0, FinalizeOptions::default())
739///     .unwrap();
740/// assert!(dict.starts_with(&[0x37, 0xA4, 0x30, 0xEC]));
741/// ```
742pub fn create_legacy_dict_from_slice<W: io::Write>(
743    samples: &[u8],
744    sample_sizes: &[usize],
745    output: &mut W,
746    dict_size: usize,
747    selectivity: u32,
748    finalize: FinalizeOptions,
749) -> io::Result<()> {
750    let described = sample_sizes
751        .iter()
752        .try_fold(0usize, |total, &size| total.checked_add(size));
753    if described != Some(samples.len()) {
754        return Err(io::Error::new(
755            io::ErrorKind::InvalidInput,
756            "the sample sizes do not add up to the corpus",
757        ));
758    }
759    let content =
760        legacy::train_legacy_raw(samples, sample_sizes, dict_size, selectivity).map_err(|err| {
761            let reason = match err {
762                legacy::TooSmall::Dictionary => format!(
763                    "a legacy dictionary must be at least {} bytes",
764                    legacy::DICT_SIZE_MIN
765                ),
766                legacy::TooSmall::Corpus => format!(
767                    "the samples total {} bytes; the legacy trainer needs at least {}",
768                    samples.len(),
769                    legacy::MIN_SAMPLES_SIZE
770                ),
771                legacy::TooSmall::Content => {
772                    "the samples repeat too little to yield dictionary content".into()
773                }
774            };
775            io::Error::new(io::ErrorKind::InvalidInput, reason)
776        })?;
777    // Every sample, not only those the content search kept: the reference cuts
778    // the corpus to its size limit inside the search alone, for its suffix
779    // sort (zdict.c, `ZDICT_trainBuffer_legacy`), and builds the entropy tables
780    // from all of them (`ZDICT_trainFromBuffer_unsafe_legacy`).
781    let finalized = finalize_raw_dict(content.as_slice(), samples, dict_size, finalize)?;
782    output.write_all(finalized.as_slice())
783}
784
785/// The legacy trainer's content alone, for the reference comparison in
786/// `ffi-bench`.
787#[cfg(feature = "bench-internals")]
788pub(crate) fn legacy_dict_content(
789    samples: &[u8],
790    sample_sizes: &[usize],
791    dict_size: usize,
792    selectivity: u32,
793) -> Option<Vec<u8>> {
794    legacy::train_legacy_raw(samples, sample_sizes, dict_size, selectivity).ok()
795}
796
797/// Build a finalized FastCOVER dictionary, attach it to a fastest-level
798/// frame compressor, and compress a fresh payload. Returns
799/// `(finalized_dictionary, compressed_frame, original_payload)` so a
800/// roundtrip check can decode `compressed_frame` against
801/// `finalized_dictionary` and compare to `original_payload`. The
802/// C-decoder roundtrip that consumes this lives in the `ffi-bench` crate;
803/// this side stays pure Rust.
804#[cfg(feature = "bench-internals")]
805pub(crate) fn dict_roundtrip_fixture() -> (
806    alloc::vec::Vec<u8>,
807    alloc::vec::Vec<u8>,
808    alloc::vec::Vec<u8>,
809) {
810    use crate::decoding::Dictionary;
811    use crate::encoding::{CompressionLevel, FrameCompressor};
812
813    let mut sample = alloc::vec::Vec::new();
814    for i in 0..512u32 {
815        sample.extend_from_slice(
816            alloc::format!(
817                "tenant=demo table=orders key={i} region=eu payload=aaaaabbbbbcccccdddddeeeee\n"
818            )
819            .as_bytes(),
820        );
821    }
822
823    let dict_size = 4096usize;
824    let content_budget = finalized_content_budget(sample.as_slice(), sample.as_slice(), dict_size)
825        .expect("content budget should be computable");
826    let raw = fastcover::train_fastcover_raw(
827        sample.as_slice(),
828        content_budget,
829        fastcover::FastCoverParams {
830            k: 256,
831            d: 8,
832            f: 20,
833            accel: 1,
834        },
835    )
836    .expect("a 2^20 table fits");
837    let finalized = finalize_raw_dict(
838        raw.as_slice(),
839        sample.as_slice(),
840        dict_size,
841        FinalizeOptions::default(),
842    )
843    .expect("finalization should succeed");
844    let parsed =
845        Dictionary::decode_dict(finalized.as_slice()).expect("finalized dictionary should parse");
846    assert!(!parsed.dict_content.is_empty());
847
848    let mut payload = alloc::vec::Vec::new();
849    for idx in 0..96u32 {
850        payload.extend_from_slice(
851            alloc::format!("tenant=demo op=put key={idx} value=aaaaabbbbbcccccdddddeeeee\n")
852                .as_bytes(),
853        );
854    }
855
856    let mut compressed = alloc::vec::Vec::new();
857    let mut compressor = FrameCompressor::new(CompressionLevel::Fastest);
858    compressor
859        .set_dictionary(parsed)
860        .expect("dictionary should attach");
861    compressor.set_source(payload.as_slice());
862    compressor.set_drain(&mut compressed);
863    compressor.compress();
864
865    (finalized, compressed, payload)
866}
867
868#[cfg(test)]
869mod tests;