Skip to main content

rete_core/
dict.rs

1//! Front-coded, restart-indexed dictionary sections (SPEC.md §5.1).
2//!
3//! A section holds the UTF-8 terms of one kind (shared / subjects / objects /
4//! predicates / graphs), sorted and assigned dense 1-based IDs. Terms are stored
5//! in runs of `R`; each run starts with a full term and front-codes the rest.
6
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9
10use crate::chunk_cache::{CacheEntry, ChunkCache};
11use crate::varint::{read_uvarint, write_uvarint};
12
13/// Default restart interval: a full term every `R` entries.
14pub const DEFAULT_RESTART_INTERVAL: u32 = 16;
15
16/// Reserved ID meaning "no such term".
17pub const ABSENT: u32 = 0;
18
19/// How many not-yet-resident chunks a windowed resolution
20/// ([`ChunkedSection::decode_and_walk`]) faults concurrently in one group. It
21/// bounds the transient set of decoded bodies held at once (this many `Arc`s,
22/// each ≤ one chunk), so the parallel decode's memory is a small constant on top
23/// of the `--memory-budget-mb` cache, independent of the window size or budget.
24/// Also the group size, so a larger value means fewer (larger) parallel
25/// dispatches — amortizing the per-dispatch cost on the tight-budget path where
26/// real decoding happens.
27#[cfg(feature = "parallel")]
28const PAR_DECODE_FANOUT: usize = 32;
29
30/// A small, dedicated rayon pool for parallel chunk decode. Separate from (and
31/// smaller than) the global pool so the number of decode worker threads — and
32/// thus their allocator arenas, the dominant driver of the resident-set bump
33/// under parallel decode — is a small constant, not the machine's full core
34/// count. Decode is memory-bandwidth/CPU bound and a handful of threads saturate
35/// it, so this bounds peak RSS without materially costing throughput. Built once.
36#[cfg(feature = "parallel")]
37fn decode_pool() -> &'static rayon::ThreadPool {
38    use std::sync::OnceLock;
39    static POOL: OnceLock<rayon::ThreadPool> = OnceLock::new();
40    POOL.get_or_init(|| {
41        let n = std::thread::available_parallelism()
42            .map(|n| n.get())
43            .unwrap_or(4)
44            .clamp(1, 8);
45        rayon::ThreadPoolBuilder::new()
46            .num_threads(n)
47            .thread_name(|i| format!("rete-decode-{i}"))
48            .build()
49            .expect("build dedicated chunk-decode thread pool")
50    })
51}
52
53#[derive(Debug, thiserror::Error)]
54pub enum DictError {
55    #[error("malformed dictionary section: {0}")]
56    Malformed(&'static str),
57}
58
59/// Build a dictionary section from terms (any order; sorted + deduped here).
60#[derive(Default)]
61pub struct DictSectionBuilder {
62    terms: Vec<String>,
63    restart_interval: u32,
64}
65
66/// Terms per restart run, overridable via `RETE_DICT_RESTART_INTERVAL`.
67///
68/// The interval sets random-access granularity as well as front-coding gain:
69/// chunks are cut on whole-run boundaries, so with very large literals (e.g.
70/// base64-embedded images) a single run dwarfs the 64 KiB chunk budget and one
71/// term lookup drags in ~`r` terms' worth of bytes. Setting `1` makes every term
72/// its own restart — direct seek, larger restart table, no front-coding (which
73/// is worthless for base64 anyway, since such terms share no prefix).
74pub fn env_restart_interval() -> u32 {
75    static R: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
76    *R.get_or_init(|| {
77        std::env::var("RETE_DICT_RESTART_INTERVAL")
78            .ok()
79            .and_then(|v| v.parse::<u32>().ok())
80            .filter(|&v| v >= 1)
81            .unwrap_or(DEFAULT_RESTART_INTERVAL)
82    })
83}
84
85impl DictSectionBuilder {
86    pub fn new() -> Self {
87        Self {
88            terms: Vec::new(),
89            restart_interval: env_restart_interval(),
90        }
91    }
92
93    pub fn with_restart_interval(mut self, r: u32) -> Self {
94        assert!(r >= 1, "restart interval must be >= 1");
95        self.restart_interval = r;
96        self
97    }
98
99    pub fn push(&mut self, term: impl Into<String>) {
100        self.terms.push(term.into());
101    }
102
103    /// Serialize to bytes. Terms are sorted and deduped; the resulting IDs are
104    /// `1..=n` in sorted order.
105    pub fn build(mut self) -> Vec<u8> {
106        self.terms.sort_unstable();
107        self.terms.dedup();
108        let r = self.restart_interval as usize;
109        let n = self.terms.len();
110        let num_restarts = n.div_ceil(r);
111
112        // Encode body and capture each run's starting offset (relative to body).
113        let mut body = Vec::new();
114        let mut restart_offsets = Vec::with_capacity(num_restarts);
115        let mut prev = "";
116        for (i, term) in self.terms.iter().enumerate() {
117            if i % r == 0 {
118                restart_offsets.push(body.len() as u64);
119                // restart entry: shared = 0, full term
120                write_uvarint(&mut body, 0);
121                write_uvarint(&mut body, term.len() as u64);
122                body.extend_from_slice(term.as_bytes());
123            } else {
124                let shared = common_prefix_len(prev, term);
125                let suffix = &term.as_bytes()[shared..];
126                write_uvarint(&mut body, shared as u64);
127                write_uvarint(&mut body, suffix.len() as u64);
128                body.extend_from_slice(suffix);
129            }
130            prev = term;
131        }
132
133        // header || restart-offset table || body
134        let mut out = Vec::new();
135        write_uvarint(&mut out, n as u64);
136        write_uvarint(&mut out, self.restart_interval as u64);
137        write_uvarint(&mut out, num_restarts as u64);
138        for off in &restart_offsets {
139            write_uvarint(&mut out, *off);
140        }
141        out.extend_from_slice(&body);
142        out
143    }
144}
145
146/// Parsed section metadata (the restart table) — cache this once and reuse it
147/// across lookups instead of re-parsing the section header every time.
148#[derive(Debug, Clone)]
149pub struct SectionMeta {
150    pub term_count: u32,
151    pub restart_interval: u32,
152    /// Absolute offsets into the section bytes for each run start.
153    ///
154    /// `u64`, not `usize`: a section may exceed 4 GiB and wasm32 is a 32-bit
155    /// target, so a `usize` offset silently truncates there (see #70). A
156    /// dictionary carrying embedded media is exactly this case — WikiArt's is
157    /// 23.4 GB.
158    pub restart_offsets: Vec<u64>,
159}
160
161/// Parse only the header/restart table of a section.
162pub fn parse_meta(bytes: &[u8]) -> Result<SectionMeta, DictError> {
163    let mut pos = 0;
164    let take = |pos: &mut usize| -> Result<u64, DictError> {
165        let (v, n) =
166            read_uvarint(&bytes[*pos..]).ok_or(DictError::Malformed("truncated header"))?;
167        *pos += n;
168        Ok(v)
169    };
170    let term_count = take(&mut pos)? as u32;
171    let restart_interval = take(&mut pos)? as u32;
172    let num_restarts = take(&mut pos)? as usize;
173    if restart_interval == 0 {
174        return Err(DictError::Malformed("zero restart interval"));
175    }
176    // `num_restarts` is untrusted; each restart is ≥1 byte, so cap the
177    // pre-allocation at the buffer length to avoid an OOM on a bogus count.
178    let mut rel = Vec::with_capacity(num_restarts.min(bytes.len()));
179    for _ in 0..num_restarts {
180        rel.push(take(&mut pos)?);
181    }
182    let body_start = pos as u64;
183    Ok(SectionMeta {
184        term_count,
185        restart_interval,
186        restart_offsets: rel.into_iter().map(|o| body_start + o).collect(),
187    })
188}
189
190/// Decode the front-coded entry at `pos` *into* `buf`: truncate `buf` to the
191/// entry's shared-prefix length and append its suffix bytes. For a restart
192/// entry the stored shared length is 0, so the same decode works for both
193/// entry kinds. Returns the next entry's position; `None` on malformed bytes
194/// (including a shared length longer than the previous term). Allocation-free
195/// after `buf`'s first growth — this is the hot path of every term resolution.
196#[inline]
197fn entry_into(bytes: &[u8], pos: usize, buf: &mut Vec<u8>) -> Option<usize> {
198    let (shared, n1) = read_uvarint(bytes.get(pos..)?)?;
199    let p = pos + n1;
200    let (suf, n2) = read_uvarint(bytes.get(p..)?)?;
201    let start = p + n2;
202    let end = start
203        .checked_add(suf as usize)
204        .filter(|&e| e <= bytes.len())?;
205    if shared as usize > buf.len() {
206        return None;
207    }
208    buf.truncate(shared as usize);
209    buf.extend_from_slice(&bytes[start..end]);
210    Some(end)
211}
212
213/// Decode a run's restart (full-term) entry at `off` into `buf`.
214fn run_entry_into(bytes: &[u8], off: usize, buf: &mut Vec<u8>) -> Option<usize> {
215    buf.clear(); // a restart entry stands alone; stale prefix bytes must not leak
216    entry_into(bytes, off, buf)
217}
218
219/// Resolve `id` (1-based) to its term using cached metadata. Returns `None` on
220/// any inconsistency in untrusted metadata/bytes rather than panicking.
221pub fn section_term(bytes: &[u8], meta: &SectionMeta, id: u32) -> Option<String> {
222    if id == ABSENT || id > meta.term_count {
223        return None;
224    }
225    let idx = (id - 1) as usize;
226    let run = idx / meta.restart_interval as usize;
227    let steps = idx % meta.restart_interval as usize;
228    let mut buf = Vec::new();
229    let mut pos = run_entry_into(bytes, *meta.restart_offsets.get(run)? as usize, &mut buf)?;
230    for _ in 0..steps {
231        pos = entry_into(bytes, pos, &mut buf)?;
232    }
233    Some(String::from_utf8_lossy(&buf).into_owned())
234}
235
236/// Resolve `term` to its ID using cached metadata.
237pub fn section_id(bytes: &[u8], meta: &SectionMeta, term: &str) -> Option<u32> {
238    let mut buf = Vec::new();
239    // Binary search restart runs by their first (full) term.
240    let mut lo = 0usize;
241    let mut hi = meta.restart_offsets.len();
242    while lo < hi {
243        let mid = (lo + hi) / 2;
244        run_entry_into(bytes, meta.restart_offsets[mid] as usize, &mut buf)?;
245        if buf.as_slice() <= term.as_bytes() {
246            lo = mid + 1;
247        } else {
248            hi = mid;
249        }
250    }
251    if lo == 0 {
252        return None; // smaller than every term
253    }
254    let run = lo - 1;
255    let mut pos = run_entry_into(bytes, meta.restart_offsets[run] as usize, &mut buf)?;
256    let base_id = (run * meta.restart_interval as usize) as u32 + 1;
257    // saturating_sub: corrupt metadata where run*interval > term_count must not
258    // underflow-panic.
259    let run_len = meta.restart_interval.min(
260        meta.term_count
261            .saturating_sub(run as u32 * meta.restart_interval),
262    );
263    for step in 0..run_len {
264        if buf.as_slice() == term.as_bytes() {
265            return Some(base_id + step);
266        }
267        if buf.as_slice() > term.as_bytes() {
268            return None;
269        }
270        if step + 1 < run_len {
271            pos = entry_into(bytes, pos, &mut buf)?;
272        }
273    }
274    None
275}
276
277// --- chunked sections ---------------------------------------------------------
278//
279// A section's body is split (on run boundaries) into **chunks** that are
280// compressed and fetched independently (format v0.2), so a remote client
281// resolves a term with one chunk fault instead of downloading the whole
282// section. Local sections are the degenerate case: one pre-set chunk holding
283// the entire serialized section — a single code path serves both.
284
285/// Fetches one chunk's decompressed body slice on demand (`None` = the fetch
286/// failed; the section records it and the lookup misses — callers over remote
287/// data must check [`ChunkedSection::load_incomplete`] after evaluating).
288pub type ChunkLoader = Box<dyn Fn(usize) -> Option<Vec<u8>> + Send + Sync>;
289
290/// Fetches **many** chunks in one round trip: given ascending chunk indices,
291/// returns each chunk's decompressed body in the same order. The ranged
292/// reader implements this by coalescing byte-adjacent chunk ranges into
293/// single range reads — a full-dictionary sweep (export, dump) costs a few
294/// requests per section instead of one per chunk. `None` = the batch failed;
295/// callers fall back to the per-chunk [`ChunkLoader`].
296pub type ChunkBulkLoader = Box<dyn Fn(&[usize]) -> Option<Vec<Vec<u8>>> + Send + Sync>;
297
298/// Fetches chunks of **several sections** of one dictionary in one round trip:
299/// given `(section, chunk)` pairs, returns each chunk's decompressed body in
300/// the same order. Decoding a result touches every section (an IRI subject, a
301/// literal object, a predicate), and faulting them section by section cost one
302/// round trip per section on a remote file. The ranged reader issues the whole
303/// batch through one [`crate::RangeReader::read_many`]. `None` = the batch
304/// failed; the chunks stay unloaded for the per-chunk [`ChunkLoader`].
305pub type MultiChunkBulkLoader = Box<dyn Fn(&[(u8, usize)]) -> Option<Vec<Vec<u8>>> + Send + Sync>;
306
307/// One chunk: a run-aligned slice of the section body. `body_start` is the
308/// offset (in the section's coordinate space — the same space
309/// [`SectionMeta::restart_offsets`] uses) where `data[0]` sits.
310pub struct SectionChunk {
311    first_run: usize,
312    /// The chunk's **routing key** — the only thing the chunk-level binary
313    /// search in [`ChunkedSection::id`] compares against. It is a *separator*,
314    /// **not a term**:
315    ///
316    /// ```text
317    /// last_term(chunk i-1)  <  key(i)  <=  first_term(chunk i)
318    /// ```
319    ///
320    /// and chunk 0's key is empty (`b"" <= anything`, and nothing routes before
321    /// chunk 0 anyway). Writers store the *shortest* such string — see
322    /// [`shortest_separator`] — which is why a file's chunk directory is a few
323    /// bytes per chunk instead of a full copy of every boundary term.
324    ///
325    /// Files written before that change carry the boundary term verbatim; the
326    /// verbatim first term is the degenerate separator, so both read the same
327    /// way and no version check is needed. Nothing may reconstruct a term from
328    /// this field, compare it for equality with a term, or report it as one: a
329    /// key that is *not* a separator (a truncation, a hash) mis-routes silently
330    /// — `term(id)` and every dump keep working because they route by
331    /// `first_run`, and only `id(term)` lies.
332    ///
333    /// Unused (empty) for the single local chunk.
334    key: Vec<u8>,
335    body_start: u64,
336}
337
338impl SectionChunk {
339    /// A chunk descriptor: routing metadata only. The decompressed body (and
340    /// its derived run-offset table) lives in the section's [`ChunkCache`],
341    /// keyed by `(section_index, chunk_index)`, faulted in on first touch and
342    /// evictable — not held here. `key` is the routing separator, **not a
343    /// term** (see the `key` field).
344    pub fn new(first_run: usize, key: Vec<u8>, body_start: u64) -> Self {
345        SectionChunk {
346            first_run,
347            key,
348            body_start,
349        }
350    }
351}
352
353/// Scan a chunk's decompressed body for the byte offset of each run start (see
354/// [`SectionChunk::run_offsets`]). O(terms-in-chunk), run once per faulted chunk.
355fn chunk_run_offsets(data: &[u8], restart_interval: usize) -> Vec<usize> {
356    let mut offs = vec![0usize];
357    let mut pos = 0usize;
358    let mut buf = Vec::new();
359    let mut count = 0usize;
360    while pos < data.len() {
361        let Some(next) = entry_into(data, pos, &mut buf) else {
362            break;
363        };
364        count += 1;
365        pos = next;
366        if pos < data.len() && count.is_multiple_of(restart_interval) {
367            offs.push(pos);
368        }
369    }
370    offs
371}
372
373/// The first (full) term of the run starting at `off`, as raw bytes. `None`
374/// on malformed bytes.
375pub fn run_first_term(bytes: &[u8], off: usize) -> Option<Vec<u8>> {
376    let mut buf = Vec::new();
377    run_entry_into(bytes, off, &mut buf)?;
378    Some(buf)
379}
380
381/// The LAST term encoded in `bytes[run_off..end]`, where `run_off` is a run
382/// (restart) boundary and `end` is the end of that run's chunk. Walks the
383/// front-coded entries forward, so it decodes at most `restart_interval` of
384/// them. `None` on malformed bytes.
385///
386/// Writers use it for one thing: the previous chunk's last term, the lower
387/// bound of the next chunk's [`shortest_separator`].
388pub fn run_last_term(bytes: &[u8], run_off: usize, end: usize) -> Option<Vec<u8>> {
389    let lim = bytes.get(..end)?;
390    let mut buf = Vec::new();
391    let mut pos = run_entry_into(lim, run_off, &mut buf)?;
392    while pos < end {
393        pos = entry_into(lim, pos, &mut buf)?;
394    }
395    Some(buf)
396}
397
398/// The **shortest** byte string `s` with `prev_last < s <= first`: `first`
399/// truncated one byte past where it first differs from `prev_last`.
400///
401/// This is the routing key a chunk directory stores (`SectionChunk::key`).
402/// `first` is the chunk's first term and `prev_last`
403/// the previous chunk's last term, so `prev_last < first` holds for any sorted
404/// section; if it does not (malformed input), the full `first` is returned —
405/// always a valid separator, just not a short one.
406///
407/// It really is the shortest. Let `k` be the length of the common prefix of
408/// `prev_last` and `first`; the result has length `k + 1`. No shorter `s`
409/// exists: any `s` with `|s| <= k` shares that prefix's bytes with both, so it
410/// is either a prefix of `prev_last` (hence `<= prev_last`), or it first
411/// differs from the shared prefix — below it, making `s < prev_last`, or above
412/// it, making `s > first`. Every case is outside `(prev_last, first]`.
413pub fn shortest_separator(prev_last: &[u8], first: &[u8]) -> Vec<u8> {
414    let shared = prev_last
415        .iter()
416        .zip(first)
417        .take_while(|(a, b)| a == b)
418        .count();
419    if shared >= first.len() {
420        // `first` is a prefix of `prev_last`, i.e. `first <= prev_last`: not a
421        // sorted pair. Keep the whole term.
422        return first.to_vec();
423    }
424    first[..shared + 1].to_vec()
425}
426
427/// A dictionary section whose body is served chunk-by-chunk: metadata + chunk
428/// directory always present, chunk bytes local or faulted in on first touch.
429pub struct ChunkedSection {
430    meta: SectionMeta,
431    chunks: Vec<SectionChunk>,
432    loader: Option<ChunkLoader>,
433    bulk: Option<ChunkBulkLoader>,
434    failed: AtomicBool,
435    /// Decompressed chunk bodies (and their derived run tables) live here, keyed
436    /// by `(section_index, chunk_index)`, faulted in on first touch and
437    /// evictable. Shared across a [`Dictionary`]'s four sections. Phase 0 caps
438    /// it at unlimited, so residency is identical to the old per-chunk
439    /// `OnceLock`s.
440    cache: Arc<ChunkCache>,
441    /// This section's discriminant in the shared cache's key space (0 shared,
442    /// 1 subject-only, 2 object-only, 3 predicates).
443    section_index: u8,
444}
445
446impl ChunkedSection {
447    /// A local section: the whole serialized section (header + body) as one
448    /// resident chunk at coordinate 0, so the absolute restart offsets index it
449    /// directly. Malformed bytes degrade to an empty section (no panics on
450    /// untrusted files), matching the previous reader behavior. The body is
451    /// stored in `cache` at `(section_index, 0)` (unlimited cap ⇒ resident for
452    /// the section's lifetime, exactly as before).
453    pub fn local(section_bytes: Vec<u8>, cache: Arc<ChunkCache>, section_index: u8) -> Self {
454        let meta = parse_meta(&section_bytes).unwrap_or(SectionMeta {
455            term_count: 0,
456            restart_interval: 1,
457            restart_offsets: Vec::new(),
458        });
459        cache.insert((section_index, 0), Arc::from(section_bytes));
460        ChunkedSection {
461            meta,
462            chunks: vec![SectionChunk::new(0, Vec::new(), 0)],
463            loader: None,
464            bulk: None,
465            failed: AtomicBool::new(false),
466            cache,
467            section_index,
468        }
469    }
470
471    /// A section from parsed parts: metadata + chunk list, with an optional
472    /// loader for non-resident chunks (the remote lazy-open path) — resident
473    /// chunk lists (a locally-decoded chunked section) supply their bodies
474    /// through `resident` instead.
475    pub fn from_parts(
476        meta: SectionMeta,
477        chunks: Vec<SectionChunk>,
478        loader: Option<ChunkLoader>,
479        cache: Arc<ChunkCache>,
480        section_index: u8,
481    ) -> Self {
482        ChunkedSection {
483            meta,
484            chunks,
485            loader,
486            bulk: None,
487            failed: AtomicBool::new(false),
488            cache,
489            section_index,
490        }
491    }
492
493    /// A section whose chunk bodies are already decoded (the local chunked-file
494    /// open path): the descriptors and their bodies arrive together and the
495    /// bodies are seeded into the cache. No loader — every chunk is resident.
496    pub fn resident(
497        meta: SectionMeta,
498        chunks: Vec<(SectionChunk, Vec<u8>)>,
499        cache: Arc<ChunkCache>,
500        section_index: u8,
501    ) -> Self {
502        let descriptors = chunks
503            .into_iter()
504            .enumerate()
505            .map(|(ci, (chunk, body))| {
506                cache.insert((section_index, ci as u32), Arc::from(body));
507                chunk
508            })
509            .collect();
510        ChunkedSection {
511            meta,
512            chunks: descriptors,
513            loader: None,
514            bulk: None,
515            failed: AtomicBool::new(false),
516            cache,
517            section_index,
518        }
519    }
520
521    /// Attach a batched chunk fetcher (see [`ChunkBulkLoader`]): full-section
522    /// sweeps ([`prefetch_all`](Self::prefetch_all)) go through it instead of
523    /// faulting chunk by chunk.
524    pub fn with_bulk_loader(mut self, bulk: ChunkBulkLoader) -> Self {
525        self.bulk = Some(bulk);
526        self
527    }
528
529    /// Batch-fault every unloaded chunk through the bulk loader, if one is
530    /// attached and at least two chunks are missing. Callers about to sweep
531    /// the whole section (export/dump term resolution) call this once; a
532    /// failed batch leaves the chunks unloaded for the per-chunk loader to
533    /// retry (and record failures) lookup by lookup.
534    pub fn prefetch_all(&self) {
535        self.prefetch_chunks(&(0..self.chunks.len()).collect::<Vec<_>>());
536    }
537
538    /// Batch-fault a *specific* set of chunks (the subset a bounded query's
539    /// output touches) through the bulk loader, coalescing byte-adjacent ranges
540    /// into single reads. `cis` should be ascending and deduped; chunks already
541    /// resident are skipped. Like [`prefetch_all`](Self::prefetch_all) a single
542    /// missing chunk is left for the per-chunk loader, and a failed batch leaves
543    /// the chunks unloaded for that loader to retry (and record failures).
544    pub fn prefetch_chunks(&self, cis: &[usize]) {
545        let Some(bulk) = &self.bulk else { return };
546        let missing: Vec<usize> = cis
547            .iter()
548            .copied()
549            .filter(|&ci| {
550                ci < self.chunks.len() && !self.cache.contains((self.section_index, ci as u32))
551            })
552            .collect();
553        if missing.len() < 2 {
554            return;
555        }
556        if let Some(bodies) = bulk(&missing) {
557            if bodies.len() == missing.len() {
558                for (&ci, body) in missing.iter().zip(bodies) {
559                    self.cache
560                        .insert((self.section_index, ci as u32), Arc::from(body));
561                }
562            }
563        }
564    }
565
566    /// The chunks of `cis` a batch would still have to fetch: in range, not
567    /// resident, and only on a section that faults chunks in (a local section
568    /// has none to fetch). Order is kept.
569    pub(crate) fn missing_chunks(&self, cis: &[usize]) -> Vec<usize> {
570        if self.loader.is_none() {
571            return Vec::new();
572        }
573        cis.iter()
574            .copied()
575            .filter(|&ci| {
576                ci < self.chunks.len() && !self.cache.contains((self.section_index, ci as u32))
577            })
578            .collect()
579    }
580
581    /// Seed one batch-fetched chunk body into the cache.
582    pub(crate) fn seed_chunk(&self, ci: usize, body: Vec<u8>) {
583        self.cache
584            .insert((self.section_index, ci as u32), Arc::from(body));
585    }
586
587    pub fn meta(&self) -> &SectionMeta {
588        &self.meta
589    }
590
591    pub fn term_count(&self) -> u32 {
592        self.meta.term_count
593    }
594
595    /// The shared chunk cache backing this section (all four sections of a
596    /// [`crate::dictionary::Dictionary`] share one instance) — the handle the
597    /// bounded-export budget (phase 2) sets a byte cap on.
598    pub(crate) fn cache(&self) -> &Arc<ChunkCache> {
599        &self.cache
600    }
601
602    /// Heap bytes this section holds for as long as it is open, independent
603    /// of the chunk cache: the chunk directory (one descriptor plus its
604    /// routing key per chunk), the per-chunk byte ranges the ranged loaders
605    /// share, and any eager restart-offset table. Grows with the chunk count,
606    /// never with what a dump touches.
607    pub fn directory_bytes(&self) -> u64 {
608        let n = self.chunks.len() as u64;
609        let keys: u64 = self.chunks.iter().map(|c| c.key.capacity() as u64).sum();
610        let ranges = if self.loader.is_some() { 16 * n } else { 0 };
611        n * std::mem::size_of::<SectionChunk>() as u64
612            + keys
613            + ranges
614            + self.meta.restart_offsets.capacity() as u64 * 8
615    }
616
617    /// Did any chunk fetch fail since this section was opened — or since the
618    /// last [`reset_load_failure`](Self::reset_load_failure)?
619    pub fn load_incomplete(&self) -> bool {
620        self.failed.load(Ordering::Relaxed)
621    }
622
623    /// Forget recorded fetch failures (see `GraphIndex::reset_load_failure` —
624    /// the per-query reset for resident sessions). Failed chunks were never
625    /// cached, so the next resolution retries them.
626    pub fn reset_load_failure(&self) {
627        self.failed.store(false, Ordering::Relaxed);
628    }
629
630    /// The cache entry for chunk `ci`, faulting its body in through the loader
631    /// on a miss. Returns an owned [`CacheEntry`] handle (an `Arc`) the caller
632    /// holds for the duration of one decode — valid even if a later eviction
633    /// drops the map slot (the handout invariant).
634    ///
635    /// A FAILED fetch records the failure and caches nothing (returns `None`),
636    /// so a later resolution retries the chunk — a transient network error must
637    /// not permanently poison a resident session. A local section with no
638    /// loader returns an empty body for a missing chunk, matching the old
639    /// `get_or_init(Vec::new)` behavior.
640    fn chunk_entry(&self, ci: usize) -> Option<Arc<CacheEntry>> {
641        let key = (self.section_index, ci as u32);
642        if let Some(e) = self.cache.get(key) {
643            return Some(e);
644        }
645        match &self.loader {
646            Some(load) => match load(ci) {
647                Some(bytes) => Some(self.cache.insert(key, Arc::from(bytes))),
648                None => {
649                    self.failed.store(true, Ordering::Relaxed);
650                    None
651                }
652            },
653            None => Some(self.cache.insert(key, Arc::from(Vec::new()))),
654        }
655    }
656
657    /// The chunk holding `run` (chunks ascend by `first_run`; the first chunk
658    /// always starts at run 0).
659    fn chunk_of_run(&self, run: usize) -> Option<usize> {
660        let i = self.chunks.partition_point(|c| c.first_run <= run);
661        i.checked_sub(1)
662    }
663
664    /// The chunk index holding term `id` (1-based, section-local), or `None` if
665    /// out of range. Mirrors the run→chunk math in [`term`](Self::term) so a
666    /// caller can group a set of output ids by chunk and batch-prefetch them.
667    pub fn chunk_of_id(&self, id: u32) -> Option<usize> {
668        if id == ABSENT || id > self.meta.term_count {
669            return None;
670        }
671        let run = (id - 1) as usize / self.meta.restart_interval as usize;
672        self.chunk_of_run(run)
673    }
674
675    /// The byte offset (into chunk `ci`'s decompressed body) of `run`.
676    /// Full/local sections use the section-wide restart table (unchanged
677    /// behavior); a *lite* remote section (empty `restart_offsets`) derives it
678    /// from the chunk itself — that per-chunk run table is cached inside the
679    /// `entry` (evicted with the body), so the open never holds the whole table.
680    fn run_off_in_chunk(
681        &self,
682        ci: usize,
683        run: usize,
684        entry: &CacheEntry,
685        ri: usize,
686    ) -> Option<usize> {
687        let chunk = &self.chunks[ci];
688        if self.meta.restart_offsets.is_empty() {
689            entry
690                .runs(|b| chunk_run_offsets(b, ri))
691                .get(run.checked_sub(chunk.first_run)?)
692                .copied()
693        } else {
694            self.meta
695                .restart_offsets
696                .get(run)?
697                .checked_sub(chunk.body_start)
698                .map(|o| o as usize)
699        }
700    }
701
702    /// One past the last run index held by chunk `ci` (its run range is
703    /// `[first_run, run_end)`).
704    fn run_end_of_chunk(&self, ci: usize, entry: &CacheEntry, ri: usize) -> usize {
705        if let Some(next) = self.chunks.get(ci + 1) {
706            return next.first_run;
707        }
708        let chunk = &self.chunks[ci];
709        if self.meta.restart_offsets.is_empty() {
710            chunk.first_run + entry.runs(|b| chunk_run_offsets(b, ri)).len()
711        } else {
712            self.meta.restart_offsets.len()
713        }
714    }
715
716    /// Resolve `id` (1-based) to its term. One chunk fault at most.
717    /// Chunk index holding term `id` — [`term`](Self::term)'s routing without
718    /// faulting the chunk. `None` for an absent/out-of-range id.
719    pub(crate) fn chunk_of_term(&self, id: u32) -> Option<usize> {
720        if id == ABSENT || id > self.meta.term_count {
721            return None;
722        }
723        self.chunk_of_run((id - 1) as usize / self.meta.restart_interval as usize)
724    }
725
726    pub fn term(&self, id: u32) -> Option<String> {
727        if id == ABSENT || id > self.meta.term_count {
728            return None;
729        }
730        let idx = (id - 1) as usize;
731        let ri = self.meta.restart_interval as usize;
732        let run = idx / ri;
733        let steps = idx % ri;
734        let ci = self.chunk_of_run(run)?;
735        let entry = self.chunk_entry(ci)?;
736        let bytes: &[u8] = entry.body();
737        let off = self.run_off_in_chunk(ci, run, &entry, ri)?;
738        let mut buf = Vec::new();
739        let mut pos = run_entry_into(bytes, off, &mut buf)?;
740        for _ in 0..steps {
741            pos = entry_into(bytes, pos, &mut buf)?;
742        }
743        Some(String::from_utf8_lossy(&buf).into_owned())
744    }
745
746    /// Resolve many ids of THIS section in one chunk-ordered, single-pass walk,
747    /// filling `out[slot]` for each `(id, slot)` in `jobs`.
748    ///
749    /// `jobs` is sorted in place by id. Because chunks and runs are *contiguous
750    /// ascending* id ranges, sorting by id also groups the jobs by chunk and by
751    /// run — so each chunk is faulted **once** and each run walked **once**
752    /// forward, picking off every requested id as the decode passes its
753    /// position. This is where the redundant per-id re-walk that
754    /// [`term`](Self::term) does (decode from the run start for *every* id)
755    /// disappears: within a run the walk advances monotonically across all the
756    /// window's ids that live there.
757    ///
758    /// For any id this fills `out[slot]` with exactly what [`term`](Self::term)
759    /// returns for that id (the same decoded bytes, the same
760    /// `String::from_utf8_lossy`), and leaves `out[slot]` untouched where
761    /// `term(id)` is `None` — an absent/out-of-range id, a failed chunk fault,
762    /// or malformed bytes. Duplicate ids each fill their own slot. It is the
763    /// batch twin of `term`, used by the export window
764    /// ([`Dictionary::resolve_window`](crate::dictionary::Dictionary::resolve_window))
765    /// so a window's scattered object ids touch each chunk once instead of
766    /// re-faulting and re-walking per row.
767    ///
768    /// [`crate::dictionary::Dictionary::resolve_window`]: the sole caller.
769    pub fn resolve_into(&self, jobs: &mut [(u32, usize)], out: &mut [Option<String>]) {
770        let ri = self.meta.restart_interval as usize;
771        if ri == 0 {
772            return;
773        }
774        let tc = self.meta.term_count;
775        // Sort by id: chunks and runs are contiguous ascending id ranges, so
776        // this groups jobs by chunk and by run in one pass.
777        jobs.sort_unstable_by_key(|&(id, _)| id);
778        // Partition the sorted jobs into contiguous per-chunk segments
779        // `(chunk, [start, end))`. Every job of a chunk is one contiguous slice
780        // (chunks are ascending id ranges, `jobs` is id-sorted). Absent (id
781        // `ABSENT` = 0) and out-of-range (`id > tc`) ids sort to the ends and
782        // belong to no chunk — they form no segment, so their slots stay None,
783        // exactly as `term(id) == None` leaves them.
784        let mut segments: Vec<(usize, usize, usize)> = Vec::new();
785        let mut i = 0;
786        while i < jobs.len() {
787            let id0 = jobs[i].0;
788            if id0 == ABSENT || id0 > tc {
789                i += 1;
790                continue;
791            }
792            let Some(ci) = self.chunk_of_run((id0 - 1) as usize / ri) else {
793                i += 1;
794                continue;
795            };
796            let start = i;
797            i += 1;
798            while i < jobs.len() {
799                let id = jobs[i].0;
800                if id == ABSENT || id > tc {
801                    i += 1;
802                    continue;
803                }
804                if self.chunk_of_run((id - 1) as usize / ri) != Some(ci) {
805                    break; // first id of the next chunk
806                }
807                i += 1;
808            }
809            segments.push((ci, start, i));
810        }
811        if segments.is_empty() {
812            return;
813        }
814        self.decode_and_walk(&segments, jobs, out, ri, tc);
815    }
816
817    /// Fault each segment's chunk and fill its slots. The chunk decodes are
818    /// independent and CPU-bound (zstd), so with the `parallel` feature a group
819    /// whose chunks are **not yet resident** is decoded concurrently on a
820    /// bounded pool before its (serial) fill; without it, one at a time — the
821    /// exact behavior this refactor extracted. Either way each chunk is faulted
822    /// once and its slots filled in segment order, so the output is byte-for-byte
823    /// identical.
824    ///
825    /// **Parallel decode runs only where there is decode work.** A group whose
826    /// chunks are already resident (a generous `--memory-budget-mb` after the
827    /// window prefetch warmed them, or a chunk re-touched by a later window) is
828    /// walked serially with **no** parallel dispatch — so the single-pass /
829    /// large-budget export keeps its serial speed and the rayon fan-out only
830    /// pays for itself in the tight-budget regime, where the whole dictionary
831    /// does not fit and chunks are genuinely re-decoded. Skipping the dispatch
832    /// on cache-warm windows is what keeps a large budget from regressing.
833    ///
834    /// The bound matters for `--memory-budget-mb`: a decoding group holds at
835    /// most [`PAR_DECODE_FANOUT`] decoded bodies at once (a small constant,
836    /// independent of the window size or the budget) and each is held behind its
837    /// cache `Arc` for the group's fill — so even a budget that evicts a body
838    /// the moment it lands cannot force a re-decode before the fill consumes it.
839    /// The decode runs on a small dedicated pool ([`decode_pool`]), so the number
840    /// of worker threads (and their allocator arenas) is bounded regardless of
841    /// the machine's core count.
842    #[cfg(feature = "parallel")]
843    fn decode_and_walk(
844        &self,
845        segments: &[(usize, usize, usize)],
846        jobs: &[(u32, usize)],
847        out: &mut [Option<String>],
848        ri: usize,
849        tc: u32,
850    ) {
851        use rayon::prelude::*;
852        for group in segments.chunks(PAR_DECODE_FANOUT) {
853            let has_miss = group
854                .iter()
855                .any(|&(ci, _, _)| !self.cache.contains((self.section_index, ci as u32)));
856            if has_miss {
857                // Real decode work: fault this group's chunks in parallel on the
858                // bounded pool (order preserved so each entry lines up with its
859                // segment), then fill serially. A failed fault yields `None`
860                // (recorded in `self.failed`), same as the serial path.
861                let entries: Vec<Option<Arc<CacheEntry>>> = decode_pool().install(|| {
862                    group
863                        .par_iter()
864                        .map(|&(ci, _, _)| self.chunk_entry(ci))
865                        .collect()
866                });
867                for (&(ci, s, e), entry) in group.iter().zip(entries) {
868                    if let Some(entry) = entry {
869                        self.walk_chunk(ci, &entry, &jobs[s..e], out, ri, tc);
870                    }
871                }
872            } else {
873                // Cache-warm: no decode to parallelize, so avoid the dispatch.
874                for &(ci, s, e) in group {
875                    if let Some(entry) = self.chunk_entry(ci) {
876                        self.walk_chunk(ci, &entry, &jobs[s..e], out, ri, tc);
877                    }
878                }
879            }
880        }
881    }
882
883    /// Serial fallback for builds without the `parallel` feature (wasm, and
884    /// `--no-default-features`): fault and walk one chunk at a time.
885    #[cfg(not(feature = "parallel"))]
886    fn decode_and_walk(
887        &self,
888        segments: &[(usize, usize, usize)],
889        jobs: &[(u32, usize)],
890        out: &mut [Option<String>],
891        ri: usize,
892        tc: u32,
893    ) {
894        for &(ci, s, e) in segments {
895            if let Some(entry) = self.chunk_entry(ci) {
896                self.walk_chunk(ci, &entry, &jobs[s..e], out, ri, tc);
897            }
898        }
899    }
900
901    /// Fill the output slots owned by one chunk: `chunk_jobs` is the id-sorted
902    /// slice of `(id, slot)` jobs that all live in chunk `ci`, and `entry` is
903    /// that chunk's already-faulted body. Walks the chunk's front-coded runs
904    /// once each, forward, picking off every requested id as the decode passes
905    /// it — the single-pass twin of `term(id)`, writing exactly the bytes
906    /// `term(id)` would for each id and leaving a slot None where `term(id)` is
907    /// None (a malformed run entry, past which no larger id in that run decodes).
908    fn walk_chunk(
909        &self,
910        ci: usize,
911        entry: &CacheEntry,
912        chunk_jobs: &[(u32, usize)],
913        out: &mut [Option<String>],
914        ri: usize,
915        tc: u32,
916    ) {
917        let bytes: &[u8] = entry.body();
918        let mut k = 0;
919        while k < chunk_jobs.len() {
920            let id = chunk_jobs[k].0;
921            if id == ABSENT || id > tc {
922                k += 1;
923                continue;
924            }
925            let run = (id - 1) as usize / ri;
926            // Decode this run's restart entry. Any failure here means
927            // `term(id)` is None for the whole run — skip every job in it.
928            let run_start = self.run_off_in_chunk(ci, run, entry, ri).and_then(|off| {
929                let mut buf = Vec::new();
930                run_entry_into(bytes, off, &mut buf).map(|pos| (buf, pos))
931            });
932            let Some((mut buf, mut pos)) = run_start else {
933                while k < chunk_jobs.len() {
934                    let id = chunk_jobs[k].0;
935                    if id == ABSENT || id > tc {
936                        k += 1;
937                        continue;
938                    }
939                    if (id - 1) as usize / ri != run {
940                        break;
941                    }
942                    k += 1;
943                }
944                continue;
945            };
946            // `buf` holds the run's term #`cur_step`; advance monotonically.
947            let mut cur_step = 0usize;
948            let mut broken = false;
949            while k < chunk_jobs.len() {
950                let id = chunk_jobs[k].0;
951                if id == ABSENT || id > tc {
952                    k += 1;
953                    continue;
954                }
955                if (id - 1) as usize / ri != run {
956                    break; // next run
957                }
958                let steps = (id - 1) as usize % ri;
959                if !broken {
960                    while cur_step < steps {
961                        match entry_into(bytes, pos, &mut buf) {
962                            Some(next) => {
963                                pos = next;
964                                cur_step += 1;
965                            }
966                            None => {
967                                // Malformed past this point: `term(id)` is None
968                                // here and for every larger id in the run (they
969                                // all decode through this entry).
970                                broken = true;
971                                break;
972                            }
973                        }
974                    }
975                    if !broken && cur_step == steps {
976                        out[chunk_jobs[k].1] = Some(String::from_utf8_lossy(&buf).into_owned());
977                    }
978                }
979                k += 1;
980            }
981        }
982    }
983
984    /// The chunk [`id`](Self::id) would fault to look `term` up — its routing
985    /// on the (resident) chunk directory, without touching a chunk body. `None`
986    /// for an empty section, or a term that sorts before the first chunk.
987    pub(crate) fn chunk_for_term(&self, term: &str) -> Option<usize> {
988        match self.chunks.len() {
989            0 => None,
990            1 => Some(0),
991            _ => self
992                .chunks
993                .partition_point(|c| c.key.as_slice() <= term.as_bytes())
994                .checked_sub(1),
995        }
996    }
997
998    /// How many terms of this section sort (bytewise) before `bound`: the
999    /// routed chunk is the only one the boundary can lie in (every term of an
1000    /// earlier chunk sorts before its routing key, every term of a later one at
1001    /// or after the next key), so this faults that one chunk and binary-searches
1002    /// its ids. `None` when a chunk cannot be read.
1003    pub(crate) fn rank(&self, bound: &str) -> Option<u32> {
1004        let tc = self.meta.term_count;
1005        let ri = self.meta.restart_interval as usize;
1006        if tc == 0 || ri == 0 {
1007            return Some(0);
1008        }
1009        let Some(ci) = self.chunk_for_term(bound) else {
1010            return Some(0); // `bound` sorts before the first chunk's key
1011        };
1012        // Ids of chunk `ci`: its first run's first id up to the id before the
1013        // next chunk's first run (or the last id).
1014        let first = (self.chunks[ci].first_run.checked_mul(ri)? as u64 + 1).min(tc as u64 + 1);
1015        let last = match self.chunks.get(ci + 1) {
1016            Some(next) => (next.first_run.checked_mul(ri)? as u64).min(tc as u64),
1017            None => tc as u64,
1018        };
1019        // The first id in [first, last] whose term is >= bound (last + 1 if none).
1020        let (mut lo, mut hi) = (first, last + 1);
1021        while lo < hi {
1022            let mid = lo + (hi - lo) / 2;
1023            let term = self.term(u32::try_from(mid).ok()?)?;
1024            if term.as_bytes() < bound.as_bytes() {
1025                lo = mid + 1;
1026            } else {
1027                hi = mid;
1028            }
1029        }
1030        u32::try_from(lo - 1).ok()
1031    }
1032
1033    /// Does this section fault chunk bodies in on demand (a lazy or ranged
1034    /// open), rather than hold them all resident?
1035    pub(crate) fn faults_chunks(&self) -> bool {
1036        self.loader.is_some()
1037    }
1038
1039    /// Resolve `term` to its ID. Chunk-level binary search runs on the (local)
1040    /// chunk directory, so this also costs at most one chunk fault.
1041    pub fn id(&self, term: &str) -> Option<u32> {
1042        if self.chunks.is_empty() {
1043            return None;
1044        }
1045        let ri = self.meta.restart_interval as usize;
1046        // Pick the chunk: the last one whose ROUTING KEY is <= `term` (the
1047        // single local chunk skips the search — its key is unset).
1048        //
1049        // [`chunk_for_term`](Self::chunk_for_term) is the ONLY read of
1050        // [`SectionChunk::key`] in the codebase (the batched lookup prefetch
1051        // routes through it too, so it faults exactly the chunk this reads), and
1052        // all it needs is the separator invariant `last(i-1) < key(i) <=
1053        // first(i)`: `term` lands in chunk `i` exactly when `key(i) <= term <
1054        // key(i+1)`, which covers every term of chunk `i` and nothing in any
1055        // other chunk. A verbatim first term satisfies it too, which is why
1056        // files written either way route identically.
1057        //
1058        // With separators the search can land on chunk `i` for a `term` in the
1059        // gap `key(i) <= term < first(i)`; the run search below then hits its
1060        // `lo == first_run` branch and answers `None`, which is correct — such
1061        // a term is strictly between two chunks, i.e. absent.
1062        let ci = self.chunk_for_term(term)?;
1063        let first_run = self.chunks[ci].first_run;
1064        let entry = self.chunk_entry(ci)?;
1065        let bytes: &[u8] = entry.body();
1066
1067        // Binary search this chunk's runs by their first (full) term.
1068        let run_end = self.run_end_of_chunk(ci, &entry, ri);
1069        let mut buf = Vec::new();
1070        let mut lo = first_run;
1071        let mut hi = run_end;
1072        while lo < hi {
1073            let mid = (lo + hi) / 2;
1074            let off = self.run_off_in_chunk(ci, mid, &entry, ri)?;
1075            run_entry_into(bytes, off, &mut buf)?;
1076            if buf.as_slice() <= term.as_bytes() {
1077                lo = mid + 1;
1078            } else {
1079                hi = mid;
1080            }
1081        }
1082        if lo == first_run {
1083            // Smaller than every term in (and before) this chunk. Unreachable
1084            // when the key is a verbatim first term; reachable, and correct,
1085            // when it is a separator: `key(ci) <= term < first(ci)` puts the
1086            // term strictly between two chunks.
1087            return None;
1088        }
1089        let run = lo - 1;
1090        let off = self.run_off_in_chunk(ci, run, &entry, ri)?;
1091        let mut pos = run_entry_into(bytes, off, &mut buf)?;
1092        let base_id = (run * ri) as u32 + 1;
1093        // saturating_sub: corrupt metadata must not underflow-panic.
1094        let run_len = self.meta.restart_interval.min(
1095            self.meta
1096                .term_count
1097                .saturating_sub(run as u32 * self.meta.restart_interval),
1098        );
1099        for step in 0..run_len {
1100            if buf.as_slice() == term.as_bytes() {
1101                return Some(base_id + step);
1102            }
1103            if buf.as_slice() > term.as_bytes() {
1104                return None;
1105            }
1106            if step + 1 < run_len {
1107                pos = entry_into(bytes, pos, &mut buf)?;
1108            }
1109        }
1110        None
1111    }
1112
1113    /// The full serialized section (header + body), for the file writer. The
1114    /// local single-chunk case returns the stored bytes; a chunked section
1115    /// re-assembles them (header re-encoded from the metadata).
1116    pub fn raw_section_bytes(&self) -> Vec<u8> {
1117        if self.chunks.len() == 1 && self.chunks[0].body_start == 0 {
1118            if let Some(entry) = self.cache.get((self.section_index, 0)) {
1119                return entry.body().to_vec();
1120            }
1121        }
1122        let mut out = encode_section_header(&self.meta);
1123        for ci in 0..self.chunks.len() {
1124            if let Some(entry) = self.chunk_entry(ci) {
1125                out.extend_from_slice(entry.body());
1126            }
1127        }
1128        out
1129    }
1130}
1131
1132/// Re-encode a section header (term_count, interval, restart table) from its
1133/// parsed metadata — the inverse of [`parse_meta`]. Restart offsets are stored
1134/// body-relative on disk; `meta` holds them absolute, so the body start is
1135/// re-derived as the first run's offset.
1136pub fn encode_section_header(meta: &SectionMeta) -> Vec<u8> {
1137    let body_start = meta.restart_offsets.first().copied().unwrap_or(0);
1138    let mut out = Vec::new();
1139    write_uvarint(&mut out, meta.term_count as u64);
1140    write_uvarint(&mut out, meta.restart_interval as u64);
1141    write_uvarint(&mut out, meta.restart_offsets.len() as u64);
1142    for off in &meta.restart_offsets {
1143        write_uvarint(&mut out, off.saturating_sub(body_start));
1144    }
1145    out
1146}
1147
1148/// A parsed, read-only dictionary section (parses its metadata on construction).
1149pub struct DictSection<'a> {
1150    bytes: &'a [u8],
1151    meta: SectionMeta,
1152}
1153
1154impl<'a> DictSection<'a> {
1155    pub fn parse(bytes: &'a [u8]) -> Result<Self, DictError> {
1156        Ok(Self {
1157            bytes,
1158            meta: parse_meta(bytes)?,
1159        })
1160    }
1161
1162    pub fn len(&self) -> u32 {
1163        self.meta.term_count
1164    }
1165
1166    pub fn is_empty(&self) -> bool {
1167        self.meta.term_count == 0
1168    }
1169
1170    /// Resolve `id` (1-based) to its term, or `None` if out of range.
1171    pub fn term(&self, id: u32) -> Option<String> {
1172        section_term(self.bytes, &self.meta, id)
1173    }
1174
1175    /// Resolve `term` to its ID, or `None` if absent.
1176    pub fn id(&self, term: &str) -> Option<u32> {
1177        section_id(self.bytes, &self.meta, term)
1178    }
1179}
1180
1181fn common_prefix_len(a: &str, b: &str) -> usize {
1182    a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count()
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187    use super::*;
1188
1189    /// Build a *lite* (empty section-wide restart table), genuinely multi-chunk
1190    /// [`ChunkedSection`] from `terms`: the exact shape a ranged open produces,
1191    /// so `resolve_into`'s per-chunk run-table branch and its cross-chunk /
1192    /// cross-run walk are all exercised. `runs_per_chunk` chops the runs into
1193    /// several chunks regardless of size, so a tiny fixture still spans many.
1194    fn lite_multichunk(terms: &[&str], ri: u32, runs_per_chunk: usize) -> (ChunkedSection, u32) {
1195        let mut b = DictSectionBuilder::new().with_restart_interval(ri);
1196        for t in terms {
1197            b.push(*t);
1198        }
1199        let raw = b.build();
1200        let full_meta = parse_meta(&raw).unwrap();
1201        let n_runs = full_meta.restart_offsets.len();
1202        let mut chunks = Vec::new();
1203        let mut bodies: Vec<Vec<u8>> = Vec::new();
1204        let mut r = 0;
1205        while r < n_runs {
1206            let first_run = r;
1207            let start = full_meta.restart_offsets[r] as usize;
1208            let r2 = (r + runs_per_chunk).min(n_runs);
1209            let end = if r2 < n_runs {
1210                full_meta.restart_offsets[r2] as usize
1211            } else {
1212                raw.len()
1213            };
1214            chunks.push(SectionChunk::new(first_run, Vec::new(), start as u64));
1215            bodies.push(raw[start..end].to_vec());
1216            r = r2;
1217        }
1218        assert!(chunks.len() > 1, "fixture must be multi-chunk");
1219        // Lite: no section-wide restart table -> run offsets come from each
1220        // decoded chunk body, cached in its cache entry (the ranged path).
1221        let lite_meta = SectionMeta {
1222            term_count: full_meta.term_count,
1223            restart_interval: full_meta.restart_interval,
1224            restart_offsets: Vec::new(),
1225        };
1226        let loader: ChunkLoader = Box::new(move |ci| bodies.get(ci).cloned());
1227        let sec = ChunkedSection::from_parts(
1228            lite_meta,
1229            chunks,
1230            Some(loader),
1231            ChunkCache::unlimited_arc(),
1232            0,
1233        );
1234        (sec, full_meta.term_count)
1235    }
1236
1237    /// The windowed batch resolver must fill exactly what a per-id `term()`
1238    /// would, for a scrambled, duplicated, out-of-range id set spread across
1239    /// many chunks and runs — the byte-identity guarantee `dump_filtered_each`
1240    /// rests on (phase 1). Also cross-checked against the ground-truth
1241    /// `section_term` decoder.
1242    #[test]
1243    fn resolve_into_matches_term_across_chunks_and_runs() {
1244        // Shared prefixes (front-coding), a long literal (a run that dwarfs its
1245        // neighbors), blank nodes — 240 distinct terms.
1246        let owned: Vec<String> = (0..240)
1247            .map(|i| match i % 4 {
1248                0 => format!("<http://example.org/entity/{i:05}>"),
1249                1 => format!("<http://example.org/entity/{i:05}/sub/leaf>"),
1250                2 => format!("\"literal value {} padded {}\"", i, "x".repeat(i % 50)),
1251                _ => format!("_:b{i:05}"),
1252            })
1253            .collect();
1254        let terms: Vec<&str> = owned.iter().map(String::as_str).collect();
1255
1256        for (ri, rpc) in [(1u32, 5usize), (4, 3), (16, 2)] {
1257            let (sec, count) = lite_multichunk(&terms, ri, rpc);
1258            let raw = {
1259                let mut b = DictSectionBuilder::new().with_restart_interval(ri);
1260                for t in &terms {
1261                    b.push(*t);
1262                }
1263                b.build()
1264            };
1265            let full_meta = parse_meta(&raw).unwrap();
1266
1267            // A scrambled probe set: every id once, a run of duplicates, some
1268            // out-of-range ids (0 and past the end) that must stay `None`.
1269            let mut probe: Vec<u32> = (1..=count).collect();
1270            probe.extend([1, 1, count, count, ABSENT, count + 3, count + 100]);
1271            // Deterministic shuffle so chunk order != probe order.
1272            let mut state = 0xDEAD_BEEF_1234_5678u64;
1273            for k in (1..probe.len()).rev() {
1274                state ^= state << 13;
1275                state ^= state >> 7;
1276                state ^= state << 17;
1277                probe.swap(k, (state % (k as u64 + 1)) as usize);
1278            }
1279
1280            let mut jobs: Vec<(u32, usize)> = probe.iter().map(|&id| (id, 0)).collect();
1281            for (slot, j) in jobs.iter_mut().enumerate() {
1282                j.1 = slot;
1283            }
1284            let mut out: Vec<Option<String>> = vec![None; probe.len()];
1285            sec.resolve_into(&mut jobs, &mut out);
1286
1287            for (slot, &id) in probe.iter().enumerate() {
1288                let via_term = sec.term(id);
1289                assert_eq!(
1290                    out[slot], via_term,
1291                    "resolve_into(id={id}) != term(id) at ri={ri} rpc={rpc}"
1292                );
1293                // Ground truth for in-range ids.
1294                let truth = section_term(&raw, &full_meta, id);
1295                assert_eq!(out[slot], truth, "resolve_into(id={id}) != section_term");
1296            }
1297        }
1298    }
1299
1300    fn sample() -> Vec<String> {
1301        // Deliberately unsorted, with shared prefixes and a duplicate.
1302        [
1303            "http://ex.org/Alice",
1304            "http://ex.org/Bob",
1305            "http://ex.org/Alan",
1306            "http://ex.org/knows",
1307            "http://ex.org/Alice", // dup
1308            "zeta",
1309        ]
1310        .iter()
1311        .map(|s| s.to_string())
1312        .collect()
1313    }
1314
1315    #[test]
1316    fn round_trip_all_ids_and_terms() {
1317        for r in [1u32, 2, 16, 1000] {
1318            let mut b = DictSectionBuilder::new().with_restart_interval(r);
1319            for t in sample() {
1320                b.push(t);
1321            }
1322            let bytes = b.build();
1323            let sec = DictSection::parse(&bytes).unwrap();
1324
1325            // Expected sorted unique set.
1326            let mut expected = sample();
1327            expected.sort();
1328            expected.dedup();
1329            assert_eq!(sec.len() as usize, expected.len());
1330
1331            for (i, term) in expected.iter().enumerate() {
1332                let id = (i + 1) as u32;
1333                assert_eq!(
1334                    sec.term(id).as_deref(),
1335                    Some(term.as_str()),
1336                    "term({id}) r={r}"
1337                );
1338                assert_eq!(sec.id(term), Some(id), "id({term}) r={r}");
1339            }
1340        }
1341    }
1342
1343    #[test]
1344    fn lookups_for_absent_terms() {
1345        let mut b = DictSectionBuilder::new();
1346        for t in sample() {
1347            b.push(t);
1348        }
1349        let bytes = b.build();
1350        let sec = DictSection::parse(&bytes).unwrap();
1351        assert_eq!(sec.id("aaa-before-everything"), None);
1352        assert_eq!(sec.id("zzz-after-everything"), None);
1353        assert_eq!(sec.id("http://ex.org/Alic"), None); // prefix, not present
1354        assert_eq!(sec.term(0), None);
1355        assert_eq!(sec.term(9999), None);
1356    }
1357
1358    /// Property-style stress of the front-coded decode paths: a large
1359    /// deterministic-pseudo-random term pool (IRIs with heavy shared prefixes,
1360    /// literals, blank nodes — duplicates likely) must round-trip every id and
1361    /// term across restart intervals, and near-miss probes (a stored term ± a
1362    /// suffix/truncation, including run-boundary terms) must resolve to `None`.
1363    /// Term resolution is the engine's hot path, so this is the test that
1364    /// guards the buffer-reuse decode.
1365    #[test]
1366    fn randomized_round_trip_and_near_misses_across_restart_intervals() {
1367        let mut state = 0x9E37_79B9_7F4A_7C15u64;
1368        let mut next = move || {
1369            state ^= state << 13;
1370            state ^= state >> 7;
1371            state ^= state << 17;
1372            state
1373        };
1374        let pool: Vec<String> = (0..1500)
1375            .map(|i| {
1376                let n = next();
1377                match n % 4 {
1378                    0 => format!("<http://example.org/entity/{n:x}>"),
1379                    1 => format!("\"literal value {} with spaces\"", n % 300), // dups likely
1380                    2 => format!("<http://example.org/entity/{}/sub/{i}>", n % 64), // long shared prefixes
1381                    _ => format!("_:b{}", n % 256),
1382                }
1383            })
1384            .collect();
1385        let mut expected = pool.clone();
1386        expected.sort();
1387        expected.dedup();
1388
1389        for r in [1u32, 3, 16, 64] {
1390            let mut b = DictSectionBuilder::new().with_restart_interval(r);
1391            for t in &pool {
1392                b.push(t.clone());
1393            }
1394            let bytes = b.build();
1395            let sec = DictSection::parse(&bytes).unwrap();
1396            assert_eq!(sec.len() as usize, expected.len(), "r={r}");
1397
1398            for (i, term) in expected.iter().enumerate() {
1399                let id = (i + 1) as u32;
1400                assert_eq!(
1401                    sec.term(id).as_deref(),
1402                    Some(term.as_str()),
1403                    "term({id}) r={r}"
1404                );
1405                assert_eq!(sec.id(term), Some(id), "id({term}) r={r}");
1406            }
1407            // Near misses around run boundaries (and a sample of the rest):
1408            // an appended suffix or a truncation is never a stored term unless
1409            // it happens to collide with one.
1410            for (i, term) in expected.iter().enumerate() {
1411                let near_boundary = (i as u32) % r <= 1;
1412                if !near_boundary && i % 37 != 0 {
1413                    continue;
1414                }
1415                let longer = format!("{term}\u{1}");
1416                assert_eq!(sec.id(&longer), None, "near-miss long r={r}");
1417                let mut shorter = term.clone();
1418                shorter.pop();
1419                if !shorter.is_empty() && expected.binary_search(&shorter).is_err() {
1420                    assert_eq!(sec.id(&shorter), None, "near-miss short {shorter:?} r={r}");
1421                }
1422            }
1423            assert_eq!(sec.term(expected.len() as u32 + 1), None, "past-end r={r}");
1424        }
1425    }
1426}