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 and source size. The
170/// [`StrategyTag`] selects the compile-time strategy consts; the runtime
171/// [`BackendTag`] used by the driver dispatcher comes from the resolved
172/// search method ([`Self::backend`]), so an override or a size tier that
173/// changes the search changes the backend with it.
174///
175/// [`StrategyTag`]: crate::encoding::strategy::StrategyTag
176/// [`BackendTag`]: crate::encoding::strategy::BackendTag
177#[derive(Copy, Clone, PartialEq, Eq)]
178pub(crate) struct LevelParams {
179    pub(crate) strategy_tag: crate::encoding::strategy::StrategyTag,
180    /// Decoupled search-method axis. Independent of `strategy_tag`'s
181    /// parse half: a level can pair any parse (greedy / lazy depth via
182    /// `lazy_depth`) with any search backend here. Defaults to the
183    /// historical pairing (`strategy_tag.search()`) but is overridable
184    /// per level so the parse×search matrix can be swept and tuned.
185    pub(crate) search: crate::encoding::strategy::SearchMethod,
186    pub(crate) window_log: u8,
187    pub(crate) lazy_depth: u8,
188    /// Per-strategy tuning. Exactly one is `Some` on each level row, matching
189    /// `strategy_tag`'s backend, so the table self-documents which knobs a
190    /// level actually consumes (the others are `None`, not dead placeholders):
191    /// `fast` for the Fast/Simple backend, `dfast` for Double-Fast, `hc` for
192    /// the HashChain (lazy / btopt / btultra*) backend, `row` for the Row
193    /// (greedy L5) backend.
194    pub(crate) fast: Option<FastConfig>,
195    pub(crate) dfast: Option<DfastConfig>,
196    pub(crate) hc: Option<HcConfig>,
197    pub(crate) row: Option<RowConfig>,
198}
199
200impl LevelParams {
201    /// Backend family (storage variant) for the driver dispatcher.
202    /// Derived from the decoupled `search` axis so a level can route to
203    /// a different search backend than its `strategy_tag` historically
204    /// implied.
205    pub(crate) fn backend(&self) -> crate::encoding::strategy::BackendTag {
206        self.search.backend()
207    }
208
209    /// Parse mode derived from the decoupled `search` axis: the binary-tree
210    /// search path carries `ParseMode::Optimal`; every other search backend
211    /// derives greedy/lazy/lazy2 from `lazy_depth`. Reading `search` (not the
212    /// strategy tag) keeps the parse×search decoupling complete even when a
213    /// level whose tag is `Bt*` is overridden to a non-BT search backend.
214    pub(crate) fn parse(&self) -> crate::encoding::strategy::ParseMode {
215        match self.search {
216            crate::encoding::strategy::SearchMethod::BinaryTree => {
217                crate::encoding::strategy::ParseMode::Optimal
218            }
219            _ => crate::encoding::strategy::ParseMode::from_lazy_depth(self.lazy_depth),
220        }
221    }
222
223    /// Cheap fingerprint pre-splitter level (the `ZSTD_splitBlock` level;
224    /// `0` = from-borders heuristic, `1..=4` = byChunks with sampling tier
225    /// `level - 1`, rates 43 / 11 / 5 / 1). See [`pre_split_for`].
226    pub(crate) fn pre_split(&self) -> Option<u8> {
227        Some(pre_split_for(self.strategy_tag, self.lazy_depth))
228    }
229
230    /// `log2` of how much of a dictionary's end the match-finder tables index
231    /// when it is loaded: a longer dictionary is indexed only that far from its
232    /// end, since tables this size could not keep more of it anyway (upstream
233    /// `ZSTD_loadDictionaryContent`, `zstd_compress.c:4987`:
234    /// `MIN(MAX(hashLog + 3, chainLog + 1), 31)`). Every byte of it stays in
235    /// the window.
236    pub(crate) fn dictionary_reach_log(&self) -> u32 {
237        use crate::encoding::strategy::SearchMethod;
238        // The active backend's config: an override may leave the level row's
239        // own beside it.
240        let logs = match self.search {
241            SearchMethod::Fast => self.fast.map(|fast| (fast.hash_log as usize, 0)),
242            SearchMethod::DoubleFast => self
243                .dfast
244                .map(|dfast| (dfast.long_hash_log as usize, dfast.short_hash_log as usize)),
245            SearchMethod::RowHash | SearchMethod::BinaryTreeLazy => {
246                self.row.map(|row| (row.hash_bits, row.chain_log))
247            }
248            SearchMethod::HashChain | SearchMethod::BinaryTree => {
249                self.hc.map(|hc| (hc.hash_log, hc.chain_log))
250            }
251        };
252        // The reset synthesises the active config when a row lacks it, so a
253        // missing one only means a table-less level: nothing to index.
254        let Some((hash_log, chain_log)) = logs else {
255            return 0;
256        };
257        (hash_log + 3).max(chain_log + 1).min(31) as u32
258    }
259
260    /// The level's upstream `cParams.minMatch`: the shortest match its search
261    /// accepts, and so the shortest piece of a long-distance match a block cut
262    /// leaves worth keeping (`maybeSplitSequence`, `zstd_ldm.c:644`).
263    #[cfg(feature = "ldm")]
264    pub(crate) fn min_match(&self) -> usize {
265        use crate::encoding::strategy::SearchMethod;
266        let configured = match self.search {
267            SearchMethod::Fast => self.fast.map(|fast| fast.mls as usize),
268            SearchMethod::DoubleFast => Some(crate::encoding::match_generator::DFAST_MIN_MATCH_LEN),
269            SearchMethod::RowHash | SearchMethod::BinaryTreeLazy => self.row.map(|row| row.mls),
270            SearchMethod::HashChain => Some(crate::encoding::hc::HC_MIN_MATCH_LEN),
271            SearchMethod::BinaryTree => self.hc.map(|hc| hc.search_mls),
272        };
273        configured.unwrap_or(crate::encoding::cost_model::HC_FORMAT_MINMATCH)
274    }
275}
276
277/// The pre-splitter level for an effective strategy (the tag plus, for the
278/// collapsed `Lazy` tag, its lazy depth).
279///
280/// Upstream's default is `splitLevels[strategy] = {0,0,1,2,2,3,3,4,4,4}`
281/// (zstd_compress.c:4552, used as is; only an explicit `blockSplitterLevel`
282/// is shifted down by 2). It is followed up to lazy depth 1 — the finer
283/// sampling is a real ratio win on mixed data (decodecorpus at greedy/lazy:
284/// the borders tier compressed 4.6-4.9 % WORSE than upstream, the upstream
285/// rate-11 tier <= upstream) — but the lazy2/btlazy2 rate-5 and optimal-band
286/// rate-1 tiers are DELIBERATELY kept two steps coarser: on periodic input
287/// they over-split every block into tiny pieces exactly like upstream does
288/// (100 MiB of repeated log lines at L8-L12: 140,625 bytes and 3.6x the
289/// time upstream-tier, 9,742 bytes coarse — 14x better than upstream) while
290/// on real data they buy under 0.1 % (decodecorpus L8-L12: 170 bytes of
291/// 483 KiB). The drop-in contract asks for ratio <= upstream, not for
292/// upstream's block boundaries.
293pub(crate) fn pre_split_for(tag: crate::encoding::strategy::StrategyTag, lazy_depth: u8) -> u8 {
294    use crate::encoding::strategy::StrategyTag;
295    match tag {
296        // Upstream tiers: borders / byChunks rate 43 / rate 11.
297        StrategyTag::Fast => 0,
298        StrategyTag::Dfast => 1,
299        StrategyTag::Greedy => 2,
300        // lazy (depth 1) = upstream rate 11; lazy2 coarsened to rate 43.
301        StrategyTag::Lazy => {
302            if lazy_depth >= 2 {
303                1
304            } else {
305                2
306            }
307        }
308        StrategyTag::Btlazy2 => 1,
309        // Coarsened to byChunks rate 11 (upstream: rate 1).
310        StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2 => 2,
311    }
312}
313
314/// Apply the public-parameter per-knob overrides onto the
315/// level-resolved [`LevelParams`], in place. Runs in
316/// [`Matcher::reset`](crate::encoding::Matcher::reset)
317/// after the level params are computed and before backend selection, so
318/// a strategy override re-routes the backend uniformly. An all-`None`
319/// override is a no-op the caller skips via
320/// [`crate::encoding::parameters::ParamOverrides::is_empty`], keeping the default
321/// level geometry byte-identical.
322pub(crate) fn apply_param_overrides(
323    params: &mut LevelParams,
324    ov: &crate::encoding::parameters::ParamOverrides,
325) {
326    use crate::encoding::strategy::SearchMethod;
327
328    // 1. Strategy override re-derives tag / search / lazy depth.
329    if let Some(strategy) = ov.strategy {
330        let tag = strategy.tag();
331        params.strategy_tag = tag;
332        params.search = tag.search();
333        params.lazy_depth = strategy.lazy_depth();
334    }
335
336    // 2. Ensure the active backend's config row exists (synthesize a
337    //    default when a strategy override moved off the native row).
338    match params.search {
339        SearchMethod::Fast => {
340            params.fast.get_or_insert(FAST_L1);
341        }
342        SearchMethod::DoubleFast => {
343            params.dfast.get_or_insert(DFAST_L3);
344        }
345        SearchMethod::RowHash | SearchMethod::BinaryTreeLazy => {
346            // `ROW_L5` already carries `mls = ROW_MIN_MATCH_LEN = 5`, the
347            // upstream `minMatch` of the whole greedy..btlazy2 band at the
348            // >256 KiB tier, so a synthesized btlazy2 override hashes at the
349            // same width as a native btlazy2 row.
350            let row = params.row.get_or_insert(ROW_L5);
351            row.bt = matches!(params.search, SearchMethod::BinaryTreeLazy);
352        }
353        SearchMethod::HashChain | SearchMethod::BinaryTree => {
354            params.hc.get_or_insert(HC_OVERRIDE_DEFAULT);
355        }
356    }
357
358    // 3. window_log (bounds-checked at <= 30 by the builder).
359    if let Some(window_log) = ov.window_log {
360        params.window_log = window_log;
361    }
362
363    // 4. Per-backend numeric knobs map into the active config, mirroring
364    //    the upstream zstd `cParams` -> matcher translation documented on each
365    //    config struct.
366    match params.search {
367        SearchMethod::Fast => {
368            if let Some(fast) = params.fast.as_mut() {
369                if let Some(hash_log) = ov.hash_log {
370                    fast.hash_log = hash_log;
371                }
372                if let Some(min_match) = ov.min_match {
373                    fast.mls = fast_key_len(min_match);
374                }
375                // targetLength is the Fast strategy's step, as it is on the
376                // level's own row: upstream zstd_fast.c `stepSize =
377                // targetLength + !targetLength + 1`.
378                if let Some(target_length) = ov.target_length {
379                    fast.step_size = (target_length as usize).max(1) + 1;
380                }
381            }
382        }
383        SearchMethod::DoubleFast => {
384            if let Some(dfast) = params.dfast.as_mut() {
385                // hashLog -> long table, chainLog -> short table (the
386                // dfast secondary index). Both bounds-checked <= 30, so
387                // the `u8` casts are lossless.
388                if let Some(hash_log) = ov.hash_log {
389                    dfast.long_hash_log = hash_log as u8;
390                }
391                if let Some(chain_log) = ov.chain_log {
392                    dfast.short_hash_log = chain_log as u8;
393                }
394            }
395        }
396        SearchMethod::RowHash | SearchMethod::BinaryTreeLazy => {
397            if let Some(row) = params.row.as_mut() {
398                // Row hash-table width override (mirrors dfast `long_hash_log`
399                // / hc `hash_log`); `chain_log` sizes the hash chain the same
400                // backend searches on a <= 2^14 window, or its binary tree.
401                if let Some(hash_log) = ov.hash_log {
402                    row.hash_bits = hash_log as usize;
403                }
404                if let Some(chain_log) = ov.chain_log {
405                    row.chain_log = chain_log as usize;
406                }
407                if let Some(search_log) = ov.search_log {
408                    // Upstream zstd: rowLog = clamp(searchLog, 4, 6); the
409                    // compare budget stays the FULL `1 << searchLog`
410                    // (`nbAttempts`) — the chain walk and the tree walk
411                    // consume it directly, and the row probe bounds its own
412                    // budget by the row size at the search site.
413                    row.row_log = (search_log as usize).clamp(4, 6);
414                    row.search_depth = 1usize << search_log;
415                }
416                if let Some(target_length) = ov.target_length {
417                    row.target_len = target_length as usize;
418                }
419                if let Some(min_match) = ov.min_match {
420                    row.mls = min_match as usize;
421                }
422            }
423        }
424        SearchMethod::HashChain | SearchMethod::BinaryTree => {
425            if let Some(hc) = params.hc.as_mut() {
426                if let Some(hash_log) = ov.hash_log {
427                    hc.hash_log = hash_log as usize;
428                }
429                if let Some(chain_log) = ov.chain_log {
430                    hc.chain_log = chain_log as usize;
431                }
432                if let Some(search_log) = ov.search_log {
433                    hc.search_depth = 1usize << search_log;
434                }
435                if let Some(target_length) = ov.target_length {
436                    hc.target_len = target_length as usize;
437                }
438                if let Some(min_match) = ov.min_match {
439                    // BT finder hash width, derived from cParams.minMatch exactly
440                    // as upstream zstd: `mls = BOUNDED(3, cParams.minMatch, 6)`
441                    // (zstd_opt.c:896 ZSTD_selectBtGetAllMatches). minMatch=3
442                    // tiers hash on 3 bytes (btultra/btultra2 path). Only the BT
443                    // body reads `search_mls`; HC/lazy hash on 4 bytes regardless.
444                    hc.search_mls = (min_match as usize).clamp(3, 6);
445                }
446            }
447        }
448    }
449}
450
451/// The key width the fast strategy hashes for a `minMatch`: upstream's fast
452/// block compressor takes 3 as 4 (zstd_fast.c, `ZSTD_compressBlock_fast`:
453/// `default: /* includes case 3 */`). A 3 reaches the fast strategy from the
454/// knob, or from an optimal level's CDict row a strategy knob moved onto it.
455fn fast_key_len(min_match: u32) -> u32 {
456    min_match.max(4)
457}
458
459/// Map the resolved runtime strategy to the upstream zstd LDM strategy ordinal
460/// (1..=9) that [`crate::encoding::ldm::params::LdmParams::adjust_for`] expects.
461/// The collapsed `Lazy` tag splits on `lazy_depth` (lazy = 4, lazy2 = 5).
462#[cfg(feature = "ldm")]
463pub(crate) fn ldm_strategy_ordinal(
464    tag: crate::encoding::strategy::StrategyTag,
465    lazy_depth: u8,
466) -> u32 {
467    use crate::encoding::strategy::StrategyTag;
468    match tag {
469        StrategyTag::Fast => 1,
470        StrategyTag::Dfast => 2,
471        StrategyTag::Greedy => 3,
472        StrategyTag::Lazy => {
473            if lazy_depth >= 2 {
474                5
475            } else {
476                4
477            }
478        }
479        // Upstream zstd `ZSTD_btlazy2` ordinal.
480        StrategyTag::Btlazy2 => 6,
481        StrategyTag::BtOpt => 7,
482        StrategyTag::BtUltra => 8,
483        StrategyTag::BtUltra2 => 9,
484    }
485}
486
487/// `ceil(log2(size))` of a source-size hint, with a zero hint floored to
488/// [`MIN_WINDOW_LOG`]. This is the single quantization every hint-dependent
489/// matcher parameter is derived from: the window-log cap, the HC / Fast hash
490/// and chain widths, the Dfast / Row table widths, the L22 config buckets, and
491/// the Fast attach-vs-copy cutoff. Two hints sharing this value resolve to the
492/// identical matcher shape, which is why it (not the raw byte count) keys the
493/// primed-dictionary snapshot — see `match_generator::PrimedKey`. Operates on the full `u64`
494/// so callers comparing a hint against a cutoff get the same bucketed decision
495/// here and at the driver, with no `as usize` truncation on 32-bit targets.
496pub(crate) fn source_size_ceil_log(size: u64) -> u8 {
497    if size == 0 {
498        MIN_WINDOW_LOG
499    } else {
500        (64 - (size - 1).leading_zeros()) as u8
501    }
502}
503
504/// Attach-vs-copy cutoff for the Fast strategy, as a ceil-log bucket: a hint at
505/// or below `2^this` (or unknown, `None`) ATTACHES the dictionary (a separate
506/// immutable table scanned in place via the borrowed dual-base kernel); a larger
507/// hint would COPY it into the live table.
508///
509/// `13` is upstream zstd's Fast cutoff (`attachDictSizeCutoffs[ZSTD_fast]` is
510/// 8 KB, zstd_compress.c:2296; `ZSTD_shouldAttachDict`, :2309). Above it the
511/// dictionary is COPIED, and the copy is what makes the dictionary pay on a
512/// larger source: the scan then runs over a table already holding the
513/// dictionary's positions, so ordinary NEAR matches improve everywhere. Attach
514/// mode starts with an empty table and reaches the dictionary only through the
515/// separate exact table, at the positions the step happens to land on.
516///
517/// This was `31` (attach every source up to 2 GiB) on a speed argument alone,
518/// and the missing byte column is where it went wrong. Per frame, `z000033`
519/// (1,022,035 B) and its leading 10 KiB, with a 16 KiB dictionary trained over
520/// its 10 KiB chunks, on the i9: two prebuilt binaries and libzstd alternated
521/// in one session, `perf stat -r 3`, three rounds, ranges non-overlapping.
522///
523/// | case | bytes attach | bytes copy | reference | cycles attach | cycles copy | insn attach | insn copy |
524/// |---|---|---|---|---|---|---|---|
525/// | 10 KiB, L1 | 6,976 | 7,122 | 7,122 | 236,355 (1.95x) | 169,116 (1.39x) | 633,127 (1.78x) | 478,066 (1.34x) |
526/// | 10 KiB, L-5 | 9,660 | 9,124 | 9,130 | 51,678 (1.22x) | 56,444 (1.33x) | 152,281 (1.14x) | 153,369 (1.15x) |
527/// | 1 MiB, L1 | 550,810 | 551,584 | 570,765 | 19.84M (1.76x) | 16.21M (1.44x) | 54.01M (1.91x) | 39.60M (1.40x) |
528/// | 1 MiB, L-5 | 689,127 | 647,735 | 669,826 | 8.80M (1.46x) | 10.67M (1.77x) | 21.62M (1.54x) | 22.81M (1.63x) |
529///
530/// Copy is the better arm on both axes at the positive levels: it takes 18-28%
531/// fewer cycles and 22-27% fewer instructions, and its bytes are the
532/// reference's exactly on the small frame and 3.4% under the reference on the
533/// large one. At the ultra-fast levels it buys ratio with time: 21% more cycles
534/// on the 1 MiB frame for 6.0% fewer bytes, 9% more on the small one for 5.5%
535/// fewer. That trade is what the cutoff is for. Attach put us 2.9% ABOVE the
536/// reference on the 1 MiB ultra-fast frame — the dictionary made our frame
537/// bigger than our own no-dict frame there, while it made the reference's
538/// smaller — because it found 21,897 sequences where the reference finds
539/// 27,546. Copy puts us 3.3% under it.
540///
541/// The remaining gap is now a same-mode one: the reference does this copy in
542/// 1.0x where we take 1.3-1.8x, which is a target with an apples-to-apples
543/// reference rather than a mode the reference never runs.
544///
545/// The borrowed attach kernel stores virtual positions as `u32`
546/// (`cur_abs as u32`), so it could not attach past bucket `31` regardless; that
547/// ceiling is now far above the cutoff and no longer the binding constraint.
548/// Shared by `reset` (records the mode in the primed-snapshot key) and
549/// `prime_with_dictionary` (acts on it).
550pub(crate) const FAST_ATTACH_DICT_CUTOFF_LOG: u8 = 13;
551
552/// Whether a dictionary frame in size bucket `size_log` reuses the captured
553/// primed snapshot rather than re-priming: only a known size above the
554/// strategy's attach cutoff `cutoff_log`, the upstream zstd copy regime
555/// (`ZSTD_shouldAttachDict`). The frame compressor restores on this and the
556/// Fast reset keeps its table only on it, so the two cannot disagree.
557pub(crate) fn restores_primed_snapshot(size_log: Option<u8>, cutoff_log: u8) -> bool {
558    size_log.is_some_and(|log| log > cutoff_log)
559}
560
561/// Largest dictionary region (bytes) the Fast attach path can index. The tagged
562/// dict table packs each position into `32 - DICT_TAG_BITS` (= 24) bits, so a
563/// region past `2^24` (16 MiB) would overflow the packed position. Dictionaries
564/// this large fall back to COPY mode, whose live table stores full `u32`
565/// positions and handles them. The size hint set on dict load equals the actual
566/// dict content length, so the attach-vs-copy decision (and the matching
567/// snapshot-key / epoch bits) can gate on it consistently at reset time.
568pub(crate) const MAX_FAST_ATTACH_DICT_REGION: usize = 1 << 24;
569
570/// Dfast counterpart of [`FAST_ATTACH_DICT_CUTOFF_LOG`]: upstream zstd
571/// `ZSTD_dictMatchState` attach cutoff for the double-fast strategy is 16 KiB
572/// (`2^14`), so small / unknown-size inputs ATTACH (separate immutable dict
573/// long+short tables + dual-probe in `start_matching_fast_loop`) and larger
574/// known-size inputs COPY (re-prime the dict into the live tables, where the
575/// dense scan matches it as window history). The attach build also self-gates
576/// on `use_fast_loop` inside `skip_matching_for_dict_attach` — only the
577/// fast-loop levels (L3 / Default / L0) carry the dual-probe.
578pub(crate) const DFAST_ATTACH_DICT_CUTOFF_LOG: u8 = 14;
579
580/// 32 KiB (`2^15`, upstream zstd `attachDictSizeCutoffs[ZSTD_lazy2]`): small /
581/// unknown-size inputs ATTACH the dict as a separate hash-chain dms (the dual
582/// search in `find_best_match` walks the live input chain + the dms), larger
583/// known-size inputs dense-COPY (merge the dict into the live chain and search
584/// the one combined chain).
585pub(crate) const HC_ATTACH_DICT_CUTOFF_LOG: u8 = 15;
586
587/// BT/optimal attach cutoff for `btlazy2` + `btopt`: 32 KiB (`2^15`, upstream
588/// zstd `attachDictSizeCutoffs[ZSTD_btlazy2]` == `[ZSTD_btopt]`). Small /
589/// unknown-size inputs ATTACH the dict as a separate DUBT dms; larger known-size
590/// inputs COPY the dict into the LIVE binary tree (upstream zstd
591/// `ZSTD_resetCCtx_byCopyingCDict`).
592pub(crate) const BT_OPT_ATTACH_DICT_CUTOFF_LOG: u8 = 15;
593
594/// BT/optimal attach cutoff for `btultra` + `btultra2`: 8 KiB (`2^13`, upstream
595/// zstd `attachDictSizeCutoffs[ZSTD_btultra]` == `[ZSTD_btultra2]`). The deepest
596/// parses copy the dict into the live tree past a much smaller source than the
597/// `btopt` tier, matching upstream's per-strategy cutoff table.
598pub(crate) const BT_ULTRA_ATTACH_DICT_CUTOFF_LOG: u8 = 13;
599
600// Source-size cap for the dfast hash bits when a size hint is present: a tiny
601// input needs no larger hash than its window. The upstream zstd `cParams.hashLog` /
602// `chainLog` (from `DfastConfig`) caps it from above at the call site.
603pub(crate) fn dfast_hash_bits_for_window(max_window_size: usize) -> usize {
604    let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
605    window_log.max(MIN_WINDOW_LOG as usize)
606}
607
608pub(crate) fn row_hash_bits_for_window(max_window_size: usize) -> usize {
609    // Upstream zstd `ZSTD_adjustCParams_internal` cap: `hashLog <= windowLog + 1`.
610    // The `+ 1` is load-bearing for L12, whose upstream zstd hashLog (23) exceeds
611    // its windowLog (22) — a plain `windowLog` cap would shrink the L12
612    // table on EVERY hinted reset and split primed snapshots between
613    // hinted and unhinted frames that resolve to the identical geometry.
614    // No constant upper clamp: the old `ROW_HASH_BITS` (20) ceiling
615    // predates the lazy band moving onto Row (L9-12 carry upstream zstd hashLog
616    // 21-23).
617    let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
618    (window_log + 1).max(MIN_WINDOW_LOG as usize)
619}
620
621/// `floor(log2(window))` for the HashChain table-log cap (upstream zstd
622/// `ZSTD_adjustCParams_internal`). The caller clamps the level's `hash_log` /
623/// `chain_log` from above with this so a small hinted input doesn't allocate the
624/// full level's tables.
625pub(crate) fn hc_hash_bits_for_window(max_window_size: usize) -> usize {
626    let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
627    window_log.max(MIN_WINDOW_LOG as usize)
628}
629
630/// Smallest window_log the encoder will use regardless of source size.
631pub(crate) const MIN_WINDOW_LOG: u8 = 10;
632
633/// Largest window_log the public parameter API accepts
634/// ([`CParameter::WindowLog`](crate::encoding::CParameter)'s upper bound), and
635/// so the largest a workspace estimate can be asked to describe. The estimate
636/// answers for whatever it is given, and shifting by more than the width of a
637/// `usize` is undefined, so a request beyond this is answered with this.
638pub(crate) const MAX_ESTIMATED_WINDOW_LOG: u8 = 30;
639
640/// Translate a verbatim upstream `ZSTD_defaultCParameters[tier][level]` row
641/// (`cparams::CParams`) into our resolved [`LevelParams`], reproducing
642/// upstream's cParams -> matcher-config derivation so the encoder follows C's
643/// source-size-tiered STRATEGY + table widths rather than a single hand-tuned
644/// `LEVEL_TABLE`. Derivation (verified against L6/L16/L22 vs clevels.h):
645/// `search_depth = 1 << searchLog`; row `row_log = clamp(searchLog, 4, 6)`; the
646/// per-strategy sub-config carries the verbatim `hashLog` / `chainLog` /
647/// `targetLength` / `minMatch`. Strategy numbers are upstream `ZSTD_strategy`
648/// (fast=1, dfast=2, greedy=3, lazy=4, lazy2=5, btlazy2=6, btopt=7, btultra=8,
649/// btultra2=9).
650fn level_params_from_cparams(cp: crate::encoding::cparams::CParams) -> LevelParams {
651    use crate::encoding::strategy::{SearchMethod, StrategyTag};
652    let window_log = cp.window_log as u8;
653    let search_depth = 1usize << cp.search_log;
654    let target_len = cp.target_length as usize;
655    let hc = HcConfig {
656        hash_log: cp.hash_log as usize,
657        chain_log: cp.chain_log as usize,
658        search_depth,
659        target_len,
660        // Clamp UP to 4: C's BT finder uses mls=3 on L18-22, but our optimal
661        // parser diverges on the resulting 3-byte matches (breaks level-22
662        // sequence parity), so we keep the finder at >=4 as a workaround until
663        // the parser is C-faithful at minMatch 3. See `HcConfig::search_mls` (#337).
664        search_mls: cp.min_match.clamp(4, 6) as usize,
665    };
666    let row = RowConfig {
667        hash_bits: cp.hash_log as usize,
668        row_log: cp.search_log.clamp(4, 6) as usize,
669        search_depth,
670        target_len,
671        mls: cp.min_match as usize,
672        chain_log: cp.chain_log as usize,
673        // Upstream `ZSTD_btlazy2` (strategy 6).
674        bt: cp.strategy == 6,
675    };
676    let bt = |tag| LevelParams {
677        strategy_tag: tag,
678        search: SearchMethod::BinaryTree,
679        window_log,
680        lazy_depth: 2,
681        fast: None,
682        dfast: None,
683        hc: Some(hc),
684        row: None,
685    };
686    let row_lvl = |tag, search, lazy_depth| LevelParams {
687        strategy_tag: tag,
688        search,
689        window_log,
690        lazy_depth,
691        fast: None,
692        dfast: None,
693        hc: None,
694        row: Some(row),
695    };
696    match cp.strategy {
697        1 => LevelParams {
698            strategy_tag: StrategyTag::Fast,
699            search: SearchMethod::Fast,
700            window_log,
701            lazy_depth: 0,
702            // Upstream fast `stepSize`: `targetLength + 1` (0 -> 1, so step 2).
703            fast: Some(FastConfig {
704                hash_log: cp.hash_log,
705                mls: fast_key_len(cp.min_match),
706                step_size: target_len.max(1) + 1,
707            }),
708            dfast: None,
709            hc: None,
710            row: None,
711        },
712        2 => LevelParams {
713            strategy_tag: StrategyTag::Dfast,
714            search: SearchMethod::DoubleFast,
715            window_log,
716            lazy_depth: 1,
717            fast: None,
718            dfast: Some(DfastConfig {
719                long_hash_log: cp.hash_log as u8,
720                short_hash_log: cp.chain_log as u8,
721            }),
722            hc: None,
723            row: None,
724        },
725        3 => row_lvl(StrategyTag::Greedy, SearchMethod::RowHash, 0),
726        4 => row_lvl(StrategyTag::Lazy, SearchMethod::RowHash, 1),
727        5 => row_lvl(StrategyTag::Lazy, SearchMethod::RowHash, 2),
728        // btlazy2: the same lazy2 parse over the lazily-sorted binary tree.
729        6 => row_lvl(StrategyTag::Btlazy2, SearchMethod::BinaryTreeLazy, 2),
730        7 => bt(StrategyTag::BtOpt),
731        8 => bt(StrategyTag::BtUltra),
732        _ => bt(StrategyTag::BtUltra2),
733    }
734}
735
736/// Down-size a synthesized backend's window / hash / chain logs for a known
737/// source size by routing through the single C-faithful adjuster
738/// [`adjust_cparams`](crate::encoding::cparams::adjust_cparams)
739/// (`ZSTD_adjustCParams_internal`).
740///
741/// Used only on the param-override re-cap path: [`apply_param_overrides`]
742/// synthesizes a backend's full-size default config when a strategy override
743/// moves off the native level row, and this re-applies the source-size cap
744/// C-faithfully — the SAME adjuster the `get_cparams` main path uses, so the
745/// override path and the level path now down-size identically (no extra hinted
746/// 16 KiB window floor, no per-backend headroom that diverged from C). The
747/// Dfast backend self-sizes its tables in `reset`, so only its window is capped.
748pub(crate) fn adjust_params_for_source_size(mut params: LevelParams, src_size: u64) -> LevelParams {
749    use crate::encoding::cparams::{CParams, adjust_cparams};
750    use crate::encoding::strategy::{BackendTag, StrategyTag};
751
752    let backend = params.backend();
753    // Lift the active backend's source-cappable logs into a flat CParams. Dfast
754    // contributes none (window-only); its table widths self-size in `reset`.
755    let (hash_log, chain_log): (u32, u32) = match backend {
756        BackendTag::Simple => (params.fast.as_ref().map_or(0, |f| f.hash_log), 0),
757        BackendTag::HashChain => params
758            .hc
759            .as_ref()
760            .map_or((0, 0), |h| (h.hash_log as u32, h.chain_log as u32)),
761        BackendTag::Row => params
762            .row
763            .as_ref()
764            .map_or((0, 0), |r| (r.hash_bits as u32, r.chain_log as u32)),
765        BackendTag::Dfast => (0, 0),
766    };
767    // The chain cap (`ZSTD_cycleLog`) reads only `strategy >= btlazy2(6)`, so a
768    // coarse 6/3 split is exact for it; `adjust_cparams`'s other strategy use
769    // (`cdict_indices_are_tagged`) is gated on `create_cdict = false` here.
770    let strategy = if matches!(
771        params.strategy_tag,
772        StrategyTag::Btlazy2 | StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2
773    ) {
774        6
775    } else {
776        3
777    };
778    let adj = adjust_cparams(
779        CParams {
780            window_log: u32::from(params.window_log),
781            chain_log,
782            hash_log,
783            search_log: 1,
784            min_match: 4,
785            target_length: 0,
786            strategy,
787        },
788        src_size,
789        0,
790        false,
791    );
792    params.window_log = adj.window_log as u8;
793    match backend {
794        BackendTag::Simple => {
795            if let Some(f) = params.fast.as_mut() {
796                f.hash_log = adj.hash_log;
797            }
798        }
799        BackendTag::HashChain => {
800            if let Some(h) = params.hc.as_mut() {
801                h.hash_log = adj.hash_log as usize;
802                h.chain_log = adj.chain_log as usize;
803            }
804        }
805        BackendTag::Row => {
806            if let Some(r) = params.row.as_mut() {
807                r.hash_bits = adj.hash_log as usize;
808                r.chain_log = adj.chain_log as usize;
809            }
810        }
811        BackendTag::Dfast => {}
812    }
813    params
814}
815
816/// Apply the caller's parameter overrides to the level params a frame
817/// resolved to: the step the matcher's reset takes after the level (and a
818/// dictionary's CDict tier) is resolved, kept in one place so the workspace
819/// estimate builds exactly what the encoder does. An all-`None` set leaves the
820/// params untouched, which keeps plain level-based geometry byte-identical.
821pub(crate) fn apply_frame_overrides(
822    params: &mut LevelParams,
823    ov: &crate::encoding::parameters::ParamOverrides,
824    dictionary_frame: bool,
825    hint: Option<u64>,
826) {
827    if ov.is_empty() {
828        return;
829    }
830    if dictionary_frame {
831        // A dictionary frame runs the CDict's cParams (upstream
832        // `ZSTD_resetCCtx_byAttachingCDict` / `byCopyingCDict`), and the
833        // search knobs are already part of them
834        // (`resolve_level_params_with_dict`); the frame's own knob is the
835        // window.
836        //
837        // The window still answers to the source, as it does for every
838        // other frame: `ZSTD_adjustCParams_internal` caps it by the source
839        // and dictionary extent, and a window neither can fill only makes
840        // decoders reserve memory the frame never uses. Capped here rather
841        // than through the full adjuster, which would reshape the search.
842        if let Some(window_log) = ov.window_log {
843            params.window_log = match hint {
844                // The source caps the window even here, and even with an
845                // explicit request: the reference command declares 2 KiB
846                // for `--ultra -22 --long=27 -D dict` on a 2 KiB file, and
847                // a window the content cannot fill only makes every decoder
848                // reserve memory the frame never uses. The floor that
849                // travels with the cap in `adjust_cparams` applies too, or
850                // a hint of a few dozen bytes asks for a window smaller
851                // than the format's smallest.
852                //
853                // The dictionary's own size is NOT part of that cap: the
854                // reference declares the same 2 KiB whether the dictionary
855                // is 4 KiB or 256 KiB, because a small window does not put
856                // the dictionary out of reach: sequences may reference it
857                // at offsets beyond the window while the output so far is
858                // within it (RFC 8878, Dictionary_Content). Counting it
859                // made our frames ask decoders for up to 256x what the
860                // reference asks.
861                Some(src) => {
862                    (crate::encoding::cparams::adjusted_window_log(u32::from(window_log), src, 0)
863                        as u8)
864                        .max(MIN_WINDOW_LOG)
865                }
866                None => window_log,
867            };
868        }
869    } else {
870        apply_param_overrides(params, ov);
871        // The level's own resolution applied the source-size cap for the
872        // LEVEL's native backend. If a strategy override moved the frame
873        // onto a different backend, `apply_param_overrides` synthesized that
874        // backend's DEFAULT config (FAST_L1 / HC_OVERRIDE_DEFAULT) with
875        // full-size table logs AFTER that cap ran. Re-apply the hint cap so a
876        // tiny hinted frame doesn't allocate the new backend's full-size
877        // tables.
878        //
879        // The cap covers an explicit `window_log` too, as
880        // `ZSTD_adjustCParams_internal` does upstream: the window is a
881        // promise about the memory decoding will need, and a source that
882        // cannot fill it makes that promise for nothing: every decoder
883        // opening the frame would reserve the whole declared window to read
884        // a few bytes. The override still raises the window as far as the
885        // source can use.
886        if let Some(hint_size) = hint {
887            *params = adjust_params_for_source_size(*params, hint_size);
888        }
889    }
890}
891
892/// The long-distance matcher's parameters for a frame: the caller-pinned knobs
893/// seeded first, then the upstream derivation fills the rest so the set stays
894/// consistent (`hash_rate_log = window_log - hash_log`, and so on); clobbering
895/// after the derivation would hand the producer an inconsistent set. Shared by
896/// the matcher's reset and the workspace estimate.
897#[cfg(feature = "ldm")]
898pub(crate) fn frame_ldm_params(
899    params: &LevelParams,
900    ldm: &crate::encoding::parameters::LdmOverride,
901) -> crate::encoding::ldm::params::LdmParams {
902    let seed = crate::encoding::ldm::params::LdmParams {
903        window_log: params.window_log as u32,
904        hash_log: ldm.hash_log.unwrap_or(0),
905        hash_rate_log: ldm.hash_rate_log.unwrap_or(0),
906        min_match_length: ldm.min_match.unwrap_or(0),
907        bucket_size_log: ldm.bucket_size_log.unwrap_or(0),
908    };
909    seed.derive(ldm_strategy_ordinal(params.strategy_tag, params.lazy_depth))
910}
911
912/// Estimated steady-state heap footprint of a one-shot compression context
913/// at `level` (window history + match-finder tables + block staging), in
914/// bytes. Computed from the same per-level tuning table the encoder
915/// resolves at frame start, so the estimate tracks the real allocations;
916/// it is an upper-bound style budget figure, not an exact accounting.
917pub fn estimated_compression_workspace_bytes(level: CompressionLevel) -> usize {
918    estimated_compression_workspace_bytes_for_source(level, None)
919}
920
921/// The same estimate for a source whose size is known.
922///
923/// The window and the match-finder tables are capped by the source, so a level
924/// that would reserve hundreds of MiB for an arbitrary stream reserves a
925/// fraction of that for a small one — and a caller budgeting memory for a
926/// compression it is about to run knows which. `None` is the arbitrary-stream
927/// figure that [`estimated_compression_workspace_bytes`] reports.
928pub fn estimated_compression_workspace_bytes_for_source(
929    level: CompressionLevel,
930    src_size_hint: Option<u64>,
931) -> usize {
932    estimated_compression_workspace_bytes_for_run(level, src_size_hint, None, false, None)
933}
934
935/// The same estimate for a run whose window, long-distance matching and
936/// dictionary are what the caller has actually asked for.
937///
938/// A `window_log` override enlarges the history the frame keeps, and
939/// long-distance matching adds a hash table of its own on top — neither of them
940/// visible in the level's own preset. A dictionary goes further than adding to
941/// the figure: it *decides* it. The frame runs the dictionary's own compression
942/// parameters, so a small source compressed against a large dictionary builds
943/// tables sized for the dictionary, which at the higher levels is the
944/// difference between tens of KiB and hundreds of MiB. `None`, `false` and
945/// `None` give the preset, which is what
946/// [`estimated_compression_workspace_bytes_for_source`] reports.
947///
948/// `dictionary` is what a caller weighing a run before it parses the blob can
949/// answer with [`DictionarySizes::raw_content`] on the blob's own length: the
950/// content of a trained dictionary is smaller than the blob it came in, and
951/// overstating it can only move the estimate toward the copy-mode geometry,
952/// which is the larger of the two.
953///
954/// [`DictionarySizes::raw_content`]: crate::encoding::DictionarySizes::raw_content
955pub fn estimated_compression_workspace_bytes_for_run(
956    level: CompressionLevel,
957    src_size_hint: Option<u64>,
958    window_log: Option<u8>,
959    long_distance_matching: bool,
960    dictionary: Option<crate::encoding::DictionarySizes>,
961) -> usize {
962    let mut params = match dictionary.filter(|sizes| sizes.content != 0) {
963        Some(sizes) => {
964            resolve_level_params_with_dict(
965                level,
966                src_size_hint,
967                sizes,
968                &crate::encoding::parameters::ParamOverrides::default(),
969            )
970            .0
971        }
972        None => resolve_level_params(level, src_size_hint),
973    };
974    // The override is what the frame will keep, but never below the floor the
975    // format sets or above what the source can fill — the same two bounds the
976    // encoder applies to it.
977    if let Some(requested) = window_log {
978        // Bounded to what the encoder itself accepts before anything shifts by
979        // it. This answers for whatever it is asked, and a shift past the width
980        // of the type is undefined rather than merely large: the largest window
981        // there is, is the honest answer to a request beyond it.
982        let requested = requested.min(MAX_ESTIMATED_WINDOW_LOG);
983        let capped = match src_size_hint {
984            Some(src) => {
985                crate::encoding::cparams::adjusted_window_log(u32::from(requested), src, 0) as u8
986            }
987            None => requested,
988        };
989        params.window_log = capped.clamp(MIN_WINDOW_LOG, MAX_ESTIMATED_WINDOW_LOG);
990    }
991    // The long-distance matcher's own table, sized from the window it searches
992    // (upstream `ZSTD_ldm_adjustParameters`). Only the `ldm` build has one.
993    #[cfg(feature = "ldm")]
994    let ldm = if long_distance_matching {
995        let strategy = ldm_strategy_ordinal(params.strategy_tag, params.lazy_depth);
996        crate::encoding::ldm::estimated_workspace_bytes(
997            &crate::encoding::ldm::params::LdmParams::adjust_for(
998                u32::from(params.window_log),
999                strategy,
1000            ),
1001        )
1002    } else {
1003        0
1004    };
1005    #[cfg(not(feature = "ldm"))]
1006    let ldm = {
1007        let _ = long_distance_matching;
1008        0
1009    };
1010    workspace_bytes(&params, ldm)
1011}
1012
1013/// The workspace estimate for a frame run under `parameters`: the level, every
1014/// knob that overrides it, the source size and the dictionary, resolved the way
1015/// the encoder resolves them at frame start.
1016///
1017/// [`estimated_compression_workspace_bytes_for_run`] answers for a level with a
1018/// window and long-distance matching on top; a caller that also sets `hashLog`,
1019/// `chainLog`, a strategy or the long-distance matcher's own table sizes needs
1020/// this one, since each of those resizes what the frame allocates. A dictionary
1021/// frame runs the geometry the dictionary is prepared with, the knobs included,
1022/// as the encoder does.
1023///
1024/// # Examples
1025///
1026/// ```
1027/// use structured_zstd::encoding::{
1028///     estimated_compression_workspace_bytes_for_parameters, CompressionLevel,
1029///     CompressionParameters,
1030/// };
1031///
1032/// let level = CompressionParameters::builder(CompressionLevel::Level(3)).build().unwrap();
1033/// let wide = CompressionParameters::builder(CompressionLevel::Level(3))
1034///     .window_log(27)
1035///     .hash_log(24)
1036///     .build()
1037///     .unwrap();
1038/// let source = Some(512 << 20);
1039/// assert!(
1040///     estimated_compression_workspace_bytes_for_parameters(&wide, source, None)
1041///         > estimated_compression_workspace_bytes_for_parameters(&level, source, None)
1042/// );
1043/// ```
1044pub fn estimated_compression_workspace_bytes_for_parameters(
1045    parameters: &crate::encoding::CompressionParameters,
1046    src_size_hint: Option<u64>,
1047    dictionary: Option<crate::encoding::DictionarySizes>,
1048) -> usize {
1049    let level = parameters.level();
1050    let overrides = parameters.overrides();
1051    let dictionary = dictionary.filter(|sizes| sizes.content != 0);
1052    let mut params = match dictionary {
1053        Some(sizes) => resolve_level_params_with_dict(level, src_size_hint, sizes, &overrides).0,
1054        None => resolve_level_params(level, src_size_hint),
1055    };
1056    apply_frame_overrides(&mut params, &overrides, dictionary.is_some(), src_size_hint);
1057    #[cfg(feature = "ldm")]
1058    let ldm = overrides.ldm.map_or(0, |ldm| {
1059        crate::encoding::ldm::estimated_workspace_bytes(&frame_ldm_params(&params, &ldm))
1060    });
1061    #[cfg(not(feature = "ldm"))]
1062    let ldm = 0;
1063    workspace_bytes(&params, ldm)
1064}
1065
1066/// `entry` bytes for each of `1 << log` slots, pinned at `usize::MAX` where
1067/// the table is more than a `usize` counts: the estimate is a budget, and a
1068/// figure that wrapped would call an impossible table affordable.
1069fn table_bytes(entry: usize, log: usize) -> usize {
1070    u32::try_from(log)
1071        .ok()
1072        .and_then(|log| 1usize.checked_shl(log))
1073        .and_then(|slots| slots.checked_mul(entry))
1074        .unwrap_or(usize::MAX)
1075}
1076
1077/// Window, match-finder tables, optimal-parser scratch and block staging for a
1078/// frame resolved to `params`, plus `ldm` bytes of long-distance table.
1079fn workspace_bytes(params: &LevelParams, ldm: usize) -> usize {
1080    use crate::encoding::strategy::{SearchMethod, StrategyTag};
1081    // A 30-bit window is a gibibyte, which a 32-bit `usize` cannot count: the
1082    // widest window is more memory than such a machine has, so the figure is
1083    // pinned rather than wrapped.
1084    let window = 1usize
1085        .checked_shl(u32::from(params.window_log))
1086        .unwrap_or(usize::MAX);
1087    // Mirror `configure()`: the HC3 short-match side table exists only on
1088    // the btultra/btultra2 tags (minMatch 3), capped by the window log; the
1089    // BT pointer-pair layout fits inside the `4 << chain_log` chain term
1090    // (pairs over `chain_log - 1` nodes).
1091    let wants_hash3 = matches!(
1092        params.strategy_tag,
1093        StrategyTag::BtUltra | StrategyTag::BtUltra2
1094    );
1095    let uses_bt = matches!(
1096        params.strategy_tag,
1097        StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2
1098    );
1099    // Only the backend `params.search` selects is built: `reset` swaps in one
1100    // matcher storage per frame. A strategy override leaves the level's own
1101    // row in place beside the one it synthesized, so summing every populated
1102    // config would charge a frame for tables it never allocates.
1103    // Every term goes through `table_bytes` and every sum saturates: an
1104    // override can ask for a table past what a 32-bit `usize` counts, and a
1105    // shift that dropped its high bits would report that table as free.
1106    let tables = match params.search {
1107        SearchMethod::Fast => params
1108            .fast
1109            .map_or(0, |f| table_bytes(4, f.hash_log as usize)),
1110        SearchMethod::DoubleFast => params.dfast.map_or(0, |d| {
1111            table_bytes(4, usize::from(d.long_hash_log))
1112                .saturating_add(table_bytes(4, usize::from(d.short_hash_log)))
1113        }),
1114        // The lazy backend's chain / tree finders (window <= 2^14, or a
1115        // btlazy2 level) use a plain hash table (`4 << hash_bits`) plus the
1116        // chain / tree table (`4 << chain_log`) instead of the row tables.
1117        SearchMethod::RowHash | SearchMethod::BinaryTreeLazy => params.row.map_or(0, |r| {
1118            if r.bt || params.window_log <= 14 {
1119                table_bytes(4, r.hash_bits).saturating_add(table_bytes(4, r.chain_log))
1120            } else {
1121                table_bytes(4, r.hash_bits).saturating_add(table_bytes(2, r.hash_bits))
1122            }
1123        }),
1124        SearchMethod::HashChain | SearchMethod::BinaryTree => params.hc.map_or(0, |h| {
1125            let hash3 = if wants_hash3 {
1126                table_bytes(
1127                    4,
1128                    crate::encoding::match_table::storage::HC3_HASH_LOG
1129                        .min(params.window_log as usize),
1130                )
1131            } else {
1132                0
1133            };
1134            table_bytes(4, h.hash_log)
1135                .saturating_add(table_bytes(4, h.chain_log))
1136                .saturating_add(hash3)
1137        }),
1138    };
1139    // BT modes box a `BtMatcher`; its retained scratch layout is budgeted
1140    // next to the struct so estimator and allocator evolve together.
1141    let bt = if uses_bt {
1142        crate::encoding::bt::BtMatcher::estimated_workspace_bytes()
1143    } else {
1144        0
1145    };
1146    // Block staging: literal + sequence buffers plus the compressed-block
1147    // scratch, each bounded by the 128 KiB block size.
1148    let staging = 3 * (128 * 1024);
1149    // Saturating: the parts are each bounded, but their sum at the widest
1150    // window is more than a 32-bit `usize` counts, and a total that wrapped
1151    // would report a run as fitting a limit it cannot.
1152    window
1153        .saturating_add(tables)
1154        .saturating_add(bt)
1155        .saturating_add(staging)
1156        .saturating_add(ldm)
1157}
1158
1159/// Extra steady-state workspace the optimal strategies (ordinals 7..=9,
1160/// btopt..btultra2) retain beyond the hash/chain tables: the boxed matcher
1161/// plus its scratch arenas, and the HC3 short-match side table for
1162/// btultra/btultra2 (capped by the window log). 0 for the other ordinals
1163/// (btlazy2's tree lives in the lazy backend's chain table).
1164pub fn estimated_bt_strategy_extra_bytes(strategy_ordinal: u32, window_log: u32) -> usize {
1165    if !(7..=9).contains(&strategy_ordinal) {
1166        return 0;
1167    }
1168    let hash3 = if matches!(strategy_ordinal, 8 | 9) {
1169        4usize << crate::encoding::match_table::storage::HC3_HASH_LOG.min(window_log as usize)
1170    } else {
1171        0
1172    };
1173    crate::encoding::bt::BtMatcher::estimated_workspace_bytes() + hash3
1174}
1175
1176/// Resolve a [`CompressionLevel`] (+ optional source-size hint) to the
1177/// concrete [`LevelParams`] the matcher runs: strategy tag, search method
1178/// (match-finder), window log, and per-backend config.
1179///
1180/// ## CRITICAL: input size changes the match-finder (and can change strategy)
1181///
1182/// The resolved geometry is a function of the SOURCE SIZE, not the level
1183/// alone. This is the easy-to-miss part (so read this before assuming a level
1184/// maps to one fixed match-finder). Every level except `Uncompressed` goes
1185/// through [`get_cparams`](crate::encoding::cparams::get_cparams), the port of
1186/// upstream zstd `ZSTD_getCParams_internal`, which runs its two stages:
1187///
1188/// 1. the `clevels.h` row for the (level, source-size tier) pair. The tier
1189///    changes the STRATEGY as well as the table widths: level 11 is lazy2 on
1190///    a large source and btopt on one of 16 KiB or less;
1191/// 2. `ZSTD_adjustCParams_internal`, which caps `window_log` to
1192///    ~`ceil_log2(source_size)` and shrinks the hash and chain logs to match.
1193///
1194/// THEN, inside the Row backend, the greedy/lazy band searches a hash chain
1195/// instead of rows when the resolved `window_log <= 14`
1196/// (`RowMatchGenerator::finder`) — exactly upstream's
1197/// `ZSTD_resolveRowMatchFinderMode` (the Row match-finder is used for
1198/// greedy/lazy/lazy2 ONLY when `windowLog > 14`) — and a btlazy2 level
1199/// searches the lazily-sorted binary tree; the parse itself is the same
1200/// `lazy_generic` body in all three cases. Net effect for the SAME level:
1201///
1202/// * small input (e.g. a 10 KiB fixture → `window_log` 14) → hash chain
1203///   (`ZSTD_HcFindBestMatch`, scalar chain walk);
1204/// * large input (e.g. 1 MiB → `window_log` 20) → rows (the SIMD-tag
1205///   row match-finder).
1206///
1207/// A dictionary frame resolves through [`resolve_level_params_with_dict`]
1208/// instead: the CDict's cParams (its own size tier) with the frame's
1209/// source-derived `window_log`. When comparing against C on a fixture,
1210/// resolve the match-finder from the fixture's size (and dictionary) first,
1211/// or you may optimise/benchmark a path C does not even take for that input.
1212pub(crate) fn resolve_level_params(
1213    level: CompressionLevel,
1214    source_size: Option<u64>,
1215) -> LevelParams {
1216    // Uncompressed = raw blocks, no match-finder. Not a cParams level, so it is
1217    // the one row resolved by hand rather than through `get_cparams`.
1218    if matches!(level, CompressionLevel::Uncompressed) {
1219        return LevelParams {
1220            strategy_tag: crate::encoding::strategy::StrategyTag::Fast,
1221            search: crate::encoding::strategy::SearchMethod::Fast,
1222            // Raw frames emit literal blocks and never reference history;
1223            // advertising a wider window only inflates the decoder-side buffer
1224            // reservation, so clamp to 17 (128 KiB) regardless of input size.
1225            window_log: 17,
1226            lazy_depth: 0,
1227            // Beyond-upstream: hash_log=14 (vs upstream's row-0 13) for ~2× fewer
1228            // collisions on structured corpora; mls=6 / step_size=2 mirror the
1229            // upstream "base for negative" row (targetLength=1 -> step 2).
1230            fast: Some(FastConfig {
1231                hash_log: 14,
1232                mls: 6,
1233                step_size: 2,
1234            }),
1235            dfast: None,
1236            hc: None,
1237            row: None,
1238        };
1239    }
1240    // Every other level resolves through the SINGLE C-faithful cParams source,
1241    // `cparams::get_cparams` (the port of `ZSTD_getCParams`). One place selects
1242    // strategy + table widths + the negative-level acceleration per
1243    // (level, srcSize) + the source-size window/hash down-clamp, so the encoder
1244    // never re-derives parameters from a parallel hand-tuned path. Named presets
1245    // map to their numeric level; the cParams source clamps out-of-range levels
1246    // (>22 to 22, negatives to MIN_CLEVEL) itself.
1247    let numeric = numeric_level(level);
1248    let src = source_size.unwrap_or(crate::encoding::cparams::CONTENTSIZE_UNKNOWN);
1249    level_params_from_cparams(crate::encoding::cparams::get_cparams(numeric, src, 0))
1250}
1251
1252/// The upstream numeric level a preset maps to.
1253pub(crate) fn numeric_level(level: CompressionLevel) -> i32 {
1254    match level {
1255        CompressionLevel::Uncompressed => unreachable!("raw frames resolve no cParams"),
1256        // Fastest = upstream level 1 (fast strategy, smallest real-compression
1257        // tables).
1258        CompressionLevel::Fastest => 1,
1259        // Default = upstream level 3 (the libzstd default).
1260        CompressionLevel::Default => CompressionLevel::DEFAULT_LEVEL,
1261        // Better = level 7: the lazy2 band — clearly above the fast/dfast levels
1262        // on ratio while still well under the binary-tree cost cliff.
1263        CompressionLevel::Better => 7,
1264        // Best = level 13: the first point of the deep binary-tree band that
1265        // strictly dominates every level below it on ratio (lower levels can tie
1266        // on window-bound corpora), so the alias sits on a config that always
1267        // wins rather than on a hair-thin margin.
1268        CompressionLevel::Best => 13,
1269        CompressionLevel::Level(n) => n,
1270    }
1271}
1272
1273/// How a greedy / lazy frame compressed with a dictionary takes its
1274/// match-finder from the dictionary's own cParams (upstream
1275/// `ZSTD_resetCCtx_usingCDict`).
1276#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1277pub(crate) struct RowDictPlan {
1278    /// `ZSTD_shouldAttachDict`: the dictionary tables are searched in place
1279    /// (`dictMatchState`) instead of being copied into the frame's tables.
1280    pub(crate) attach: bool,
1281    /// The CDict's `useRowMatchFinder`, inherited by the frame.
1282    pub(crate) use_row: bool,
1283    /// The CDict's cParams: the geometry (`hash_log`, `chain_log`,
1284    /// `search_log` → row width) and key width its tables were built with.
1285    pub(crate) cdict: crate::encoding::cparams::CParams,
1286}
1287
1288/// [`resolve_level_params`] for a frame that compresses with a dictionary of
1289/// `dict_size` bytes. Upstream builds the CDict with
1290/// `ZSTD_getCParams(level, UNKNOWN, dictSize, createCDict)` and the frame
1291/// then runs the CDict's strategy / widths / search depth / match-finder:
1292/// re-adjusted to the source when the dictionary is attached
1293/// (`ZSTD_resetCCtx_byAttachingCDict`), verbatim when it is copied
1294/// (`ZSTD_resetCCtx_byCopyingCDict`); only the frame's own `windowLog` is
1295/// kept. The CDict's strategy is taken whatever backend family the plain
1296/// level resolved to; a lazy-band CDict (greedy..btlazy2) also carries the
1297/// lazy backend's [`RowDictPlan`]. The caller's `overrides` are part of the
1298/// CDict's cParams, as they are for a dictionary upstream loads into a context
1299/// that carries them.
1300pub(crate) fn resolve_level_params_with_dict(
1301    level: CompressionLevel,
1302    source_size: Option<u64>,
1303    sizes: crate::encoding::DictionarySizes,
1304    overrides: &crate::encoding::parameters::ParamOverrides,
1305) -> (LevelParams, Option<RowDictPlan>) {
1306    use crate::encoding::cparams::{
1307        CONTENTSIZE_UNKNOWN, attach_cparams, copy_cparams, get_cdict_cparams, should_attach_dict,
1308        uses_row_match_finder,
1309    };
1310    let base = resolve_level_params(level, source_size);
1311    if sizes.content == 0 {
1312        return (base, None);
1313    }
1314    // The CDict's cParams decide the frame's strategy REGARDLESS of the
1315    // backend family the plain level resolved to for this source size
1316    // (upstream takes them unconditionally): L13 on a 4 KiB source is
1317    // btopt, but a 300 KiB CDict is btlazy2 and the frame runs btlazy2; L4
1318    // on a 1 MiB source is dfast, but a 4 KiB CDict is greedy. Only the
1319    // frame's own `windowLog` is kept.
1320    let cdict = get_cdict_cparams(numeric_level(level), sizes.serialized, overrides);
1321    // `ZSTD_shouldAttachDict`, bounded by the backend's attach representability:
1322    // the Fast / Dfast attached tables pack the dict position next to a tag, so
1323    // they index at most 2^24 content bytes. A larger dictionary is primed in
1324    // COPY mode, and the frame must then run the CDict's verbatim table
1325    // geometry (`byCopyingCDict`) — copying it into source-capped attach-mode
1326    // tables would collide away its matches.
1327    let attach_fits = match cdict.strategy {
1328        1 => sizes.content <= MAX_FAST_ATTACH_DICT_REGION,
1329        2 => sizes.content <= crate::encoding::dfast::DFAST_ATTACH_DICT_MAX_LEN,
1330        _ => true,
1331    };
1332    let attach = should_attach_dict(&cdict, source_size) && attach_fits;
1333    let window_log = u32::from(base.window_log);
1334    let frame = if attach {
1335        attach_cparams(
1336            cdict,
1337            source_size.unwrap_or(CONTENTSIZE_UNKNOWN),
1338            window_log,
1339        )
1340    } else {
1341        copy_cparams(cdict, window_log)
1342    };
1343    let params = level_params_from_cparams(frame);
1344    if !(3..=6).contains(&cdict.strategy) {
1345        // Fast / dfast / optimal strategies: their backends prime the
1346        // dictionary themselves; no lazy-backend plan.
1347        return (params, None);
1348    }
1349    (
1350        params,
1351        Some(RowDictPlan {
1352            attach,
1353            use_row: uses_row_match_finder(&cdict),
1354            cdict,
1355        }),
1356    )
1357}
1358
1359/// The cheap fingerprint pre-splitter level for a compression level (the
1360/// C-like `blockSplitterLevel`), resolved through the same per-level
1361/// `LevelParams` table as every other tuning knob. `None` keeps the whole
1362/// 128 KiB block. The frame loop reads this instead of hardcoding the
1363/// level→split mapping at the call site.
1364pub(crate) fn level_pre_split(level: CompressionLevel) -> Option<usize> {
1365    // Resolve through `resolve_level_params` directly — NOT via the legacy
1366    // `numeric_level()` alias — so named presets read the SAME table row as
1367    // every other tuning knob (`Best` maps to its own row there, which is
1368    // not the row its numeric alias points at). `Uncompressed` (raw
1369    // blocks) never splits.
1370    if matches!(level, CompressionLevel::Uncompressed) {
1371        return None;
1372    }
1373    resolve_level_params(level, None)
1374        .pre_split()
1375        .map(usize::from)
1376}
1377
1378#[cfg(test)]
1379mod tests;