x4204

Compressing Lookup Tables

§0 Table of Contents

§1 Introduction

In my free time I am implementing a small search engine I call sg[1] as a way to get a deeper understanding and hands-on experience with search engine internals. It is written in C and compiled to WASM. The idea is to have a static search engine that can be used on the client-side without the need to make requests to a backend service for search, which is very useful for statically generated websites such as blogs and documentation.

In a search engine, query terms typically go through a few preprocessing steps before they are indexed or used for search. These steps include things like removing diacritics, case normalisation and stemming. In this post I will be looking at how diacritics are removed in sg and which techniques I use to compress the lookup table of replacement rules.

§2 Why Remove Diacritics?

In this preprocessing step, symbols such as accented letters are replaced with their closest ASCII match. A few example replacements are:

  • ü -> u (German)
  • ă -> a (Romanian)
  • ‟ -> " (punctuation)
  • © -> (C) (symbols)

The reason I wanted to apply this preprocessing step is so that most of the text can represented using plain ASCII. This makes the search a bit more fuzzy, but on the other hand, it can also be an advantage.

A good example, where this step might be useful, is searching texts written in the Romanian language. Romanian speakers will often skip diacritics entirely in informal text, since it's relatively easy to figure out the correct word from context. This means that the collection of documents might have a mix of texts with and without diacritics. If someone searches for mămăligă[2] and this preprocessing step is skipped, then the search engine will match only documents that contain the word with diacritics. The documents that contain mamaliga (without diacritics) will be skipped, because it's technically a different term. In most cases, this is not a desirable behaviour.

§3 Storing the Replacement Rules

There are many diacritics replacement lists out there, but I decided to use the one from PostgreSQL's unaccent.rules[3]. The file contains 2662 replacement rules and represents the dataset that we want to compress. Each rule is a separate line in the file and contains the symbol to be replaced and its replacement, separated by a tab.

Since the replacement rules are nothing more than a key-value map, I initially considered using a simple hash table to store it. A hash table, however, doesn't work well for me for multiple reasons.

I want the data structure to be as compact as possible and waste the least amount of space on disk, ideally none, even before I apply any kind of compression. No matter what collision resolution techniques hash tables employ, be it open addressing or chaining, they are known to use more space than actually needed. This is so that the amount of collisions is kept low.

To avoid any wasted space, a hash table could use a minimal perfect hash function. Such a hash function maps N inputs to N slots without any collisions. After some experimetation with gperf[4] it became clear that I can't make it build a minimal perfect hash function for the replacement rules.

There are other ways to create perfect hash tables, but the simplest ones typically require an intermediary table, which takes additional space and would need to be compressed separately. This made me reject the idea of using hash tables to store the replacement rules.

My next option, and the one I settled on, was to store the replacement rules in sorted order by key and then use binary search for lookup. The big advantage here is that it does not use extra space, unlike hash tables, and the implementation is super simple. An entry and the corresponding table can be represented this way:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#define ENTRY_DATA_LEN 8

typedef struct UnaccentEntry {
  uint8_t symb_len;
  uint8_t repl_len;
  uint8_t data[ENTRY_DATA_LEN];
} UnaccentEntry;

typedef struct UnaccentTable {
  int32_t len;
  UnaccentEntry items[];
} UnaccentTable;

Here, symb_len is the length in bytes of the symbol to be replaced, repl_len is the length in bytes of the replacement and data stores the symbol and its replacement concatenated together. Maximum symbol length is 4 bytes and maximum replacement length is 5 bytes, but concatenated, their maximum length is 8 bytes. As a result, an UnaccentEntry takes 10 bytes and the entire table takes 26624 bytes. This will be our baseline.

Looking up a symbol in the table is then a simple binary search. Other data structures might also work as a lookup table (e.g. prefix trees), but I didn't experiment with them and don't know how well they can be compressed.

§4 Dictionary Encoding

