Skip to main content

urna_format/encoding/
zstd_dict.rs

1//! trained-dictionary zstd over per-chunk canonical-text streams (the
2//! `zstd_dict` wire codec, encoding id 5). re-frames each `chunks_canonical`
3//! (0x02) stream against ONE shared dictionary so a tiny, structurally
4//! similar chunk compresses against cross-record statistics instead of a
5//! cold per-frame entropy table. the dictionary lives in the optional
6//! `SECTION_DICTIONARY` (0x0A), excluded from `content_hash`, so adding it
7//! never moves a `urna://` citation.
8//!
9//! this is the `TXT_STREAMS_V2` variant: it reuses the txt_streams container
10//! byte-for-byte (kind byte + count + intpack offset table + N streams,
11//! giving O(1) single-chunk reopen) but each stream is zstd-encoded WITH the
12//! shared dict via `EncoderDictionary`/`DecoderDictionary`. [`decode`]
13//! rebuilds the EXACT canonical payload byte-for-byte, so `content_hash` is
14//! preserved. every read is bounds-checked and returns a typed `UrnaError`,
15//! never a panic on a truncated or hostile payload.
16//!
17//! determinism: zstd's ZDICT trainer (`from_continuous`) is a pure function
18//! of its sorted sample bytes + sizes, pinned to a fixed zstd version, so
19//! two builds over the same corpus produce a byte-identical dictionary (and
20//! file). draws from facebook/zstd (ZDICT/COVER trained dictionaries),
21//! rocksdb (one cross-block dictionary sampled across many small records),
22//! and duckdb (per-section analyze-and-pick over candidate codecs).
23
24use super::intpack::{IntpackReader, pack_u64s};
25use super::txt_streams::{build_canonical, malformed, write_container};
26use crate::bytes::le_u64;
27use crate::error::UrnaError;
28use zstd::bulk::{Compressor, Decompressor};
29use zstd::dict::{DecoderDictionary, EncoderDictionary};
30
31/// kind/version byte for the dict-framed variant. distinct from
32/// `TXT_STREAMS_V1` so a reader dispatches the right per-stream codec
33/// without a new encoding id beyond zstd_dict (5) on the section entry.
34pub const TXT_STREAMS_V2: u8 = 1;
35
36/// zstd level used for both dict training and per-stream compression. matches
37/// `DEFAULT_ZSTD_LEVEL` so the dict path competes on equal footing.
38const DICT_LEVEL: i32 = super::zstd_codec::DEFAULT_ZSTD_LEVEL;
39
40/// max trained-dictionary size in bytes (~110 KiB). the ZDICT trainer
41/// returns a SMALLER dict when the sample set does not justify the cap, so
42/// this is an upper bound, not a fixed size. chosen in the 64-112 KiB band
43/// the work order pins; large enough to capture pt-br boilerplate, small
44/// enough to stay a negligible slice of the file.
45pub const MAX_DICT_BYTES: usize = 112 * 1024;
46
47/// per-stream decompress capacity ceiling when the frame carries no size
48/// hint. one canonical chunk is small; this bound just prevents a hostile
49/// frame from forcing a giant allocation.
50const STREAM_CAP: usize = 64 * 1024 * 1024;
51
52/// train ONE deterministic zstd dictionary over the canonical texts.
53///
54/// the samples are sorted and deduplicated by the caller (the writer) so the
55/// trainer input is canonical; `from_continuous` is then a pure function of
56/// the concatenated bytes + per-sample sizes, pinned to the workspace zstd
57/// version. returns `None` when there is too little material to train a
58/// useful dictionary (the trainer needs a handful of samples); the caller
59/// then simply does not offer the dict variant for that build.
60pub fn train_dict(sorted_unique: &[String]) -> Option<Vec<u8>> {
61    // the trainer rejects empty / too-small corpora; guard so a tiny build
62    // (e.g. the golden fixture path) never offers a dict variant.
63    if sorted_unique.len() < 8 {
64        return None;
65    }
66    let total: usize = sorted_unique.iter().map(|s| s.len()).sum();
67    if total == 0 {
68        return None;
69    }
70    let mut data: Vec<u8> = Vec::with_capacity(total);
71    let mut sizes: Vec<usize> = Vec::with_capacity(sorted_unique.len());
72    for s in sorted_unique {
73        data.extend_from_slice(s.as_bytes());
74        sizes.push(s.len());
75    }
76    // cap the dict proportionately: a dict bigger than ~1/10 of the corpus is
77    // pure overhead it can never repay (the chooser counts the dict blob, so
78    // an oversized dict just loses). small builds thus train a small dict; the
79    // 64-112 KiB band is the ceiling for large corpora. minimum 4 KiB so the
80    // trainer has room to capture real boilerplate.
81    let cap = (total / 10).clamp(4 * 1024, MAX_DICT_BYTES);
82    // zero-length samples are valid input but contribute nothing; the trainer
83    // tolerates them. a failed train (corpus too uniform / too small) is not
84    // an error: the build just keeps the non-dict candidates.
85    zstd::dict::from_continuous(&data, &sizes, cap).ok()
86}
87
88/// encode `texts` as per-chunk dict-framed zstd streams behind the shared
89/// txt_streams offset table. byte-identical output for identical inputs +
90/// identical `dict` (the dict itself is deterministic), so two builds match.
91pub fn encode(texts: &[String], dict: &[u8]) -> crate::Result<Vec<u8>> {
92    let edict = EncoderDictionary::copy(dict, DICT_LEVEL);
93    let mut comp = Compressor::with_prepared_dictionary(&edict)
94        .map_err(|e| UrnaError::InvalidInput(format!("zstd_dict: compressor init: {}", e)))?;
95    let mut streams: Vec<u8> = Vec::new();
96    let mut offsets: Vec<u64> = Vec::with_capacity(texts.len() + 1);
97    offsets.push(0);
98    for t in texts {
99        let c = comp
100            .compress(t.as_bytes())
101            .map_err(|e| UrnaError::InvalidInput(format!("zstd_dict: compress: {}", e)))?;
102        streams.extend_from_slice(&c);
103        offsets.push(streams.len() as u64);
104    }
105    let table = pack_u64s(&offsets);
106    Ok(write_container(
107        TXT_STREAMS_V2,
108        texts.len(),
109        &table,
110        &streams,
111    ))
112}
113
114/// decompress a single dict-framed stream with the shared `dict`, validating
115/// utf-8 (the canonical encoder only ever wrote utf-8). bounds + typed
116/// errors only, never a panic on a hostile frame.
117fn decompress_stream(dec: &mut Decompressor<'_>, stream: &[u8]) -> crate::Result<Vec<u8>> {
118    let cap = Decompressor::upper_bound(stream)
119        .unwrap_or(STREAM_CAP)
120        .min(STREAM_CAP);
121    let raw = dec
122        .decompress(stream, cap)
123        .map_err(|e| malformed(format!("zstd_dict: decompress: {}", e)))?;
124    std::str::from_utf8(&raw).map_err(|e| malformed(format!("zstd_dict: invalid utf-8: {}", e)))?;
125    Ok(raw)
126}
127
128/// reconstruct the canonical `chunks_canonical` payload from a dict-framed
129/// `txt_streams` V2 payload using the shared `dict`. byte-identical to
130/// `sections::encode_chunks_canonical`, so `content_hash` is preserved.
131pub fn decode(bytes: &[u8], dict: &[u8]) -> crate::Result<Vec<u8>> {
132    let (count, offsets, streams) = parse_v2(bytes)?;
133    let ddict = DecoderDictionary::copy(dict);
134    let mut dec = Decompressor::with_prepared_dictionary(&ddict)
135        .map_err(|e| UrnaError::InvalidInput(format!("zstd_dict: decompressor init: {}", e)))?;
136    let mut bodies: Vec<Vec<u8>> = Vec::with_capacity(count);
137    for i in 0..count {
138        let s = stream_slice(streams, &offsets, i)?;
139        bodies.push(decompress_stream(&mut dec, s)?);
140    }
141    build_canonical(count, &bodies)
142}
143
144/// parse the V2 container header + intpack offset table, returning the
145/// chunk count, the n+1 byte offsets, and the streams region. mirrors
146/// `TxtStreams::parse` but for the V2 kind byte.
147fn parse_v2(bytes: &[u8]) -> crate::Result<(usize, Vec<u64>, &[u8])> {
148    let (kind, rest) = bytes
149        .split_first()
150        .ok_or_else(|| malformed("zstd_dict: empty"))?;
151    if *kind != TXT_STREAMS_V2 {
152        return Err(malformed(format!("zstd_dict: unknown kind {}", *kind)));
153    }
154    if rest.len() < 8 {
155        return Err(malformed("zstd_dict: truncated count"));
156    }
157    let declared = le_u64(&rest[0..8])?;
158    let table_bytes = &rest[8..];
159    let reader = IntpackReader::parse(table_bytes)?;
160    if reader.is_empty() {
161        return Err(malformed("zstd_dict: offset table must hold n+1 >= 1"));
162    }
163    let count = reader.len() - 1;
164    if declared != count as u64 {
165        return Err(malformed("zstd_dict: declared count != offset count - 1"));
166    }
167    let mut offsets = Vec::with_capacity(reader.len());
168    for i in 0..reader.len() {
169        offsets.push(reader.get(i)?);
170    }
171    if offsets[0] != 0 {
172        return Err(malformed("zstd_dict: first offset must be 0"));
173    }
174    let table_len = pack_u64s(&offsets).len();
175    if table_len > table_bytes.len() {
176        return Err(malformed("zstd_dict: truncated offset table"));
177    }
178    let streams = &table_bytes[table_len..];
179    for w in offsets.windows(2) {
180        if w[1] < w[0] {
181            return Err(malformed("zstd_dict: non-monotonic offsets"));
182        }
183    }
184    if offsets.last().copied() != Some(streams.len() as u64) {
185        return Err(malformed("zstd_dict: final offset != streams length"));
186    }
187    Ok((count, offsets, streams))
188}
189
190#[inline]
191fn stream_slice<'a>(streams: &'a [u8], offsets: &[u64], i: usize) -> crate::Result<&'a [u8]> {
192    let start = offsets[i] as usize;
193    let end = offsets[i + 1] as usize;
194    streams
195        .get(start..end)
196        .ok_or_else(|| malformed("zstd_dict: stream slice out of bounds"))
197}