feat(compress): move dictionary entropy loading to Rust

Port ZSTD_dictNCountRepeat and ZSTD_loadCEntropy to a dedicated Rust compression module. Preserve the existing C-facing ZSTD_loadCEntropy symbol through a narrow shim while keeping dictionary content and compression-context ownership in C.

Test Plan:

- cargo test --manifest-path rust/Cargo.toml --no-default-features --features compression

- cargo clippy --manifest-path rust/Cargo.toml --no-default-features --features compression --benches --tests

- make -B -C lib -j2 lib

- make -C tests -j2 test-invalidDictionaries

- make -C tests -j2 test-zstream
This commit is contained in:
2026-07-18 07:33:06 +02:00
parent 77841fcfd2
commit 37eb757585
3 changed files with 485 additions and 104 deletions
+5 -104
View File
@@ -303,6 +303,8 @@ size_t ZSTD_rust_determineBlockSize(int mode, size_t blockSize, size_t remaining
size_t ZSTD_rust_validateSequence(U32 offBase, U32 matchLength, U32 minMatch,
size_t posInSrc, U32 windowLog, size_t dictSize,
int useSequenceProducer);
size_t ZSTD_rust_loadCEntropy(ZSTD_compressedBlockState_t* bs, void* workspace,
const void* dict, size_t dictSize);
size_t ZSTD_rust_transferSequencesWBlockDelim(
SeqStore_t* seqStore, ZSTD_SequencePosition* seqPos,
const ZSTD_Sequence* inSeqs, size_t inSeqsSize,
@@ -3566,113 +3568,12 @@ ZSTD_loadDictionaryContent(ZSTD_MatchState_t* ms,
}
/* Dictionaries that assign zero probability to symbols that show up causes problems
* when FSE encoding. Mark dictionaries with zero probability symbols as FSE_repeat_check
* and only dictionaries with 100% valid symbols can be assumed valid.
*/
static FSE_repeat ZSTD_dictNCountRepeat(short* normalizedCounter, unsigned dictMaxSymbolValue, unsigned maxSymbolValue)
{
U32 s;
if (dictMaxSymbolValue < maxSymbolValue) {
return FSE_repeat_check;
}
for (s = 0; s <= maxSymbolValue; ++s) {
if (normalizedCounter[s] == 0) {
return FSE_repeat_check;
}
}
return FSE_repeat_valid;
}
/* Dictionary entropy-header loading lives in Rust; C keeps the public internal
* symbol and the dictionary-content orchestration around it. */
size_t ZSTD_loadCEntropy(ZSTD_compressedBlockState_t* bs, void* workspace,
const void* const dict, size_t dictSize)
{
short offcodeNCount[MaxOff+1];
unsigned offcodeMaxValue = MaxOff;
const BYTE* dictPtr = (const BYTE*)dict; /* skip magic num and dict ID */
const BYTE* const dictEnd = dictPtr + dictSize;
dictPtr += 8;
bs->entropy.huf.repeatMode = HUF_repeat_check;
{ unsigned maxSymbolValue = 255;
unsigned hasZeroWeights = 1;
size_t const hufHeaderSize = HUF_readCTable((HUF_CElt*)bs->entropy.huf.CTable, &maxSymbolValue, dictPtr,
(size_t)(dictEnd-dictPtr), &hasZeroWeights);
/* We only set the loaded table as valid if it contains all non-zero
* weights. Otherwise, we set it to check */
if (!hasZeroWeights && maxSymbolValue == 255)
bs->entropy.huf.repeatMode = HUF_repeat_valid;
RETURN_ERROR_IF(HUF_isError(hufHeaderSize), dictionary_corrupted, "");
dictPtr += hufHeaderSize;
}
{ unsigned offcodeLog;
size_t const offcodeHeaderSize = FSE_readNCount(offcodeNCount, &offcodeMaxValue, &offcodeLog, dictPtr, (size_t)(dictEnd-dictPtr));
RETURN_ERROR_IF(FSE_isError(offcodeHeaderSize), dictionary_corrupted, "");
RETURN_ERROR_IF(offcodeLog > OffFSELog, dictionary_corrupted, "");
/* fill all offset symbols to avoid garbage at end of table */
RETURN_ERROR_IF(FSE_isError(FSE_buildCTable_wksp(
bs->entropy.fse.offcodeCTable,
offcodeNCount, MaxOff, offcodeLog,
workspace, HUF_WORKSPACE_SIZE)),
dictionary_corrupted, "");
/* Defer checking offcodeMaxValue because we need to know the size of the dictionary content */
dictPtr += offcodeHeaderSize;
}
{ short matchlengthNCount[MaxML+1];
unsigned matchlengthMaxValue = MaxML, matchlengthLog;
size_t const matchlengthHeaderSize = FSE_readNCount(matchlengthNCount, &matchlengthMaxValue, &matchlengthLog, dictPtr, (size_t)(dictEnd-dictPtr));
RETURN_ERROR_IF(FSE_isError(matchlengthHeaderSize), dictionary_corrupted, "");
RETURN_ERROR_IF(matchlengthLog > MLFSELog, dictionary_corrupted, "");
RETURN_ERROR_IF(FSE_isError(FSE_buildCTable_wksp(
bs->entropy.fse.matchlengthCTable,
matchlengthNCount, matchlengthMaxValue, matchlengthLog,
workspace, HUF_WORKSPACE_SIZE)),
dictionary_corrupted, "");
bs->entropy.fse.matchlength_repeatMode = ZSTD_dictNCountRepeat(matchlengthNCount, matchlengthMaxValue, MaxML);
dictPtr += matchlengthHeaderSize;
}
{ short litlengthNCount[MaxLL+1];
unsigned litlengthMaxValue = MaxLL, litlengthLog;
size_t const litlengthHeaderSize = FSE_readNCount(litlengthNCount, &litlengthMaxValue, &litlengthLog, dictPtr, (size_t)(dictEnd-dictPtr));
RETURN_ERROR_IF(FSE_isError(litlengthHeaderSize), dictionary_corrupted, "");
RETURN_ERROR_IF(litlengthLog > LLFSELog, dictionary_corrupted, "");
RETURN_ERROR_IF(FSE_isError(FSE_buildCTable_wksp(
bs->entropy.fse.litlengthCTable,
litlengthNCount, litlengthMaxValue, litlengthLog,
workspace, HUF_WORKSPACE_SIZE)),
dictionary_corrupted, "");
bs->entropy.fse.litlength_repeatMode = ZSTD_dictNCountRepeat(litlengthNCount, litlengthMaxValue, MaxLL);
dictPtr += litlengthHeaderSize;
}
RETURN_ERROR_IF(dictPtr+12 > dictEnd, dictionary_corrupted, "");
bs->rep[0] = MEM_readLE32(dictPtr+0);
bs->rep[1] = MEM_readLE32(dictPtr+4);
bs->rep[2] = MEM_readLE32(dictPtr+8);
dictPtr += 12;
{ size_t const dictContentSize = (size_t)(dictEnd - dictPtr);
U32 offcodeMax = MaxOff;
if (dictContentSize <= ((U32)-1) - 128 KB) {
U32 const maxOffset = (U32)dictContentSize + 128 KB; /* The maximum offset that must be supported */
offcodeMax = ZSTD_highbit32(maxOffset); /* Calculate minimum offset code required to represent maxOffset */
}
/* All offset values <= dictContentSize + 128 KB must be representable for a valid table */
bs->entropy.fse.offcode_repeatMode = ZSTD_dictNCountRepeat(offcodeNCount, offcodeMaxValue, MIN(offcodeMax, MaxOff));
/* All repCodes must be <= dictContentSize and != 0 */
{ U32 u;
for (u=0; u<3; u++) {
RETURN_ERROR_IF(bs->rep[u] == 0, dictionary_corrupted, "");
RETURN_ERROR_IF(bs->rep[u] > dictContentSize, dictionary_corrupted, "");
} } }
return (size_t)(dictPtr - (const BYTE*)dict);
return ZSTD_rust_loadCEntropy(bs, workspace, dict, dictSize);
}
/* Dictionary format :
+2
View File
@@ -37,6 +37,8 @@ pub mod zstd_compress;
#[cfg(feature = "compression")]
pub mod zstd_compress_api;
#[cfg(feature = "compression")]
pub mod zstd_compress_dictionary;
#[cfg(feature = "compression")]
pub mod zstd_compress_frame;
#[cfg(feature = "compression")]
pub mod zstd_compress_literals;
+478
View File
@@ -0,0 +1,478 @@
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(clippy::too_many_arguments)]
//! Dictionary entropy-header loading.
//!
//! This is the Rust leaf for `ZSTD_dictNCountRepeat()` and
//! `ZSTD_loadCEntropy()` in `lib/compress/zstd_compress.c`. C retains the
//! original symbol through a thin compatibility shim and keeps dictionary
//! content loading and compression-context ownership on its side.
use crate::bits::ZSTD_highbit32;
use crate::common::{LL_FSE_LOG, MAX_LL, MAX_ML, MAX_OFF, ML_FSE_LOG, OFF_FSE_LOG};
use crate::entropy_common::FSE_readNCount;
use crate::errors::{ERR_isError, ZstdErrorCode, ERROR};
use crate::fse_compress::FSE_buildCTable_wksp;
use crate::huf_compress::HUF_readCTable;
use crate::zstd_compress_stats::ZSTD_compressedBlockState_t;
use std::ffi::c_void;
use std::os::raw::{c_int, c_short, c_uint};
use std::slice;
const HUF_REPEAT_CHECK: c_int = 1;
const HUF_REPEAT_VALID: c_int = 2;
const FSE_REPEAT_CHECK: c_int = 1;
const FSE_REPEAT_VALID: c_int = 2;
const HUF_WORKSPACE_SIZE: usize = (8 << 10) + 512;
const DICTIONARY_ID_AND_MAGIC_SIZE: usize = 8;
const REPCODE_SECTION_SIZE: usize = 12;
#[inline]
fn dictionary_corrupted() -> usize {
ERROR(ZstdErrorCode::DictionaryCorrupted)
}
/// Matches C's `ZSTD_dictNCountRepeat()`.
#[inline]
fn dict_n_count_repeat(
normalized_counter: &[c_short],
dict_max_symbol_value: usize,
max_symbol_value: usize,
) -> c_int {
if dict_max_symbol_value < max_symbol_value {
return FSE_REPEAT_CHECK;
}
if normalized_counter.len() <= max_symbol_value
|| normalized_counter[..=max_symbol_value].contains(&0)
{
return FSE_REPEAT_CHECK;
}
FSE_REPEAT_VALID
}
/// Reads and builds one dictionary FSE table, advancing `offset` on success.
///
/// C supplies `max_symbol_value` from `FSE_readNCount()` to the table builder
/// for the match-length and literal-length tables, but deliberately builds the
/// offset table with `MaxOff` so the unused tail is initialized. The caller
/// passes that distinction through `table_max_symbol_value`.
unsafe fn read_and_build_fse_table(
dictionary: &[u8],
offset: &mut usize,
normalized_counter: &mut [c_short],
initial_max_symbol_value: c_uint,
max_table_log: c_uint,
table: *mut c_uint,
table_max_symbol_value: c_uint,
workspace: *mut c_void,
) -> Result<c_uint, ()> {
let input = &dictionary[*offset..];
let mut max_symbol_value = initial_max_symbol_value;
let mut table_log = 0u32;
let header_size = unsafe {
FSE_readNCount(
normalized_counter.as_mut_ptr(),
&mut max_symbol_value,
&mut table_log,
input.as_ptr().cast(),
input.len(),
)
};
if ERR_isError(header_size)
|| header_size > input.len()
|| max_symbol_value as usize >= normalized_counter.len()
|| table_log > max_table_log
{
return Err(());
}
let build_result = unsafe {
FSE_buildCTable_wksp(
table,
normalized_counter.as_ptr(),
table_max_symbol_value,
table_log,
workspace,
HUF_WORKSPACE_SIZE,
)
};
if ERR_isError(build_result) {
return Err(());
}
*offset = offset.checked_add(header_size).ok_or(())?;
Ok(max_symbol_value)
}
/// Rust implementation of C `ZSTD_loadCEntropy()`.
///
/// The input is assumed to have the dictionary magic and ID in its first eight
/// bytes, matching the C function's documented precondition. Malformed or
/// truncated entropy headers are converted to `ZSTD_error_dictionaryCorrupted`
/// just as the C `RETURN_ERROR_IF(..., dictionary_corrupted, ...)` paths do.
#[no_mangle]
pub unsafe extern "C" fn ZSTD_rust_loadCEntropy(
bs: *mut ZSTD_compressedBlockState_t,
workspace: *mut c_void,
dict: *const c_void,
dict_size: usize,
) -> usize {
if bs.is_null()
|| dict.is_null()
|| workspace.is_null()
|| dict_size < DICTIONARY_ID_AND_MAGIC_SIZE
{
return dictionary_corrupted();
}
let dictionary = unsafe { slice::from_raw_parts(dict.cast::<u8>(), dict_size) };
let mut offset = DICTIONARY_ID_AND_MAGIC_SIZE;
unsafe {
(*bs).entropy.huf.repeatMode = HUF_REPEAT_CHECK;
}
let mut huf_max_symbol_value = 255u32;
let mut huf_has_zero_weights = 1u32;
let huf_header_size = unsafe {
HUF_readCTable(
(*bs).entropy.huf.CTable.as_mut_ptr(),
&mut huf_max_symbol_value,
dictionary[offset..].as_ptr().cast(),
dictionary.len() - offset,
&mut huf_has_zero_weights,
)
};
if ERR_isError(huf_header_size) || huf_header_size > dictionary.len().saturating_sub(offset) {
return dictionary_corrupted();
}
if huf_has_zero_weights == 0 && huf_max_symbol_value == 255 {
unsafe {
(*bs).entropy.huf.repeatMode = HUF_REPEAT_VALID;
}
}
offset = match offset.checked_add(huf_header_size) {
Some(value) => value,
None => return dictionary_corrupted(),
};
let mut offcode_ncount = [0i16; MAX_OFF + 1];
let offcode_max_symbol_value = MAX_OFF as c_uint;
let offcode_header_max = match unsafe {
read_and_build_fse_table(
dictionary,
&mut offset,
&mut offcode_ncount,
offcode_max_symbol_value,
OFF_FSE_LOG as c_uint,
(*bs).entropy.fse.offcodeCTable.as_mut_ptr(),
MAX_OFF as c_uint,
workspace,
)
} {
Ok(value) => value,
Err(()) => return dictionary_corrupted(),
};
let mut matchlength_ncount = [0i16; MAX_ML + 1];
let matchlength_max_symbol_value = MAX_ML as c_uint;
let matchlength_header_max = match unsafe {
read_and_build_fse_table(
dictionary,
&mut offset,
&mut matchlength_ncount,
matchlength_max_symbol_value,
ML_FSE_LOG as c_uint,
(*bs).entropy.fse.matchlengthCTable.as_mut_ptr(),
matchlength_max_symbol_value,
workspace,
)
} {
Ok(value) => value,
Err(()) => return dictionary_corrupted(),
};
unsafe {
(*bs).entropy.fse.matchlength_repeatMode =
dict_n_count_repeat(&matchlength_ncount, matchlength_header_max as usize, MAX_ML);
}
let mut litlength_ncount = [0i16; MAX_LL + 1];
let litlength_max_symbol_value = MAX_LL as c_uint;
let litlength_header_max = match unsafe {
read_and_build_fse_table(
dictionary,
&mut offset,
&mut litlength_ncount,
litlength_max_symbol_value,
LL_FSE_LOG as c_uint,
(*bs).entropy.fse.litlengthCTable.as_mut_ptr(),
litlength_max_symbol_value,
workspace,
)
} {
Ok(value) => value,
Err(()) => return dictionary_corrupted(),
};
unsafe {
(*bs).entropy.fse.litlength_repeatMode =
dict_n_count_repeat(&litlength_ncount, litlength_header_max as usize, MAX_LL);
}
if dictionary.len().saturating_sub(offset) < REPCODE_SECTION_SIZE {
return dictionary_corrupted();
}
unsafe {
(*bs).rep[0] = u32::from_le_bytes(dictionary[offset..offset + 4].try_into().unwrap());
(*bs).rep[1] = u32::from_le_bytes(dictionary[offset + 4..offset + 8].try_into().unwrap());
(*bs).rep[2] = u32::from_le_bytes(dictionary[offset + 8..offset + 12].try_into().unwrap());
}
offset += REPCODE_SECTION_SIZE;
let dict_content_size = dictionary.len() - offset;
let offcode_max = if dict_content_size <= (u32::MAX as usize) - (128 << 10) {
let max_offset = (dict_content_size + (128 << 10)) as u32;
ZSTD_highbit32(max_offset) as usize
} else {
MAX_OFF
};
let required_offcode_max = offcode_max.min(MAX_OFF);
let offcode_repeat_mode = dict_n_count_repeat(
&offcode_ncount,
offcode_header_max as usize,
required_offcode_max,
);
unsafe {
(*bs).entropy.fse.offcode_repeatMode = offcode_repeat_mode;
}
unsafe {
for &rep in &(*bs).rep {
if rep == 0 || rep as usize > dict_content_size {
return dictionary_corrupted();
}
}
}
offset
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::ERR_getErrorCode;
use crate::fse_compress::{FSE_normalizeCount, FSE_writeNCount};
use crate::huf_compress::{HUF_buildCTable_wksp, HUF_writeCTable_wksp};
use crate::mem::MEM_writeLE32;
use std::mem::{size_of, MaybeUninit};
const DICT_CONTENT_SIZE: usize = 16;
#[derive(Clone, Copy)]
enum ZeroWeight {
None,
Huffman,
MatchLength,
LiteralLength,
Offset,
}
fn assert_dictionary_corrupted(result: usize) {
assert!(ERR_isError(result));
assert_eq!(
ERR_getErrorCode(result),
ZstdErrorCode::DictionaryCorrupted as i32
);
}
unsafe fn write_fse_header(
dictionary: &mut [u8],
offset: &mut usize,
max_symbol_value: usize,
table_log: u32,
zero_weight: bool,
) {
let mut counts = vec![1u32; max_symbol_value + 1];
if zero_weight {
counts[0] = 0;
}
let total = counts.iter().map(|&count| count as usize).sum();
let mut normalized = vec![0i16; max_symbol_value + 1];
let normalized_log = FSE_normalizeCount(
normalized.as_mut_ptr(),
table_log,
counts.as_ptr(),
total,
max_symbol_value as c_uint,
1,
);
assert!(!ERR_isError(normalized_log));
let header_size = FSE_writeNCount(
dictionary.as_mut_ptr().add(*offset).cast(),
dictionary.len() - *offset,
normalized.as_ptr(),
max_symbol_value as c_uint,
normalized_log as c_uint,
);
assert!(!ERR_isError(header_size));
*offset += header_size;
}
fn make_dictionary(zero_weight: ZeroWeight) -> Vec<u8> {
let mut dictionary = vec![0u8; 2048];
unsafe {
MEM_writeLE32(dictionary.as_mut_ptr().cast(), 0xEC30_A437);
MEM_writeLE32(dictionary.as_mut_ptr().add(4).cast(), 1234);
}
let mut offset = DICTIONARY_ID_AND_MAGIC_SIZE;
let mut huf_counts = std::array::from_fn::<_, 256, _>(|symbol| (symbol + 1) as u32);
if matches!(zero_weight, ZeroWeight::Huffman) {
huf_counts[0] = 0;
}
let mut huf_table = [0usize; 257];
let mut workspace = [0u32; HUF_WORKSPACE_SIZE / size_of::<u32>()];
let huff_log = unsafe {
HUF_buildCTable_wksp(
huf_table.as_mut_ptr(),
huf_counts.as_ptr(),
255,
11,
workspace.as_mut_ptr().cast(),
size_of::<[u32; HUF_WORKSPACE_SIZE / size_of::<u32>()]>(),
)
};
assert!(!ERR_isError(huff_log));
let huf_header_size = unsafe {
HUF_writeCTable_wksp(
dictionary.as_mut_ptr().add(offset).cast(),
dictionary.len() - offset,
huf_table.as_ptr(),
255,
huff_log as c_uint,
workspace.as_mut_ptr().cast(),
size_of::<[u32; HUF_WORKSPACE_SIZE / size_of::<u32>()]>(),
)
};
assert!(!ERR_isError(huf_header_size));
offset += huf_header_size;
unsafe {
write_fse_header(
&mut dictionary,
&mut offset,
MAX_OFF,
OFF_FSE_LOG as u32,
matches!(zero_weight, ZeroWeight::Offset),
);
write_fse_header(
&mut dictionary,
&mut offset,
MAX_ML,
ML_FSE_LOG as u32,
matches!(zero_weight, ZeroWeight::MatchLength),
);
write_fse_header(
&mut dictionary,
&mut offset,
MAX_LL,
LL_FSE_LOG as u32,
matches!(zero_weight, ZeroWeight::LiteralLength),
);
MEM_writeLE32(dictionary.as_mut_ptr().add(offset).cast(), 1);
MEM_writeLE32(dictionary.as_mut_ptr().add(offset + 4).cast(), 4);
MEM_writeLE32(dictionary.as_mut_ptr().add(offset + 8).cast(), 8);
}
offset += REPCODE_SECTION_SIZE;
dictionary[offset..offset + DICT_CONTENT_SIZE].fill(0xA5);
offset += DICT_CONTENT_SIZE;
dictionary.truncate(offset);
dictionary
}
fn load(dictionary: &[u8]) -> (usize, ZSTD_compressedBlockState_t) {
let mut state =
unsafe { MaybeUninit::<ZSTD_compressedBlockState_t>::zeroed().assume_init() };
let mut workspace = [0u32; HUF_WORKSPACE_SIZE / size_of::<u32>()];
let result = unsafe {
ZSTD_rust_loadCEntropy(
&mut state,
workspace.as_mut_ptr().cast(),
dictionary.as_ptr().cast(),
dictionary.len(),
)
};
(result, state)
}
#[test]
fn valid_dictionary_loads_entropy_and_marks_repeat_tables_valid() {
let dictionary = make_dictionary(ZeroWeight::None);
let (header_size, state) = load(&dictionary);
assert_eq!(header_size, dictionary.len() - DICT_CONTENT_SIZE);
assert_eq!(state.rep, [1, 4, 8]);
assert_eq!(state.entropy.huf.repeatMode, HUF_REPEAT_VALID);
assert_eq!(state.entropy.fse.offcode_repeatMode, FSE_REPEAT_VALID);
assert_eq!(state.entropy.fse.matchlength_repeatMode, FSE_REPEAT_VALID);
assert_eq!(state.entropy.fse.litlength_repeatMode, FSE_REPEAT_VALID);
}
#[test]
fn zero_weights_select_check_repeat_modes() {
for zero_weight in [
ZeroWeight::Huffman,
ZeroWeight::Offset,
ZeroWeight::MatchLength,
ZeroWeight::LiteralLength,
] {
let dictionary = make_dictionary(zero_weight);
let (header_size, state) = load(&dictionary);
assert_eq!(header_size, dictionary.len() - DICT_CONTENT_SIZE);
match zero_weight {
ZeroWeight::Huffman => {
assert_eq!(state.entropy.huf.repeatMode, HUF_REPEAT_CHECK)
}
ZeroWeight::Offset => {
assert_eq!(state.entropy.fse.offcode_repeatMode, FSE_REPEAT_CHECK)
}
ZeroWeight::MatchLength => {
assert_eq!(state.entropy.fse.matchlength_repeatMode, FSE_REPEAT_CHECK)
}
ZeroWeight::LiteralLength => {
assert_eq!(state.entropy.fse.litlength_repeatMode, FSE_REPEAT_CHECK)
}
ZeroWeight::None => unreachable!(),
}
}
}
#[test]
fn truncated_entropy_headers_are_rejected() {
let dictionary = make_dictionary(ZeroWeight::None);
let (header_size, _) = load(&dictionary);
for truncated_size in [0, 7, 8, header_size - 1] {
let truncated = &dictionary[..truncated_size];
let (result, _) = load(truncated);
assert_dictionary_corrupted(result);
}
}
#[test]
fn corrupt_repcode_header_is_rejected() {
let mut dictionary = make_dictionary(ZeroWeight::None);
let (header_size, _) = load(&dictionary);
let repcode_offset = header_size - REPCODE_SECTION_SIZE;
dictionary[repcode_offset..repcode_offset + 4].fill(0);
let (result, _) = load(&dictionary);
assert_dictionary_corrupted(result);
}
#[test]
fn dict_n_count_repeat_requires_coverage_and_nonzero_weights() {
let mut normalized = [1i16; 4];
assert_eq!(dict_n_count_repeat(&normalized, 3, 3), FSE_REPEAT_VALID);
normalized[2] = 0;
assert_eq!(dict_n_count_repeat(&normalized, 3, 3), FSE_REPEAT_CHECK);
assert_eq!(dict_n_count_repeat(&normalized, 2, 2), FSE_REPEAT_CHECK);
}
}