When compressing, we want to reduce the amount of space needed to represent a sequence of bytes. Dictionary encoding[5] is one efficient and simple to implement technique that achieves that. The idea is very simple: keep a dictionary of substitution rules A -> B, where A is the code and B is the sequence of symbols being substituted. While encoding, check if the subsequence exists in the dictionary. If there is a match, emit the corresponding code; otherwise, emit the symbol as is and go to next symbol. Decoding is the opposite operation: if a code is encountered, emit the corresponding sequence from the dictionary; otherwise, emit the symbol.

Our dictionary encoding algorithm will be based on byte-pair encoding[6]. There, the most frequent pair of symbols in the string is found. A rule is added to the dictionary, then all occurrences in the string are substituted with the new code. This is repeated until the dictionary size reaches a threshold or there are no more pairs in the string with a frequency greater than 1. Here is the pseudocode:

1
2
3
4
5
6
7
8
9
str = "..."
dict = {}
while true:
  counts = count frequency of every pair in str
  pair, count = find top pair in counts
  if count == 1: break
  code = find next code
  dict[code] = pair
  replace pair in str

We need to make a couple adjustments to the algorithm to adapt it to our usecase. In the regular byte-pair encoding algorithm, it is the most frequent pair that is being substituted at every step. That's fine if we are working with only one string, but in our case there are 2662 byte strings. Our goal is not just reduce the length of a subset of strings, it's to reduce the maximum length ENTRY_DATA_LEN. To do that, we consider only the pairs found in the longest strings. Here is the pseudocode:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
entries = [...]
dict = {}
while true:
  max_entries = find entries of max length
  candidates = set of all pairs in all entries from max_entries
  counts = count frequency of every candidate pair in all entries
  pair, count = find top pair in counts
  if count == 1: break
  code = find next code
  dict[code] = pair
  replace pair in all entries

Second adjustment is related to how a code is chosen for the next rule. Normally, finding the next code is just code = 1 + (max code in dict). This, however, will not work well for the following reason. Since we're processing UTF-8 strings, all codes below 256 will be allocated for use in actual symbols. This means that codes can only take values that are greater than or equal to 256. Otherwise, in a sequence of bytes, it won't be clear which byte represents a code and which one a symbol. As a result, every symbol and code has to be represented as a 16-bit integer, which is wasteful.

We can do better by exploiting the characteristics of the data we are working with. Not all values in the range 0..255 are taken. We can reuse these values for our codes and since they never appear in the data, there won't be any ambiguity in whether the value represents a code or a symbol. By doing this, we manage to keep everything in 8-bit. Here is the pseudocode to find the unused values:

1
2
3
4
available_codes = set of all values in interval 0..255
for entry in entries:
  for byte in entry:
    discard byte from available_codes

This gives us the following list of values we can choose codes from:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
available_codes = [
    0,   1,   2,   3,   4,   5,   6,   7,   8,   9,
   10,  11,  12,  13,  14,  15,  16,  17,  18,  19,
   20,  21,  22,  23,  24,  25,  26,  27,  28,  29,
   30,  31,

                                    127,

           192, 193,

  210, 211, 212, 213, 214, 215, 216, 217, 218, 219,
  220, 221, 222, 223, 224,                228, 229,
  230, 231, 232, 233,      235, 236, 237, 238,

       241, 242, 243, 244, 245, 246, 247, 248, 249,
  250, 251, 252, 253, 254, 255
]

Note the gaps where values are missing. These are the bytes that appear in the dataset and can't be repurposed. The most promising values we could use for dictionary codes are the ones from the range 127..238. The exact reason for this will become clearer in the section on frame of reference encoding. In short, all values found in our dataset fit in the range 32..240. Ideally, we don't want to widen this range by assigning values from outside it to dictionary codes, and 127..238 is promising, because it fits entirely inside 32..240.

We now have everything needed to build the substitution dictionary. After some experimentation, I ended up with the following one. Details on how exactly it was built will be given in a later section.

