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}