src/Sstable.bend

mylsm-lsm-store@0.3.1.0 · 537 lines · 0 definitions

1import Base
2import mylsm-lsm-store@0.3.1.0/src/Keys.bend as Keys
3import mylsm-lsm-store@0.3.1.0/src/MemTable.bend as MemTable
4import mylsm-lsm-store@0.3.1.0/src/BitTree.bend as BitTree
5import mylsm-lsm-store@0.3.1.0/src/SortedRun.bend as SortedRun
6
7# SSTables (Task 4): immutable strict sorted runs with BitTree Bloom filters.
8# `build` canonicalizes newest-first raw entries; `from_sorted_unique` trusts an
9# already strict, unique run and deliberately performs no sorting.
10
11type Bloom is Data:
12  Blm{bits: BitTree.BitTree, size: Nat}
13
14type Table is Data:
15  Tbl{
16    entries: List<&2, MemTable.Entry>,
17    filter: Bloom,
18    nbits: Nat,
19    smallest: Maybe<&2, String>,
20    largest: Maybe<&2, String>,
21    count: Nat,
22    chunks: List<&2, List<&2, MemTable.Entry>>,
23    blocks: List<&2, String>
24  }
25
26def block_entries() -> Nat:
27  64n
28
29def chunk_go(fuel: Nat, +entries: List<&2, MemTable.Entry>, +cur: List<&2, MemTable.Entry>, room: Nat, acc: List<&2, List<&2, MemTable.Entry>>) -> List<&2, List<&2, MemTable.Entry>>:
30  match fuel:
31    case 0n:
32      List.reverse(&2, List<&2, MemTable.Entry>, Con{List.reverse(&2, MemTable.Entry, cur), acc})
33    case 1n+f:
34      match entries:
35        case Nil{}:
36          List.reverse(&2, List<&2, MemTable.Entry>, Con{List.reverse(&2, MemTable.Entry, cur), acc})
37        case Con{e, t}:
38          match room:
39            case 0n:
40              chunk_go(f, t, Con{e, Nil{}}, Nat.sub(block_entries(), 1n), Con{List.reverse(&2, MemTable.Entry, cur), acc})
41            case 1n+r:
42              chunk_go(f, t, Con{e, cur}, r, acc)
43
44def chunk(+entries: List<&2, MemTable.Entry>) -> List<&2, List<&2, MemTable.Entry>>:
45  match entries:
46    case Nil{}:
47      Nil{}
48    case Con{e, t}:
49      chunk_go(List.length(&2, MemTable.Entry, entries), t, Con{e, Nil{}}, Nat.sub(block_entries(), 1n), Nil{})
50
51def chunk_head_key(+xs: List<&2, MemTable.Entry>) -> Maybe<&2, String>:
52  match xs:
53    case Nil{}:
54      None{}
55    case Con{MemTable.Entry{key, val}, t}:
56      Some{key}
57
58def block_cons_acc(head: Maybe<&2, String>, acc: List<&2, String>) -> List<&2, String>:
59  match head:
60    case None{}:
61      acc
62    case Some{k}:
63      Con{k, acc}
64
65def block_keys(+chunks: List<&2, List<&2, MemTable.Entry>>, acc: List<&2, String>) -> List<&2, String>:
66  match chunks:
67    case Nil{}:
68      List.reverse(&2, String, acc)
69    case Con{c, t}:
70      block_keys(t, block_cons_acc(chunk_head_key(c), acc))
71
72type Metadata is Data:
73  Meta{smallest: Maybe<&2, String>, largest: Maybe<&2, String>, count: Nat}
74
75# --- Bloom allocator (Monkey-style: smaller/upper levels get more bits) ---
76# Closed form pinned here; Dayan-exact coefficients are tuning follow-up.
77def bloom_bits(level: Nat, est_keys: Nat) -> Nat:
78  match level:
79    case 0n:
80      Nat.mul(est_keys, 20n)
81    case 1n+m:
82      match m:
83        case 0n:
84          Nat.mul(est_keys, 10n)
85        case 1n+p:
86          Nat.mul(est_keys, 5n)
87
88# --- Local byte hash (kept stable for on-disk/rebuild compatibility) ---
89def bhash(str: String, acc: U32) -> U32:
90  match str:
91    case SNil{}:
92      acc
93    case SCon{c, t}:
94      bhash(t, U32.add(U32.mul(acc, 31), Char.to_u32(c)))
95
96# --- U32->Nat bridges that stay checker-tractable ---
97# The Bend checker does not share across sequentially-chained U32.mul terms:
98# `U32.to_nat` applied to a hash accumulated over 4+ bytes diverges in
99# `{==}` elaboration (runtime is unaffected). The bridges below therefore
100# never materialize a chained U32 as a Nat: each `to_nat` covers at most two
101# U32 ops (shift+mask on the ORIGINAL word — parallel, shared), and all
102# accumulation happens in small-Nat land. Both are EXACT (no semantic
103# change): bytes are the base-256 digits of the word (Horner), so
104# u32_to_nat_exact(w) == U32.to_nat(w) and bhash_n computes the same mod.
105def u32_byte(+word: U32, shift: Nat) -> Nat:
106  U32.to_nat(U32.and(U32.shrn(word, shift), 255))
107
108def u32_to_nat_exact(+word: U32) -> Nat:
109  +b0 = u32_byte(word, 24n)
110  +b1 = u32_byte(word, 16n)
111  +b2 = u32_byte(word, 8n)
112  +b3 = u32_byte(word, 0n)
113  Nat.add(Nat.mul(b0, Nat.mul(Nat.mul(256n, 256n), 256n)), Nat.add(Nat.mul(b1, Nat.mul(256n, 256n)), Nat.add(Nat.mul(b2, 256n), b3)))
114
115def bhash_n_pos(+str: String, +seed: U32, +mod: Nat) -> Nat:
116  +w = bhash(str, seed)
117  +a0 = Nat.mod(u32_byte(w, 24n), mod)
118  +a1 = Nat.mod(Nat.add(Nat.mul(a0, 256n), u32_byte(w, 16n)), mod)
119  +a2 = Nat.mod(Nat.add(Nat.mul(a1, 256n), u32_byte(w, 8n)), mod)
120  Nat.mod(Nat.add(Nat.mul(a2, 256n), u32_byte(w, 0n)), mod)
121
122def bhash_n(str: String, seed: U32, mod: Nat) -> Nat:
123  match mod:
124    case 0n:
125      0n
126    case 1n+p:
127      bhash_n_pos(str, seed, mod)
128
129# BitTree stores whole U32 words while Bloom.size preserves the exact logical
130# bit count selected by bloom_bits.
131def bit_words(num: Nat) -> Nat:
132  match num:
133    case 0n:
134      0n
135    case 1n+m:
136      Nat.add(1n, Nat.div(m, 32n))
137
138def bits_new(+num: Nat) -> BitTree.BitTree:
139  BitTree.new(bit_words(num))
140
141def bit_set(idx: Nat, bits: BitTree.BitTree) -> BitTree.BitTree:
142  BitTree.set(bits, idx)
143
144def bit_get(idx: Nat, bits: BitTree.BitTree) -> Bool:
145  BitTree.test(bits, idx)
146
147def bloom_new(+num: Nat) -> Bloom:
148  Blm{bits_new(num), num}
149
150def bloom_well_formed(bloom: Bloom) -> Bool:
151  match bloom:
152    case Blm{+bits, +size}:
153      Bool.and(
154        BitTree.well_formed(bits),
155        Bool.and(
156          Nat.is_eq(BitTree.word_count(bits), bit_words(size)),
157          Nat.is_le(size, BitTree.bit_count(bits))))
158
159def bloom_add(bloom: Bloom, +key: String) -> Bloom:
160  match bloom:
161    case Blm{bits, +size}:
162      Blm{bit_set(bhash_n(key, 17, size), bit_set(bhash_n(key, 257, size), bits)), size}
163
164def both(lhs: Bool, rhs: Bool) -> Bool:
165  match lhs:
166    case True{}:
167      rhs
168    case False{}:
169      False{}
170
171def bloom_test(bloom: Bloom, +key: String) -> Bool:
172  match bloom:
173    case Blm{+bits, +size}:
174      both(bit_get(bhash_n(key, 17, size), bits), bit_get(bhash_n(key, 257, size), bits))
175
176def table_bits(tab: Table) -> Nat:
177  match tab:
178    case Tbl{entries, filter, nbits, smallest, largest, count, chunks, blocks}:
179      nbits
180
181def maybe_present(tab: Table, +key: String) -> Bool:
182  match tab:
183    case Tbl{entries, filter, nbits, smallest, largest, count, chunks, blocks}:
184      bloom_test(filter, key)
185
186# --- Parallel probe counters (Task 1): sequential leaf, two-way fork, `!` gate ---
187# Single probe as 0/1 for counting.
188def probe_hit_if(present: Bool) -> Nat:
189  match present:
190    case True{}:
191      1n
192    case False{}:
193      0n
194
195# Count matching probes sequentially.
196def probe_hit(+tab: Table, +key: String) -> Nat:
197  probe_hit_if(maybe_present(tab, key))
198
199# Sequential probe count over a probe list.
200def probe_count_seq(+probes: List<&2, String>, +tab: Table) -> Nat:
201  match probes:
202    case Nil{}:
203      0n
204    case Con{+q, t}:
205      Nat.add(probe_count_seq(t, tab), probe_hit(tab, q))
206
207# Two-way parallel probe count; halves share no state.
208def probe_count_par(+probes: List<&2, String>, +tab: Table) -> Nat:
209  +half = Nat.div(List.length(&2, String, probes), 2n)
210  left right = probe_count_seq(List.take(&2, String, probes, half), tab) probe_count_seq(List.drop(&2, String, probes, half), tab)
211  Nat.add(left, right)
212
213# Bang gate: GPU pays only with enough probes to fill lanes.
214def probe_count_bang_gate(+num: Nat) -> Bool:
215  Nat.is_le(16384n, num)
216
217# Pick CPU-pool or GPU execution for the same fork tree.
218def probe_count_pick(gate: Bool, +probes: List<&2, String>, +tab: Table) -> Nat:
219  match gate:
220    case False{}:
221      probe_count_par(probes, tab)
222    case True{}:
223      probe_count_par!(probes, tab)
224
225# Size-gated entry point for parallel probe counting.
226def probe_count_auto(+probes: List<&2, String>, +tab: Table) -> Nat:
227  probe_count_pick(probe_count_bang_gate(List.length(&2, String, probes)), probes, tab)
228
229# --- Canonicalization and construction ---
230def bloom_of(entries: List<&2, MemTable.Entry>, +blm: Bloom) -> Bloom:
231  match entries:
232    case Nil{}:
233      blm
234    case Con{e, t}:
235      match e:
236        case MemTable.Entry{key, val}:
237          bloom_of(t, bloom_add(blm, key))
238
239def bloom_union_trees(+abits: BitTree.BitTree, +bbits: BitTree.BitTree, size: Nat) -> Bloom:
240  match abits:
241    case BitTree.BT{aroot, awords, acap, aheight}:
242      match bbits:
243        case BitTree.BT{broot, bwords, bcap, bheight}:
244          Blm{BitTree.BT{BitTree.union_bits(aroot, broot), awords, acap, aheight}, size}
245
246def bloom_union(ba: Bloom, bb: Bloom) -> Bloom:
247  match ba:
248    case Blm{abits, asize}:
249      match bb:
250        case Blm{bbits, bsize}:
251          bloom_union_trees(abits, bbits, Nat.max(asize, bsize))
252
253def bloom_of_chunk(+chunk: List<&2, MemTable.Entry>, +nb: Nat) -> Bloom:
254  bloom_of(chunk, bloom_new(nb))
255
256# Two-half parallel build (spike 1): one spawn, one union. Sequential below.
257def bloom_of_par(+entries: List<&2, MemTable.Entry>, +nb: Nat) -> Bloom:
258  +half = Nat.div(List.length(&2, MemTable.Entry, entries), 2n)
259  left right = bloom_of_chunk(List.take(&2, MemTable.Entry, entries, half), nb) bloom_of_chunk(List.drop(&2, MemTable.Entry, entries, half), nb)
260  bloom_union(left, right)
261
262# --- Four-chunk parallel build (Task 2): two-level fork, three unions ---
263# Third chunk slice as take-after-drop.
264def chunk_third(+entries: List<&2, MemTable.Entry>, +skip: Nat, +take: Nat) -> List<&2, MemTable.Entry>:
265  List.take(&2, MemTable.Entry, List.drop(&2, MemTable.Entry, entries, skip), take)
266
267# Four-way chunk build with a two-level fork tree.
268def bloom_of_chunks(+entries: List<&2, MemTable.Entry>, +nb: Nat) -> Bloom:
269  +num = List.length(&2, MemTable.Entry, entries)
270  +q = Nat.div(num, 4n)
271  a b = bloom_of_chunk(List.take(&2, MemTable.Entry, entries, q), nb) bloom_of_chunk(chunk_third(entries, q, q), nb)
272  c d = bloom_of_chunk(chunk_third(entries, Nat.mul(q, 2n), q), nb) bloom_of_chunk(List.drop(&2, MemTable.Entry, entries, Nat.mul(q, 3n)), nb)
273  bloom_union(bloom_union(a, b), bloom_union(c, d))
274
275# Bang gate: GPU pays only with enough entries to feed lanes.
276def bloom_bang_gate(+num: Nat) -> Bool:
277  Nat.is_le(8192n, num)
278
279# Pick CPU-pool or GPU execution for the same chunk tree.
280def bloom_of_pick(gate: Bool, +entries: List<&2, MemTable.Entry>, +nb: Nat) -> Bloom:
281  match gate:
282    case False{}:
283      bloom_of_chunks(entries, nb)
284    case True{}:
285      bloom_of_chunks!(entries, nb)
286
287# Size-gated `!` entry point: the bang fires only at >= 8192 entries, where
288# every flush in the durable write path lands (frozen merge of 4096 + 4096).
289def bloom_of_bang(+entries: List<&2, MemTable.Entry>, +nb: Nat) -> Bloom:
290  bloom_of_pick(bloom_bang_gate(List.length(&2, MemTable.Entry, entries)), entries, nb)
291
292# --- Hybrid bloom (spike): parallel index collection, sequential assembly ---
293# Phase 1 emits a flat index list (two per key); joins concatenate, so no
294# chunk tree or union is ever built. Phase 2 folds the indexes into ONE tree
295# on the CPU. This removes the tree-allocation amplification that costs ~10%
296# at 1M scale in the chunk+union design, while keeping a fork tree that a
297# future `!` can ship once the GPU recovery crash is fixed upstream.
298# Both bit positions of one key as a flat two-element list.
299def key_idxs(+key: String, +size: Nat) -> List<&2, Nat>:
300  Con{bhash_n(key, 17, size), Con{bhash_n(key, 257, size), Nil{}}}
301
302# Sequential index collection over entries.
303def hash_idxs_seq(+entries: List<&2, MemTable.Entry>, +size: Nat) -> List<&2, Nat>:
304  match entries:
305    case Nil{}:
306      Nil{}
307    case Con{MemTable.Entry{key, val}, t}:
308      List.append(&2, Nat, key_idxs(key, size), hash_idxs_seq(t, size))
309
310# Balanced parallel index collection; reusable fuel bounds the fork depth.
311def hash_idxs_par_go(+fuel: Nat, +entries: List<&2, MemTable.Entry>, +size: Nat) -> List<&2, Nat>:
312  match fuel:
313    case 0n:
314      hash_idxs_seq(entries, size)
315    case 1n+f:
316      match entries:
317        case Nil{}:
318          Nil{}
319        case Con{MemTable.Entry{key, val}, Nil{}}:
320          key_idxs(key, size)
321        case Con{_, Con{_, _}}:
322          +half = Nat.div(List.length(&2, MemTable.Entry, entries), 2n)
323          left right = hash_idxs_par_go(f, List.take(&2, MemTable.Entry, entries, half), size) hash_idxs_par_go(f, List.drop(&2, MemTable.Entry, entries, half), size)
324          List.append(&2, Nat, left, right)
325
326# Parallel entry point; fuel covers the fork depth.
327def hash_idxs_par(+entries: List<&2, MemTable.Entry>, +size: Nat) -> List<&2, Nat>:
328  +fuel = List.length(&2, MemTable.Entry, entries)
329  hash_idxs_par_go(fuel, entries, size)
330
331# Sequential assembly of one filter from a flat index list.
332def bloom_set_all(+idxs: List<&2, Nat>, +blm: Bloom) -> Bloom:
333  match idxs:
334    case Nil{}:
335      blm
336    case Con{+i, t}:
337      match blm:
338        case Blm{bits, +size}:
339          bloom_set_all(t, Blm{bit_set(i, bits), size})
340
341# Hybrid build: parallel hashing, single-tree assembly.
342def bloom_of_hybrid(+entries: List<&2, MemTable.Entry>, +nb: Nat) -> Bloom:
343  bloom_set_all(hash_idxs_par(entries, nb), bloom_new(nb))
344
345# Size-gated entry point: sequential on both sides of the gate.
346# MEASURED VERDICT (2026-09-26, 1M interleaved A/B, Darwin arm64, Bend 2.0.28):
347# sequential median 225105 ms (4442 ops/s) vs 4-chunk 249150 ms (4014 ops/s)
348# vs hybrid 261746 ms — the parallel builds cost ~10% (fork/join plus
349# allocation overhead dominates; invisible at 100k, compounds over 244
350# flushes). Confirmed under two machine states (all arms shifted +15k ms on
351# the slow day; relative order unchanged). The parallel builds stay as
352# law-covered spikes; the `!` variants stay opt-in behind the GPU recovery
353# crash documented below.
354def bloom_pick(small: Bool, +entries: List<&2, MemTable.Entry>, +nb: Nat) -> Bloom:
355  match small:
356    case True{}:
357      bloom_of(entries, bloom_new(nb))
358    case False{}:
359      bloom_of(entries, bloom_new(nb))
360
361def bloom_of_auto(+entries: List<&2, MemTable.Entry>, +nb: Nat) -> Bloom:
362  bloom_pick(Nat.is_lt(List.length(&2, MemTable.Entry, entries), 2048n), entries, nb)
363
364def metadata_tail(entries: List<&2, MemTable.Entry>, +first: String, last: String, +count: Nat) -> Metadata:
365  match entries:
366    case Nil{}:
367      Meta{Some{first}, Some{last}, count}
368    case Con{MemTable.Entry{key, val}, rest}:
369      metadata_tail(rest, first, key, Nat.add(count, 1n))
370
371def metadata(entries: List<&2, MemTable.Entry>) -> Metadata:
372  match entries:
373    case Nil{}:
374      Meta{None{}, None{}, 0n}
375    case Con{MemTable.Entry{+key, val}, rest}:
376      metadata_tail(rest, key, key, 1n)
377
378def from_sorted_unique_est_meta(+entries: List<&2, MemTable.Entry>, level: Nat, est_keys: Nat, meta: Metadata) -> Table:
379  match meta:
380    case Meta{smallest, largest, count}:
381      +nb = bloom_bits(level, est_keys)
382      Tbl{entries, bloom_of_auto(entries, nb), nb, smallest, largest, count, chunk(entries), block_keys(chunk(entries), Nil{})}
383
384def from_sorted_unique_meta(+entries: List<&2, MemTable.Entry>, level: Nat, meta: Metadata) -> Table:
385  match meta:
386    case Meta{smallest, largest, +count}:
387      +nb = bloom_bits(level, count)
388      Tbl{entries, bloom_of_auto(entries, nb), nb, smallest, largest, count, chunk(entries), block_keys(chunk(entries), Nil{})}
389
390def from_sorted_unique(+entries: List<&2, MemTable.Entry>, level: Nat) -> Table:
391  from_sorted_unique_meta(entries, level, metadata(entries))
392
393def build_sorted(+entries: List<&2, MemTable.Entry>, level: Nat, est_keys: Nat) -> Table:
394  from_sorted_unique_est_meta(entries, level, est_keys, metadata(entries))
395
396def build(+entries: List<&2, MemTable.Entry>, level: Nat, est_keys: Nat) -> Table:
397  +effective = Nat.max(est_keys, List.length(&2, MemTable.Entry, entries))
398  build_sorted(SortedRun.sort_newest(entries), level, effective)
399
400# Metadata accessors keep clients independent of the Tbl field layout.
401def table_smallest(tab: Table) -> Maybe<&2, String>:
402  match tab:
403    case Tbl{entries, filter, nbits, smallest, largest, count, chunks, blocks}:
404      smallest
405
406def table_largest(tab: Table) -> Maybe<&2, String>:
407  match tab:
408    case Tbl{entries, filter, nbits, smallest, largest, count, chunks, blocks}:
409      largest
410
411def table_count(tab: Table) -> Nat:
412  match tab:
413    case Tbl{entries, filter, nbits, smallest, largest, count, chunks, blocks}:
414      count
415
416# Total table identity for the read cache: smallest|largest|count.
417def opt_name(+opt: Maybe<&2, String>) -> String:
418  match opt:
419    case None{}:
420      "<none>"
421    case Some{s}:
422      s
423
424def table_id(tab: Table) -> String:
425  match tab:
426    case Tbl{entries, filter, nbits, smallest, largest, count, chunks, blocks}:
427      opt_name(smallest) ++ "|" ++ opt_name(largest) ++ "|" ++ Nat.show(count)
428
429# Open bridge: metadata is matched explicitly so the Bloom schedule projects
430# through `build` without relying on reduction of an open entry list.
431def table_bits_from_meta(
432  +entries: List<&2, MemTable.Entry>,
433  level: Nat,
434  est_keys: Nat,
435  meta: Metadata,
436) -> {table_bits(from_sorted_unique_est_meta(entries, level, est_keys, meta)) == bloom_bits(level, est_keys) : Nat}:
437  match meta:
438    case Meta{smallest, largest, count}:
439      {==}
440
441def table_bits_build(
442  +entries: List<&2, MemTable.Entry>,
443  level: Nat,
444  est_keys: Nat,
445) -> {table_bits(build(entries, level, est_keys)) == bloom_bits(level, Nat.max(est_keys, List.length(&2, MemTable.Entry, entries))) : Nat}:
446  +effective = Nat.max(est_keys, List.length(&2, MemTable.Entry, entries))
447  table_bits_from_meta(SortedRun.sort_newest(entries), level, effective, metadata(SortedRun.sort_newest(entries)))
448
449
450def ranges_disjoint(+ta: Table, +tb: Table) -> Bool:
451  SortedRun.range_disjoint_bounds(table_smallest(ta), table_largest(ta), table_smallest(tb), table_largest(tb))
452
453def size_of(bloom: Bloom) -> Nat:
454  match bloom:
455    case Blm{bits, size}:
456      size
457
458# Bloom insertions only set tree bits and preserve the exact logical size.
459def bloom_size_stable(entries: List<&2, MemTable.Entry>, bits: BitTree.BitTree, +size: Nat) -> {size_of(bloom_of(entries, Blm{bits, size})) == size : Nat}:
460  match entries:
461    case Nil{}:
462      {==}
463    case Con{e, t}:
464      match e:
465        case MemTable.Entry{+key, val}:
466          bloom_size_stable(t, bit_set(bhash_n(key, 17, size), bit_set(bhash_n(key, 257, size), bits)), size)
467
468# Block-scoped lookup. Checker constraints (proven by scratch experiments):
469# multi-scrutinee matches follow declaration order, and only Nat answers flow
470# through match arms — so selection returns an index, picked up by chunk_at_go.
471def pick_ix(bt: List<&2, String>, ct: List<&2, List<&2, MemTable.Entry>>, ix: Nat, +key: String, order: Cmp) -> Nat:
472  match bt ct order:
473    case _ _ GT{}:
474      Nat.sub(ix, 1n)
475    case Nil{} _ _:
476      ix
477    case _ Nil{} _:
478      ix
479    case Con{b2, bt2} Con{d2, ct2} LT{}:
480      pick_ix(bt2, ct2, Nat.add(ix, 1n), key, Keys.cmp(b2, key))
481    case Con{b2, bt2} Con{d2, ct2} EQ{}:
482      ix
483
484def chunk_at_go(fuel: Nat, +chunks: List<&2, List<&2, MemTable.Entry>>, ix: Nat) -> List<&2, MemTable.Entry>:
485  match fuel chunks:
486    case 0n _:
487      Nil{}
488    case 1n+f Nil{}:
489      Nil{}
490    case 1n+f Con{c, t}:
491      match ix:
492        case 0n:
493          c
494        case 1n+p:
495          chunk_at_go(f, t, p)
496
497def chunk_at(+chunks: List<&2, List<&2, MemTable.Entry>>, ix: Nat) -> List<&2, MemTable.Entry>:
498  chunk_at_go(List.length(&2, List<&2, MemTable.Entry>, chunks), chunks, ix)
499
500def block_scan(+chunk: List<&2, MemTable.Entry>, +key: String) -> Maybe<&2, String>:
501  MemTable.get(MemTable.MT{chunk}, key)
502
503def block_pick(+chunks: List<&2, List<&2, MemTable.Entry>>, +blocks: List<&2, String>, +key: String) -> List<&2, MemTable.Entry>:
504  match chunks blocks:
505    case Nil{} _:
506      Nil{}
507    case _ Nil{}:
508      Nil{}
509    case Con{c, ct} Con{b, bt}:
510      chunk_at(chunks, pick_ix(bt, ct, 0n, key, Keys.cmp(b, key)))
511
512def block_finish(picked: List<&2, MemTable.Entry>, +entries: List<&2, MemTable.Entry>, +key: String) -> Maybe<&2, String>:
513  match picked:
514    case Nil{}:
515      MemTable.get(MemTable.MT{entries}, key)
516    case Con{e, t}:
517      block_scan(Con{e, t}, key)
518
519def hit_finish(picked: List<&2, MemTable.Entry>, +entries: List<&2, MemTable.Entry>, +key: String) -> Maybe<&2, Maybe<&2, String>>:
520  match picked:
521    case Nil{}:
522      MemTable.get_hit(MemTable.MT{entries}, key)
523    case Con{e, t}:
524      MemTable.get_hit(MemTable.MT{Con{e, t}}, key)
525
526def block_get_hit(tab: Table, +key: String) -> Maybe<&2, Maybe<&2, String>>:
527  match tab:
528    case Tbl{entries, filter, nbits, smallest, largest, count, chunks, blocks}:
529      hit_finish(block_pick(chunks, blocks, key), entries, key)
530
531def block_get(tab: Table, +key: String) -> Maybe<&2, String>:
532  match tab:
533    case Tbl{entries, filter, nbits, smallest, largest, count, chunks, blocks}:
534      block_finish(block_pick(chunks, blocks, key), entries, key)
535
536def lookup(tab: Table, +key: String) -> Maybe<&2, String>:
537  block_get(tab, key)