1
2
3
4
5
6
7
8
9
7f: [28,32]     c0: [2f,33]     c1: [f0,9d]     d2: [e3,8f]
d3: [f0,9f]     d4: [ef,b8]     d5: [86,43]     d6: [20,35]
d7: [82,61]     d8: [e2,91]     d9: [2f,38]     da: [e2,a9]
db: [2f,35]     dc: [e2,85]     dd: [d3,84]     de: [e2,82]
df: [20,31]     e0: [e3,8e]     e4: [63,61]     e5: [72,61]
e6: [e2,80]     e7: [e2,86]     e8: [49,49]     e9: [28,31]
eb: [20,33]     ec: [e2,92]     ed: [2e,6d]     ee: [df,2f]
f1: [ef,ac]     f2: [69,69]     f3: [e3,8d]     f4: [e2,84]
f5: [2f,73]

In total, there are 33 substitution rules. In the dictionary above, all values are in hexadecimal. Every rule represents a substitution of one byte with two other bytes. For example, the pair [28,32] will be substituted with 7f when encoding and viceversa when decoding. By using just this small dictionary of only 33 rules, it was possible to reduce the maximum length ENTRY_DATA_LEN from 8 to 5 bytes.

Note that, unfortunately, we were not able to entirely avoid widening the range 32..240, since there are only 28 available values within 127..238. The range was widened by five more values, namely 241..245 (i.e. codes f1..f5). Without these 5 additional codes, dictionary encoding would have reduced the maximum length to 6 instead of 5 bytes.

Overall, size of an UnaccentEntry is now 7 bytes instead of the 10 bytes we started with. This saves us 7986 bytes across the entire lookup table. Not bad for just 33 substitution rules, but we can compress it a bit more.

§5 Mixed Radix Numeral System Encoding

A mixed radix numeral system[7] (MRNS) is a numeral system where the radix varies from position to position. This is not a special concept. In fact, it's pretty simple and there are plenty of every day examples where such a system is in use. Time is expressed in hour (radix 24), minute (radix 60) and seconds (radix 60). Rotation is expressed in degrees (radix 360), minutes (radix 60) and seconds (radix 60). Length in imperial units is expressed in miles (radix 1760), yards (radix 3) and feet (radix 12). This is unlike other numeral system like base 2, where the base is the same in every position.

We will now follow an example with time. According to the paragraph above, the base for time is (24, 60, 60). Therefore, 15:49:31 represents 56971 seconds. This is how we encode:

1
2
3
4
5
hours, minutes, seconds = 15, 49, 31
encoded = 0
encoded = encoded * 24 + hours    # -> 15
encoded = encoded * 60 + minutes  # -> 949
encoded = encoded * 60 + seconds  # -> 56971

And this is how we decode:

1
2
3
4
5
6
7
8
seconds = encoded % 60    # -> 31
encoded = encoded // 60   # -> 949

minutes = encoded % 60    # -> 49
encoded = encoded // 60   # -> 15

hours = encoded % 24      # -> 15
encoded = encoded // 24   # -> 0

The values 15:49:31 and 56971 are equivalent. They are the same thing really, just represented in different ways. What makes a difference, however, is the way we store this information in memory, the amount of space it takes and the way each time component is accessed. Let's say that we use a byte to represent each time component (i.e. hour, minute, second). We get that we need 3 * 8 = 24 bits to store the time. Now let's see how many bits are needed if we represent it as a plain integer. In total, we need ceil(log2(24 * 60 * 60)) = 17 bits, which is 7 bits less. An MRNS gives us the opportunity to save space by packing a sequence of values in a much more compact way.

We could interpret an UnaccentEntry as a sequence of values that we then encode using an MRNS. In this case we are dealing with a sequence of 7 bytes (i.e. symbol length, replacement length and 5 bytes of dictionary encoded data). To find the base for MRNS, we need to find the maximum value for every byte in the sequence. After running a small script, these are the max values we get:

1
2
3
     | symb_len | repl_len | data[0] | data[1] | data[2] | data[3] | data[4]
