Skip to main content

structured_zstd/encoding/levels/
config.rs

1//! Per-level compression tuning: the matcher config structs, the level
2//! parameter table, and the level → params resolution chain.
3//!
4//! Moved verbatim from `match_generator.rs` (no behaviour change): the
5//! `HcConfig` / `RowConfig` / `DfastConfig` / `FastConfig` knobs, `LevelParams`
6//! and `LEVEL_TABLE`, the public-parameter overrides, the source-size tiering,
7//! and the workspace estimators. `match_generator` imports this resolution API
8//! instead of carrying it inline. Encoding-level paths are written absolute
9//! (`crate::encoding::…`) so the module can live under `levels/` unchanged.
10
11use crate::encoding::CompressionLevel;
12use crate::encoding::match_generator::{HC_SEARCH_DEPTH, HC_TARGET_LEN, ROW_MIN_MATCH_LEN};
13#[cfg(test)]
14use crate::encoding::match_generator::{ROW_HASH_BITS, ROW_LOG, ROW_SEARCH_DEPTH, ROW_TARGET_LEN};
15#[cfg(test)]
16use crate::encoding::match_table::storage::{HC_CHAIN_LOG, HC_HASH_LOG};
17/// Bundled tuning knobs for the hash-chain matcher. Using a typed config
18/// instead of positional `usize` args eliminates parameter-order hazards.
19#[derive(Copy, Clone, PartialEq, Eq)]
20pub(crate) struct HcConfig {
21    pub(crate) hash_log: usize,
22    pub(crate) chain_log: usize,
23    pub(crate) search_depth: usize,
24    pub(crate) target_len: usize,
25    /// Binary-tree finder hash width. Upstream uses `mls = BOUNDED(3, minMatch, 6)`
26    /// (`ZSTD_selectBtGetAllMatches`, zstd_opt.c:896) — i.e. mls=3 on the
27    /// btultra/btultra2 levels (L18-22, minMatch=3) — and surfaces 3-byte matches
28    /// through a fallback-only HC3 finder (zstd_opt.c:691-720: distance < 256 KiB,
29    /// taken only when no longer/repcode match exists). Our optimal parser does
30    /// NOT yet replicate that 3-byte handling — it emits short matches C prices
31    /// out, breaking level-22 sequence parity — so the BT hash width is clamped UP
32    /// to 4 (`cp.min_match.clamp(4, 6)`) to keep the finder from surfacing those
33    /// 3-byte matches and so match C's output. This is a deliberate workaround,
34    /// NOT C's finder width; drop the clamp once the optimal parser is C-faithful
35    /// at minMatch 3 (tracked in #337). Carried explicitly per level so a
36    /// `target_length` override can't silently change the finder's hashing width.
37    /// Only the BT body reads it; HC / lazy levels keep it at 4.
38    pub(crate) search_mls: usize,
39}
40
41#[derive(Copy, Clone, PartialEq, Eq)]
42pub(crate) struct RowConfig {
43    pub(crate) hash_bits: usize,
44    pub(crate) row_log: usize,
45    pub(crate) search_depth: usize,
46    pub(crate) target_len: usize,
47    /// Upstream zstd `cParams.minMatch` for the row matcher: the regular-search
48    /// acceptance floor (a row candidate must extend to >= `mls` bytes).
49    /// The C-like advanced API surfaces this as the row min-match knob.
50    /// `ROW_MIN_MATCH_LEN` (5) is the default; the row hash key width stays
51    /// 4 bytes (an internal detail), so this only tunes the acceptance
52    /// floor, not the candidate hash distribution.
53    pub(crate) mls: usize,
54    /// Upstream `cParams.chainLog`: the hash-chain table the greedy/lazy
55    /// parse searches instead of rows when the window is 2^14 or smaller
56    /// (upstream `ZSTD_resolveRowMatchFinderMode`), or the binary tree
57    /// (`2^(chainLog-1)` nodes) of a btlazy2 level.
58    pub(crate) chain_log: usize,
59    /// Upstream `ZSTD_btlazy2`: the lazy parse searches the lazily-sorted
60    /// binary tree (`ZSTD_BtFindBestMatch`) instead of rows / the chain.
61    pub(crate) bt: bool,
62}
63
64// Only used as the default HashChain config when the test-only parse×search
65// override pairs a level with a backend its native row doesn't populate.
66#[cfg(test)]
67pub(crate) const HC_CONFIG: HcConfig = HcConfig {
68    hash_log: HC_HASH_LOG,
69    chain_log: HC_CHAIN_LOG,
70    search_depth: HC_SEARCH_DEPTH,
71    target_len: HC_TARGET_LEN,
72    search_mls: 4,
73};
74
75/// Base HashChain config synthesized when a public-parameter strategy
76/// override ([`crate::encoding::parameters`]) routes a level to the HC / BT
77/// backend whose native level row didn't populate `hc` (e.g. forcing
78/// `Strategy::Lazy2` onto a level the table resolves to Fast). Mirrors
79/// the mid-band lazy defaults; the per-knob overrides then refine it.
80pub(crate) const HC_OVERRIDE_DEFAULT: HcConfig = HcConfig {
81    hash_log: crate::encoding::match_table::storage::HC_HASH_LOG,
82    chain_log: crate::encoding::match_table::storage::HC_CHAIN_LOG,
83    search_depth: HC_SEARCH_DEPTH,
84    target_len: HC_TARGET_LEN,
85    search_mls: 4,
86};
87
88// Default Row config: only used by tests and the test-only parse×search
89// override (production greedy L5 carries its own `ROW_L5`).
90#[cfg(test)]
91pub(crate) const ROW_CONFIG: RowConfig = RowConfig {
92    hash_bits: ROW_HASH_BITS,
93    row_log: ROW_LOG,
94    search_depth: ROW_SEARCH_DEPTH,
95    target_len: ROW_TARGET_LEN,
96    mls: ROW_MIN_MATCH_LEN,
97    chain_log: ROW_HASH_BITS,
98    bt: false,
99};
100
101// Level-5 greedy is the ONLY strategy routed to the Row backend
102// (`StrategyTag::backend`: greedy -> Row; lazy / btopt / btultra* ->
103// HashChain), so it is the only level whose `row:` field is read. The upstream zstd
104// `clevels.h` default row (srcSize > 256 KB) for level 5 is searchLog=3,
105// targetLength=2, from which the row matcher derives:
106//   rowLog       = clamp(searchLog, 4, 6) = 4
107//   search_depth = 1 << min(searchLog, rowLog) = 8   (= nbAttempts)
108//   target_len   = targetLength = 2                  (nice-match early-out)
109// The shared `ROW_CONFIG` (row_log=5, search_depth=16, target_len=48) ran a
110// level-12-grade search here: 16 slots per row, never early-exiting until a
111// 48-byte match. That exhaustive walk was the dominant cost in greedy L5's
112// encode-speed regression vs FFI. `hash_bits` matches upstream zstd's
113// `ZSTD_getCParams(5, .., 0).hashLog` = 19 (verified via
114// `cparams_check 5`), so the row table is the same width as upstream's
115// (2^19 slots); the previous `ROW_HASH_BITS` (20) doubled both row tables vs
116// upstream, the dominant peak-memory excess on the greedy band.
117pub(crate) const ROW_L5: RowConfig = RowConfig {
118    hash_bits: 19,
119    row_log: 4,
120    search_depth: 8,
121    target_len: 2,
122    mls: ROW_MIN_MATCH_LEN,
123    chain_log: 18,
124    bt: false,
125};
126
127/// Per-level Double-Fast hash sizing, mirroring the upstream zstd `clevels.h` columns
128/// (config-driven, not a hardcoded constant): `long_hash_log` =
129/// `cParams.hashLog` (the long 8-byte hash table), `short_hash_log` =
130/// `cParams.chainLog` (the short hash table dfast repurposes as its
131/// secondary index). Only the Dfast backend reads it, so non-dfast level
132/// rows carry `dfast: None`. `minMatch` stays the upstream zstd-fixed `5`
133/// (`DFAST_MIN_MATCH_LEN`, used in const contexts).
134#[derive(Copy, Clone, PartialEq, Eq)]
135pub(crate) struct DfastConfig {
136    pub(crate) long_hash_log: u8,
137    pub(crate) short_hash_log: u8,
138}
139
140// Upstream zstd clevels.h default row (srcSize > 256 KB): L3 {hashLog 17, chainLog 16}.
141pub(crate) const DFAST_L3: DfastConfig = DfastConfig {
142    long_hash_log: 17,
143    short_hash_log: 16,
144};
145
146/// Per-level Fast-strategy tuning, only consumed by the `FastKernelMatcher`
147/// (Simple backend): `hash_log` = upstream zstd `cParams.hashLog`, `mls` = upstream zstd
148/// `cParams.minMatch` (4..=8), `step_size` = upstream zstd `stepSize`. Carried as
149/// `LevelParams.fast` (`Some` only on Fast level rows; `None` elsewhere).
150#[derive(Copy, Clone, PartialEq, Eq)]
151pub(crate) struct FastConfig {
152    pub(crate) hash_log: u32,
153    pub(crate) mls: u32,
154    pub(crate) step_size: usize,
155}
156
157pub(crate) const FAST_L1: FastConfig = FastConfig {
158    hash_log: 14,
159    // Tier-0 (srcSize > 256 KiB) `cParams.minMatch`. Upstream zstd selects the
160    // Level-1 row from a 4-way srcSize-tiered table (`ZSTD_getCParams_internal`
161    // → `ZSTD_defaultCParameters[tableID][1]`), and minMatch shrinks for
162    // smaller inputs: 7 (>256 KiB) / 6 (16..256 KiB) / 5 (<=16 KiB). The base
163    // here is the tier-0 value; `fast_l1_mls_for_source_size` lowers it per the
164    // tier in `adjust_params_for_source_size`.
165    mls: 7,
166    step_size: 2,
167};
168
169/// Resolved tuning parameters for a compression level. The
170/// [`StrategyTag`] is the single source of truth for the backend
171/// family and the compile-time strategy consts; the runtime
172/// [`BackendTag`] used by the driver dispatcher is derived via
173/// [`StrategyTag::backend`] so the two cannot drift.
174#[derive(Copy, Clone, PartialEq, Eq)]
175pub(crate) struct LevelParams {
176    pub(crate) strategy_tag: crate::encoding::strategy::StrategyTag,
177    /// Decoupled search-method axis. Independent of `strategy_tag`'s
178    /// parse half: a level can pair any parse (greedy / lazy depth via
179    /// `lazy_depth`) with any search backend here. Defaults to the
180    /// historical pairing (`strategy_tag.search()`) but is overridable
181    /// per level so the parse×search matrix can be swept and tuned.
182    pub(crate) search: crate::encoding::strategy::SearchMethod,
183    pub(crate) window_log: u8,
184    pub(crate) lazy_depth: u8,
185    /// Per-strategy tuning. Exactly one is `Some` on each level row, matching
186    /// `strategy_tag`'s backend, so the table self-documents which knobs a
187    /// level actually consumes (the others are `None`, not dead placeholders):
188    /// `fast` for the Fast/Simple backend, `dfast` for Double-Fast, `hc` for
189    /// the HashChain (lazy / btopt / btultra*) backend, `row` for the Row
190    /// (greedy L5) backend.
191    pub(crate) fast: Option<FastConfig>,
192    pub(crate) dfast: Option<DfastConfig>,
193    pub(crate) hc: Option<HcConfig>,
194    pub(crate) row: Option<RowConfig>,
195}
196
197impl LevelParams {
198    /// Backend family (storage variant) for the driver dispatcher.
199    /// Derived from the decoupled `search` axis so a level can route to
200    /// a different search backend than its `strategy_tag` historically
201    /// implied.
202    pub(crate) fn backend(&self) -> crate::encoding::strategy::BackendTag {
203        self.search.backend()
204    }
205
206    /// Parse mode derived from the decoupled `search` axis: the binary-tree
207    /// search path carries `ParseMode::Optimal`; every other search backend
208    /// derives greedy/lazy/lazy2 from `lazy_depth`. Reading `search` (not the
209    /// strategy tag) keeps the parse×search decoupling complete even when a
210    /// level whose tag is `Bt*` is overridden to a non-BT search backend.
211    pub(crate) fn parse(&self) -> crate::encoding::strategy::ParseMode {
212        match self.search {
213            crate::encoding::strategy::SearchMethod::BinaryTree => {
214                crate::encoding::strategy::ParseMode::Optimal
215            }
216            _ => crate::encoding::strategy::ParseMode::from_lazy_depth(self.lazy_depth),
217        }
218    }
219
220    /// Cheap fingerprint pre-splitter level (the `ZSTD_splitBlock` level;
221    /// `0` = from-borders heuristic, `1..=4` = byChunks with sampling tier
222    /// `level - 1`, rates 43 / 11 / 5 / 1). See [`pre_split_for`].
223    pub(crate) fn pre_split(&self) -> Option<u8> {
224        Some(pre_split_for(self.strategy_tag, self.lazy_depth))
225    }
226}
227
228/// The pre-splitter level for an effective strategy (the tag plus, for the
229/// collapsed `Lazy` tag, its lazy depth).
230///
231/// Upstream's default is `splitLevels[strategy] = {0,0,1,2,2,3,3,4,4,4}`
232/// (zstd_compress.c:4552, used as is; only an explicit `blockSplitterLevel`
233/// is shifted down by 2). It is followed up to lazy depth 1 — the finer
234/// sampling is a real ratio win on mixed data (decodecorpus at greedy/lazy:
235/// the borders tier compressed 4.6-4.9 % WORSE than upstream, the upstream
236/// rate-11 tier <= upstream) — but the lazy2/btlazy2 rate-5 and optimal-band
237/// rate-1 tiers are DELIBERATELY kept two steps coarser: on periodic input
238/// they over-split every block into tiny pieces exactly like upstream does
239/// (100 MiB of repeated log lines at L8-L12: 140,625 bytes and 3.6x the
240/// time upstream-tier, 9,742 bytes coarse — 14x better than upstream) while
241/// on real data they buy under 0.1 % (decodecorpus L8-L12: 170 bytes of
242/// 483 KiB). The drop-in contract asks for ratio <= upstream, not for
243/// upstream's block boundaries.
244pub(crate) fn pre_split_for(tag: crate::encoding::strategy::StrategyTag, lazy_depth: u8) -> u8 {
245    use crate::encoding::strategy::StrategyTag;
246    match tag {
247        // Upstream tiers: borders / byChunks rate 43 / rate 11.
248        StrategyTag::Fast => 0,
249        StrategyTag::Dfast => 1,
250        StrategyTag::Greedy => 2,
251        // lazy (depth 1) = upstream rate 11; lazy2 coarsened to rate 43.
252        StrategyTag::Lazy => {
253            if lazy_depth >= 2 {
254                1
255            } else {
256                2
257            }
258        }
259        StrategyTag::Btlazy2 => 1,
260        // Coarsened to byChunks rate 11 (upstream: rate 1).
261        StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2 => 2,
262    }
263}
264
265/// Apply the public-parameter per-knob overrides (#27) onto the
266/// level-resolved [`LevelParams`], in place. Runs in [`Matcher::reset`]
267/// after the level params are computed and before backend selection, so
268/// a strategy override re-routes the backend uniformly. An all-`None`
269/// override is a no-op the caller skips via
270/// [`crate::encoding::parameters::ParamOverrides::is_empty`], keeping the default
271/// level geometry byte-identical.
272pub(crate) fn apply_param_overrides(
273    params: &mut LevelParams,
274    ov: &crate::encoding::parameters::ParamOverrides,
275) {
276    use crate::encoding::strategy::SearchMethod;
277
278    // 1. Strategy override re-derives tag / search / lazy depth.
279    if let Some(strategy) = ov.strategy {
280        let tag = strategy.tag();
281        params.strategy_tag = tag;
282        params.search = tag.search();
283        params.lazy_depth = strategy.lazy_depth();
284    }
285
286    // 2. Ensure the active backend's config row exists (synthesize a
287    //    default when a strategy override moved off the native row).
288    match params.search {
289        SearchMethod::Fast => {
290            params.fast.get_or_insert(FAST_L1);
291        }
292        SearchMethod::DoubleFast => {
293            params.dfast.get_or_insert(DFAST_L3);
294        }
295        SearchMethod::RowHash | SearchMethod::BinaryTreeLazy => {
296            // `ROW_L5` already carries `mls = ROW_MIN_MATCH_LEN = 5`, the
297            // upstream `minMatch` of the whole greedy..btlazy2 band at the
298            // >256 KiB tier, so a synthesized btlazy2 override hashes at the
299            // same width as a native btlazy2 row.
300            let row = params.row.get_or_insert(ROW_L5);
301            row.bt = matches!(params.search, SearchMethod::BinaryTreeLazy);
302        }
303        SearchMethod::HashChain | SearchMethod::BinaryTree => {
304            params.hc.get_or_insert(HC_OVERRIDE_DEFAULT);
305        }
306    }
307
308    // 3. window_log (bounds-checked at <= 30 by the builder).
309    if let Some(window_log) = ov.window_log {
310        params.window_log = window_log;
311    }
312
313    // 4. Per-backend numeric knobs map into the active config, mirroring
314    //    the upstream zstd `cParams` -> matcher translation documented on each
315    //    config struct.
316    match params.search {
317        SearchMethod::Fast => {
318            if let Some(fast) = params.fast.as_mut() {
319                if let Some(hash_log) = ov.hash_log {
320                    fast.hash_log = hash_log;
321                }
322                if let Some(min_match) = ov.min_match {
323                    fast.mls = min_match;
324                }
325            }
326        }
327        SearchMethod::DoubleFast => {
328            if let Some(dfast) = params.dfast.as_mut() {
329                // hashLog -> long table, chainLog -> short table (the
330                // dfast secondary index). Both bounds-checked <= 30, so
331                // the `u8` casts are lossless.
332                if let Some(hash_log) = ov.hash_log {
333                    dfast.long_hash_log = hash_log as u8;
334                }
335                if let Some(chain_log) = ov.chain_log {
336                    dfast.short_hash_log = chain_log as u8;
337                }
338            }
339        }
340        SearchMethod::RowHash | SearchMethod::BinaryTreeLazy => {
341            if let Some(row) = params.row.as_mut() {
342                // Row hash-table width override (mirrors dfast `long_hash_log`
343                // / hc `hash_log`); `chain_log` sizes the hash chain the same
344                // backend searches on a <= 2^14 window, or its binary tree.
345                if let Some(hash_log) = ov.hash_log {
346                    row.hash_bits = hash_log as usize;
347                }
348                if let Some(chain_log) = ov.chain_log {
349                    row.chain_log = chain_log as usize;
350                }
351                if let Some(search_log) = ov.search_log {
352                    // Upstream zstd: rowLog = clamp(searchLog, 4, 6); the
353                    // compare budget stays the FULL `1 << searchLog`
354                    // (`nbAttempts`) — the chain walk and the tree walk
355                    // consume it directly, and the row probe bounds its own
356                    // budget by the row size at the search site.
357                    row.row_log = (search_log as usize).clamp(4, 6);
358                    row.search_depth = 1usize << search_log;
359                }
360                if let Some(target_length) = ov.target_length {
361                    row.target_len = target_length as usize;
362                }
363                if let Some(min_match) = ov.min_match {
364                    row.mls = min_match as usize;
365                }
366            }
367        }
368        SearchMethod::HashChain | SearchMethod::BinaryTree => {
369            if let Some(hc) = params.hc.as_mut() {
370                if let Some(hash_log) = ov.hash_log {
371                    hc.hash_log = hash_log as usize;
372                }
373                if let Some(chain_log) = ov.chain_log {
374                    hc.chain_log = chain_log as usize;
375                }
376                if let Some(search_log) = ov.search_log {
377                    hc.search_depth = 1usize << search_log;
378                }
379                if let Some(target_length) = ov.target_length {
380                    hc.target_len = target_length as usize;
381                }
382                if let Some(min_match) = ov.min_match {
383                    // BT finder hash width, derived from cParams.minMatch exactly
384                    // as upstream zstd: `mls = BOUNDED(3, cParams.minMatch, 6)`
385                    // (zstd_opt.c:896 ZSTD_selectBtGetAllMatches). minMatch=3
386                    // tiers hash on 3 bytes (btultra/btultra2 path). Only the BT
387                    // body reads `search_mls`; HC/lazy hash on 4 bytes regardless.
388                    hc.search_mls = (min_match as usize).clamp(3, 6);
389                }
390            }
391        }
392    }
393}
394
395/// Map the resolved runtime strategy to the upstream zstd LDM strategy ordinal
396/// (1..=9) that [`crate::encoding::ldm::params::LdmParams::adjust_for`] expects.
397/// The collapsed `Lazy` tag splits on `lazy_depth` (lazy = 4, lazy2 = 5).
398#[cfg(feature = "ldm")]
399pub(crate) fn ldm_strategy_ordinal(
400    tag: crate::encoding::strategy::StrategyTag,
401    lazy_depth: u8,
402) -> u32 {
403    use crate::encoding::strategy::StrategyTag;
404    match tag {
405        StrategyTag::Fast => 1,
406        StrategyTag::Dfast => 2,
407        StrategyTag::Greedy => 3,
408        StrategyTag::Lazy => {
409            if lazy_depth >= 2 {
410                5
411            } else {
412                4
413            }
414        }
415        // Upstream zstd `ZSTD_btlazy2` ordinal.
416        StrategyTag::Btlazy2 => 6,
417        StrategyTag::BtOpt => 7,
418        StrategyTag::BtUltra => 8,
419        StrategyTag::BtUltra2 => 9,
420    }
421}
422
423/// `ceil(log2(size))` of a source-size hint, with a zero hint floored to
424/// [`MIN_WINDOW_LOG`]. This is the single quantization every hint-dependent
425/// matcher parameter is derived from: the window-log cap, the HC / Fast hash
426/// and chain widths, the Dfast / Row table widths, the L22 config buckets, and
427/// the Fast attach-vs-copy cutoff. Two hints sharing this value resolve to the
428/// identical matcher shape, which is why it (not the raw byte count) keys the
429/// primed-dictionary snapshot — see [`PrimedKey`]. Operates on the full `u64`
430/// so callers comparing a hint against a cutoff get the same bucketed decision
431/// here and at the driver, with no `as usize` truncation on 32-bit targets.
432pub(crate) fn source_size_ceil_log(size: u64) -> u8 {
433    if size == 0 {
434        MIN_WINDOW_LOG
435    } else {
436        (64 - (size - 1).leading_zeros()) as u8
437    }
438}
439
440/// Attach-vs-copy cutoff for the Fast strategy, as a ceil-log bucket: a hint at
441/// or below `2^this` (or unknown, `None`) ATTACHES the dictionary (a separate
442/// immutable table scanned in place via the borrowed dual-base kernel); a larger
443/// hint would COPY it into the live table.
444///
445/// We set this to `31` so every dictionary source up to 2 GiB attaches,
446/// diverging from upstream zstd's 8 KiB `ZSTD_shouldAttachDict` cutoff ON
447/// PURPOSE: upstream copy mode copies the small CDict TABLES into the cctx and
448/// still scans the input in place, but our flat-history copy path memmoves the
449/// whole INPUT into history every frame (profiled at 30% `__memmove` + 14%
450/// `__memset` on a reused 1 MiB dict encode). Attach mode scans the caller's
451/// input in place with the dict as a separate prefix base, so it is strictly
452/// faster for every frame size here (measured: 1 MiB dict frame 167 us -> 52 us,
453/// 0.42x of C; 10 KiB 20.4 us -> 4.4 us, 0.17x of C). The dual-base kernel
454/// carries `window_low`, so over-window inputs stay in-window and C-decodable.
455///
456/// `31` is also the largest bucket the borrowed kernel can attach: it stores
457/// virtual positions as `u32` (`cur_abs as u32`), so the maximum attached source
458/// `1 << 31` (plus the dict prefix) stays below `u32::MAX`; the next bucket `32`
459/// (4 GiB) would wrap that arithmetic. Sources past 2 GiB therefore fall back to
460/// copy mode — rare in practice, and the relative copy cost shrinks as the
461/// source grows. Per the drop-in-not-binary-parity contract, we make this match
462/// decision ourselves.
463/// Shared by `reset` (records the mode in the primed-snapshot key) and
464/// `prime_with_dictionary` (acts on it).
465pub(crate) const FAST_ATTACH_DICT_CUTOFF_LOG: u8 = 31;
466
467/// Largest dictionary region (bytes) the Fast attach path can index. The tagged
468/// dict table packs each position into `32 - DICT_TAG_BITS` (= 24) bits, so a
469/// region past `2^24` (16 MiB) would overflow the packed position. Dictionaries
470/// this large fall back to COPY mode, whose live table stores full `u32`
471/// positions and handles them. The size hint set on dict load equals the actual
472/// dict content length, so the attach-vs-copy decision (and the matching
473/// snapshot-key / epoch bits) can gate on it consistently at reset time.
474pub(crate) const MAX_FAST_ATTACH_DICT_REGION: usize = 1 << 24;
475
476/// Dfast counterpart of [`FAST_ATTACH_DICT_CUTOFF_LOG`]: upstream zstd
477/// `ZSTD_dictMatchState` attach cutoff for the double-fast strategy is 16 KiB
478/// (`2^14`), so small / unknown-size inputs ATTACH (separate immutable dict
479/// long+short tables + dual-probe in `start_matching_fast_loop`) and larger
480/// known-size inputs COPY (re-prime the dict into the live tables, where the
481/// dense scan matches it as window history). The attach build also self-gates
482/// on `use_fast_loop` inside `skip_matching_for_dict_attach` — only the
483/// fast-loop levels (L3 / Default / L0) carry the dual-probe.
484pub(crate) const DFAST_ATTACH_DICT_CUTOFF_LOG: u8 = 14;
485
486/// 32 KiB (`2^15`, upstream zstd `attachDictSizeCutoffs[ZSTD_lazy2]`): small /
487/// unknown-size inputs ATTACH the dict as a separate hash-chain dms (the dual
488/// search in `find_best_match` walks the live input chain + the dms), larger
489/// known-size inputs dense-COPY (merge the dict into the live chain and search
490/// the one combined chain).
491pub(crate) const HC_ATTACH_DICT_CUTOFF_LOG: u8 = 15;
492
493/// BT/optimal attach cutoff for `btlazy2` + `btopt`: 32 KiB (`2^15`, upstream
494/// zstd `attachDictSizeCutoffs[ZSTD_btlazy2]` == `[ZSTD_btopt]`). Small /
495/// unknown-size inputs ATTACH the dict as a separate DUBT dms; larger known-size
496/// inputs COPY the dict into the LIVE binary tree (upstream zstd
497/// `ZSTD_resetCCtx_byCopyingCDict`).
498pub(crate) const BT_OPT_ATTACH_DICT_CUTOFF_LOG: u8 = 15;
499
500/// BT/optimal attach cutoff for `btultra` + `btultra2`: 8 KiB (`2^13`, upstream
501/// zstd `attachDictSizeCutoffs[ZSTD_btultra]` == `[ZSTD_btultra2]`). The deepest
502/// parses copy the dict into the live tree past a much smaller source than the
503/// `btopt` tier, matching upstream's per-strategy cutoff table.
504pub(crate) const BT_ULTRA_ATTACH_DICT_CUTOFF_LOG: u8 = 13;
505
506// Source-size cap for the dfast hash bits when a size hint is present: a tiny
507// input needs no larger hash than its window. The upstream zstd `cParams.hashLog` /
508// `chainLog` (from `DfastConfig`) caps it from above at the call site.
509pub(crate) fn dfast_hash_bits_for_window(max_window_size: usize) -> usize {
510    let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
511    window_log.max(MIN_WINDOW_LOG as usize)
512}
513
514pub(crate) fn row_hash_bits_for_window(max_window_size: usize) -> usize {
515    // Upstream zstd `ZSTD_adjustCParams_internal` cap: `hashLog <= windowLog + 1`.
516    // The `+ 1` is load-bearing for L12, whose upstream zstd hashLog (23) exceeds
517    // its windowLog (22) — a plain `windowLog` cap would shrink the L12
518    // table on EVERY hinted reset and split primed snapshots between
519    // hinted and unhinted frames that resolve to the identical geometry.
520    // No constant upper clamp: the old `ROW_HASH_BITS` (20) ceiling
521    // predates the lazy band moving onto Row (L9-12 carry upstream zstd hashLog
522    // 21-23).
523    let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
524    (window_log + 1).max(MIN_WINDOW_LOG as usize)
525}
526
527/// `floor(log2(window))` for the HashChain table-log cap (upstream zstd
528/// `ZSTD_adjustCParams_internal`). The caller clamps the level's `hash_log` /
529/// `chain_log` from above with this so a small hinted input doesn't allocate the
530/// full level's tables.
531pub(crate) fn hc_hash_bits_for_window(max_window_size: usize) -> usize {
532    let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
533    window_log.max(MIN_WINDOW_LOG as usize)
534}
535
536/// Smallest window_log the encoder will use regardless of source size.
537pub(crate) const MIN_WINDOW_LOG: u8 = 10;
538
539/// Largest window_log the public parameter API accepts
540/// ([`CParameter::WindowLog`](crate::encoding::CParameter)'s upper bound), and
541/// so the largest a workspace estimate can be asked to describe. The estimate
542/// answers for whatever it is given, and shifting by more than the width of a
543/// `usize` is undefined, so a request beyond this is answered with this.
544pub(crate) const MAX_ESTIMATED_WINDOW_LOG: u8 = 30;
545
546/// Translate a verbatim upstream `ZSTD_defaultCParameters[tier][level]` row
547/// (`cparams::CParams`) into our resolved [`LevelParams`], reproducing
548/// upstream's cParams -> matcher-config derivation so the encoder follows C's
549/// source-size-tiered STRATEGY + table widths rather than a single hand-tuned
550/// `LEVEL_TABLE`. Derivation (verified against L6/L16/L22 vs clevels.h):
551/// `search_depth = 1 << searchLog`; row `row_log = clamp(searchLog, 4, 6)`; the
552/// per-strategy sub-config carries the verbatim `hashLog` / `chainLog` /
553/// `targetLength` / `minMatch`. Strategy numbers are upstream `ZSTD_strategy`
554/// (fast=1, dfast=2, greedy=3, lazy=4, lazy2=5, btlazy2=6, btopt=7, btultra=8,
555/// btultra2=9).
556fn level_params_from_cparams(cp: crate::encoding::cparams::CParams) -> LevelParams {
557    use crate::encoding::strategy::{SearchMethod, StrategyTag};
558    let window_log = cp.window_log as u8;
559    let search_depth = 1usize << cp.search_log;
560    let target_len = cp.target_length as usize;
561    let hc = HcConfig {
562        hash_log: cp.hash_log as usize,
563        chain_log: cp.chain_log as usize,
564        search_depth,
565        target_len,
566        // Clamp UP to 4: C's BT finder uses mls=3 on L18-22, but our optimal
567        // parser diverges on the resulting 3-byte matches (breaks level-22
568        // sequence parity), so we keep the finder at >=4 as a workaround until
569        // the parser is C-faithful at minMatch 3. See `HcConfig::search_mls` (#337).
570        search_mls: cp.min_match.clamp(4, 6) as usize,
571    };
572    let row = RowConfig {
573        hash_bits: cp.hash_log as usize,
574        row_log: cp.search_log.clamp(4, 6) as usize,
575        search_depth,
576        target_len,
577        mls: cp.min_match as usize,
578        chain_log: cp.chain_log as usize,
579        // Upstream `ZSTD_btlazy2` (strategy 6).
580        bt: cp.strategy == 6,
581    };
582    let bt = |tag| LevelParams {
583        strategy_tag: tag,
584        search: SearchMethod::BinaryTree,
585        window_log,
586        lazy_depth: 2,
587        fast: None,
588        dfast: None,
589        hc: Some(hc),
590        row: None,
591    };
592    let row_lvl = |tag, search, lazy_depth| LevelParams {
593        strategy_tag: tag,
594        search,
595        window_log,
596        lazy_depth,
597        fast: None,
598        dfast: None,
599        hc: None,
600        row: Some(row),
601    };
602    match cp.strategy {
603        1 => LevelParams {
604            strategy_tag: StrategyTag::Fast,
605            search: SearchMethod::Fast,
606            window_log,
607            lazy_depth: 0,
608            // Upstream fast `stepSize`: `targetLength + 1` (0 -> 1, so step 2).
609            fast: Some(FastConfig {
610                hash_log: cp.hash_log,
611                mls: cp.min_match,
612                step_size: target_len.max(1) + 1,
613            }),
614            dfast: None,
615            hc: None,
616            row: None,
617        },
618        2 => LevelParams {
619            strategy_tag: StrategyTag::Dfast,
620            search: SearchMethod::DoubleFast,
621            window_log,
622            lazy_depth: 1,
623            fast: None,
624            dfast: Some(DfastConfig {
625                long_hash_log: cp.hash_log as u8,
626                short_hash_log: cp.chain_log as u8,
627            }),
628            hc: None,
629            row: None,
630        },
631        3 => row_lvl(StrategyTag::Greedy, SearchMethod::RowHash, 0),
632        4 => row_lvl(StrategyTag::Lazy, SearchMethod::RowHash, 1),
633        5 => row_lvl(StrategyTag::Lazy, SearchMethod::RowHash, 2),
634        // btlazy2: the same lazy2 parse over the lazily-sorted binary tree.
635        6 => row_lvl(StrategyTag::Btlazy2, SearchMethod::BinaryTreeLazy, 2),
636        7 => bt(StrategyTag::BtOpt),
637        8 => bt(StrategyTag::BtUltra),
638        _ => bt(StrategyTag::BtUltra2),
639    }
640}
641
642/// Down-size a synthesized backend's window / hash / chain logs for a known
643/// source size by routing through the single C-faithful adjuster
644/// [`adjust_cparams`](crate::encoding::cparams::adjust_cparams)
645/// (`ZSTD_adjustCParams_internal`).
646///
647/// Used only on the param-override re-cap path: [`apply_param_overrides`]
648/// synthesizes a backend's full-size default config when a strategy override
649/// moves off the native level row, and this re-applies the source-size cap
650/// C-faithfully — the SAME adjuster the `get_cparams` main path uses, so the
651/// override path and the level path now down-size identically (no extra hinted
652/// 16 KiB window floor, no per-backend headroom that diverged from C). The
653/// Dfast backend self-sizes its tables in `reset`, so only its window is capped.
654pub(crate) fn adjust_params_for_source_size(mut params: LevelParams, src_size: u64) -> LevelParams {
655    use crate::encoding::cparams::{CParams, adjust_cparams};
656    use crate::encoding::strategy::{BackendTag, StrategyTag};
657
658    let backend = params.backend();
659    // Lift the active backend's source-cappable logs into a flat CParams. Dfast
660    // contributes none (window-only); its table widths self-size in `reset`.
661    let (hash_log, chain_log): (u32, u32) = match backend {
662        BackendTag::Simple => (params.fast.as_ref().map_or(0, |f| f.hash_log), 0),
663        BackendTag::HashChain => params
664            .hc
665            .as_ref()
666            .map_or((0, 0), |h| (h.hash_log as u32, h.chain_log as u32)),
667        BackendTag::Row => params
668            .row
669            .as_ref()
670            .map_or((0, 0), |r| (r.hash_bits as u32, r.chain_log as u32)),
671        BackendTag::Dfast => (0, 0),
672    };
673    // The chain cap (`ZSTD_cycleLog`) reads only `strategy >= btlazy2(6)`, so a
674    // coarse 6/3 split is exact for it; `adjust_cparams`'s other strategy use
675    // (`cdict_indices_are_tagged`) is gated on `create_cdict = false` here.
676    let strategy = if matches!(
677        params.strategy_tag,
678        StrategyTag::Btlazy2 | StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2
679    ) {
680        6
681    } else {
682        3
683    };
684    let adj = adjust_cparams(
685        CParams {
686            window_log: u32::from(params.window_log),
687            chain_log,
688            hash_log,
689            search_log: 1,
690            min_match: 4,
691            target_length: 0,
692            strategy,
693        },
694        src_size,
695        0,
696        false,
697    );
698    params.window_log = adj.window_log as u8;
699    match backend {
700        BackendTag::Simple => {
701            if let Some(f) = params.fast.as_mut() {
702                f.hash_log = adj.hash_log;
703            }
704        }
705        BackendTag::HashChain => {
706            if let Some(h) = params.hc.as_mut() {
707                h.hash_log = adj.hash_log as usize;
708                h.chain_log = adj.chain_log as usize;
709            }
710        }
711        BackendTag::Row => {
712            if let Some(r) = params.row.as_mut() {
713                r.hash_bits = adj.hash_log as usize;
714                r.chain_log = adj.chain_log as usize;
715            }
716        }
717        BackendTag::Dfast => {}
718    }
719    params
720}
721
722/// Estimated steady-state heap footprint of a one-shot compression context
723/// at `level` (window history + match-finder tables + block staging), in
724/// bytes. Computed from the same per-level tuning table the encoder
725/// resolves at frame start, so the estimate tracks the real allocations;
726/// it is an upper-bound style budget figure, not an exact accounting.
727pub fn estimated_compression_workspace_bytes(level: CompressionLevel) -> usize {
728    estimated_compression_workspace_bytes_for_source(level, None)
729}
730
731/// The same estimate for a source whose size is known.
732///
733/// The window and the match-finder tables are capped by the source, so a level
734/// that would reserve hundreds of MiB for an arbitrary stream reserves a
735/// fraction of that for a small one — and a caller budgeting memory for a
736/// compression it is about to run knows which. `None` is the arbitrary-stream
737/// figure that [`estimated_compression_workspace_bytes`] reports.
738pub fn estimated_compression_workspace_bytes_for_source(
739    level: CompressionLevel,
740    src_size_hint: Option<u64>,
741) -> usize {
742    estimated_compression_workspace_bytes_for_run(level, src_size_hint, None, false, None)
743}
744
745/// The same estimate for a run whose window, long-distance matching and
746/// dictionary are what the caller has actually asked for.
747///
748/// A `window_log` override enlarges the history the frame keeps, and
749/// long-distance matching adds a hash table of its own on top — neither of them
750/// visible in the level's own preset. A dictionary goes further than adding to
751/// the figure: it *decides* it. The frame runs the dictionary's own compression
752/// parameters, so a small source compressed against a large dictionary builds
753/// tables sized for the dictionary, which at the higher levels is the
754/// difference between tens of KiB and hundreds of MiB. `None`, `false` and
755/// `None` give the preset, which is what
756/// [`estimated_compression_workspace_bytes_for_source`] reports.
757///
758/// `dictionary` is what a caller weighing a run before it parses the blob can
759/// answer with [`DictionarySizes::raw_content`] on the blob's own length: the
760/// content of a trained dictionary is smaller than the blob it came in, and
761/// overstating it can only move the estimate toward the copy-mode geometry,
762/// which is the larger of the two.
763///
764/// [`DictionarySizes::raw_content`]: crate::encoding::DictionarySizes::raw_content
765pub fn estimated_compression_workspace_bytes_for_run(
766    level: CompressionLevel,
767    src_size_hint: Option<u64>,
768    window_log: Option<u8>,
769    long_distance_matching: bool,
770    dictionary: Option<crate::encoding::DictionarySizes>,
771) -> usize {
772    use crate::encoding::strategy::StrategyTag;
773    let mut params = match dictionary.filter(|sizes| sizes.content != 0) {
774        Some(sizes) => resolve_level_params_with_dict(level, src_size_hint, sizes).0,
775        None => resolve_level_params(level, src_size_hint),
776    };
777    // The override is what the frame will keep, but never below the floor the
778    // format sets or above what the source can fill — the same two bounds the
779    // encoder applies to it.
780    if let Some(requested) = window_log {
781        // Bounded to what the encoder itself accepts before anything shifts by
782        // it. This answers for whatever it is asked, and a shift past the width
783        // of the type is undefined rather than merely large: the largest window
784        // there is, is the honest answer to a request beyond it.
785        let requested = requested.min(MAX_ESTIMATED_WINDOW_LOG);
786        let capped = match src_size_hint {
787            Some(src) => {
788                crate::encoding::cparams::adjusted_window_log(u32::from(requested), src, 0) as u8
789            }
790            None => requested,
791        };
792        params.window_log = capped.clamp(MIN_WINDOW_LOG, MAX_ESTIMATED_WINDOW_LOG);
793    }
794    // The long-distance matcher's own table, sized from the window it searches
795    // (upstream `ZSTD_ldm_adjustParameters`). Only the `ldm` build has one.
796    #[cfg(feature = "ldm")]
797    let ldm = if long_distance_matching {
798        let strategy = ldm_strategy_ordinal(params.strategy_tag, params.lazy_depth);
799        let ldm_params = crate::encoding::ldm::params::LdmParams::adjust_for(
800            u32::from(params.window_log),
801            strategy,
802        );
803        crate::encoding::ldm::table::LdmHashTable::estimated_workspace_bytes(
804            ldm_params.hash_log,
805            ldm_params.bucket_size_log,
806        )
807    } else {
808        0
809    };
810    #[cfg(not(feature = "ldm"))]
811    let ldm = {
812        let _ = long_distance_matching;
813        0
814    };
815    // A 30-bit window is a gibibyte, which a 32-bit `usize` cannot count: the
816    // widest window is more memory than such a machine has, so the figure is
817    // pinned rather than wrapped.
818    let window = 1usize
819        .checked_shl(u32::from(params.window_log))
820        .unwrap_or(usize::MAX);
821    // Mirror `configure()`: the HC3 short-match side table exists only on
822    // the btultra/btultra2 tags (minMatch 3), capped by the window log; the
823    // BT pointer-pair layout fits inside the `4 << chain_log` chain term
824    // (pairs over `chain_log - 1` nodes).
825    let wants_hash3 = matches!(
826        params.strategy_tag,
827        StrategyTag::BtUltra | StrategyTag::BtUltra2
828    );
829    let uses_bt = matches!(
830        params.strategy_tag,
831        StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2
832    );
833    // The lazy backend's chain / tree finders (window <= 2^14, or a btlazy2
834    // level) use a plain hash table (`4 << hash_bits`) plus the chain / tree
835    // table (`4 << chain_log`) instead of the row tables.
836    let row_chain = params
837        .row
838        .filter(|r| r.bt || params.window_log <= 14)
839        .map_or(0, |r| (4usize << r.hash_bits) + (4usize << r.chain_log));
840    let tables = params.fast.map(|f| 4usize << f.hash_log).unwrap_or(0)
841        + row_chain
842        + params
843            .dfast
844            .map(|d| (4usize << d.long_hash_log) + (4usize << d.short_hash_log))
845            .unwrap_or(0)
846        + params
847            .hc
848            .map(|h| {
849                let hash3 = if wants_hash3 {
850                    4usize
851                        << crate::encoding::match_table::storage::HC3_HASH_LOG
852                            .min(params.window_log as usize)
853                } else {
854                    0
855                };
856                (4usize << h.hash_log) + (4usize << h.chain_log) + hash3
857            })
858            .unwrap_or(0)
859        + params
860            .row
861            .filter(|r| !(r.bt || params.window_log <= 14))
862            .map(|r| (4usize << r.hash_bits) + (2usize << r.hash_bits))
863            .unwrap_or(0);
864    // BT modes box a `BtMatcher`; its retained scratch layout is budgeted
865    // next to the struct so estimator and allocator evolve together.
866    let bt = if uses_bt {
867        crate::encoding::bt::BtMatcher::estimated_workspace_bytes()
868    } else {
869        0
870    };
871    // Block staging: literal + sequence buffers plus the compressed-block
872    // scratch, each bounded by the 128 KiB block size.
873    let staging = 3 * (128 * 1024);
874    // Saturating: the parts are each bounded, but their sum at the widest
875    // window is more than a 32-bit `usize` counts, and a total that wrapped
876    // would report a run as fitting a limit it cannot.
877    window
878        .saturating_add(tables)
879        .saturating_add(bt)
880        .saturating_add(staging)
881        .saturating_add(ldm)
882}
883
884/// Extra steady-state workspace the optimal strategies (ordinals 7..=9,
885/// btopt..btultra2) retain beyond the hash/chain tables: the boxed matcher
886/// plus its scratch arenas, and the HC3 short-match side table for
887/// btultra/btultra2 (capped by the window log). 0 for the other ordinals
888/// (btlazy2's tree lives in the lazy backend's chain table).
889pub fn estimated_bt_strategy_extra_bytes(strategy_ordinal: u32, window_log: u32) -> usize {
890    if !(7..=9).contains(&strategy_ordinal) {
891        return 0;
892    }
893    let hash3 = if matches!(strategy_ordinal, 8 | 9) {
894        4usize << crate::encoding::match_table::storage::HC3_HASH_LOG.min(window_log as usize)
895    } else {
896        0
897    };
898    crate::encoding::bt::BtMatcher::estimated_workspace_bytes() + hash3
899}
900
901/// Resolve a [`CompressionLevel`] (+ optional source-size hint) to the
902/// concrete [`LevelParams`] the matcher runs: strategy tag, search method
903/// (match-finder), window log, and per-backend config.
904///
905/// ## CRITICAL: input size changes the match-finder (and can change strategy)
906///
907/// The resolved geometry is a function of the SOURCE SIZE, not the level
908/// alone. This is the easy-to-miss part (so read this before assuming a level
909/// maps to one fixed match-finder). It mirrors three upstream zstd stages:
910///
911/// 1. [`LEVEL_TABLE`] holds the tier-0 (source > 256 KiB) base row per level
912///    (upstream `ZSTD_defaultCParameters[0]`). L6-L12 carry
913///    `SearchMethod::RowHash` (the Row match-finder), like upstream's
914///    greedy/lazy default.
915/// 2. [`apply_cparams_tier`] overrides the table-shaping widths for the
916///    smaller source tiers (upstream `ZSTD_getCParams_internal` tier table).
917///    NOTE: upstream ALSO switches STRATEGY in some tiers (L2 → dfast, L4 →
918///    greedy on small sources); those backend switches are NOT yet replicated,
919///    so those levels keep their base strategy on small inputs.
920/// 3. [`adjust_params_for_source_size`] caps `window_log` to
921///    ~`ceil_log2(source_size)` (upstream `ZSTD_adjustCParams_internal`).
922///
923/// THEN, inside the Row backend, the greedy/lazy band searches a hash chain
924/// instead of rows when the resolved `window_log <= 14`
925/// (`RowMatchGenerator::finder`) — exactly upstream's
926/// `ZSTD_resolveRowMatchFinderMode` (the Row match-finder is used for
927/// greedy/lazy/lazy2 ONLY when `windowLog > 14`) — and a btlazy2 level
928/// searches the lazily-sorted binary tree; the parse itself is the same
929/// `lazy_generic` body in all three cases. Net effect for the SAME level:
930///
931/// * small input (e.g. a 10 KiB fixture → `window_log` 14) → hash chain
932///   (`ZSTD_HcFindBestMatch`, scalar chain walk);
933/// * large input (e.g. 1 MiB → `window_log` 20) → rows (the SIMD-tag
934///   row match-finder).
935///
936/// A dictionary frame resolves through [`resolve_level_params_with_dict`]
937/// instead: the CDict's cParams (its own size tier) with the frame's
938/// source-derived `window_log`. When comparing against C on a fixture,
939/// resolve the match-finder from the fixture's size (and dictionary) first,
940/// or you may optimise/benchmark a path C does not even take for that input.
941pub(crate) fn resolve_level_params(
942    level: CompressionLevel,
943    source_size: Option<u64>,
944) -> LevelParams {
945    // Uncompressed = raw blocks, no match-finder. Not a cParams level, so it is
946    // the one row resolved by hand rather than through `get_cparams`.
947    if matches!(level, CompressionLevel::Uncompressed) {
948        return LevelParams {
949            strategy_tag: crate::encoding::strategy::StrategyTag::Fast,
950            search: crate::encoding::strategy::SearchMethod::Fast,
951            // Raw frames emit literal blocks and never reference history;
952            // advertising a wider window only inflates the decoder-side buffer
953            // reservation, so clamp to 17 (128 KiB) regardless of input size.
954            window_log: 17,
955            lazy_depth: 0,
956            // Beyond-upstream: hash_log=14 (vs upstream's row-0 13) for ~2× fewer
957            // collisions on structured corpora; mls=6 / step_size=2 mirror the
958            // upstream "base for negative" row (targetLength=1 -> step 2).
959            fast: Some(FastConfig {
960                hash_log: 14,
961                mls: 6,
962                step_size: 2,
963            }),
964            dfast: None,
965            hc: None,
966            row: None,
967        };
968    }
969    // Every other level resolves through the SINGLE C-faithful cParams source,
970    // `cparams::get_cparams` (the port of `ZSTD_getCParams`). One place selects
971    // strategy + table widths + the negative-level acceleration per
972    // (level, srcSize) + the source-size window/hash down-clamp, so the encoder
973    // never re-derives parameters from a parallel hand-tuned path. Named presets
974    // map to their numeric level; the cParams source clamps out-of-range levels
975    // (>22 to 22, negatives to MIN_CLEVEL) itself.
976    let numeric = numeric_level(level);
977    let src = source_size.unwrap_or(crate::encoding::cparams::CONTENTSIZE_UNKNOWN);
978    level_params_from_cparams(crate::encoding::cparams::get_cparams(numeric, src, 0))
979}
980
981/// The upstream numeric level a preset maps to.
982pub(crate) fn numeric_level(level: CompressionLevel) -> i32 {
983    match level {
984        CompressionLevel::Uncompressed => unreachable!("raw frames resolve no cParams"),
985        // Fastest = upstream level 1 (fast strategy, smallest real-compression
986        // tables).
987        CompressionLevel::Fastest => 1,
988        // Default = upstream level 3 (the libzstd default).
989        CompressionLevel::Default => CompressionLevel::DEFAULT_LEVEL,
990        // Better = level 7: the lazy2 band — clearly above the fast/dfast levels
991        // on ratio while still well under the binary-tree cost cliff.
992        CompressionLevel::Better => 7,
993        // Best = level 13: the first point of the deep binary-tree band that
994        // strictly dominates every level below it on ratio (lower levels can tie
995        // on window-bound corpora), so the alias sits on a config that always
996        // wins rather than on a hair-thin margin.
997        CompressionLevel::Best => 13,
998        CompressionLevel::Level(n) => n,
999    }
1000}
1001
1002/// How a greedy / lazy frame compressed with a dictionary takes its
1003/// match-finder from the dictionary's own cParams (upstream
1004/// `ZSTD_resetCCtx_usingCDict`).
1005#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1006pub(crate) struct RowDictPlan {
1007    /// `ZSTD_shouldAttachDict`: the dictionary tables are searched in place
1008    /// (`dictMatchState`) instead of being copied into the frame's tables.
1009    pub(crate) attach: bool,
1010    /// The CDict's `useRowMatchFinder`, inherited by the frame.
1011    pub(crate) use_row: bool,
1012    /// The CDict's cParams: the geometry (`hash_log`, `chain_log`,
1013    /// `search_log` → row width) and key width its tables were built with.
1014    pub(crate) cdict: crate::encoding::cparams::CParams,
1015}
1016
1017/// [`resolve_level_params`] for a frame that compresses with a dictionary of
1018/// `dict_size` bytes. Upstream builds the CDict with
1019/// `ZSTD_getCParams(level, UNKNOWN, dictSize, createCDict)` and the frame
1020/// then runs the CDict's strategy / widths / search depth / match-finder:
1021/// re-adjusted to the source when the dictionary is attached
1022/// (`ZSTD_resetCCtx_byAttachingCDict`), verbatim when it is copied
1023/// (`ZSTD_resetCCtx_byCopyingCDict`); only the frame's own `windowLog` is
1024/// kept. The CDict's strategy is taken whatever backend family the plain
1025/// level resolved to; a lazy-band CDict (greedy..btlazy2) also carries the
1026/// lazy backend's [`RowDictPlan`].
1027pub(crate) fn resolve_level_params_with_dict(
1028    level: CompressionLevel,
1029    source_size: Option<u64>,
1030    sizes: crate::encoding::DictionarySizes,
1031) -> (LevelParams, Option<RowDictPlan>) {
1032    use crate::encoding::cparams::{
1033        CONTENTSIZE_UNKNOWN, attach_cparams, copy_cparams, get_cdict_cparams, should_attach_dict,
1034        uses_row_match_finder,
1035    };
1036    let base = resolve_level_params(level, source_size);
1037    if sizes.content == 0 {
1038        return (base, None);
1039    }
1040    // The CDict's cParams decide the frame's strategy REGARDLESS of the
1041    // backend family the plain level resolved to for this source size
1042    // (upstream takes them unconditionally): L13 on a 4 KiB source is
1043    // btopt, but a 300 KiB CDict is btlazy2 and the frame runs btlazy2; L4
1044    // on a 1 MiB source is dfast, but a 4 KiB CDict is greedy. Only the
1045    // frame's own `windowLog` is kept.
1046    let cdict = get_cdict_cparams(numeric_level(level), sizes.serialized);
1047    // `ZSTD_shouldAttachDict`, bounded by the backend's attach representability:
1048    // the Fast / Dfast attached tables pack the dict position next to a tag, so
1049    // they index at most 2^24 content bytes. A larger dictionary is primed in
1050    // COPY mode, and the frame must then run the CDict's verbatim table
1051    // geometry (`byCopyingCDict`) — copying it into source-capped attach-mode
1052    // tables would collide away its matches.
1053    let attach_fits = match cdict.strategy {
1054        1 => sizes.content <= MAX_FAST_ATTACH_DICT_REGION,
1055        2 => sizes.content <= crate::encoding::dfast::DFAST_ATTACH_DICT_MAX_LEN,
1056        _ => true,
1057    };
1058    let attach = should_attach_dict(&cdict, source_size) && attach_fits;
1059    let window_log = u32::from(base.window_log);
1060    let frame = if attach {
1061        attach_cparams(
1062            cdict,
1063            source_size.unwrap_or(CONTENTSIZE_UNKNOWN),
1064            window_log,
1065        )
1066    } else {
1067        copy_cparams(cdict, window_log)
1068    };
1069    let params = level_params_from_cparams(frame);
1070    if !(3..=6).contains(&cdict.strategy) {
1071        // Fast / dfast / optimal strategies: their backends prime the
1072        // dictionary themselves; no lazy-backend plan.
1073        return (params, None);
1074    }
1075    (
1076        params,
1077        Some(RowDictPlan {
1078            attach,
1079            use_row: uses_row_match_finder(&cdict),
1080            cdict,
1081        }),
1082    )
1083}
1084
1085/// The cheap fingerprint pre-splitter level for a compression level (the
1086/// C-like `blockSplitterLevel`), resolved through the same per-level
1087/// `LevelParams` table as every other tuning knob. `None` keeps the whole
1088/// 128 KiB block. The frame loop reads this instead of hardcoding the
1089/// level→split mapping at the call site.
1090pub(crate) fn level_pre_split(level: CompressionLevel) -> Option<usize> {
1091    // Resolve through `resolve_level_params` directly — NOT via the legacy
1092    // `numeric_level()` alias — so named presets read the SAME table row as
1093    // every other tuning knob (`Best` maps to its own row there, which is
1094    // not the row its numeric alias points at). `Uncompressed` (raw
1095    // blocks) never splits.
1096    if matches!(level, CompressionLevel::Uncompressed) {
1097        return None;
1098    }
1099    resolve_level_params(level, None)
1100        .pre_split()
1101        .map(usize::from)
1102}
1103
1104#[cfg(test)]
1105mod tests;