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 zstd `mls = BOUNDED(4, minMatch, 6)`),
26    /// carried explicitly per level so it is NOT inferred from `target_len`
27    /// (a `target_length` override must not silently flip the finder between
28    /// 5- and 4-byte hashing). Only the BT body reads it; HC/lazy levels keep
29    /// it at 4 (their `hash_position` is always 4-byte). 5 for the
30    /// minMatch=5 BT levels (btlazy2 + btopt L16), 4 elsewhere.
31    pub(crate) search_mls: usize,
32}
33
34#[derive(Copy, Clone, PartialEq, Eq)]
35pub(crate) struct RowConfig {
36    pub(crate) hash_bits: usize,
37    pub(crate) row_log: usize,
38    pub(crate) search_depth: usize,
39    pub(crate) target_len: usize,
40    /// Upstream zstd `cParams.minMatch` for the row matcher: the regular-search
41    /// acceptance floor (a row candidate must extend to >= `mls` bytes).
42    /// The C-like advanced API surfaces this as the row min-match knob.
43    /// `ROW_MIN_MATCH_LEN` (5) is the default; the row hash key width stays
44    /// 4 bytes (an internal detail), so this only tunes the acceptance
45    /// floor, not the candidate hash distribution.
46    pub(crate) mls: usize,
47}
48
49// Only used as the default HashChain config when the test-only parse×search
50// override pairs a level with a backend its native row doesn't populate.
51#[cfg(test)]
52pub(crate) const HC_CONFIG: HcConfig = HcConfig {
53    hash_log: HC_HASH_LOG,
54    chain_log: HC_CHAIN_LOG,
55    search_depth: HC_SEARCH_DEPTH,
56    target_len: HC_TARGET_LEN,
57    search_mls: 4,
58};
59
60/// Base HashChain config synthesized when a public-parameter strategy
61/// override ([`crate::encoding::parameters`]) routes a level to the HC / BT
62/// backend whose native level row didn't populate `hc` (e.g. forcing
63/// `Strategy::Lazy2` onto a level the table resolves to Fast). Mirrors
64/// the mid-band lazy defaults; the per-knob overrides then refine it.
65pub(crate) const HC_OVERRIDE_DEFAULT: HcConfig = HcConfig {
66    hash_log: crate::encoding::match_table::storage::HC_HASH_LOG,
67    chain_log: crate::encoding::match_table::storage::HC_CHAIN_LOG,
68    search_depth: HC_SEARCH_DEPTH,
69    target_len: HC_TARGET_LEN,
70    search_mls: 4,
71};
72
73pub(crate) const BTULTRA2_HC_CONFIG: HcConfig = HcConfig {
74    hash_log: 24,
75    chain_log: 24,
76    search_depth: 512,
77    target_len: 256,
78    search_mls: 4,
79};
80
81pub(crate) const BTULTRA2_HC_CONFIG_L22: HcConfig = HcConfig {
82    hash_log: 25,
83    chain_log: 27,
84    search_depth: 512,
85    target_len: 999,
86    search_mls: 4,
87};
88
89pub(crate) const BTULTRA2_HC_CONFIG_L22_256K: HcConfig = HcConfig {
90    hash_log: 19,
91    chain_log: 19,
92    search_depth: 1 << 13,
93    target_len: 999,
94    search_mls: 4,
95};
96
97pub(crate) const BTULTRA2_HC_CONFIG_L22_128K: HcConfig = HcConfig {
98    hash_log: 17,
99    chain_log: 18,
100    search_depth: 1 << 11,
101    target_len: 999,
102    search_mls: 4,
103};
104
105pub(crate) const BTULTRA2_HC_CONFIG_L22_16K: HcConfig = HcConfig {
106    hash_log: 15,
107    chain_log: 15,
108    search_depth: 1 << 10,
109    target_len: 999,
110    search_mls: 4,
111};
112
113// Default Row config: only used by tests and the test-only parse×search
114// override (production greedy L5 carries its own `ROW_L5`).
115#[cfg(test)]
116pub(crate) const ROW_CONFIG: RowConfig = RowConfig {
117    hash_bits: ROW_HASH_BITS,
118    row_log: ROW_LOG,
119    search_depth: ROW_SEARCH_DEPTH,
120    target_len: ROW_TARGET_LEN,
121    mls: ROW_MIN_MATCH_LEN,
122};
123
124// Level-5 greedy is the ONLY strategy routed to the Row backend
125// (`StrategyTag::backend`: greedy -> Row; lazy / btopt / btultra* ->
126// HashChain), so it is the only level whose `row:` field is read. The upstream zstd
127// `clevels.h` default row (srcSize > 256 KB) for level 5 is searchLog=3,
128// targetLength=2, from which the row matcher derives:
129//   rowLog       = clamp(searchLog, 4, 6) = 4
130//   search_depth = 1 << min(searchLog, rowLog) = 8   (= nbAttempts)
131//   target_len   = targetLength = 2                  (nice-match early-out)
132// The shared `ROW_CONFIG` (row_log=5, search_depth=16, target_len=48) ran a
133// level-12-grade search here: 16 slots per row, never early-exiting until a
134// 48-byte match. That exhaustive walk was the dominant cost in greedy L5's
135// encode-speed regression vs FFI. `hash_bits` matches upstream zstd's
136// `ZSTD_getCParams(5, .., 0).hashLog` = 19 (verified via
137// `cparams_check 5`), so the row table is the same width as upstream's
138// (2^19 slots); the previous `ROW_HASH_BITS` (20) doubled both row tables vs
139// upstream, the dominant peak-memory excess on the greedy band.
140pub(crate) const ROW_L5: RowConfig = RowConfig {
141    hash_bits: 19,
142    row_log: 4,
143    search_depth: 8,
144    target_len: 2,
145    mls: ROW_MIN_MATCH_LEN,
146};
147
148// Upstream zstd `clevels.h` unbounded defaults for the lazy band, verified via
149// `ZSTD_getCParams(level, 0, 0)`:
150//   L6  { w21 c18 h19 s3 mml5 t4  lazy  } → rowLog 4, depth 1<<3 = 8
151//   L7  { w21 c19 h20 s4 mml5 t8  lazy  } → rowLog 4, depth 16
152//   L8  { w21 c19 h20 s4 mml5 t16 lazy2 } → rowLog 4, depth 16
153//   L9  { w22 c20 h21 s4 mml5 t16 lazy2 } → rowLog 4, depth 16
154//   L10 { w22 c21 h22 s5 mml5 t16 lazy2 } → rowLog 5, depth 32
155//   L11 { w22 c21 h22 s6 mml5 t16 lazy2 } → rowLog 6, depth 64
156//   L12 { w22 c22 h23 s6 mml5 t32 lazy2 } → rowLog 6, depth 64
157// `rowLog = clamp(searchLog, 4, 6)`, `depth = 1 << min(searchLog, rowLog)`
158// (same derivation as `ROW_L5` above). `hash_bits` carries the upstream zstd
159// `hashLog`; the hinted-source clamp in `configure` caps it by the window
160// exactly like the upstream zstd `ZSTD_adjustCParams` path.
161pub(crate) const ROW_L6: RowConfig = RowConfig {
162    hash_bits: 19,
163    row_log: 4,
164    search_depth: 8,
165    target_len: 4,
166    mls: ROW_MIN_MATCH_LEN,
167};
168pub(crate) const ROW_L7: RowConfig = RowConfig {
169    hash_bits: 20,
170    row_log: 4,
171    search_depth: 16,
172    target_len: 8,
173    mls: ROW_MIN_MATCH_LEN,
174};
175pub(crate) const ROW_L8: RowConfig = RowConfig {
176    hash_bits: 20,
177    row_log: 4,
178    search_depth: 16,
179    target_len: 16,
180    mls: ROW_MIN_MATCH_LEN,
181};
182pub(crate) const ROW_L9: RowConfig = RowConfig {
183    hash_bits: 21,
184    row_log: 4,
185    search_depth: 16,
186    target_len: 16,
187    mls: ROW_MIN_MATCH_LEN,
188};
189pub(crate) const ROW_L10: RowConfig = RowConfig {
190    hash_bits: 22,
191    row_log: 5,
192    search_depth: 32,
193    target_len: 16,
194    mls: ROW_MIN_MATCH_LEN,
195};
196pub(crate) const ROW_L11: RowConfig = RowConfig {
197    hash_bits: 22,
198    row_log: 6,
199    search_depth: 64,
200    target_len: 16,
201    mls: ROW_MIN_MATCH_LEN,
202};
203pub(crate) const ROW_L12: RowConfig = RowConfig {
204    hash_bits: 23,
205    row_log: 6,
206    search_depth: 64,
207    target_len: 32,
208    mls: ROW_MIN_MATCH_LEN,
209};
210
211/// Per-level Double-Fast hash sizing, mirroring the upstream zstd `clevels.h` columns
212/// (config-driven, not a hardcoded constant): `long_hash_log` =
213/// `cParams.hashLog` (the long 8-byte hash table), `short_hash_log` =
214/// `cParams.chainLog` (the short hash table dfast repurposes as its
215/// secondary index). Only the Dfast backend reads it, so non-dfast level
216/// rows carry `dfast: None`. `minMatch` stays the upstream zstd-fixed `5`
217/// (`DFAST_MIN_MATCH_LEN`, used in const contexts).
218#[derive(Copy, Clone, PartialEq, Eq)]
219pub(crate) struct DfastConfig {
220    pub(crate) long_hash_log: u8,
221    pub(crate) short_hash_log: u8,
222}
223
224// Upstream zstd clevels.h default row (srcSize > 256 KB): L3 {hashLog 17, chainLog 16},
225// L4 {hashLog 18, chainLog 18}.
226pub(crate) const DFAST_L3: DfastConfig = DfastConfig {
227    long_hash_log: 17,
228    short_hash_log: 16,
229};
230pub(crate) const DFAST_L4: DfastConfig = DfastConfig {
231    long_hash_log: 18,
232    short_hash_log: 18,
233};
234
235/// Per-level Fast-strategy tuning, only consumed by the `FastKernelMatcher`
236/// (Simple backend): `hash_log` = upstream zstd `cParams.hashLog`, `mls` = upstream zstd
237/// `cParams.minMatch` (4..=8), `step_size` = upstream zstd `stepSize`. Carried as
238/// `LevelParams.fast` (`Some` only on Fast level rows; `None` elsewhere).
239#[derive(Copy, Clone, PartialEq, Eq)]
240pub(crate) struct FastConfig {
241    pub(crate) hash_log: u32,
242    pub(crate) mls: u32,
243    pub(crate) step_size: usize,
244}
245
246pub(crate) const FAST_L1: FastConfig = FastConfig {
247    hash_log: 14,
248    // Tier-0 (srcSize > 256 KiB) `cParams.minMatch`. Upstream zstd selects the
249    // Level-1 row from a 4-way srcSize-tiered table (`ZSTD_getCParams_internal`
250    // → `ZSTD_defaultCParameters[tableID][1]`), and minMatch shrinks for
251    // smaller inputs: 7 (>256 KiB) / 6 (16..256 KiB) / 5 (<=16 KiB). The base
252    // here is the tier-0 value; `fast_l1_mls_for_source_size` lowers it per the
253    // tier in `adjust_params_for_source_size`.
254    mls: 7,
255    step_size: 2,
256};
257pub(crate) const FAST_L2: FastConfig = FastConfig {
258    hash_log: 16,
259    mls: 6,
260    step_size: 2,
261};
262
263/// Resolved tuning parameters for a compression level. The
264/// [`StrategyTag`] is the single source of truth for the backend
265/// family and the compile-time strategy consts; the runtime
266/// [`BackendTag`] used by the driver dispatcher is derived via
267/// [`StrategyTag::backend`] so the two cannot drift.
268#[derive(Copy, Clone, PartialEq, Eq)]
269pub(crate) struct LevelParams {
270    pub(crate) strategy_tag: crate::encoding::strategy::StrategyTag,
271    /// Decoupled search-method axis. Independent of `strategy_tag`'s
272    /// parse half: a level can pair any parse (greedy / lazy depth via
273    /// `lazy_depth`) with any search backend here. Defaults to the
274    /// historical pairing (`strategy_tag.search()`) but is overridable
275    /// per level so the parse×search matrix can be swept and tuned.
276    pub(crate) search: crate::encoding::strategy::SearchMethod,
277    pub(crate) window_log: u8,
278    pub(crate) lazy_depth: u8,
279    /// Per-strategy tuning. Exactly one is `Some` on each level row, matching
280    /// `strategy_tag`'s backend, so the table self-documents which knobs a
281    /// level actually consumes (the others are `None`, not dead placeholders):
282    /// `fast` for the Fast/Simple backend, `dfast` for Double-Fast, `hc` for
283    /// the HashChain (lazy / btopt / btultra*) backend, `row` for the Row
284    /// (greedy L5) backend.
285    pub(crate) fast: Option<FastConfig>,
286    pub(crate) dfast: Option<DfastConfig>,
287    pub(crate) hc: Option<HcConfig>,
288    pub(crate) row: Option<RowConfig>,
289}
290
291impl LevelParams {
292    /// Backend family (storage variant) for the driver dispatcher.
293    /// Derived from the decoupled `search` axis so a level can route to
294    /// a different search backend than its `strategy_tag` historically
295    /// implied.
296    pub(crate) fn backend(&self) -> crate::encoding::strategy::BackendTag {
297        self.search.backend()
298    }
299
300    /// Parse mode derived from the decoupled `search` axis: the binary-tree
301    /// search path carries `ParseMode::Optimal`; every other search backend
302    /// derives greedy/lazy/lazy2 from `lazy_depth`. Reading `search` (not the
303    /// strategy tag) keeps the parse×search decoupling complete even when a
304    /// level whose tag is `Bt*` is overridden to a non-BT search backend.
305    pub(crate) fn parse(&self) -> crate::encoding::strategy::ParseMode {
306        match self.search {
307            crate::encoding::strategy::SearchMethod::BinaryTree => {
308                crate::encoding::strategy::ParseMode::Optimal
309            }
310            _ => crate::encoding::strategy::ParseMode::from_lazy_depth(self.lazy_depth),
311        }
312    }
313
314    /// Cheap fingerprint pre-splitter level (the C-like `blockSplitterLevel`):
315    /// the EFFECTIVE upstream `ZSTD_splitBlock` level that
316    /// `ZSTD_optimalBlockSize` dispatches, i.e. `splitLevels[strategy] - 2`
317    /// (clamped at 0), NOT the raw `splitLevels[]` value. `split_level == 0`
318    /// routes to the cheap from-borders heuristic; `1..=4` to byChunks with
319    /// internal sampling level `split_level - 1`. See the body for the
320    /// per-strategy tier table and why the raw-table mapping was wrong.
321    pub(crate) fn pre_split(&self) -> Option<u8> {
322        use crate::encoding::strategy::StrategyTag;
323        // Effective upstream `ZSTD_splitBlock` level = `splitLevels[strat] - 2`
324        // (clamped at 0). Upstream `splitLevels[] = {0,0,1,2,2,3,3,4,4,4}` then
325        // subtracts 2 before dispatch, so the byChunks sampling tier is two
326        // steps coarser than the raw table: greedy/lazy(d1)=0 (from-borders),
327        // lazy2/btlazy2=1 (byChunks rate 43), btopt+=2 (byChunks rate 11).
328        // An earlier version mirrored the RAW table AND bumped lazy2 to the
329        // rate-1 full scan (split 4) to dodge a periodic-input phantom-split —
330        // that ran the pre-splitter at up to 43x upstream's sampling cost
331        // (~87% of L9 encode time on the decode corpus). Per the drop-in
332        // contract ratio only needs to stay <= upstream, so matching upstream's
333        // sampling tier (and accepting upstream's identical over-split on
334        // periodic input) is the dominant large-input encode-speed win.
335        Some(match self.strategy_tag {
336            // splitLevels 0/1 -> 0: upstream does not pre-split fast/dfast at
337            // all; from-borders is the cheapest stand-in and rarely splits.
338            StrategyTag::Fast | StrategyTag::Dfast => 0,
339            // greedy / lazy(depth 1): splitLevels 2 -> 0 (from-borders).
340            StrategyTag::Greedy => 0,
341            StrategyTag::Lazy => {
342                if self.lazy_depth >= 2 {
343                    1 // lazy2: splitLevels 3 -> 1 (byChunks rate 43)
344                } else {
345                    0 // lazy depth 1: splitLevels 2 -> 0 (from-borders)
346                }
347            }
348            StrategyTag::Btlazy2 => 1, // splitLevels 3 -> 1 (byChunks rate 43)
349            StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2 => 2,
350        })
351    }
352}
353
354/// Apply the public-parameter per-knob overrides (#27) onto the
355/// level-resolved [`LevelParams`], in place. Runs in [`Matcher::reset`]
356/// after the level params are computed and before backend selection, so
357/// a strategy override re-routes the backend uniformly. An all-`None`
358/// override is a no-op the caller skips via
359/// [`crate::encoding::parameters::ParamOverrides::is_empty`], keeping the default
360/// level geometry byte-identical.
361pub(crate) fn apply_param_overrides(
362    params: &mut LevelParams,
363    ov: &crate::encoding::parameters::ParamOverrides,
364) {
365    use crate::encoding::strategy::SearchMethod;
366
367    // 1. Strategy override re-derives tag / search / lazy depth.
368    if let Some(strategy) = ov.strategy {
369        let tag = strategy.tag();
370        params.strategy_tag = tag;
371        params.search = tag.search();
372        params.lazy_depth = strategy.lazy_depth();
373    }
374
375    // 2. Ensure the active backend's config row exists (synthesize a
376    //    default when a strategy override moved off the native row).
377    match params.search {
378        SearchMethod::Fast => {
379            params.fast.get_or_insert(FAST_L1);
380        }
381        SearchMethod::DoubleFast => {
382            params.dfast.get_or_insert(DFAST_L3);
383        }
384        SearchMethod::RowHash => {
385            params.row.get_or_insert(ROW_L5);
386        }
387        SearchMethod::HashChain | SearchMethod::BinaryTree => {
388            // A `Btlazy2` strategy override moved off a non-HC row needs the
389            // BT 5-byte finder hash (upstream zstd minMatch 5); other synthesized HC
390            // rows keep the 4-byte default. An explicit `min_match` override
391            // below refines this further.
392            params.hc.get_or_insert(HcConfig {
393                search_mls: if matches!(
394                    params.strategy_tag,
395                    crate::encoding::strategy::StrategyTag::Btlazy2
396                ) {
397                    5
398                } else {
399                    HC_OVERRIDE_DEFAULT.search_mls
400                },
401                ..HC_OVERRIDE_DEFAULT
402            });
403        }
404    }
405
406    // 3. window_log (bounds-checked at <= 30 by the builder).
407    if let Some(window_log) = ov.window_log {
408        params.window_log = window_log;
409    }
410
411    // 4. Per-backend numeric knobs map into the active config, mirroring
412    //    the upstream zstd `cParams` -> matcher translation documented on each
413    //    config struct.
414    match params.search {
415        SearchMethod::Fast => {
416            if let Some(fast) = params.fast.as_mut() {
417                if let Some(hash_log) = ov.hash_log {
418                    fast.hash_log = hash_log;
419                }
420                if let Some(min_match) = ov.min_match {
421                    fast.mls = min_match;
422                }
423            }
424        }
425        SearchMethod::DoubleFast => {
426            if let Some(dfast) = params.dfast.as_mut() {
427                // hashLog -> long table, chainLog -> short table (the
428                // dfast secondary index). Both bounds-checked <= 30, so
429                // the `u8` casts are lossless.
430                if let Some(hash_log) = ov.hash_log {
431                    dfast.long_hash_log = hash_log as u8;
432                }
433                if let Some(chain_log) = ov.chain_log {
434                    dfast.short_hash_log = chain_log as u8;
435                }
436            }
437        }
438        SearchMethod::RowHash => {
439            if let Some(row) = params.row.as_mut() {
440                // Row hash-table width override (mirrors dfast `long_hash_log`
441                // / hc `hash_log`). Row has no separate chain table — the
442                // per-row depth comes from `search_log` below — so only
443                // `hash_log` maps here; `chain_log` has no Row analogue.
444                if let Some(hash_log) = ov.hash_log {
445                    row.hash_bits = hash_log as usize;
446                }
447                if let Some(search_log) = ov.search_log {
448                    // Upstream zstd: rowLog = clamp(searchLog, 4, 6);
449                    //        nbAttempts = 1 << min(searchLog, rowLog).
450                    let row_log = (search_log as usize).clamp(4, 6);
451                    row.row_log = row_log;
452                    row.search_depth = 1usize << (search_log as usize).min(row_log);
453                }
454                if let Some(target_length) = ov.target_length {
455                    row.target_len = target_length as usize;
456                }
457                if let Some(min_match) = ov.min_match {
458                    row.mls = min_match as usize;
459                }
460            }
461        }
462        SearchMethod::HashChain | SearchMethod::BinaryTree => {
463            if let Some(hc) = params.hc.as_mut() {
464                if let Some(hash_log) = ov.hash_log {
465                    hc.hash_log = hash_log as usize;
466                }
467                if let Some(chain_log) = ov.chain_log {
468                    hc.chain_log = chain_log as usize;
469                }
470                if let Some(search_log) = ov.search_log {
471                    hc.search_depth = 1usize << search_log;
472                }
473                if let Some(target_length) = ov.target_length {
474                    hc.target_len = target_length as usize;
475                }
476                if let Some(min_match) = ov.min_match {
477                    // BT finder hash width, derived from cParams.minMatch exactly
478                    // as upstream zstd: `mls = BOUNDED(3, cParams.minMatch, 6)`
479                    // (zstd_opt.c:896 ZSTD_selectBtGetAllMatches). minMatch=3
480                    // tiers hash on 3 bytes (btultra/btultra2 path). Only the BT
481                    // body reads `search_mls`; HC/lazy hash on 4 bytes regardless.
482                    hc.search_mls = (min_match as usize).clamp(3, 6);
483                }
484            }
485        }
486    }
487}
488
489/// Map the resolved runtime strategy to the upstream zstd LDM strategy ordinal
490/// (1..=9) that [`crate::encoding::ldm::params::LdmParams::adjust_for`] expects.
491/// The collapsed `Lazy` tag splits on `lazy_depth` (lazy = 4, lazy2 = 5).
492#[cfg(feature = "hash")]
493pub(crate) fn ldm_strategy_ordinal(
494    tag: crate::encoding::strategy::StrategyTag,
495    lazy_depth: u8,
496) -> u32 {
497    use crate::encoding::strategy::StrategyTag;
498    match tag {
499        StrategyTag::Fast => 1,
500        StrategyTag::Dfast => 2,
501        StrategyTag::Greedy => 3,
502        StrategyTag::Lazy => {
503            if lazy_depth >= 2 {
504                5
505            } else {
506                4
507            }
508        }
509        // Upstream zstd `ZSTD_btlazy2` ordinal.
510        StrategyTag::Btlazy2 => 6,
511        StrategyTag::BtOpt => 7,
512        StrategyTag::BtUltra => 8,
513        StrategyTag::BtUltra2 => 9,
514    }
515}
516
517/// `ceil(log2(size))` of a source-size hint, with a zero hint floored to
518/// [`MIN_WINDOW_LOG`]. This is the single quantization every hint-dependent
519/// matcher parameter is derived from: the window-log cap, the HC / Fast hash
520/// and chain widths, the Dfast / Row table widths, the L22 config buckets, and
521/// the Fast attach-vs-copy cutoff. Two hints sharing this value resolve to the
522/// identical matcher shape, which is why it (not the raw byte count) keys the
523/// primed-dictionary snapshot — see [`PrimedKey`]. Operates on the full `u64`
524/// so callers comparing a hint against a cutoff get the same bucketed decision
525/// here and at the driver, with no `as usize` truncation on 32-bit targets.
526pub(crate) fn source_size_ceil_log(size: u64) -> u8 {
527    if size == 0 {
528        MIN_WINDOW_LOG
529    } else {
530        (64 - (size - 1).leading_zeros()) as u8
531    }
532}
533
534/// Attach-vs-copy cutoff for the Fast strategy, as a ceil-log bucket: a hint at
535/// or below `2^this` (or unknown, `None`) ATTACHES the dictionary (a separate
536/// immutable table scanned in place via the borrowed dual-base kernel); a larger
537/// hint would COPY it into the live table.
538///
539/// We set this to `31` so every dictionary source up to 2 GiB attaches,
540/// diverging from upstream zstd's 8 KiB `ZSTD_shouldAttachDict` cutoff ON
541/// PURPOSE: upstream copy mode copies the small CDict TABLES into the cctx and
542/// still scans the input in place, but our flat-history copy path memmoves the
543/// whole INPUT into history every frame (profiled at 30% `__memmove` + 14%
544/// `__memset` on a reused 1 MiB dict encode). Attach mode scans the caller's
545/// input in place with the dict as a separate prefix base, so it is strictly
546/// faster for every frame size here (measured: 1 MiB dict frame 167 us -> 52 us,
547/// 0.42x of C; 10 KiB 20.4 us -> 4.4 us, 0.17x of C). The dual-base kernel
548/// carries `window_low`, so over-window inputs stay in-window and C-decodable.
549///
550/// `31` is also the largest bucket the borrowed kernel can attach: it stores
551/// virtual positions as `u32` (`cur_abs as u32`), so the maximum attached source
552/// `1 << 31` (plus the dict prefix) stays below `u32::MAX`; the next bucket `32`
553/// (4 GiB) would wrap that arithmetic. Sources past 2 GiB therefore fall back to
554/// copy mode — rare in practice, and the relative copy cost shrinks as the
555/// source grows. Per the drop-in-not-binary-parity contract, we make this match
556/// decision ourselves.
557/// Shared by `reset` (records the mode in the primed-snapshot key) and
558/// `prime_with_dictionary` (acts on it).
559pub(crate) const FAST_ATTACH_DICT_CUTOFF_LOG: u8 = 31;
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/// `ZSTD_dictMatchState` attach cutoff for the Row (greedy/lazy) strategy is
581/// 32 KiB (`2^15`, upstream zstd `attachDictSizeCutoffs`): small / unknown-size inputs
582/// ATTACH the dict into the separate immutable row index (bounded dual-probe in
583/// `row_candidate_rl`), larger known-size inputs dense-COPY into the live rows.
584pub(crate) const ROW_ATTACH_DICT_CUTOFF_LOG: u8 = 15;
585
586/// 32 KiB (`2^15`, upstream zstd `attachDictSizeCutoffs[ZSTD_lazy2]`): small /
587/// unknown-size inputs ATTACH the dict as a separate hash-chain dms (the dual
588/// search in `find_best_match` walks the live input chain + the dms), larger
589/// known-size inputs dense-COPY (merge the dict into the live chain and search
590/// the one combined chain).
591pub(crate) const HC_ATTACH_DICT_CUTOFF_LOG: u8 = 15;
592
593/// BT/optimal attach cutoff for `btlazy2` + `btopt`: 32 KiB (`2^15`, upstream
594/// zstd `attachDictSizeCutoffs[ZSTD_btlazy2]` == `[ZSTD_btopt]`). Small /
595/// unknown-size inputs ATTACH the dict as a separate DUBT dms; larger known-size
596/// inputs COPY the dict into the LIVE binary tree (upstream zstd
597/// `ZSTD_resetCCtx_byCopyingCDict`).
598pub(crate) const BT_OPT_ATTACH_DICT_CUTOFF_LOG: u8 = 15;
599
600/// BT/optimal attach cutoff for `btultra` + `btultra2`: 8 KiB (`2^13`, upstream
601/// zstd `attachDictSizeCutoffs[ZSTD_btultra]` == `[ZSTD_btultra2]`). The deepest
602/// parses copy the dict into the live tree past a much smaller source than the
603/// `btopt` tier, matching upstream's per-strategy cutoff table.
604pub(crate) const BT_ULTRA_ATTACH_DICT_CUTOFF_LOG: u8 = 13;
605
606// Source-size cap for the dfast hash bits when a size hint is present: a tiny
607// input needs no larger hash than its window. The upstream zstd `cParams.hashLog` /
608// `chainLog` (from `DfastConfig`) caps it from above at the call site.
609pub(crate) fn dfast_hash_bits_for_window(max_window_size: usize) -> usize {
610    let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
611    window_log.max(MIN_WINDOW_LOG as usize)
612}
613
614pub(crate) fn row_hash_bits_for_window(max_window_size: usize) -> usize {
615    // Upstream zstd `ZSTD_adjustCParams_internal` cap: `hashLog <= windowLog + 1`.
616    // The `+ 1` is load-bearing for L12, whose upstream zstd hashLog (23) exceeds
617    // its windowLog (22) — a plain `windowLog` cap would shrink the L12
618    // table on EVERY hinted reset and split primed snapshots between
619    // hinted and unhinted frames that resolve to the identical geometry.
620    // No constant upper clamp: the old `ROW_HASH_BITS` (20) ceiling
621    // predates the lazy band moving onto Row (L9-12 carry upstream zstd hashLog
622    // 21-23).
623    let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
624    (window_log + 1).max(MIN_WINDOW_LOG as usize)
625}
626
627/// `floor(log2(window))` for the HashChain table-log cap (upstream zstd
628/// `ZSTD_adjustCParams_internal`). The caller clamps the level's `hash_log` /
629/// `chain_log` from above with this so a small hinted input doesn't allocate the
630/// full level's tables.
631pub(crate) fn hc_hash_bits_for_window(max_window_size: usize) -> usize {
632    let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
633    window_log.max(MIN_WINDOW_LOG as usize)
634}
635
636/// Parameter table for numeric compression levels 1–22.
637///
638/// Each entry maps a zstd compression level to the best-available matcher
639/// backend and tuning knobs. High levels map to dedicated parse modes:
640/// btopt (16-17), btultra (18), btultra2 (19-22) — matching upstream zstd
641/// `clevels.h` (level 19 is `ZSTD_btultra2`, not plain btultra).
642///
643/// Index 0 = level 1, index 21 = level 22.
644#[rustfmt::skip]
645pub(crate) const LEVEL_TABLE: [LevelParams; 22] = [
646    // Exactly one of fast/dfast/hc/row is Some per row, matching the strategy
647    // backend; the rest are None (not dead placeholders).
648    // Lvl  Strategy       wlog  lazy  per-strategy config
649    // ---  -------------- ----  ----  -------------------
650    /* 1 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Fast, search: crate::encoding::strategy::SearchMethod::Fast, window_log: 19, lazy_depth: 0, fast: Some(FAST_L1), dfast: None, hc: None, row: None },
651    /* 2 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Fast, search: crate::encoding::strategy::SearchMethod::Fast, window_log: 20, lazy_depth: 0, fast: Some(FAST_L2), dfast: None, hc: None, row: None },
652    /* 3 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Dfast, search: crate::encoding::strategy::SearchMethod::DoubleFast, window_log: 21, lazy_depth: 1, fast: None, dfast: Some(DFAST_L3), hc: None, row: None },
653    /* 4 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Dfast, search: crate::encoding::strategy::SearchMethod::DoubleFast, window_log: 21, lazy_depth: 1, fast: None, dfast: Some(DFAST_L4), hc: None, row: None },
654    // target_len column for L5..=L15 matches upstream zstd cParams.targetLength
655    // from clevels.h table[0] (default — srcSize > 256 KB). Upstream zstd uses
656    // it as the lazy outer loop's `sufficient_len` (nice-match) threshold.
657    // Inflating it above upstream zstd forces the chain walk to complete
658    // search_depth iterations instead of breaking on the first
659    // long-enough match — the dominant cost in the L5..=L15 speed
660    // regression vs FFI (see lazy_band_target_len_matches_default_table).
661    /* 5 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Greedy, search: crate::encoding::strategy::SearchMethod::RowHash, window_log: 21, lazy_depth: 0, fast: None, dfast: None, hc: None, row: Some(ROW_L5) },
662    // L6-12: the upstream zstd runs the lazy/lazy2 strategies on the ROW-based
663    // match finder by default (`ZSTD_resolveRowMatchFinderMode`: row mode
664    // is on for greedy..lazy2 whenever SIMD is available) — a bounded
665    // SIMD tag scan per row instead of a pointer-chasing hash-chain walk.
666    // Our HashChain walk on these levels was ~75% of L10 wall time on the
667    // 1 MiB corpus (dependent chain-table loads). Same `RowConfig`
668    // derivation as `ROW_L5` above, upstream zstd values per level in the
669    // `ROW_L6..ROW_L12` comment block.
670    /* 6 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Lazy, search: crate::encoding::strategy::SearchMethod::RowHash, window_log: 21, lazy_depth: 1, fast: None, dfast: None, hc: None, row: Some(ROW_L6) },
671    /* 7 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Lazy, search: crate::encoding::strategy::SearchMethod::RowHash, window_log: 21, lazy_depth: 1, fast: None, dfast: None, hc: None, row: Some(ROW_L7) },
672    /* 8 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Lazy, search: crate::encoding::strategy::SearchMethod::RowHash, window_log: 21, lazy_depth: 2, fast: None, dfast: None, hc: None, row: Some(ROW_L8) },
673    /* 9 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Lazy, search: crate::encoding::strategy::SearchMethod::RowHash, window_log: 22, lazy_depth: 2, fast: None, dfast: None, hc: None, row: Some(ROW_L9) },
674    /*10 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Lazy, search: crate::encoding::strategy::SearchMethod::RowHash, window_log: 22, lazy_depth: 2, fast: None, dfast: None, hc: None, row: Some(ROW_L10) },
675    /*11 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Lazy, search: crate::encoding::strategy::SearchMethod::RowHash, window_log: 22, lazy_depth: 2, fast: None, dfast: None, hc: None, row: Some(ROW_L11) },
676    /*12 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Lazy, search: crate::encoding::strategy::SearchMethod::RowHash, window_log: 22, lazy_depth: 2, fast: None, dfast: None, hc: None, row: Some(ROW_L12) },
677    // L13-15: reference uses btlazy2 (binary-tree finder) with searchLog 4/5/6
678    // (search_depth 16/32/64) and targetLength 32. We run the hash-chain Lazy
679    // parser here, so we mirror the reference search budget rather than inflate
680    // it: matching the table keeps speed near the reference and makes per-level
681    // perf divergences comparable. The binary-tree finder that would let a
682    // smaller searchLog find longer matches (and re-establish a strict ratio
683    // ladder above L12) is tracked separately; until it lands these levels sit
684    // close to L12 on hash-chain inputs by design.
685    /*13 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Btlazy2, search: crate::encoding::strategy::SearchMethod::BinaryTree, window_log: 22, lazy_depth: 2, fast: None, dfast: None, hc: Some(HcConfig { hash_log: 22, chain_log: 22, search_depth: 16, target_len: 32, search_mls: 5 }), row: None },
686    /*14 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Btlazy2, search: crate::encoding::strategy::SearchMethod::BinaryTree, window_log: 22, lazy_depth: 2, fast: None, dfast: None, hc: Some(HcConfig { hash_log: 23, chain_log: 22, search_depth: 32, target_len: 32, search_mls: 5 }), row: None },
687    /*15 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::Btlazy2, search: crate::encoding::strategy::SearchMethod::BinaryTree, window_log: 22, lazy_depth: 2, fast: None, dfast: None, hc: Some(HcConfig { hash_log: 23, chain_log: 23, search_depth: 64, target_len: 32, search_mls: 5 }), row: None },
688    /*16 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::BtOpt, search: crate::encoding::strategy::SearchMethod::BinaryTree, window_log: 22, lazy_depth: 2, fast: None, dfast: None, hc: Some(HcConfig { hash_log: 22, chain_log: 22, search_depth: 32, target_len: 48, search_mls: 5 }), row: None },
689    /*17 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::BtOpt, search: crate::encoding::strategy::SearchMethod::BinaryTree, window_log: 23, lazy_depth: 2, fast: None, dfast: None, hc: Some(HcConfig { hash_log: 22, chain_log: 23, search_depth: 32, target_len: 64, search_mls: 4 }), row: None },
690    /*18 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::BtUltra, search: crate::encoding::strategy::SearchMethod::BinaryTree, window_log: 23, lazy_depth: 2, fast: None, dfast: None, hc: Some(HcConfig { hash_log: 22, chain_log: 23, search_depth: 64, target_len: 64, search_mls: 4 }), row: None },
691    /*19 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::BtUltra2, search: crate::encoding::strategy::SearchMethod::BinaryTree, window_log: 23, lazy_depth: 2, fast: None, dfast: None, hc: Some(HcConfig { hash_log: 22, chain_log: 24, search_depth: 128, target_len: 256, search_mls: 4 }), row: None },
692    /*20 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::BtUltra2, search: crate::encoding::strategy::SearchMethod::BinaryTree, window_log: 25, lazy_depth: 2, fast: None, dfast: None, hc: Some(HcConfig { hash_log: 23, chain_log: 25, search_depth: 128, target_len: 256, search_mls: 4 }), row: None },
693    /*21 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::BtUltra2, search: crate::encoding::strategy::SearchMethod::BinaryTree, window_log: 26, lazy_depth: 2, fast: None, dfast: None, hc: Some(BTULTRA2_HC_CONFIG), row: None },
694    /*22 */ LevelParams { strategy_tag: crate::encoding::strategy::StrategyTag::BtUltra2, search: crate::encoding::strategy::SearchMethod::BinaryTree, window_log: 27, lazy_depth: 2, fast: None, dfast: None, hc: Some(BTULTRA2_HC_CONFIG_L22), row: None },
695];
696
697/// Upstream `ZSTD_createCDict` table geometry: the `(hash_log, chain_log)` a
698/// dictionary's prepared match-finder tables get. Thin adapter over the single
699/// cParams source [`crate::encoding::cparams::create_cdict_table_logs`], which mirrors
700/// `ZSTD_adjustCParams_internal` under `ZSTD_cpm_createCDict`. `window_log` is
701/// the resolved compress window; `hash_log` / `chain_log` are the level's own
702/// widths; `uses_bt` selects the binary-tree `cycleLog` (`chainLog - 1`).
703pub(crate) fn cdict_table_logs(
704    window_log: u8,
705    hash_log: usize,
706    chain_log: usize,
707    uses_bt: bool,
708    dict_size: usize,
709) -> (usize, usize) {
710    let (h, c) = crate::encoding::cparams::create_cdict_table_logs(
711        window_log,
712        hash_log as u32,
713        chain_log as u32,
714        uses_bt,
715        dict_size,
716    );
717    (h as usize, c as usize)
718}
719
720/// Smallest window_log the encoder will use regardless of source size.
721pub(crate) const MIN_WINDOW_LOG: u8 = 10;
722/// Conservative floor for source-size-hinted window tuning.
723///
724/// Hinted windows below 16 KiB (`window_log < 14`) currently regress C-FFI
725/// interoperability on certain compressed-block patterns. Keep hinted
726/// windows at 16 KiB or larger until that compatibility gap is closed.
727pub(crate) const MIN_HINTED_WINDOW_LOG: u8 = 14;
728
729/// Adjust level parameters for a known source size.
730///
731/// This derives a cap from `ceil(log2(src_size))`, then clamps it to
732/// [`MIN_HINTED_WINDOW_LOG`] (16 KiB). A zero-byte size hint is treated as
733/// [`MIN_WINDOW_LOG`] for the raw ceil-log step and then promoted to the hinted
734/// floor. This keeps tables bounded for small inputs while preserving the
735/// encoder's baseline minimum supported window.
736/// For the HC backend, `hash_log` and `chain_log` are reduced
737/// proportionally.
738/// Source-size tier index, matching upstream `ZSTD_getCParams_internal`'s
739/// `tableID = (rSize<=256K)+(rSize<=128K)+(rSize<=16K)`: 0 = > 256 KiB or
740/// unknown, 1 = 128..256 KiB, 2 = 16..128 KiB, 3 = <= 16 KiB.
741fn cparams_tier(source_size: Option<u64>) -> usize {
742    match source_size {
743        Some(size) if size <= 16 * 1024 => 3,
744        Some(size) if size <= 128 * 1024 => 2,
745        Some(size) if size <= 256 * 1024 => 1,
746        _ => 0,
747    }
748}
749
750/// Override a Fast (L1/L2) or Dfast (L3) level row's table-shaping cParams
751/// (hashLog / chainLog / minMatch) by source-size tier, matching the
752/// reference `ZSTD_defaultCParameters[tableID][level]`. L1 keeps its base
753/// hashLog (the source-size window clamp in `adjust_params_for_source_size`
754/// already lands on the reference value) and only tiers minMatch; L2 also
755/// tiers hashLog (the tier-0 value 16 oversized the table on medium inputs,
756/// the page-fault pathology); L3 tiers both dfast hash widths. Strategy
757/// switches (L2 tier 1, L4) are intentionally not applied here.
758/// Translate a verbatim upstream `ZSTD_defaultCParameters[tier][level]` row
759/// (`cparams::CParams`) into our resolved [`LevelParams`], reproducing
760/// upstream's cParams -> matcher-config derivation so the encoder follows C's
761/// source-size-tiered STRATEGY + table widths rather than a single hand-tuned
762/// `LEVEL_TABLE`. Derivation (verified against L6/L16/L22 vs clevels.h):
763/// `search_depth = 1 << searchLog`; row `row_log = clamp(searchLog, 4, 6)`; the
764/// per-strategy sub-config carries the verbatim `hashLog` / `chainLog` /
765/// `targetLength` / `minMatch`. Strategy numbers are upstream `ZSTD_strategy`
766/// (fast=1, dfast=2, greedy=3, lazy=4, lazy2=5, btlazy2=6, btopt=7, btultra=8,
767/// btultra2=9).
768fn level_params_from_cparams(cp: crate::encoding::cparams::CParams) -> LevelParams {
769    use crate::encoding::strategy::{SearchMethod, StrategyTag};
770    let window_log = cp.window_log as u8;
771    let search_depth = 1usize << cp.search_log;
772    let target_len = cp.target_length as usize;
773    let hc = HcConfig {
774        hash_log: cp.hash_log as usize,
775        chain_log: cp.chain_log as usize,
776        search_depth,
777        target_len,
778        search_mls: cp.min_match as usize,
779    };
780    let row = RowConfig {
781        hash_bits: cp.hash_log as usize,
782        row_log: cp.search_log.clamp(4, 6) as usize,
783        search_depth,
784        target_len,
785        mls: cp.min_match as usize,
786    };
787    let bt = |tag| LevelParams {
788        strategy_tag: tag,
789        search: SearchMethod::BinaryTree,
790        window_log,
791        lazy_depth: 2,
792        fast: None,
793        dfast: None,
794        hc: Some(hc),
795        row: None,
796    };
797    let row_lvl = |tag, lazy_depth| LevelParams {
798        strategy_tag: tag,
799        search: SearchMethod::RowHash,
800        window_log,
801        lazy_depth,
802        fast: None,
803        dfast: None,
804        hc: None,
805        row: Some(row),
806    };
807    match cp.strategy {
808        1 => LevelParams {
809            strategy_tag: StrategyTag::Fast,
810            search: SearchMethod::Fast,
811            window_log,
812            lazy_depth: 0,
813            // Upstream fast `stepSize`: `targetLength + 1` (0 -> 1, so step 2).
814            fast: Some(FastConfig {
815                hash_log: cp.hash_log,
816                mls: cp.min_match,
817                step_size: target_len.max(1) + 1,
818            }),
819            dfast: None,
820            hc: None,
821            row: None,
822        },
823        2 => LevelParams {
824            strategy_tag: StrategyTag::Dfast,
825            search: SearchMethod::DoubleFast,
826            window_log,
827            lazy_depth: 1,
828            fast: None,
829            dfast: Some(DfastConfig {
830                long_hash_log: cp.hash_log as u8,
831                short_hash_log: cp.chain_log as u8,
832            }),
833            hc: None,
834            row: None,
835        },
836        3 => row_lvl(StrategyTag::Greedy, 0),
837        4 => row_lvl(StrategyTag::Lazy, 1),
838        5 => row_lvl(StrategyTag::Lazy, 2),
839        6 => bt(StrategyTag::Btlazy2),
840        7 => bt(StrategyTag::BtOpt),
841        8 => bt(StrategyTag::BtUltra),
842        _ => bt(StrategyTag::BtUltra2),
843    }
844}
845
846fn apply_cparams_tier(level: i32, source_size: Option<u64>, p: &mut LevelParams) {
847    let tier = cparams_tier(source_size);
848    // Single source for the table data: the verbatim upstream
849    // `ZSTD_defaultCParameters[tier][level]` row (`cparams::default_cparams`).
850    // The encoder consumes only the table-shaping widths here; the window /
851    // `table_log` clamp lives in `adjust_params_for_source_size`.
852    match level {
853        // Fast, all tiers — minMatch only (hashLog handled by the window clamp).
854        1 => {
855            if let Some(f) = p.fast.as_mut() {
856                f.mls = crate::encoding::cparams::default_cparams(tier, 1).min_match;
857            }
858        }
859        // Fast (base strategy; tier 1 is dfast upstream — not switched here).
860        2 => {
861            if let Some(f) = p.fast.as_mut() {
862                let cp = crate::encoding::cparams::default_cparams(tier, 2);
863                f.hash_log = cp.hash_log;
864                f.mls = cp.min_match;
865            }
866        }
867        // Dfast, all tiers — long hashLog (`hash_log`) + short chainLog (`chain_log`).
868        3 => {
869            if let Some(d) = p.dfast.as_mut() {
870                let cp = crate::encoding::cparams::default_cparams(tier, 3);
871                d.long_hash_log = cp.hash_log as u8;
872                d.short_hash_log = cp.chain_log as u8;
873            }
874        }
875        _ => {}
876    }
877}
878
879pub(crate) fn adjust_params_for_source_size(mut params: LevelParams, src_size: u64) -> LevelParams {
880    // Derive a source-size-based cap from ceil(log2(src_size)), then
881    // clamp first to MIN_WINDOW_LOG (baseline encoder minimum) and then to
882    // MIN_HINTED_WINDOW_LOG (16 KiB hinted floor). For tiny or zero hints we
883    // therefore keep a 16 KiB effective minimum window in hinted mode.
884    // Raw ceil(log2(src_size)) drives the internal table sizes. The
885    // advertised `window_log` is separately floored at MIN_HINTED_WINDOW_LOG
886    // (a decoder-interop requirement on the wire format), but the hash /
887    // chain table widths are internal and never appear in the frame, so they
888    // can track the actual source size below that floor.
889    let raw_src_log = source_size_ceil_log(src_size);
890    let src_log = raw_src_log.max(MIN_WINDOW_LOG).max(MIN_HINTED_WINDOW_LOG);
891    if src_log < params.window_log {
892        params.window_log = src_log;
893    }
894    // Internal match-finder tables are sized from `table_log` — the RAW
895    // source log (floored only at the baseline `MIN_WINDOW_LOG`), NOT the
896    // wire `window_log` floor. The table widths never appear in the frame, so
897    // for small inputs they can track the actual source size and avoid
898    // zeroing a window-sized table per frame; large inputs keep the level's
899    // widths. The cap is applied with the same per-backend headroom the
900    // level table uses, so the load factor (and match quality) is unchanged.
901    // The Dfast backend derives its table widths from the source in `reset`
902    // (`set_hash_bits` recomputes there), so it is not adjusted here. The Row
903    // backend's width IS capped here, mirroring the upstream zstd (see the Row branch).
904    let table_log = raw_src_log.max(MIN_WINDOW_LOG);
905    let backend = params.backend();
906    if backend == crate::encoding::strategy::BackendTag::HashChain {
907        let hc = params
908            .hc
909            .as_mut()
910            .expect("HashChain level row carries an HcConfig");
911        if (table_log + 2) < hc.hash_log as u8 {
912            hc.hash_log = (table_log + 2) as usize;
913        }
914        if (table_log + 1) < hc.chain_log as u8 {
915            hc.chain_log = (table_log + 1) as usize;
916        }
917    } else if backend == crate::encoding::strategy::BackendTag::Row {
918        let row = params
919            .row
920            .as_mut()
921            .expect("Row level row carries a RowConfig");
922        // Upstream zstd `ZSTD_adjustCParams_internal` (zstd_compress.c): once
923        // the window is source-capped, `hashLog <= windowLog + 1`. The row
924        // table is `2^hash_bits` slots, exactly upstream's row hashTable
925        // `2^hashLog` slots, so the same cap applies. Without it the row table
926        // stays at the level's unbounded width (e.g. L12 hash_bits 23 = 4x
927        // upstream's source-capped 21), the dominant peak-memory excess on the
928        // row band.
929        let row_cap = (table_log + 1) as usize;
930        if row_cap < row.hash_bits {
931            row.hash_bits = row_cap;
932        }
933    } else if backend == crate::encoding::strategy::BackendTag::Simple {
934        let fast = params
935            .fast
936            .as_mut()
937            .expect("Fast level row carries a FastConfig");
938        let fast_cap = (table_log + 1) as u32;
939        if fast_cap < fast.hash_log {
940            fast.hash_log = fast_cap;
941        }
942    }
943    params
944}
945
946fn level22_btultra2_params_for_source_size(source_size: Option<u64>) -> LevelParams {
947    let mut hc = match source_size {
948        Some(size) if size <= 16 * 1024 => BTULTRA2_HC_CONFIG_L22_16K,
949        Some(size) if size <= 128 * 1024 => BTULTRA2_HC_CONFIG_L22_128K,
950        Some(size) if size <= 256 * 1024 => BTULTRA2_HC_CONFIG_L22_256K,
951        _ => BTULTRA2_HC_CONFIG_L22,
952    };
953    let mut window_log = match source_size {
954        Some(size) if size <= 16 * 1024 => 14,
955        Some(size) if size <= 128 * 1024 => 17,
956        Some(size) if size <= 256 * 1024 => 18,
957        _ => 27,
958    };
959    if let Some(size) = source_size
960        && size > 256 * 1024
961    {
962        let src_log = source_size_ceil_log(size);
963        window_log = window_log.min(src_log.max(MIN_WINDOW_LOG));
964        let adjusted_table_log = window_log as usize + 1;
965        hc.hash_log = hc.hash_log.min(adjusted_table_log);
966        hc.chain_log = hc.chain_log.min(adjusted_table_log);
967    }
968    LevelParams {
969        strategy_tag: crate::encoding::strategy::StrategyTag::BtUltra2,
970        search: crate::encoding::strategy::SearchMethod::BinaryTree,
971        window_log,
972        lazy_depth: 2,
973        fast: None,
974        dfast: None,
975        hc: Some(hc),
976        row: None,
977    }
978}
979
980/// Estimated steady-state heap footprint of a one-shot compression context
981/// at `level` (window history + match-finder tables + block staging), in
982/// bytes. Computed from the same per-level tuning table the encoder
983/// resolves at frame start, so the estimate tracks the real allocations;
984/// it is an upper-bound style budget figure, not an exact accounting.
985pub fn estimated_compression_workspace_bytes(level: CompressionLevel) -> usize {
986    use crate::encoding::strategy::StrategyTag;
987    let params = resolve_level_params(level, None);
988    let window = 1usize << params.window_log;
989    // Mirror `configure()`: the HC3 short-match side table exists only on
990    // the btultra/btultra2 tags (minMatch 3), capped by the window log; the
991    // BT pointer-pair layout fits inside the `4 << chain_log` chain term
992    // (pairs over `chain_log - 1` nodes).
993    let wants_hash3 = matches!(
994        params.strategy_tag,
995        StrategyTag::BtUltra | StrategyTag::BtUltra2
996    );
997    let uses_bt = matches!(
998        params.strategy_tag,
999        StrategyTag::Btlazy2 | StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2
1000    );
1001    let tables = params.fast.map(|f| 4usize << f.hash_log).unwrap_or(0)
1002        + params
1003            .dfast
1004            .map(|d| (4usize << d.long_hash_log) + (4usize << d.short_hash_log))
1005            .unwrap_or(0)
1006        + params
1007            .hc
1008            .map(|h| {
1009                let hash3 = if wants_hash3 {
1010                    4usize
1011                        << crate::encoding::match_table::storage::HC3_HASH_LOG
1012                            .min(params.window_log as usize)
1013                } else {
1014                    0
1015                };
1016                (4usize << h.hash_log) + (4usize << h.chain_log) + hash3
1017            })
1018            .unwrap_or(0)
1019        + params
1020            .row
1021            .map(|r| (4usize << r.hash_bits) + (2usize << r.hash_bits))
1022            .unwrap_or(0);
1023    // BT modes box a `BtMatcher`; its retained scratch layout is budgeted
1024    // next to the struct so estimator and allocator evolve together.
1025    let bt = if uses_bt {
1026        crate::encoding::bt::BtMatcher::estimated_workspace_bytes()
1027    } else {
1028        0
1029    };
1030    // Block staging: literal + sequence buffers plus the compressed-block
1031    // scratch, each bounded by the 128 KiB block size.
1032    let staging = 3 * (128 * 1024);
1033    window + tables + bt + staging
1034}
1035
1036/// Extra steady-state workspace the binary-tree strategies (ordinals 6..=9,
1037/// btlazy2..btultra2) retain beyond the hash/chain tables: the boxed matcher
1038/// plus its scratch arenas, and the HC3 short-match side table for
1039/// btultra/btultra2 (capped by the window log). 0 for non-BT ordinals.
1040pub fn estimated_bt_strategy_extra_bytes(strategy_ordinal: u32, window_log: u32) -> usize {
1041    if !(6..=9).contains(&strategy_ordinal) {
1042        return 0;
1043    }
1044    let hash3 = if matches!(strategy_ordinal, 8 | 9) {
1045        4usize << crate::encoding::match_table::storage::HC3_HASH_LOG.min(window_log as usize)
1046    } else {
1047        0
1048    };
1049    crate::encoding::bt::BtMatcher::estimated_workspace_bytes() + hash3
1050}
1051
1052/// Resolve a [`CompressionLevel`] (+ optional source-size hint) to the
1053/// concrete [`LevelParams`] the matcher runs: strategy tag, search method
1054/// (match-finder), window log, and per-backend config.
1055///
1056/// ## CRITICAL: input size changes the match-finder (and can change strategy)
1057///
1058/// The resolved geometry is a function of the SOURCE SIZE, not the level
1059/// alone. This is the easy-to-miss part (so read this before assuming a level
1060/// maps to one fixed match-finder). It mirrors three upstream zstd stages:
1061///
1062/// 1. [`LEVEL_TABLE`] holds the tier-0 (source > 256 KiB) base row per level
1063///    (upstream `ZSTD_defaultCParameters[0]`). L6-L12 carry
1064///    `SearchMethod::RowHash` (the Row match-finder), like upstream's
1065///    greedy/lazy default.
1066/// 2. [`apply_cparams_tier`] overrides the table-shaping widths for the
1067///    smaller source tiers (upstream `ZSTD_getCParams_internal` tier table).
1068///    NOTE: upstream ALSO switches STRATEGY in some tiers (L2 → dfast, L4 →
1069///    greedy on small sources); those backend switches are NOT yet replicated,
1070///    so those levels keep their base strategy on small inputs.
1071/// 3. [`adjust_params_for_source_size`] caps `window_log` to
1072///    ~`ceil_log2(source_size)` (upstream `ZSTD_adjustCParams_internal`).
1073///
1074/// THEN, in the matcher `reset`, the greedy/lazy band falls back from
1075/// `RowHash` to `SearchMethod::HashChain` when the resolved `window_log <= 14`
1076/// — exactly upstream's `ZSTD_resolveRowMatchFinderMode` (the Row match-finder
1077/// is used for greedy/lazy/lazy2 ONLY when `windowLog > 14`). Net effect for
1078/// the SAME level:
1079///
1080/// * small input (e.g. a 10 KiB fixture → `window_log` 14) → **HashChain**
1081///   (`ZSTD_HcFindBestMatch`, scalar chain walk);
1082/// * large input (e.g. 1 MiB → `window_log` 20) → **RowHash** (the SIMD-tag
1083///   row match-finder).
1084///
1085/// A dictionary does NOT change the match-finder: it only downsizes the
1086/// prepared tables (`cdict_table_logs`, mirroring `ZSTD_createCDict`'s
1087/// small-source assumption), while `window_log` stays source-derived. So
1088/// `(L6, 10 KiB, +dict)` is HashChain and `(L6, 1 MiB, +dict)` is RowHash,
1089/// both matching upstream. When comparing against C on a fixture, resolve the
1090/// match-finder from the fixture's size first, or you may optimise/benchmark a
1091/// path C does not even take for that input.
1092pub(crate) fn resolve_level_params(
1093    level: CompressionLevel,
1094    source_size: Option<u64>,
1095) -> LevelParams {
1096    // Levels at or above MAX_LEVEL clamp to the max: route every out-of-range
1097    // level through the same btultra2 source-size resolver as Level(22), so a
1098    // caller passing Level(23+) gets the max config instead of falling through
1099    // to the generic cParams path (which would resolve a different geometry).
1100    if matches!(level, CompressionLevel::Level(n) if n >= CompressionLevel::MAX_LEVEL) {
1101        return level22_btultra2_params_for_source_size(source_size);
1102    }
1103    let params = match level {
1104        CompressionLevel::Uncompressed => LevelParams {
1105            strategy_tag: crate::encoding::strategy::StrategyTag::Fast,
1106            search: crate::encoding::strategy::SearchMethod::Fast,
1107            // Uncompressed frames emit raw blocks and never reference
1108            // history; advertising a larger window only inflates
1109            // decoder-side buffer reservation. Stay at 17 (128 KiB).
1110            window_log: 17,
1111            lazy_depth: 0,
1112            // Beyond-upstream zstd: hash_log=14 (vs upstream zstd's 13) for 2× fewer
1113            // collisions on structured corpora. Upstream zstd's "base for negative"
1114            // row has targetLength=1 → step_size = 1 + 0 + 1 = 2.
1115            fast: Some(FastConfig {
1116                hash_log: 14,
1117                mls: 6,
1118                step_size: 2,
1119            }),
1120            dfast: None,
1121            hc: None,
1122            row: None,
1123        },
1124        CompressionLevel::Fastest => {
1125            // Only the Fast-specific cParams
1126            // (fast_hash_log / fast_mls / fast_step_size) align
1127            // with Uncompressed / negative-base row. window_log
1128            // stays at LEVEL_TABLE[0]'s value (19) — Fastest still
1129            // does real compression on a full window, unlike
1130            // Uncompressed which clamps to 17.
1131            let mut p = LEVEL_TABLE[0];
1132            p.fast = Some(FastConfig {
1133                hash_log: 14,
1134                mls: 6,
1135                step_size: 2,
1136            });
1137            p
1138        }
1139        CompressionLevel::Default => {
1140            // Default == Level(DEFAULT_LEVEL); tier it the same way an explicit
1141            // positive level is, so hinted default compression shrinks its
1142            // table widths on small / medium frames instead of keeping the
1143            // tier-0 row (the oversized-table page-fault pathology).
1144            let mut p = LEVEL_TABLE[CompressionLevel::DEFAULT_LEVEL as usize - 1];
1145            apply_cparams_tier(CompressionLevel::DEFAULT_LEVEL, source_size, &mut p);
1146            p
1147        }
1148        CompressionLevel::Better => LEVEL_TABLE[6],
1149        // Level 13: the first dominant point of the deep-lazy band. The
1150        // mls-wide row key lifted the shallow band's ratio enough that
1151        // level 11 no longer strictly beats level 7 on the ladder corpus;
1152        // the `Best` alias belongs on a config that dominates everything
1153        // below it rather than on a hair-thin margin.
1154        CompressionLevel::Best => LEVEL_TABLE[12],
1155        CompressionLevel::Level(n) => {
1156            if n > 0 {
1157                // Source-size-tiered cParams, derived verbatim from upstream
1158                // `ZSTD_defaultCParameters[tableID][level]` (the same 4-way table
1159                // C selects via `ZSTD_getCParams_internal`). This follows C's
1160                // per-(level, srcSize) STRATEGY + table widths, not a single
1161                // hand-tuned `LEVEL_TABLE` row: small / medium frames now ramp to
1162                // the reference strategy (e.g. btopt at L11 for <= 16 KiB) instead
1163                // of staying on the tier-0 backend.
1164                let tier = cparams_tier(source_size);
1165                let lvl = (n as usize).min(CompressionLevel::MAX_LEVEL as usize);
1166                level_params_from_cparams(crate::encoding::cparams::default_cparams(tier, lvl))
1167            } else if n == 0 {
1168                // Level 0 = default, matching C zstd semantics. Tier it like the
1169                // `Default` alias so `Level(0)` and `Default` stay identical.
1170                let mut p = LEVEL_TABLE[CompressionLevel::DEFAULT_LEVEL as usize - 1];
1171                apply_cparams_tier(CompressionLevel::DEFAULT_LEVEL, source_size, &mut p);
1172                p
1173            } else {
1174                // Negative levels — upstream zstd sets
1175                // targetLength = -level (clampedCompressionLevel),
1176                // yielding step_size = (-level) + 1 since
1177                // !(targetLength) = 0 when targetLength > 0.
1178                // So L-1..L-7 get step_size 2..8. Acceleration
1179                // gradient comes from larger step skipping more
1180                // positions per iter (faster, worse ratio).
1181                // Clamp to upstream zstd's MIN_LEVEL before negating so
1182                // i32::MIN can't overflow on `-n`.
1183                let clamped = n.max(CompressionLevel::MIN_LEVEL);
1184                let target_length = (-clamped) as usize;
1185                let step_size = target_length + 1;
1186                // Upstream zstd row-0 ("base for negative", clevels.h srcSize>256KB):
1187                // hashLog=13, minMatch=7. The 32 KiB hash table (2^13 * 4B)
1188                // is L1d-resident on contemporary cores, so every probe is an
1189                // L1 hit; hashLog=14 (64 KiB) overflows a 32 KiB L1d and turns
1190                // each probe into an L2 access. minMatch=7 (vs 6) skips
1191                // short-distance 6-byte matches: fewer sequences, less
1192                // extension/emit work, and parity with the upstream zstd's negative
1193                // ladder on both ratio and throughput.
1194                LevelParams {
1195                    strategy_tag: crate::encoding::strategy::StrategyTag::Fast,
1196                    search: crate::encoding::strategy::SearchMethod::Fast,
1197                    window_log: 19,
1198                    lazy_depth: 0,
1199                    fast: Some(FastConfig {
1200                        hash_log: 13,
1201                        mls: 7,
1202                        step_size,
1203                    }),
1204                    dfast: None,
1205                    hc: None,
1206                    row: None,
1207                }
1208            }
1209        }
1210    };
1211    if let Some(size) = source_size {
1212        adjust_params_for_source_size(params, size)
1213    } else {
1214        params
1215    }
1216}
1217
1218/// The cheap fingerprint pre-splitter level for a compression level (the
1219/// C-like `blockSplitterLevel`), resolved through the same per-level
1220/// `LevelParams` table as every other tuning knob. `None` keeps the whole
1221/// 128 KiB block. The frame loop reads this instead of hardcoding the
1222/// level→split mapping at the call site.
1223pub(crate) fn level_pre_split(level: CompressionLevel) -> Option<usize> {
1224    // Resolve through `resolve_level_params` directly — NOT via the legacy
1225    // `numeric_level()` alias — so named presets read the SAME table row as
1226    // every other tuning knob (`Best` maps to its own row there, which is
1227    // not the row its numeric alias points at). `Uncompressed` (raw
1228    // blocks) never splits.
1229    if matches!(level, CompressionLevel::Uncompressed) {
1230        return None;
1231    }
1232    resolve_level_params(level, None)
1233        .pre_split()
1234        .map(usize::from)
1235}