-----+----------+----------+---------+---------+---------+---------+--------
 max |        5 |        6 |     244 |     215 |     242 |     245 |     245

This forms the MRNS base (5, 6, 244, 215, 242, 245, 245) that we can then use to encode an UnaccentEntry into a single integer. The current space requirement for storing an entry is 7 * 8 = 56 bits. If, however, we encode it as an integer, then the space requirement drops to ceil(log2(5 * 6 * 244 * 215 * 242 * 245 * 245)) = 45 bits, which is just over 40 bits (5 bytes). These 5 extra bits are keeping us from achieving a 5 byte encoding, but thankfully there is one more technique we can apply.

§6 Frame of Reference Encoding

Note that when encoding, say data[0], using an MRNS, we use the interval 0..244. Remember from the dictionary encoding section that we chose the dictionary codes carefully, such that we do not widen the range too much. The reason we did that is so we can apply frame of reference coding and shift the range by the biggest offset possible. In essense, data[0] has range 0..244 at the moment, but there are no values below 193 in that position in any of the entries, so its range is effectively 193..244. Frame of reference encoding shifts these endpoints, so we can work with smaller values, and as a result use less bits. We now find the minimum value of each byte and shift the corresponding ranges:

1
2
3
4
5
     | symb_len | repl_len | data[0] | data[1] | data[2] | data[3] | data[4]
-----+----------+----------+---------+---------+---------+---------+--------
 max |        5 |        6 |     244 |     215 |     242 |     245 |     245
 min |        2 |        0 |     193 |     128 |      32 |      33 |      33
diff |        3 |        6 |      52 |      88 |     211 |     213 |     213

Space requirement is now ceil(log2(3 * 6 * 52 * 88 * 211 * 213 * 213)) = 40 bits, which fits exactly into 5 bytes. The entire unaccent table takes only half of the original space (13314 bytes) and can still be used to lookup symbols without having to decompress the entire table! The entire implementation of entry encoding and decoding is presented in Annex A.

§7 Building the Code Dictionary

Searching for a good code dictionary happens in two stages and requires a bit of bruteforcing. We first need to find how many rules are needed to achieve a final entry encoding of roughly 40 bits. We try dictionaries of different lengths and use them to encode all entries, then look at how many bits each entry takes after the rest of the compression techniques are applied. If the amount of bits is close to 40, then we found a candidate dictionary, so we keep it; otherwise we discard it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
available_codes = [...]
candidate_dicts = []
for k in 1..40:
  use_codes = (
    choose k consecutive values from available_codes
    such that range 127..238 is widened the least
  )
  dict, entry_bits = build dict with use_codes
  if abs(entry_bits - 40) < 3:
    append dict to candidate_dicts

Now that we have a short list of candidates, we can proceed with the second stage. The order in which we replace pairs in strings matters, because this affects the ranges for each byte. One replacement order can give slightly better compression than another after other techniques are applied. We want to find the permutation of rules that will result in at most 40 bit final encoding. By permutation I mean assigning a different code to each pair. Every candidate dictionary from above has at least 30 rules. It's unfeasible to try all permutations, so we try 1000 random permutations and hope to find a good one. Here's the pseudocode:

1
2
3
4
5
6
7
good_dicts = []
for dict in candidate_dicts:
  for k in 1..1000:
    dict_perm = randomly permute dict rules
    entry_bits = encode entries
    if entry_bits <= 40:
      append dict_perm to good_dicts

The only thing left now is to choose whichever dictionary you like most from good_dicts. I chose the one with the least amount of substitution rules.

§8 Conclusion

In this post I described the techniques I applied to compress a symbol lookup table. I'm pretty happy with how it turned out. We were able to reduce the storage requirements in half, while preserving fairly fast lookup and not requiring a full table decompression before lookup.

It does have a few caveats, though. This approach is specifically finetuned for unaccent.rules in the state it exists at that specific commit. If anything in the file changes, then the chances are high that the compression algorithm will break completelly or become less efficient and encode entries into more bytes than what it currently does. On top of that, during lookup it fully decompresses up to O(log n) entries since it's doing binary search. This, however, is not a problem for our usecase, because at runtime diacritics are removed only from query strings. These are typically only a handful of words, and modern CPUs are really fast, so it doesn't really have a considerable impact on the runtime of the algorithm. This is an acceptable tradeoff given the amount of compression we get.

As an improvement to what was mentioned, perhaps the entry decoding can be performed incrementally, so that instead of decoding it fully, we only decode as much as we need for binary search to take place and not a byte more. Another potential improvement is to perform string comparisons directly on dictionary encoded strings and skip the dictionary decoding step during entry decompression. Both of these should improve the runtime a bit, but again, given the usecase, it's not really worth it since this preprocessing step is not a bottleneck.

§9 References

  1. sg
    https://gitlab.com/x4204/sg
  2. Mămăligă
    https://en.wikipedia.org/wiki/M%C4%83m%C4%83lig%C4%83
  3. PostgreSQL / unaccent.rules
    https://github.com/postgres/postgres/blob/57ee397953985feb95cffa65a06fcaf6a2cd5367/contrib/unaccent/unaccent.rules
  4. GNU / gperf
    https://www.gnu.org/software/gperf/
  5. Dictionary coder
    https://en.wikipedia.org/wiki/Dictionary_coder
  6. Byte-pair encoding
    https://en.wikipedia.org/wiki/Byte-pair_encoding
  7. Mixed radix
    https://en.wikipedia.org/wiki/Mixed_radix

§10 Annex A: Encoding/Decoding

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
#define UNACCENT_ENTRY_DEC_DATA_MAX 8
#define UNACCENT_ENTRY_ENC_DATA_MAX 5
#define UNACCENT_CODES_MAX 33

typedef struct UnaccentEntryDec {
  uint8_t symb_len;
  uint8_t data_len;
  uint8_t data[UNACCENT_ENTRY_DEC_DATA_MAX];
} UnaccentEntryDec;

typedef struct UnaccentEntryEnc {
  uint8_t data[UNACCENT_ENTRY_ENC_DATA_MAX];
} UnaccentEntryEnc;

typedef struct UnaccentTable {
  int32_t len;
  UnaccentEntryEnc items[];
} UnaccentTable;

typedef struct UnaccentCode {
  uint8_t rule;
  uint8_t pair0;
  uint8_t pair1;
} UnaccentCode;

UnaccentCode unaccent_codes[UNACCENT_CODES_MAX] = {
  { .rule = 0x7f, .pair0 = 0x28, .pair1 = 0x32 },
  { .rule = 0xc0, .pair0 = 0x2f, .pair1 = 0x33 },
  { .rule = 0xc1, .pair0 = 0xf0, .pair1 = 0x9d },
  { .rule = 0xd2, .pair0 = 0xe3, .pair1 = 0x8f },
  { .rule = 0xd3, .pair0 = 0xf0, .pair1 = 0x9f },
  { .rule = 0xd4, .pair0 = 0xef, .pair1 = 0xb8 },
  { .rule = 0xd5, .pair0 = 0x86, .pair1 = 0x43 },
  { .rule = 0xd6, .pair0 = 0x20, .pair1 = 0x35 },
  { .rule = 0xd7, .pair0 = 0x82, .pair1 = 0x61 },
  { .rule = 0xd8, .pair0 = 0xe2, .pair1 = 0x91 },
  { .rule = 0xd9, .pair0 = 0x2f, .pair1 = 0x38 },
  { .rule = 0xda, .pair0 = 0xe2, .pair1 = 0xa9 },
  { .rule = 0xdb, .pair0 = 0x2f, .pair1 = 0x35 },
  { .rule = 0xdc, .pair0 = 0xe2, .pair1 = 0x85 },
  { .rule = 0xdd, .pair0 = 0xd3, .pair1 = 0x84 },
  { .rule = 0xde, .pair0 = 0xe2, .pair1 = 0x82 },
  { .rule = 0xdf, .pair0 = 0x20, .pair1 = 0x31 },
  { .rule = 0xe0, .pair0 = 0xe3, .pair1 = 0x8e },
  { .rule = 0xe4, .pair0 = 0x63, .pair1 = 0x61 },
  { .rule = 0xe5, .pair0 = 0x72, .pair1 = 0x61 },
  { .rule = 0xe6, .pair0 = 0xe2, .pair1 = 0x80 },
  { .rule = 0xe7, .pair0 = 0xe2, .pair1 = 0x86 },
  { .rule = 0xe8, .pair0 = 0x49, .pair1 = 0x49 },
  { .rule = 0xe9, .pair0 = 0x28, .pair1 = 0x31 },
  { .rule = 0xeb, .pair0 = 0x20, .pair1 = 0x33 },
  { .rule = 0xec, .pair0 = 0xe2, .pair1 = 0x92 },
  { .rule = 0xed, .pair0 = 0x2e, .pair1 = 0x6d },
  { .rule = 0xee, .pair0 = 0xdf, .pair1 = 0x2f },
  { .rule = 0xf1, .pair0 = 0xef, .pair1 = 0xac },
  { .rule = 0xf2, .pair0 = 0x69, .pair1 = 0x69 },
  { .rule = 0xf3, .pair0 = 0xe3, .pair1 = 0x8d },
  { .rule = 0xf4, .pair0 = 0xe2, .pair1 = 0x84 },
  { .rule = 0xf5, .pair0 = 0x2f, .pair1 = 0x73 },
};

uint8_t unaccent_offs[] = {0, 2, 193, 128, 32, 33, 33};
#define unaccent_offs_len \
  (sizeof(unaccent_offs) / sizeof(unaccent_offs[0]))

uint8_t unaccent_mrns_base[] = {6, 3, 52, 88, 211, 213, 213};
#define unaccent_mrns_base_len \
  (sizeof(unaccent_mrns_base) / sizeof(unaccent_mrns_base[0]))

int32_t
bit_size(uint64_t n)
{
  return n == 0 ? 0 : 64 - __builtin_clzll(n);
}

// NOTE: can do binary search here since the list is sorted by
// `.rule`, but I'm too lazy to implement it and it probably
// isn't that much faster than a simple linear search
int32_t
unaccent_code_find(uint8_t rule)
{
  for (int32_t i = 0; i < UNACCENT_CODES_MAX; i += 1) {
    if (unaccent_codes[i].rule == rule) return i;
  }
  return -1;
}

void
unaccent_code_expand(UnaccentEntryDec* dec, uint8_t rule)
{
  int32_t ci = unaccent_code_find(rule);
  if (ci < 0) {
    dec->data[dec->data_len] = rule;
    dec->data_len += 1;
  } else {
    unaccent_code_expand(dec, unaccent_codes[ci].pair0);
    unaccent_code_expand(dec, unaccent_codes[ci].pair1);
  }
}

void
unaccent_encode(UnaccentEntryEnc* enc, UnaccentEntryDec* dec)
{
  // dictionary encoding
  uint8_t tmp[UNACCENT_ENTRY_DEC_DATA_MAX] = {0};
  uint8_t tmp_len = dec->data_len;
  memcpy(tmp, dec->data, tmp_len);
  for (int32_t ci = 0; ci < UNACCENT_CODES_MAX; ci += 1) {
    UnaccentCode* code = &unaccent_codes[ci];
    int32_t len = 0;
    for (int32_t i = 0; i < tmp_len; i += 1, len += 1) {
      if (
        i + 1 < tmp_len
        && tmp[i + 0] == code->pair0
        && tmp[i + 1] == code->pair1
      ) {
        tmp[len] = code->rule;
        i += 1;
      } else {
        tmp[len] = tmp[i];
      }
    }
    tmp_len = len;
  }
  memset(&tmp[tmp_len], 0, UNACCENT_ENTRY_DEC_DATA_MAX - tmp_len);
  assert(tmp_len <= UNACCENT_ENTRY_ENC_DATA_MAX);

  // prepare buffer
  uint8_t buf[] = {
    tmp_len, dec->symb_len,
    tmp[0], tmp[1], tmp[2], tmp[3], tmp[4],
  };
  int32_t buf_len = sizeof(buf) / sizeof(buf[0]);
  assert(buf_len == unaccent_offs_len);
  assert(buf_len == unaccent_mrns_base_len);

  // frame of reference encoding
  for (int32_t i = 0; i < 2 + tmp_len; i += 1) {
    buf[i] -= unaccent_offs[i];
  }

  // mixed radix numeral system encoding
  uint64_t pacc = 1;
  uint64_t sacc = 0;
  for (int32_t i = 0; i < buf_len; i += 1) {
    sacc += pacc * buf[i];
    pacc *= (uint64_t)unaccent_mrns_base[i];
  }
  assert(bit_size(sacc) <= 40);

  enc->data[0] = (sacc >> (4 * 8)) & 0xff;
  enc->data[1] = (sacc >> (3 * 8)) & 0xff;
  enc->data[2] = (sacc >> (2 * 8)) & 0xff;
  enc->data[3] = (sacc >> (1 * 8)) & 0xff;
  enc->data[4] = (sacc >> (0 * 8)) & 0xff;
}

void
unaccent_decode(UnaccentEntryDec* dec, UnaccentEntryEnc* enc)
{
  // prepare buffer
  uint8_t buf[7] = {0};
  int32_t buf_len = sizeof(buf) / sizeof(buf[0]);
  assert(buf_len == unaccent_offs_len);
  assert(buf_len == unaccent_mrns_base_len);

  // mixed radix numeral system decoding
  uint64_t sacc = (0lu
    | (uint64_t)enc->data[0] << (4 * 8)
    | (uint64_t)enc->data[1] << (3 * 8)
    | (uint64_t)enc->data[2] << (2 * 8)
    | (uint64_t)enc->data[3] << (1 * 8)
    | (uint64_t)enc->data[4] << (0 * 8)
  );
  for (int32_t i = 0; i < buf_len; i += 1) {
    buf[i] = sacc % unaccent_mrns_base[i];
    sacc /= unaccent_mrns_base[i];
  }

  // frame of reference decoding
  for (int32_t i = 0; i < 2 + buf[0]; i += 1) {
    buf[i] += unaccent_offs[i];
  }

  // dictionary decoding
  int32_t data_len = buf[0];
  for (int32_t i = 0; i < data_len; i += 1) {
    unaccent_code_expand(dec, buf[2 + i]);
  }
  dec->symb_len = buf[1];
}

bool
UnaccentTable_find(
  UnaccentEntryDec* entry_dec,
  UnaccentTable* table, uint8_t* key, int32_t key_len
)
{
  int32_t a = 0;
  int32_t b = table->len - 1;

  while (a != b) {
    int32_t m = a + (b - a + 1) / 2;

    UnaccentEntryEnc* entry_enc = &table->items[m];
    memset(entry_dec, 0, sizeof(*entry_dec));
    unaccent_decode(entry_dec, entry_enc);

    if (Buffer_cmp(key, key_len, entry_dec->data, entry_dec->symb_len) < 0) {
      b = m - 1;
    } else {
      a = m;
    }
  }

  UnaccentEntryEnc* entry_enc = &table->items[a];
  memset(entry_dec, 0, sizeof(*entry_dec));
  unaccent_decode(entry_dec, entry_enc);

  return Buffer_cmp(key, key_len, entry_dec->data, entry_dec->symb_len) == 0;
}

int32_t
Buffer_cmp(uint8_t* a, int32_t a_len, uint8_t* b, int32_t b_len)
{
  int32_t len = min(a_len, b_len);
  for (int32_t i = 0; i < len; i += 1) {
    if (a[i] < b[i]) return -1;
    if (a[i] > b[i]) return +1;
  }
  return a_len - b_len;
}