Skip to main content

rete_core/
file.rs

1//! `.rete` file assembly and reading (SPEC.md §4, §9).
2//!
3//! v0 layout:
4//!
5//! ```text
6//! [0..1024)  header ([`crate::header::HEADER_LEN`])
7//! [dict]     dictionary container: 4 front-coded sections
8//! [index]    permutation container: 6 triple blocks (SPO/POS/OSP/SOP/PSO/OPS)
9//! [pyramid]  summary meta (and, in future, tile directories)
10//! [footer]   trailing magic
11//! ```
12//!
13//! The header points at the dictionary container (`dictionary_offset/len`) and
14//! the permutation container (`root_dir_offset/len`); routed readers can fetch a
15//! single permutation payload from that container.
16
17use crate::dictionary::Dictionary;
18use crate::header::{
19    Header, FLAG_HAS_QUADS, FLAG_HAS_QUOTED_TRIPLES, FLAG_TILE_SYNOPSIS, HEADER_LEN, MAGIC,
20};
21use crate::index::{GraphIndex, IndexPermutation, Pattern, PermSet, NUM_PERMS};
22use crate::meta::{ClassNode, CommunityDescriptor, LevelLinks, LevelRollup, PyramidMeta};
23use crate::pyramid::{build_dendrogram, project_graph, PyramidAlgo};
24use crate::reader::RangeReader;
25use crate::tiling::{choose_round_for_budget, summarize, SuperEdge};
26use crate::triples::Triple;
27use crate::varint::{read_uvarint, write_uvarint};
28
29/// Default per-tile byte budget `T` (SPEC.md §7.1).
30pub const DEFAULT_TILE_BUDGET: usize = 64 * 1024;
31
32/// Build the encoded pyramid-meta section for a graph: cluster, pick a round
33/// sized to `budget`, then emit the **summary** (quotient) graph. Returns
34/// `(encoded_meta, pyramid_levels)`.
35///
36/// Per-community tiles are *not* stored: they would duplicate every triple, and
37/// the exact ranged single-pattern path now routes into one permutation section
38/// without that fourth copy. Physical community-tile directories are the next
39/// storage step (SPEC §7.2).
40pub fn build_pyramid_meta(
41    dict: &Dictionary,
42    triples: &[(u32, u32, u32)],
43    budget: usize,
44) -> (Vec<u8>, u16) {
45    build_pyramid_meta_with(dict, triples, budget, None)
46}
47
48/// Like [`build_pyramid_meta`], but `type_override` forces the schema-pyramid's
49/// type predicate (e.g. `wdt:P31`) instead of auto-detection. Uses the default
50/// [`PyramidAlgo::Louvain`] community algorithm — byte-identical to before.
51pub fn build_pyramid_meta_with(
52    dict: &Dictionary,
53    triples: &[(u32, u32, u32)],
54    budget: usize,
55    type_override: Option<&str>,
56) -> (Vec<u8>, u16) {
57    build_pyramid_meta_algo(dict, triples, budget, type_override, PyramidAlgo::Louvain)
58}
59
60/// Like [`build_pyramid_meta_with`], but selects the community [`PyramidAlgo`].
61/// [`PyramidAlgo::Types`] partitions by `rdf:type` — the deterministic,
62/// parallelizable alternative to Louvain (one linear pass, no modularity) that
63/// still emits the full summary + `query_stats`; it falls back to Louvain when the
64/// graph has no usable typing. Everything downstream of the dendrogram (round
65/// choice, summary, schema pyramid, planner stats) is shared across algorithms.
66pub fn build_pyramid_meta_algo(
67    dict: &Dictionary,
68    triples: &[(u32, u32, u32)],
69    budget: usize,
70    type_override: Option<&str>,
71    algo: PyramidAlgo,
72) -> (Vec<u8>, u16) {
73    // Optional sub-phase timing (set RETE_BUILD_TIMING=1) — the pyramid build is
74    // the dominant cost of a big `rete build`; this shows where inside it.
75    // `Instant::now()` must stay behind the flag: `std::time` is unsupported on
76    // `wasm32-unknown-unknown` and panics ("time not implemented"), so an
77    // unconditional clock read would break every in-browser `build()`.
78    let timing = std::env::var_os("RETE_BUILD_TIMING").is_some();
79    let mut t = timing.then(std::time::Instant::now);
80    let mut lap = |label: &str| {
81        if let Some(t0) = &mut t {
82            eprintln!(
83                "  [pyramid] {label}: {:.0} ms",
84                t0.elapsed().as_secs_f64() * 1000.0
85            );
86            *t0 = std::time::Instant::now();
87        }
88    };
89
90    // The community partition — the only step that differs by algorithm.
91    let louvain = |lap: &mut dyn FnMut(&str)| {
92        let g = project_graph(dict, triples);
93        lap("project_graph");
94        let d = build_dendrogram(&g);
95        lap("build_dendrogram (Louvain)");
96        d
97    };
98    let dend = match algo {
99        PyramidAlgo::Louvain => louvain(&mut lap),
100        PyramidAlgo::Types => {
101            match crate::schema_pyramid::build_type_dendrogram(dict, triples, type_override) {
102                Some(d) => {
103                    lap("build_type_dendrogram");
104                    d
105                }
106                None => {
107                    eprintln!(
108                        "  [pyramid] --pyramid-algo types: no usable rdf:type \
109                         predicate — falling back to louvain"
110                    );
111                    louvain(&mut lap)
112                }
113            }
114        }
115    };
116    let round = choose_round_for_budget(dict, triples, &dend, budget);
117    lap("choose_round_for_budget");
118    let summary = summarize(dict, triples, &dend, round);
119    lap("summarize");
120    // Attach the v2 schema pyramid (the non-exclusive subClassOf DAG + per-level
121    // type rollups + per-level lateral class relations + per-community
122    // descriptors). Empty when the graph has no usable typing, in which case the
123    // encoding stays byte-identical to a v1 pyramid-meta.
124    let sp = crate::schema_pyramid::build_schema_pyramid_with(
125        dict,
126        triples,
127        &dend,
128        round,
129        type_override,
130    );
131    lap("build_schema_pyramid");
132    let predicate_stats = compute_predicate_stats(triples);
133    lap("compute_predicate_stats");
134    let char_sets = compute_char_sets(triples);
135    lap("compute_char_sets");
136    let label_index = compute_label_index(dict, triples);
137    lap("compute_label_index");
138    let meta = PyramidMeta::new(round as u32, summary, &[])
139        .with_schema(
140            sp.class_hierarchy,
141            sp.level_rollups,
142            sp.level_links,
143            sp.descriptors,
144            sp.subclass_cycles,
145            sp.disjoint_pairs,
146            sp.equivalent_pairs,
147        )
148        .with_predicate_stats(predicate_stats)
149        .with_char_sets(char_sets)
150        .with_label_index(label_index);
151    let out = (meta.encode(), dend.rounds() as u16);
152    lap("encode");
153    out
154}
155
156/// The label predicates a [`compute_label_index`] entry can come from — the
157/// common "human-readable name of this subject" terms, angle-bracketed as the
158/// dictionary stores them. Order is irrelevant (we union their ids).
159const LABEL_PREDICATES: &[&str] = &[
160    "<http://www.w3.org/2000/01/rdf-schema#label>",
161    "<http://www.w3.org/2004/02/skos/core#prefLabel>",
162    "<http://www.w3.org/2004/02/skos/core#altLabel>",
163    "<http://xmlns.com/foaf/0.1/name>",
164    "<http://purl.org/dc/terms/title>",
165    "<http://purl.org/dc/elements/1.1/title>",
166    "<http://schema.org/name>",
167];
168
169/// Build the bounded **label index** for prefix search: the display labels of
170/// the most-connected labeled subjects, sorted by the label's lowercased form.
171/// Ranking keeps autocomplete useful on a huge graph (the prominent entities
172/// survive the bound); a graph with fewer than `MAX_LABELS` labels keeps them
173/// all. Deterministic (degree, then subject id, then label) so builds are
174/// reproducible. O(triples) transient memory, freed before the file is written.
175fn compute_label_index(
176    dict: &Dictionary,
177    triples: &[(u32, u32, u32)],
178) -> Vec<crate::meta::LabelEntry> {
179    use crate::terms::{is_literal, literal_lexical};
180    use std::collections::{HashMap, HashSet};
181    const MAX_LABELS: usize = 8192;
182
183    // Resolve the label predicates that actually occur in this graph.
184    let label_pids: HashSet<u32> = LABEL_PREDICATES
185        .iter()
186        .filter_map(|p| dict.predicate_id(p))
187        .collect();
188    if label_pids.is_empty() {
189        return Vec::new();
190    }
191    // Subject degree (triple count) — the ranking used to bound the index.
192    let mut degree: HashMap<u32, u32> = HashMap::new();
193    for &(s, _p, _o) in triples {
194        *degree.entry(s).or_insert(0) += 1;
195    }
196    // Candidate (subject, label) pairs, deduped on (subject, lowercased label).
197    let mut seen: HashSet<(u32, String)> = HashSet::new();
198    let mut candidates: Vec<(u32, String, u32)> = Vec::new(); // (degree, label, subject)
199    for &(s, p, o) in triples {
200        if !label_pids.contains(&p) {
201            continue;
202        }
203        let Some(term) = dict.object_term(o) else {
204            continue;
205        };
206        if !is_literal(&term) {
207            continue;
208        }
209        let Some(label) = literal_lexical(&term) else {
210            continue;
211        };
212        if label.is_empty() {
213            continue;
214        }
215        if seen.insert((s, label.to_lowercase())) {
216            candidates.push((*degree.get(&s).unwrap_or(&0), label, s));
217        }
218    }
219    // Keep the most-connected entities when over budget: rank by degree desc,
220    // then subject asc, then label asc (deterministic).
221    if candidates.len() > MAX_LABELS {
222        candidates.sort_by(|a, b| {
223            b.0.cmp(&a.0)
224                .then_with(|| a.2.cmp(&b.2))
225                .then_with(|| a.1.cmp(&b.1))
226        });
227        candidates.truncate(MAX_LABELS);
228    }
229    // Final order: by lowercased label (search key), then label, then subject.
230    candidates.sort_by(|a, b| {
231        a.1.to_lowercase()
232            .cmp(&b.1.to_lowercase())
233            .then_with(|| a.1.cmp(&b.1))
234            .then_with(|| a.2.cmp(&b.2))
235    });
236    candidates
237        .into_iter()
238        .map(|(_deg, label, subject)| crate::meta::LabelEntry { label, subject })
239        .collect()
240}
241
242/// Build the full-text index section (`token → subjects`) over every
243/// string-literal object: tokenize each literal into words and record the
244/// subject that carries it. Empty when the graph has no literals. Opt-in
245/// (`rete build --text-index`); O(literal bytes) transient. The token table is
246/// compressed with [`writer_codec`] (the reader decompresses with `block_codec`).
247pub(crate) fn compute_text_index(dict: &Dictionary, triples: &[(u32, u32, u32)]) -> Vec<u8> {
248    use crate::terms::{is_literal, literal_lexical};
249    let mut b = crate::text_index::TextIndexBuilder::new();
250    for &(s, _p, o) in triples {
251        let Some(term) = dict.object_term(o) else {
252            continue;
253        };
254        if !is_literal(&term) {
255            continue;
256        }
257        if let Some(lit) = literal_lexical(&term) {
258            b.add_text(&lit, s);
259        }
260    }
261    if b.is_empty() {
262        Vec::new()
263    } else {
264        b.build(writer_codec())
265    }
266}
267
268/// The top entity **shapes** (characteristic sets): group subjects by the exact
269/// set of predicates they carry, keep the most common. Bounded to `MAX_CHAR_SETS`
270/// and sorted deterministically (by subject count, then predicate list) so the
271/// encoding is reproducible. O(triples) transient memory.
272fn compute_char_sets(triples: &[(u32, u32, u32)]) -> Vec<crate::meta::CharSet> {
273    use std::collections::{BTreeSet, HashMap};
274    const MAX_CHAR_SETS: usize = 128;
275    let mut by_subject: HashMap<u32, BTreeSet<u32>> = HashMap::new();
276    for &(s, p, _o) in triples {
277        by_subject.entry(s).or_default().insert(p);
278    }
279    let mut shapes: HashMap<Vec<u32>, u64> = HashMap::new();
280    for set in by_subject.into_values() {
281        *shapes.entry(set.into_iter().collect()).or_insert(0) += 1;
282    }
283    let mut v: Vec<crate::meta::CharSet> = shapes
284        .into_iter()
285        .map(|(predicates, subjects)| crate::meta::CharSet {
286            predicates,
287            subjects,
288        })
289        .collect();
290    v.sort_by(|a, b| {
291        b.subjects
292            .cmp(&a.subjects)
293            .then_with(|| a.predicates.cmp(&b.predicates))
294    });
295    v.truncate(MAX_CHAR_SETS);
296    v
297}
298
299/// Per-predicate cardinality for the cost-based planner, in one pass over the
300/// triples (deduped, so a per-(subject,predicate) count is its distinct-object
301/// count). Returned sorted by predicate id for a reproducible encoding. Holds a
302/// transient `(subject -> count, object -> count)` map per predicate — O(triples)
303/// memory, freed before the file is written.
304fn compute_predicate_stats(triples: &[(u32, u32, u32)]) -> Vec<crate::meta::PredStat> {
305    use std::collections::HashMap;
306    #[allow(clippy::type_complexity)]
307    let mut acc: HashMap<u32, (HashMap<u32, u32>, HashMap<u32, u32>, u64)> = HashMap::new();
308    for &(s, p, o) in triples {
309        let e = acc.entry(p).or_default();
310        *e.0.entry(s).or_insert(0) += 1;
311        *e.1.entry(o).or_insert(0) += 1;
312        e.2 += 1;
313    }
314    let mut stats: Vec<crate::meta::PredStat> = acc
315        .into_iter()
316        .map(|(predicate, (subj, obj, count))| crate::meta::PredStat {
317            predicate,
318            count,
319            distinct_subjects: subj.len() as u64,
320            distinct_objects: obj.len() as u64,
321            max_objects_per_subject: subj.values().copied().max().unwrap_or(0),
322            max_subjects_per_object: obj.values().copied().max().unwrap_or(0),
323        })
324        .collect();
325    stats.sort_by_key(|p| p.predicate);
326    stats
327}
328
329/// No compression.
330pub const CODEC_NONE: u8 = 0;
331/// zstd compression (per section).
332pub const CODEC_ZSTD: u8 = 1;
333/// zstd compression level used by the writer.
334#[cfg(feature = "compression")]
335const ZSTD_LEVEL: i32 = 9;
336
337#[derive(Debug, thiserror::Error)]
338#[non_exhaustive]
339pub enum FileError {
340    #[error("header: {0}")]
341    Header(#[from] crate::header::HeaderError),
342    #[error("malformed container: {0}")]
343    Container(&'static str),
344    #[error("unknown codec: {0}")]
345    UnknownCodec(u8),
346    #[error("decompression failed: {0}")]
347    Decompress(std::io::Error),
348    #[error("io: {0}")]
349    Io(#[from] std::io::Error),
350}
351
352/// The codec the writer uses: zstd when the `compression` feature is on, else
353/// none. Reading honors whatever codec the header records (when supported).
354pub(crate) fn writer_codec() -> u8 {
355    if cfg!(feature = "compression") {
356        CODEC_ZSTD
357    } else {
358        CODEC_NONE
359    }
360}
361
362/// Intersection of two ascending-sorted, deduped id lists — the AND of two
363/// posting lists in a multi-word text search. Linear merge, output sorted.
364fn intersect_sorted(a: &[u32], b: &[u32]) -> Vec<u32> {
365    let mut out = Vec::with_capacity(a.len().min(b.len()));
366    let (mut i, mut j) = (0, 0);
367    while i < a.len() && j < b.len() {
368        match a[i].cmp(&b[j]) {
369            std::cmp::Ordering::Less => i += 1,
370            std::cmp::Ordering::Greater => j += 1,
371            std::cmp::Ordering::Equal => {
372                out.push(a[i]);
373                i += 1;
374                j += 1;
375            }
376        }
377    }
378    out
379}
380
381pub(crate) fn compress(codec: u8, bytes: &[u8]) -> Vec<u8> {
382    match codec {
383        #[cfg(feature = "compression")]
384        CODEC_ZSTD => {
385            zstd::encode_all(bytes, ZSTD_LEVEL).expect("zstd encode is infallible in-memory")
386        }
387        _ => bytes.to_vec(),
388    }
389}
390
391pub(crate) fn decompress(codec: u8, bytes: &[u8]) -> Result<Vec<u8>, FileError> {
392    match codec {
393        CODEC_NONE => Ok(bytes.to_vec()),
394        // Native (the `compression` feature — always on for the CLI, off for
395        // wasm) decodes through the C `zstd` library, the same one
396        // [`compress`] encodes with. It is roughly 2× the pure-Rust decoder
397        // and speeds every read path, not just export; the multi-pass tight
398        // budget of a bounded export (`--memory-budget-mb`) re-decodes the
399        // dictionary, so a faster decoder is the single largest lever there.
400        // A valid zstd frame decodes to the same bytes under any conformant
401        // decoder, so the output is byte-for-byte what `ruzstd` produced.
402        #[cfg(feature = "compression")]
403        CODEC_ZSTD => zstd::decode_all(bytes).map_err(FileError::Decompress),
404        // Pure-Rust decode so a target WITHOUT a C toolchain (wasm, or a
405        // `--no-default-features` build) can still read compressed files.
406        // This is the ONLY decode path wasm ever compiles — the C `zstd`
407        // crate is gated behind `compression`, which wasm never enables.
408        #[cfg(not(feature = "compression"))]
409        CODEC_ZSTD => {
410            use std::io::Read;
411            let mut dec = ruzstd::decoding::StreamingDecoder::new(bytes)
412                .map_err(|e| FileError::Decompress(std::io::Error::other(e.to_string())))?;
413            let mut out = Vec::new();
414            dec.read_to_end(&mut out).map_err(FileError::Decompress)?;
415            Ok(out)
416        }
417        other => Err(FileError::UnknownCodec(other)),
418    }
419}
420
421/// Bytes this close are cheaper fetched as one read than as two round trips:
422/// tiles are laid back-to-back so this only ever bridges a tile already made
423/// resident by an earlier window — keep it tight to avoid re-fetching it.
424const TILE_COALESCE_GAP: u64 = 4096;
425
426/// The dictionary chunks a query's output terms touch are scattered across the
427/// section (terms are sorted, output ids are not), so byte-adjacency is rare.
428/// A wider gap trades a little over-fetch for far fewer round trips — the right
429/// call on a latency-bound remote read, where one skipped 64 KiB chunk is much
430/// cheaper than another request's RTT.
431const DICT_COALESCE_GAP: u64 = 64 * 1024;
432
433/// The default `rete export --memory-budget-mb` when the flag is omitted — the
434/// same number `rete build`/`merge` default to, so one figure governs both the
435/// build and the export of a file. Comfortably above a sub-4-GB-dictionary
436/// file's decompressed dictionary (those export in a single pass at today's
437/// speed), and far under a large VM's RAM so a 50-GB-class file is bounded
438/// rather than OOM. See [`split_memory_budget`].
439pub const DEFAULT_EXPORT_BUDGET_MB: u64 = 4096;
440
441/// The default memory budget, in MiB, of a lazily-opened file — the bound on
442/// its dictionary chunk cache, index tile cache and raw block cache, split as
443/// [`split_memory_budget`] describes. `rete sparql --memory-budget-mb` (and
444/// the other query commands) default to it too, and so does every client that
445/// opens through [`Rete::open_ranged_lazy`] (Python, R, Java, the relay), so a
446/// long-lived session no longer grows with every tile a query ever touched.
447/// The same figure as [`DEFAULT_EXPORT_BUDGET_MB`]: one number governs reading
448/// a big file whatever the command.
449pub const DEFAULT_QUERY_BUDGET_MB: u64 = DEFAULT_EXPORT_BUDGET_MB;
450
451/// The [`Rete::spill_threshold`] a memory budget implies: an eighth of it. A
452/// spilling `COUNT(DISTINCT …)` buffer can briefly occupy about twice its
453/// threshold while it sorts, so this keeps it within a quarter of the budget.
454pub fn spill_threshold_for(budget: u64) -> u64 {
455    budget / 8
456}
457
458/// The lowest byte cap the dictionary chunk cache is ever given, whatever the
459/// budget. It only has to guarantee forward progress and hold a handful of
460/// chunks (a single front-coded run can dwarf the 64 KiB chunk budget — a
461/// base64-embedded image, say). Correctness never depends on it: the cache's
462/// `Arc` handout keeps a chunk alive across its one decode even if it is
463/// evicted the instant it is inserted, so a full dump completes at *any* cap.
464/// This floor only keeps a pathologically small `--memory-budget-mb` from
465/// thrashing the whole dictionary chunk-by-chunk.
466const MIN_DICT_CACHE_CAP: u64 = 64 << 20; // 64 MiB
467
468/// Upper bound of the budget share [`split_memory_budget`] holds back for
469/// transient, uncached allocations (see the `reserve` there).
470const TRANSIENT_RESERVE_MAX: u64 = 128 << 20; // 128 MiB
471
472/// How a total `--memory-budget-mb` splits across the export's three bounded
473/// consumers. The three caps sum to the total less a small reserve (≤ 128 MiB)
474/// for the window's buffers and one window's in-flight reads, which no cache
475/// owns. [`Rete::set_memory_budget`] further subtracts the open's fixed
476/// directories ([`Rete::fixed_overhead`]) from the dictionary share, so the
477/// live heap of a dump stays within the total.
478#[derive(Debug, Clone, Copy)]
479pub struct MemoryBudget {
480    /// Raw file-block LRU (the `BlockCacheReader` that backs the ranged reader).
481    pub block_cache: u64,
482    /// Decompressed dictionary chunk bodies (the old ~4.3 GB export floor).
483    pub dict_cache: u64,
484    /// Decoded index tiles of the scanned permutation.
485    pub tile_cache: u64,
486}
487
488/// Split a total byte budget across the block cache, the dictionary chunk cache
489/// and the index tile cache (bounded-export phase 2, plan §5.2). `u64::MAX`
490/// (unlimited, e.g. `--in-memory` or `--memory-budget-mb 0`) passes straight
491/// through to every consumer.
492///
493/// The dictionary is the dominant consumer on scholar files (literal-heavy
494/// object section), and its chunks recur across windows, so it gets the bulk of
495/// the budget; the tile cache is small (a scanned permutation is far smaller
496/// than the dictionary) and the block cache stays modest, capped like every
497/// other ranged reader at [`crate::block_cache::DEFAULT_CACHE_CAP`].
498pub fn split_memory_budget(total: u64) -> MemoryBudget {
499    if total == u64::MAX {
500        return MemoryBudget {
501            block_cache: u64::MAX,
502            dict_cache: u64::MAX,
503            tile_cache: u64::MAX,
504        };
505    }
506    // Raw file blocks: min(256 MiB, total/8). Repeated directory/header reads
507    // must stay cheap, but this cache is not where the floor lived.
508    let block_cache = (total / 8).min(crate::block_cache::DEFAULT_CACHE_CAP);
509    // Scanned-permutation tiles: modest — tens of MB on epfl-class files. Cap
510    // at total/16 (≤ 256 MiB) so the dictionary keeps the rest.
511    let tile_cache = (total / 16).min(256 << 20);
512    // Held back for what no cache owns: the window's id and string buffers,
513    // the coalesced read spans and freshly decoded bodies of one window's
514    // prefetch before they enter the cache, and the program itself. Measured at
515    // ~80-120 MB on epfl and openaire-relation (dev/export-memory), so 1/32 of
516    // the budget, capped at 128 MiB.
517    let reserve = (total / 32).min(TRANSIENT_RESERVE_MAX);
518    // The dictionary chunk cache gets everything else, floored so a tiny budget
519    // still holds a working set of chunks.
520    let dict_cache = total
521        .saturating_sub(block_cache)
522        .saturating_sub(tile_cache)
523        .saturating_sub(reserve)
524        .max(MIN_DICT_CACHE_CAP);
525    MemoryBudget {
526        block_cache,
527        dict_cache,
528        tile_cache,
529    }
530}
531
532/// Heap an open `Rete` holds outside its bounded caches, by structure — see
533/// [`Rete::fixed_overhead`].
534#[derive(Debug, Clone, Copy, Default)]
535pub struct FixedOverhead {
536    /// The four dictionary sections' chunk directories.
537    pub dict_directory: u64,
538    /// The default graph's tile directories (every carried permutation).
539    pub index_directory: u64,
540    /// The named-graph directory plus the open graphs' tile directories.
541    pub named_graphs: u64,
542}
543
544impl FixedOverhead {
545    /// The sum of every part.
546    pub fn total(&self) -> u64 {
547        self.dict_directory + self.index_directory + self.named_graphs
548    }
549}
550
551/// Format a cache byte cap for `RETE_OPEN_DEBUG` (`unlimited` for `u64::MAX`).
552fn fmt_cap(cap: u64) -> String {
553    if cap == u64::MAX {
554        "unlimited".to_string()
555    } else {
556        format!("{}MiB", cap >> 20)
557    }
558}
559
560/// Fetch a set of ascending, disjoint byte ranges, coalescing ranges whose gap
561/// is at most `gap` into one span, then fetching the spans through
562/// [`RangeReader::read_many`] (which a parallelizable reader issues
563/// concurrently). Returns each requested range's bytes in order; `None` if any
564/// read fails.
565fn read_coalesced<R: RangeReader + ?Sized>(
566    reader: &R,
567    ranges: &[ByteRange],
568    gap: u64,
569) -> Option<Vec<Vec<u8>>> {
570    // Build the coalesced spans and remember which span each input range maps
571    // into, so the fetched span blobs can be sliced back apart in order.
572    let mut spans: Vec<(u64, u64)> = Vec::new();
573    let mut span_of: Vec<usize> = Vec::with_capacity(ranges.len());
574    let mut i = 0;
575    while i < ranges.len() {
576        let start = ranges[i].offset;
577        let mut end = ranges[i].offset.checked_add(ranges[i].len)?;
578        let mut j = i + 1;
579        while j < ranges.len() {
580            let r = &ranges[j];
581            if r.offset < end || r.offset - end > gap {
582                break;
583            }
584            end = r.offset.checked_add(r.len)?;
585            j += 1;
586        }
587        let si = spans.len();
588        spans.push((start, end - start));
589        for _ in i..j {
590            span_of.push(si);
591        }
592        i = j;
593    }
594    let blobs = reader.read_many(&spans).ok()?;
595    if blobs.len() != spans.len() {
596        return None;
597    }
598    let mut out = Vec::with_capacity(ranges.len());
599    for (k, r) in ranges.iter().enumerate() {
600        let (span_start, _) = spans[span_of[k]];
601        let blob = &blobs[span_of[k]];
602        let lo = (r.offset - span_start) as usize;
603        let hi = lo.checked_add(r.len as usize)?;
604        out.push(blob.get(lo..hi)?.to_vec());
605    }
606    Some(out)
607}
608
609/// Content hash (first 16 bytes of blake3) over the file payload sections.
610/// Identifies the immutable content independent of the header.
611fn content_hash(parts: &[&[u8]]) -> [u8; 16] {
612    let mut h = blake3::Hasher::new();
613    for p in parts {
614        h.update(p);
615    }
616    let mut out = [0u8; 16];
617    out.copy_from_slice(&h.finalize().as_bytes()[..16]);
618    out
619}
620
621/// A resolved triple as terms.
622pub type TermTriple = (String, String, String);
623
624/// What a filtered dump will cost, before it runs — see
625/// [`Rete::dump_plan`](crate::Rete::dump_plan).
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub struct DumpPlan {
628    /// The routed scan this dump will run. `None` when it provably matches
629    /// nothing without touching the index: an absent graph IRI, or a bound term
630    /// the dictionary does not contain.
631    pub scan: Option<crate::index::ScanPlan>,
632    /// The file's whole dictionary section — the **ceiling** on what resolving
633    /// the matched rows' terms can fault, and on a literal-heavy file the
634    /// dominant term in a dump's real cost.
635    ///
636    /// It is deliberately not an estimate. A dump that matches one row faults
637    /// only the chunks that row's three terms live in, but nothing in the tile
638    /// directories says which chunks those are or how big they are, and a
639    /// preview that guessed would be worse than one that states the bound.
640    pub dictionary_bytes: u64,
641}
642
643/// One labelled byte region of a `.rete` file image (see
644/// [`Rete::file_layout`]). `kind` is a stable machine tag: `header`,
645/// `metadata`, `dictionary`, `directory`, `tile`, `pyramid`, `named-graphs`.
646#[derive(Debug, Clone)]
647pub struct LayoutSegment {
648    pub kind: &'static str,
649    pub label: String,
650    pub offset: u64,
651    pub len: u64,
652}
653
654/// A byte range in the `.rete` file image.
655#[derive(Debug, Clone, Copy, PartialEq, Eq)]
656pub struct ByteRange {
657    pub offset: u64,
658    pub len: u64,
659}
660
661impl ByteRange {
662    pub fn end(self) -> u64 {
663        self.offset + self.len
664    }
665}
666
667/// Why a triple-pattern result is present in the file.
668#[derive(Debug, Clone, PartialEq, Eq)]
669pub struct TripleProvenance {
670    /// Matched triple resolved to canonical N-Triples tokens.
671    pub terms: TermTriple,
672    /// Matched triple in dictionary ID space.
673    pub ids: Triple,
674    /// Named graph IRI, or `None` for the default graph.
675    pub graph: Option<String>,
676    /// The resolved ID-space pattern that was matched.
677    pub matched_pattern: Pattern,
678    /// Permutation selected to answer the pattern.
679    pub index_permutation: IndexPermutation,
680    /// File byte range containing the dictionary container.
681    pub dictionary_range: ByteRange,
682    /// File byte range containing the permutation index container.
683    pub index_range: ByteRange,
684    /// File byte range containing the selected permutation payload inside the
685    /// index container.
686    pub index_section_range: ByteRange,
687    /// File byte range containing the pyramid metadata, when present.
688    pub pyramid_range: Option<ByteRange>,
689    /// Physical tile identifier, once tile directories are materialized.
690    /// Physical tile identifier (`PERM/index`, e.g. `POS/3`) for tiled (v0.2)
691    /// files; `None` for pre-tiling files.
692    pub tile: Option<String>,
693    /// File byte range of that (compressed) tile — the exact bytes a ranged
694    /// client would fetch to re-derive this match.
695    pub tile_range: Option<ByteRange>,
696}
697
698/// Encode a length-prefixed container of byte sections, each compressed with
699/// `codec` independently (so a range-reading client decompresses only the
700/// sections it fetches). Stored length is the *compressed* length.
701fn encode_container(sections: &[&[u8]], codec: u8) -> Vec<u8> {
702    let mut out = Vec::new();
703    write_uvarint(&mut out, sections.len() as u64);
704    for s in sections {
705        let payload = compress(codec, s);
706        write_uvarint(&mut out, payload.len() as u64);
707        out.extend_from_slice(&payload);
708    }
709    out
710}
711
712/// Decode a container into owned, decompressed sections.
713fn decode_container(bytes: &[u8], codec: u8) -> Result<Vec<Vec<u8>>, FileError> {
714    let (n, mut pos) = read_uvarint(bytes).ok_or(FileError::Container("truncated count"))?;
715    // `n` is untrusted; each section needs ≥1 byte, so cap the pre-allocation at
716    // the buffer length rather than trusting the count (avoids an OOM on a bogus
717    // header pointing at a small region).
718    let mut out = Vec::with_capacity((n as usize).min(bytes.len()));
719    for _ in 0..n {
720        let (len, used) =
721            read_uvarint(&bytes[pos..]).ok_or(FileError::Container("truncated length"))?;
722        pos += used;
723        let end = pos + len as usize;
724        if end > bytes.len() {
725            return Err(FileError::Container("section overruns buffer"));
726        }
727        out.push(decompress(codec, &bytes[pos..end])?);
728        pos = end;
729    }
730    Ok(out)
731}
732
733fn checked_end(off: u64, len: u64) -> Result<u64, FileError> {
734    off.checked_add(len)
735        .ok_or(FileError::Container("section range overflows"))
736}
737
738/// Per-chunk budget for dictionary section bodies — same reasoning as
739/// [`crate::index::INDEX_TILE_BUDGET`]: one fetch, one decompress per touch.
740const DICT_CHUNK_BUDGET: usize = 64 * 1024;
741
742/// Encode one dictionary section as a chunked payload (format v0.2):
743/// `[header_len, raw header (term_count/interval/restart table)]
744///  [num_chunks; per chunk: Δfirst_run, key_len, key, comp_len]
745///  [individually compressed run-aligned body slices]`.
746/// The header keeps its original encoding, so restart offsets stay valid in
747/// the section's coordinate space.
748///
749/// `key` is the chunk's **routing separator**, not its first term: the
750/// shortest string strictly above the previous chunk's last term and at most
751/// the chunk's own first term (empty for chunk 0). See
752/// [`crate::dict::SectionChunk::key`] for the invariant and
753/// [`crate::dict::shortest_separator`] for the choice. Storing the first term
754/// verbatim — what this wrote before — is the degenerate case of the same
755/// invariant, so readers of either vintage route both correctly; the
756/// separator is simply orders of magnitude smaller when the boundary term is a
757/// long literal.
758fn encode_chunked_dict_section(raw: &[u8], codec: u8) -> Vec<u8> {
759    let meta = crate::dict::parse_meta(raw).unwrap_or(crate::dict::SectionMeta {
760        term_count: 0,
761        restart_interval: 1,
762        restart_offsets: Vec::new(),
763    });
764    let body_start = meta
765        .restart_offsets
766        .first()
767        .copied()
768        .unwrap_or(raw.len() as u64);
769    let header = &raw[..(body_start.min(raw.len() as u64)) as usize];
770
771    // Split runs into chunks by body-byte budget (whole runs only).
772    let n_runs = meta.restart_offsets.len();
773    let mut bounds: Vec<(usize, u64, u64)> = Vec::new(); // (first_run, start, end)
774    let mut r = 0;
775    while r < n_runs {
776        let start = meta.restart_offsets[r];
777        let mut r2 = r + 1;
778        while r2 < n_runs && meta.restart_offsets[r2] - start < DICT_CHUNK_BUDGET as u64 {
779            r2 += 1;
780        }
781        let end = if r2 < n_runs {
782            meta.restart_offsets[r2]
783        } else {
784            raw.len() as u64
785        };
786        bounds.push((r, start, end));
787        r = r2;
788    }
789
790    let compressed: Vec<Vec<u8>> = bounds
791        .iter()
792        .map(|&(_, s, e)| compress(codec, &raw[s as usize..e as usize]))
793        .collect();
794    let mut out = Vec::new();
795    write_uvarint(&mut out, header.len() as u64);
796    out.extend_from_slice(header);
797    write_uvarint(&mut out, bounds.len() as u64);
798    let mut prev_run = 0usize;
799    // The previous chunk's last term — the separator's lower bound. Decoding it
800    // walks at most `restart_interval` front-coded entries of one chunk, which
801    // is nothing beside the zstd pass over the same 64 KiB.
802    let mut prev_last: Option<Vec<u8>> = None;
803    for (i, (&(first_run, start, end), comp)) in bounds.iter().zip(&compressed).enumerate() {
804        let key = if i == 0 {
805            // Everything routes to chunk 0; it needs no separator at all.
806            Vec::new()
807        } else {
808            let first = crate::dict::run_first_term(raw, start as usize).unwrap_or_default();
809            match &prev_last {
810                // Undecodable predecessor: the verbatim first term is always a
811                // valid separator, just not a short one.
812                None => first,
813                Some(pl) => crate::dict::shortest_separator(pl, &first),
814            }
815        };
816        if i + 1 < bounds.len() {
817            // The last run of THIS chunk starts one run before the next chunk's.
818            let last_run_off = meta.restart_offsets[bounds[i + 1].0 - 1] as usize;
819            prev_last = crate::dict::run_last_term(raw, last_run_off, end as usize);
820        }
821        write_uvarint(&mut out, (first_run - prev_run) as u64);
822        write_uvarint(&mut out, key.len() as u64);
823        out.extend_from_slice(&key);
824        write_uvarint(&mut out, comp.len() as u64);
825        prev_run = first_run;
826    }
827    for comp in &compressed {
828        out.extend_from_slice(comp);
829    }
830    out
831}
832
833/// A parsed chunked-dict-section directory entry: the chunk's run/key/body
834/// coordinates plus its compressed byte range *within the payload*. `key` is a
835/// routing separator, not a term — see [`crate::dict::SectionChunk::key`].
836struct DictChunkEntry {
837    first_run: usize,
838    key: Vec<u8>,
839    body_start: u64,
840    start: u64,
841    end: u64,
842}
843
844/// Parse a chunked dictionary section's header + directory (not the chunks).
845/// `bytes` may be a prefix of the payload; compressed ranges validate against
846/// `total_len`.
847fn parse_chunked_dict_dir(
848    bytes: &[u8],
849    total_len: u64,
850) -> Result<(crate::dict::SectionMeta, Vec<DictChunkEntry>), FileError> {
851    let mut pos = 0usize;
852    let take = |pos: &mut usize| -> Result<u64, FileError> {
853        let (v, n) = read_uvarint(bytes.get(*pos..).unwrap_or(&[]))
854            .ok_or(FileError::Container("truncated dict chunk directory"))?;
855        *pos += n;
856        Ok(v)
857    };
858    let header_len = take(&mut pos)? as usize;
859    let header = bytes
860        .get(pos..pos.saturating_add(header_len))
861        .ok_or(FileError::Container("truncated dict header"))?;
862    let meta = crate::dict::parse_meta(header)
863        .map_err(|_| FileError::Container("malformed dict header"))?;
864    pos += header_len;
865
866    let num_chunks = take(&mut pos)? as usize;
867    let mut entries = Vec::with_capacity(num_chunks.min(bytes.len()));
868    let mut lens = Vec::with_capacity(num_chunks.min(bytes.len()));
869    let mut prev_run = 0usize;
870    for _ in 0..num_chunks {
871        let drun = take(&mut pos)? as usize;
872        let klen = take(&mut pos)? as usize;
873        let key = bytes
874            .get(pos..pos.saturating_add(klen))
875            .ok_or(FileError::Container("truncated dict chunk key"))?
876            .to_vec();
877        pos += klen;
878        let clen = take(&mut pos)?;
879        let first_run = prev_run + drun;
880        let body_start = meta
881            .restart_offsets
882            .get(first_run)
883            .copied()
884            .ok_or(FileError::Container("dict chunk run out of range"))?;
885        entries.push(DictChunkEntry {
886            first_run,
887            key,
888            body_start,
889            start: 0,
890            end: 0,
891        });
892        lens.push(clen);
893        prev_run = first_run;
894    }
895    let mut start = pos as u64;
896    for (e, len) in entries.iter_mut().zip(lens) {
897        let end = start
898            .checked_add(len)
899            .filter(|&e| e <= total_len)
900            .ok_or(FileError::Container("dict chunk overruns section"))?;
901        e.start = start;
902        e.end = end;
903        start = end;
904    }
905    Ok((meta, entries))
906}
907
908/// Fetch and parse a remote chunked dict section's header + directory: read a
909/// small prefix and grow it geometrically until it parses, never fetching past
910/// the section.
911/// Read a chunked dictionary section's directory over a range reader WITHOUT
912/// materializing the section-wide restart table. That table is one offset per
913/// restart run — a 50 M-term section has ~3 M of them (~24 MiB resident), and
914/// holding it is an iOS-Safari OOM on a big remote file. We read only the tiny
915/// header prefix (term_count / interval) and the chunk directory, skipping the
916/// restart-table bytes entirely; per-run offsets are derived per chunk on fault
917/// (`SectionChunk::run_offsets`). The returned meta has an empty
918/// `restart_offsets`, which the chunked lookups read as "derive per chunk".
919fn read_dict_dir_ranged<R: RangeReader>(
920    reader: &R,
921    section: ByteRange,
922) -> Result<(crate::dict::SectionMeta, Vec<DictChunkEntry>), FileError> {
923    let total = section.len;
924    // Initial prefix: the header prefix ([header_len][term_count][interval]) and,
925    // for a *small* section, the whole chunk directory too — so those still cost
926    // a single read. A big section has a huge restart table between the header
927    // and the directory; we detect that (dir_start past the prefix) and range-
928    // read only the directory below, never fetching the table.
929    let init = 8192.min(total); // never over-read past the section (a tiny/empty
930                                // section holds only its header + a stub directory)
931    let head = reader.read_at(section.offset, init)?;
932    let (header_len, n0) =
933        read_uvarint(&head).ok_or(FileError::Container("truncated dict header len"))?;
934    let hbase = n0; // first byte of the header body
935    let (term_count, n1) = read_uvarint(head.get(hbase..).unwrap_or(&[]))
936        .ok_or(FileError::Container("truncated dict term_count"))?;
937    let (restart_interval, _n2) = read_uvarint(head.get(hbase + n1..).unwrap_or(&[]))
938        .ok_or(FileError::Container("truncated dict interval"))?;
939    if restart_interval == 0 {
940        return Err(FileError::Container("zero restart interval"));
941    }
942    // The chunk directory begins right after the header body — i.e. past the
943    // `header_len` bytes, which include the restart table we never materialize.
944    let dir_start = (hbase as u64)
945        .checked_add(header_len)
946        .filter(|&d| d <= total)
947        .ok_or(FileError::Container("dict header overruns section"))?;
948    let dir_total = total - dir_start;
949    let meta = crate::dict::SectionMeta {
950        term_count: term_count as u32,
951        restart_interval: restart_interval as u32,
952        restart_offsets: Vec::new(),
953    };
954    let finish = |mut entries: Vec<DictChunkEntry>| {
955        for e in &mut entries {
956            e.start += dir_start; // dir-relative → section-relative
957            e.end += dir_start;
958        }
959        (meta.clone(), entries)
960    };
961    // Fast path: the directory already sits in the prefix we read (small section
962    // — its restart table is tiny, so the ~few KiB over-read is negligible).
963    // A short prefix is not wasted: those bytes seed the probe below.
964    let mut have: Vec<u8> = Vec::new();
965    if dir_start < head.len() as u64 {
966        match parse_chunk_dir_only(&head[dir_start as usize..], dir_total)? {
967            ChunkDirParse::Done(entries) => return Ok(finish(entries)),
968            ChunkDirParse::Truncated { .. } => have = head[dir_start as usize..].to_vec(),
969        }
970    }
971    // Big section: range-read the directory on its own, skipping the table.
972    //
973    // Nothing records the directory's byte length, so it has to be probed. Two
974    // things keep that probe near ONE directory's worth of bytes. Each round
975    // **appends** to the bytes already held instead of re-reading from the
976    // start — the previous loop re-read the whole prefix every time, so a
977    // 234 MB directory (epfl-infoscience's object section) cost 537 MB of range
978    // reads to fetch. And a truncated parse reports how many entries fit in how
979    // many bytes, which extrapolates the rest instead of blindly doubling. The
980    // extrapolation may only quadruple a round, so a wild guess on a directory
981    // with wildly uneven entries (one stored term can be hundreds of KB) can
982    // never fetch the section body.
983    let mut want = 4096u64.min(dir_total).max(1);
984    loop {
985        let held = have.len() as u64;
986        if want > held {
987            let extra = reader.read_at(section.offset + dir_start + held, want - held)?;
988            if extra.is_empty() {
989                return Err(FileError::Container("truncated dict chunk directory"));
990            }
991            have.extend_from_slice(&extra);
992        }
993        let held = have.len() as u64;
994        match parse_chunk_dir_only(&have, dir_total)? {
995            ChunkDirParse::Done(entries) => return Ok(finish(entries)),
996            ChunkDirParse::Truncated { .. } if held >= dir_total => {
997                return Err(FileError::Container("truncated dict chunk directory"));
998            }
999            ChunkDirParse::Truncated {
1000                parsed,
1001                used,
1002                total,
1003            } => {
1004                // Extrapolate from a sample big enough to mean something;
1005                // otherwise just double. Undershooting only costs another
1006                // (append-only) round, so the estimate carries a small margin
1007                // rather than a generous one.
1008                let est = if parsed >= 16 && used > 0 {
1009                    let whole = ((used as u64) / (parsed as u64)).saturating_mul(total as u64);
1010                    whole.saturating_add(whole / 8).saturating_add(64)
1011                } else {
1012                    // Too small a sample to extrapolate from: double.
1013                    held.saturating_mul(2)
1014                };
1015                want = est
1016                    .max(held.saturating_add(1))
1017                    .min(held.saturating_mul(4))
1018                    .min(dir_total);
1019            }
1020        }
1021    }
1022}
1023
1024/// What [`parse_chunk_dir_only`] made of a chunk-directory prefix: either the
1025/// whole directory, or how far a truncated one got (the probe's step hint).
1026/// Truncation is not an error here — the caller is deliberately reading a
1027/// prefix and deciding how much more to fetch.
1028enum ChunkDirParse {
1029    Done(Vec<DictChunkEntry>),
1030    Truncated {
1031        /// Entries fully decoded from the prefix.
1032        parsed: usize,
1033        /// Bytes those entries occupied (directory-relative).
1034        used: usize,
1035        /// Chunk count the directory declares.
1036        total: usize,
1037    },
1038}
1039
1040/// Parse just the chunk directory (the bytes after a section header):
1041/// `[num_chunks][per chunk: Δfirst_run, key_len, key, comp_len]`.
1042/// Chunk byte ranges (`start`/`end`) come back relative to the directory's own
1043/// start; `body_start` is 0 (a lite section never uses it — lookups derive run
1044/// offsets per chunk). Bodies aren't needed here, so `dir` may end at the first
1045/// body as long as it covers the whole directory. A `dir` that stops mid-entry
1046/// is reported as [`ChunkDirParse::Truncated`], not an error: only bytes that
1047/// cannot be a directory at any length (a chunk range overrunning the section)
1048/// fail.
1049fn parse_chunk_dir_only(dir: &[u8], dir_total: u64) -> Result<ChunkDirParse, FileError> {
1050    let mut pos = 0usize;
1051    let take = |pos: &mut usize| -> Option<u64> {
1052        let (v, n) = read_uvarint(dir.get(*pos..).unwrap_or(&[]))?;
1053        *pos += n;
1054        Some(v)
1055    };
1056    let Some(num_chunks) = take(&mut pos) else {
1057        return Ok(ChunkDirParse::Truncated {
1058            parsed: 0,
1059            used: 0,
1060            total: 0,
1061        });
1062    };
1063    let num_chunks = num_chunks as usize;
1064    let mut entries = Vec::with_capacity(num_chunks.min(dir.len()));
1065    let mut lens: Vec<u64> = Vec::with_capacity(num_chunks.min(dir.len()));
1066    let mut prev_run = 0usize;
1067    for _ in 0..num_chunks {
1068        let entry_start = pos;
1069        let short = ChunkDirParse::Truncated {
1070            parsed: entries.len(),
1071            used: entry_start,
1072            total: num_chunks,
1073        };
1074        let (Some(drun), Some(klen)) = (take(&mut pos), take(&mut pos)) else {
1075            return Ok(short);
1076        };
1077        let (drun, klen) = (drun as usize, klen as usize);
1078        let Some(key) = dir.get(pos..pos.saturating_add(klen)) else {
1079            return Ok(short);
1080        };
1081        let key = key.to_vec();
1082        pos += klen;
1083        let Some(clen) = take(&mut pos) else {
1084            return Ok(short);
1085        };
1086        let first_run = prev_run + drun;
1087        entries.push(DictChunkEntry {
1088            first_run,
1089            key,
1090            body_start: 0,
1091            start: 0,
1092            end: 0,
1093        });
1094        lens.push(clen);
1095        prev_run = first_run;
1096    }
1097    let mut start = pos as u64;
1098    for (e, len) in entries.iter_mut().zip(lens) {
1099        let end = start
1100            .checked_add(len)
1101            .filter(|&e| e <= dir_total)
1102            .ok_or(FileError::Container("dict chunk overruns section"))?;
1103        e.start = start;
1104        e.end = end;
1105        start = end;
1106    }
1107    Ok(ChunkDirParse::Done(entries))
1108}
1109
1110/// Decode one chunked dictionary section payload into a resident
1111/// [`crate::dict::ChunkedSection`] (chunks decompressed up front — the local
1112/// open path).
1113fn decode_chunked_dict_section(
1114    payload: &[u8],
1115    codec: u8,
1116    cache: std::sync::Arc<crate::chunk_cache::ChunkCache>,
1117    section_index: u8,
1118) -> Result<crate::dict::ChunkedSection, FileError> {
1119    let (meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64)?;
1120    let chunks = entries
1121        .into_iter()
1122        .map(|e| {
1123            let body = decompress(codec, &payload[e.start as usize..e.end as usize])?;
1124            Ok((
1125                crate::dict::SectionChunk::new(e.first_run, e.key, e.body_start),
1126                body,
1127            ))
1128        })
1129        .collect::<Result<Vec<_>, FileError>>()?;
1130    Ok(crate::dict::ChunkedSection::resident(
1131        meta,
1132        chunks,
1133        cache,
1134        section_index,
1135    ))
1136}
1137
1138fn decode_dictionary_container(bytes: &[u8], codec: u8) -> Result<Dictionary, FileError> {
1139    let dsecs = decode_container(bytes, CODEC_NONE)?;
1140    if dsecs.len() != 4 {
1141        return Err(FileError::Container("expected 4 dictionary sections"));
1142    }
1143    // The four sections share one (unlimited) cache — residency is identical to
1144    // the previous per-chunk `OnceLock`s.
1145    let cache = crate::chunk_cache::ChunkCache::unlimited_arc();
1146    let mut sections = Vec::with_capacity(4);
1147    for (si, sec) in dsecs.iter().enumerate() {
1148        sections.push(decode_chunked_dict_section(
1149            sec,
1150            codec,
1151            cache.clone(),
1152            si as u8,
1153        )?);
1154    }
1155    let arr: [crate::dict::ChunkedSection; 4] = sections
1156        .try_into()
1157        .map_err(|_| FileError::Container("expected 4 dictionary sections"))?;
1158    Ok(Dictionary::from_chunked_sections(arr))
1159}
1160
1161/// Encode one permutation's tiled section payload (format v0.2):
1162/// `[num_tiles][per tile: delta(min_a), max_a - min_a, compressed_len][tiles…]`,
1163/// each tile compressed independently with `codec` so a ranged reader can
1164/// fetch and decompress exactly the tiles a query routes to. The directory
1165/// itself is uncompressed (it must be readable before any tile).
1166fn encode_tiled_section(index: &GraphIndex, si: usize, codec: u8) -> Vec<u8> {
1167    let tiles = index.tile_sections()[si];
1168    let n = tiles.len();
1169    // Tile bodies live in the index's chunk cache (keyed by (section, tile));
1170    // fetch each as a shared handle. For a locally-built index every tile is
1171    // resident, so this is a cheap `Arc` clone under an uncontended lock.
1172    // Per-tile compression is the bulk of serialization time on a large graph and
1173    // the tiles are independent, so compress them across all cores. `par_iter`
1174    // preserves order, so the output is byte-identical to the serial map.
1175    #[cfg(feature = "parallel")]
1176    let compressed: Vec<Vec<u8>> = {
1177        use rayon::prelude::*;
1178        (0..n)
1179            .into_par_iter()
1180            .map(|ti| compress(codec, &index.tile_body(si, ti)))
1181            .collect()
1182    };
1183    #[cfg(not(feature = "parallel"))]
1184    let compressed: Vec<Vec<u8>> = (0..n)
1185        .map(|ti| compress(codec, &index.tile_body(si, ti)))
1186        .collect();
1187    let mut out = Vec::new();
1188    write_uvarint(&mut out, tiles.len() as u64);
1189    let mut prev_min = 0u32;
1190    for (tile, comp) in tiles.iter().zip(&compressed) {
1191        let (min_a, max_a) = tile.leading_range();
1192        write_uvarint(&mut out, (min_a - prev_min) as u64);
1193        write_uvarint(&mut out, (max_a - min_a) as u64);
1194        write_uvarint(&mut out, comp.len() as u64);
1195        prev_min = min_a;
1196    }
1197    for comp in &compressed {
1198        out.extend_from_slice(comp);
1199    }
1200    // Tile-synopsis trailer (FLAG_TILE_SYNOPSIS): per tile, the inclusive min/max
1201    // of the two non-leading columns `(min_b, span_b, min_c, span_c)`, derived
1202    // from each tile's zone map. Appended **after** the tile payloads so a reader
1203    // that predates the flag — which locates tiles by length and stops — never
1204    // reads it (backward-compatible). A reader honoring the flag reads it from the
1205    // section tail. On the (impossible for a built tile) parse failure, emit a
1206    // full range so nothing is ever wrongly pruned.
1207    for ti in 0..n {
1208        let body = index.tile_body(si, ti);
1209        let (min_b, max_b, min_c, max_c) = match crate::triples::TripleBlock::parse(&body) {
1210            Ok(b) => {
1211                let z = b.zone();
1212                (z.min_b, z.max_b, z.min_c, z.max_c)
1213            }
1214            Err(_) => (0, u32::MAX, 0, u32::MAX),
1215        };
1216        write_uvarint(&mut out, min_b as u64);
1217        write_uvarint(&mut out, (max_b - min_b) as u64);
1218        write_uvarint(&mut out, min_c as u64);
1219        write_uvarint(&mut out, (max_c - min_c) as u64);
1220    }
1221    out
1222}
1223
1224/// A parsed v0.2 tile-directory entry: leading-id range plus the tile's byte
1225/// range *within the section payload*.
1226struct TileDirEntry {
1227    min_a: u32,
1228    max_a: u32,
1229    start: u64,
1230    end: u64,
1231}
1232
1233/// One tile's synopsis: inclusive min/max of the two non-leading columns.
1234type TileSynopsis = (u32, u32, u32, u32);
1235
1236/// Parse the **tile-synopsis trailer** (when [`FLAG_TILE_SYNOPSIS`] is set): the
1237/// `num_tiles × (min_b, span_b, min_c, span_c)` records that follow the last tile
1238/// payload, starting at `trailer_start` within `payload`. Returns one synopsis per
1239/// tile, in directory order. `payload` may be just the trailer slice (remote) or
1240/// the whole section (local); `trailer_start` is the offset of the trailer within
1241/// it. A short/garbled trailer yields `None` (the caller keeps `None` synopses —
1242/// pruning simply doesn't fire, never a wrong result).
1243fn parse_tile_synopsis(
1244    payload: &[u8],
1245    trailer_start: usize,
1246    num_tiles: usize,
1247) -> Option<Vec<TileSynopsis>> {
1248    let mut pos = trailer_start;
1249    let take = |pos: &mut usize| -> Option<u32> {
1250        let (v, n) = read_uvarint(payload.get(*pos..)?)?;
1251        *pos += n;
1252        u32::try_from(v).ok()
1253    };
1254    let mut out = Vec::with_capacity(num_tiles.min(payload.len()));
1255    for _ in 0..num_tiles {
1256        let min_b = take(&mut pos)?;
1257        let max_b = min_b.checked_add(take(&mut pos)?)?;
1258        let min_c = take(&mut pos)?;
1259        let max_c = min_c.checked_add(take(&mut pos)?)?;
1260        out.push((min_b, max_b, min_c, max_c));
1261    }
1262    Some(out)
1263}
1264
1265/// Parse a tiled section payload's directory (not the tiles). `bytes` may be a
1266/// **prefix** of the payload (a ranged reader fetches the directory before any
1267/// tile); tile byte ranges are validated against `total_len`, the full payload
1268/// length. Every length is untrusted.
1269fn parse_tile_directory(bytes: &[u8], total_len: u64) -> Result<Vec<TileDirEntry>, FileError> {
1270    let mut pos = 0usize;
1271    let take = |pos: &mut usize| -> Result<u64, FileError> {
1272        let (v, n) = read_uvarint(bytes.get(*pos..).unwrap_or(&[]))
1273            .ok_or(FileError::Container("truncated tile directory"))?;
1274        *pos += n;
1275        Ok(v)
1276    };
1277    let num_tiles = take(&mut pos)? as usize;
1278    let mut entries = Vec::with_capacity(num_tiles.min(bytes.len()));
1279    let mut prev_min = 0u32;
1280    let mut lens = Vec::with_capacity(num_tiles.min(bytes.len()));
1281    for _ in 0..num_tiles {
1282        let dmin = take(&mut pos)? as u32;
1283        let span = take(&mut pos)? as u32;
1284        let len = take(&mut pos)?;
1285        let min_a = prev_min.wrapping_add(dmin);
1286        entries.push(TileDirEntry {
1287            min_a,
1288            max_a: min_a.wrapping_add(span),
1289            start: 0,
1290            end: 0,
1291        });
1292        lens.push(len);
1293        prev_min = min_a;
1294    }
1295    let mut start = pos as u64;
1296    for (e, len) in entries.iter_mut().zip(lens) {
1297        let end = start
1298            .checked_add(len)
1299            .filter(|&e| e <= total_len)
1300            .ok_or(FileError::Container("tile overruns section"))?;
1301        e.start = start;
1302        e.end = end;
1303        start = end;
1304    }
1305    Ok(entries)
1306}
1307
1308/// Fetch and parse a remote tiled section's directory: read a small prefix and
1309/// grow it geometrically until the directory parses, never fetching past the
1310/// section. A directory that still fails on the whole section is corrupt.
1311fn read_tile_directory_ranged<R: RangeReader>(
1312    reader: &R,
1313    section: ByteRange,
1314) -> Result<Vec<TileDirEntry>, FileError> {
1315    let total = section.len;
1316    let mut prefetch = 4096u64.min(total);
1317    loop {
1318        let prefix = reader.read_at(section.offset, prefetch)?;
1319        match parse_tile_directory(&prefix, total) {
1320            Ok(dir) => return Ok(dir),
1321            Err(_) if prefetch < total => prefetch = prefetch.saturating_mul(2).min(total),
1322            Err(e) => return Err(e),
1323        }
1324    }
1325}
1326
1327/// Fetch and parse a remote section's **tile-synopsis trailer** (only when the
1328/// header's [`FLAG_TILE_SYNOPSIS`] is set): one targeted range read of the bytes
1329/// past the last tile, parsed into one synopsis per tile (directory order). A
1330/// missing/short/garbled trailer degrades to all-`None` — pruning simply doesn't
1331/// fire, never a wrong result. The directory gives the trailer's start (the last
1332/// tile's end).
1333fn read_tile_synopsis_ranged<R: RangeReader>(
1334    reader: &R,
1335    section: ByteRange,
1336    dir: &[TileDirEntry],
1337) -> Vec<Option<TileSynopsis>> {
1338    let n = dir.len();
1339    let none = vec![None; n];
1340    let trailer_start = dir.iter().map(|e| e.end).max().unwrap_or(0);
1341    let total = section.len;
1342    if n == 0 || trailer_start >= total {
1343        return none; // no trailer bytes present
1344    }
1345    let trailer_len = total - trailer_start;
1346    let Ok(bytes) = reader.read_at(section.offset + trailer_start, trailer_len) else {
1347        return none;
1348    };
1349    match parse_tile_synopsis(&bytes, 0, n) {
1350        Some(v) => v.into_iter().map(Some).collect(),
1351        None => none,
1352    }
1353}
1354
1355/// Per-tile absolute file ranges of each permutation section, for provenance.
1356/// A malformed directory yields an empty section (provenance degrades, queries
1357/// are unaffected).
1358fn tile_file_ranges(
1359    index_bytes: &[u8],
1360    container_offset: u64,
1361    section_ranges: &[ByteRange; NUM_PERMS],
1362) -> [Vec<(u32, u32, ByteRange)>; NUM_PERMS] {
1363    let mut out: [Vec<(u32, u32, ByteRange)>; NUM_PERMS] = Default::default();
1364    for (section, range) in out.iter_mut().zip(section_ranges) {
1365        // A permutation the file does not carry has a zeroed range. Its
1366        // `offset - container_offset` underflows, and before this guard it
1367        // panicked the whole `open_ranged` path on the first lean file.
1368        if range.len == 0 || range.offset < container_offset {
1369            continue;
1370        }
1371        let start = (range.offset - container_offset) as usize;
1372        let Some(payload) = index_bytes.get(start..start + range.len as usize) else {
1373            continue;
1374        };
1375        if let Ok(dir) = parse_tile_directory(payload, payload.len() as u64) {
1376            *section = dir
1377                .into_iter()
1378                .map(|e| {
1379                    (
1380                        e.min_a,
1381                        e.max_a,
1382                        ByteRange {
1383                            offset: range.offset + e.start,
1384                            len: (e.end - e.start),
1385                        },
1386                    )
1387                })
1388                .collect();
1389        }
1390    }
1391    out
1392}
1393
1394/// Decode a tiled section payload into `(min_a, max_a, uncompressed tile)`
1395/// triples.
1396fn decode_tiled_section(payload: &[u8], codec: u8) -> Result<Vec<(u32, u32, Vec<u8>)>, FileError> {
1397    parse_tile_directory(payload, payload.len() as u64)?
1398        .into_iter()
1399        .map(|e| {
1400            Ok((
1401                e.min_a,
1402                e.max_a,
1403                decompress(codec, &payload[e.start as usize..e.end as usize])?,
1404            ))
1405        })
1406        .collect()
1407}
1408
1409/// Decode the index container: one raw tiled section payload per permutation
1410/// the file carries (`perms`, from the header — [`PermSet::ALL`] for every file
1411/// written before the mask existed), each tile compressed individually.
1412///
1413/// The section count is checked against `perms.len()`, so a file whose header
1414/// and container disagree is rejected rather than silently short-read. A reader
1415/// that does not know about lean files at all reaches the same conclusion by a
1416/// different route: it passes six, the container says three, and this errors —
1417/// which is why a lean file cannot be misread as an empty one.
1418fn decode_index_container(
1419    bytes: &[u8],
1420    codec: u8,
1421    perms: PermSet,
1422) -> Result<GraphIndex, FileError> {
1423    let mut isecs = decode_container(bytes, CODEC_NONE)?;
1424    if isecs.len() != perms.len() {
1425        return Err(FileError::Container(
1426            "index container section count does not match the header permutation mask",
1427        ));
1428    }
1429    let mut sections: [Vec<(u32, u32, Vec<u8>)>; NUM_PERMS] = Default::default();
1430    for (perm, sec) in perms.iter().zip(isecs.iter_mut()) {
1431        sections[perm.section_index()] = decode_tiled_section(sec, codec)?;
1432    }
1433    Ok(GraphIndex::from_tiles(sections, perms))
1434}
1435
1436fn container_section_payload_ranges(
1437    bytes: &[u8],
1438    container_offset: u64,
1439    expected_sections: usize,
1440) -> Result<Vec<ByteRange>, FileError> {
1441    let (section_count, mut pos) =
1442        read_uvarint(bytes).ok_or(FileError::Container("truncated count"))?;
1443    let section_count = usize::try_from(section_count)
1444        .map_err(|_| FileError::Container("section count too large"))?;
1445    if section_count != expected_sections {
1446        return Err(FileError::Container("unexpected section count"));
1447    }
1448
1449    let mut ranges = Vec::with_capacity(section_count);
1450    for _ in 0..section_count {
1451        let remaining = bytes
1452            .get(pos..)
1453            .ok_or(FileError::Container("truncated length"))?;
1454        let (payload_len, used) =
1455            read_uvarint(remaining).ok_or(FileError::Container("truncated length"))?;
1456        pos = pos
1457            .checked_add(used)
1458            .ok_or(FileError::Container("section range overflows"))?;
1459        let payload_len_usize = usize::try_from(payload_len)
1460            .map_err(|_| FileError::Container("section length too large"))?;
1461        let payload_end = pos
1462            .checked_add(payload_len_usize)
1463            .ok_or(FileError::Container("section range overflows"))?;
1464        if payload_end > bytes.len() {
1465            return Err(FileError::Container("section overruns buffer"));
1466        }
1467        ranges.push(ByteRange {
1468            offset: checked_end(container_offset, pos as u64)?,
1469            len: payload_len,
1470        });
1471        pos = payload_end;
1472    }
1473
1474    Ok(ranges)
1475}
1476
1477fn decode_index_section_ranges(
1478    bytes: &[u8],
1479    container_offset: u64,
1480    perms: PermSet,
1481) -> Result<[ByteRange; NUM_PERMS], FileError> {
1482    let ranges = container_section_payload_ranges(bytes, container_offset, perms.len())?;
1483    let mut out = [ByteRange { offset: 0, len: 0 }; NUM_PERMS];
1484    for (perm, range) in perms.iter().zip(ranges) {
1485        out[perm.section_index()] = range;
1486    }
1487    Ok(out)
1488}
1489
1490fn read_uvarint_at<R: RangeReader>(
1491    reader: &R,
1492    absolute_offset: u64,
1493    container_end: u64,
1494) -> Result<(u64, u64), FileError> {
1495    if absolute_offset >= container_end {
1496        return Err(FileError::Container("truncated container varint"));
1497    }
1498    let remaining = container_end - absolute_offset;
1499    let probe_len = remaining.min(10);
1500    let bytes = reader.read_at(absolute_offset, probe_len)?;
1501    read_uvarint(&bytes)
1502        .map(|(value, used)| (value, used as u64))
1503        .ok_or(FileError::Container("truncated container varint"))
1504}
1505
1506/// Locate one section's payload byte range inside a remote container, walking
1507/// only the (tiny) varint framing — no payload bytes are fetched.
1508fn locate_container_section_ranged<R: RangeReader>(
1509    reader: &R,
1510    container_offset: u64,
1511    container_len: u64,
1512    section_index: usize,
1513    expected_sections: u64,
1514) -> Result<ByteRange, FileError> {
1515    let container_end = checked_end(container_offset, container_len)?;
1516    let (section_count, used) = read_uvarint_at(reader, container_offset, container_end)?;
1517    if section_count != expected_sections {
1518        return Err(FileError::Container("unexpected container section count"));
1519    }
1520    if section_index >= section_count as usize {
1521        return Err(FileError::Container(
1522            "container section index out of bounds",
1523        ));
1524    }
1525
1526    let mut pos = checked_end(container_offset, used)?;
1527    for i in 0..section_count as usize {
1528        let (payload_len, len_used) = read_uvarint_at(reader, pos, container_end)?;
1529        pos = checked_end(pos, len_used)?;
1530        let payload_end = checked_end(pos, payload_len)?;
1531        if payload_end > container_end {
1532            return Err(FileError::Container("section overruns buffer"));
1533        }
1534        if i == section_index {
1535            return Ok(ByteRange {
1536                offset: pos,
1537                len: payload_len,
1538            });
1539        }
1540        pos = payload_end;
1541    }
1542    Err(FileError::Container("container section not found"))
1543}
1544
1545/// Open one index container (six tiled permutation sections) **lazily** over a
1546/// range reader: fetch each section's tile directory (and synopsis trailer,
1547/// when the file has one) but no tile payloads — tiles fault in on first scan.
1548/// Returns the index plus its section and tile file ranges (provenance).
1549///
1550/// This is the machinery `open_ranged_lazy` always used for the default
1551/// graph's container at `root_dir_offset`; a named graph's container is the
1552/// same format at a different offset, so the lazy named-graphs path opens a
1553/// LARGE graph through this too instead of decoding it resident.
1554#[allow(clippy::type_complexity)]
1555fn open_index_container_lazy(
1556    reader: &std::sync::Arc<dyn RangeReader + Send + Sync>,
1557    container: ByteRange,
1558    block_codec: u8,
1559    has_synopsis: bool,
1560    read_concurrency: usize,
1561    perms: PermSet,
1562) -> Result<
1563    (
1564        GraphIndex,
1565        [ByteRange; NUM_PERMS],
1566        [Vec<(u32, u32, ByteRange)>; NUM_PERMS],
1567    ),
1568    FileError,
1569> {
1570    let mut index_section_ranges = [ByteRange { offset: 0, len: 0 }; NUM_PERMS];
1571    let mut tile_ranges: [Vec<(u32, u32, ByteRange)>; NUM_PERMS] = Default::default();
1572    #[allow(clippy::type_complexity)]
1573    let mut directories: [Vec<(u32, u32, Option<TileSynopsis>)>; NUM_PERMS] = Default::default();
1574    for (pos, perm) in perms.iter().enumerate() {
1575        let si = perm.section_index();
1576        let section = locate_container_section_ranged(
1577            reader,
1578            container.offset,
1579            container.len,
1580            pos,
1581            perms.len() as u64,
1582        )?;
1583        index_section_ranges[si] = section;
1584        let dir = read_tile_directory_ranged(reader, section)?;
1585        // Tile synopses (one extra small tail read per section) let a routed
1586        // scan prune a tile by a bound secondary component before faulting it.
1587        let syn = if has_synopsis {
1588            read_tile_synopsis_ranged(reader, section, &dir)
1589        } else {
1590            vec![None; dir.len()]
1591        };
1592        directories[si] = dir
1593            .iter()
1594            .zip(syn)
1595            .map(|(e, s)| (e.min_a, e.max_a, s))
1596            .collect();
1597        tile_ranges[si] = dir
1598            .into_iter()
1599            .map(|e| {
1600                (
1601                    e.min_a,
1602                    e.max_a,
1603                    ByteRange {
1604                        offset: section.offset + e.start,
1605                        len: (e.end - e.start),
1606                    },
1607                )
1608            })
1609            .collect();
1610    }
1611
1612    // The loader fetches and decompresses one tile per call; the bulk
1613    // loader serves multi-tile scans by coalescing adjacent tile ranges
1614    // into single range reads (tiles are back-to-back in their section,
1615    // so a full-section scan is typically one request).
1616    let codec = block_codec;
1617    let loader_ranges = tile_ranges.clone();
1618    let loader_reader = reader.clone();
1619    let loader: crate::index::TileLoader = Box::new(move |si, ti| {
1620        let (_, _, range) = loader_ranges.get(si)?.get(ti)?;
1621        let bytes = loader_reader.read_at(range.offset, range.len).ok()?;
1622        decompress(codec, &bytes).ok()
1623    });
1624    let bulk_ranges = tile_ranges.clone();
1625    let bulk_reader = reader.clone();
1626    let bulk: crate::index::TileBulkLoader = Box::new(move |si, tis| {
1627        let section = bulk_ranges.get(si)?;
1628        let want: Option<Vec<ByteRange>> = tis
1629            .iter()
1630            .map(|&ti| section.get(ti).map(|&(_, _, r)| r))
1631            .collect();
1632        let blobs = read_coalesced(bulk_reader.as_ref(), &want?, TILE_COALESCE_GAP)?;
1633        blobs.iter().map(|b| decompress(codec, b).ok()).collect()
1634    });
1635    let mut index =
1636        GraphIndex::from_remote_directories(directories, perms, loader).with_bulk_loader(bulk);
1637    // Per-tile encoded lengths (from the directory) feed the join planner's
1638    // fatness gates — free here, unavailable later without a fetch.
1639    index.set_tile_lens(std::array::from_fn(|si| {
1640        tile_ranges[si]
1641            .iter()
1642            .map(|&(_, _, r)| r.len.min(u32::MAX as u64) as u32)
1643            .collect()
1644    }));
1645    // The reader's fan-out widens the planner's remote probe budget: a
1646    // desktop/CLI reader overlapping 16 range reads probes far more cheaply
1647    // than a phone's serial sync-XHR path.
1648    index.set_read_concurrency(read_concurrency);
1649    Ok((index, index_section_ranges, tile_ranges))
1650}
1651
1652/// Serialize a complete `.rete` file image from a dictionary, index, and an
1653/// (optionally empty) encoded pyramid-meta section. `pyramid_levels` records the
1654/// number of dendrogram rounds the pyramid spans (0 if no pyramid).
1655pub fn write_file(
1656    dict: &Dictionary,
1657    index: &GraphIndex,
1658    has_quads: bool,
1659    pyramid_meta: &[u8],
1660    pyramid_levels: u16,
1661) -> Vec<u8> {
1662    write_dataset(dict, index, &[], has_quads, pyramid_meta, pyramid_levels)
1663}
1664
1665/// Encode an index container (v0.2): one raw tiled section payload per
1666/// permutation the index carries, in [`crate::index::ALL_PERMS`] order, tiles
1667/// compressed individually with `codec`. A three-permutation index writes three
1668/// sections — not six with three empty, which would be indistinguishable from
1669/// an empty graph to a reader that does not check the header mask.
1670pub(crate) fn encode_index_container(index: &GraphIndex, codec: u8) -> Vec<u8> {
1671    let payloads: Vec<Vec<u8>> = index
1672        .perms()
1673        .iter()
1674        .map(|perm| encode_tiled_section(index, perm.section_index(), codec))
1675        .collect();
1676    let refs: Vec<&[u8]> = payloads.iter().map(|p| p.as_slice()).collect();
1677    encode_container(&refs, CODEC_NONE)
1678}
1679
1680/// Encode the named-graphs section: each graph as `(iri, permutation container)`.
1681fn encode_named_graphs(named: &[(String, GraphIndex)], codec: u8) -> Vec<u8> {
1682    let mut out = Vec::new();
1683    write_uvarint(&mut out, named.len() as u64);
1684    for (iri, index) in named {
1685        write_uvarint(&mut out, iri.len() as u64);
1686        out.extend_from_slice(iri.as_bytes());
1687        let container = encode_index_container(index, codec);
1688        write_uvarint(&mut out, container.len() as u64);
1689        out.extend_from_slice(&container);
1690    }
1691    out
1692}
1693
1694fn decode_named_graphs(
1695    bytes: &[u8],
1696    codec: u8,
1697    perms: PermSet,
1698) -> Result<Vec<(String, GraphIndex)>, FileError> {
1699    let (n, mut pos) = read_uvarint(bytes).ok_or(FileError::Container("truncated graph count"))?;
1700    // Bounds-checked slice within this (already bounded) section. Lengths read
1701    // below are untrusted, so every range is validated before indexing.
1702    let bound = |start: usize, len: u64| -> Result<usize, FileError> {
1703        start
1704            .checked_add(len as usize)
1705            .filter(|&e| e <= bytes.len())
1706            .ok_or(FileError::Container("named-graph field overruns buffer"))
1707    };
1708    let mut out = Vec::with_capacity((n as usize).min(bytes.len()));
1709    for _ in 0..n {
1710        let (ilen, u1) = read_uvarint(bytes.get(pos..).unwrap_or(&[]))
1711            .ok_or(FileError::Container("truncated iri len"))?;
1712        pos += u1;
1713        let iend = bound(pos, ilen)?;
1714        let iri = String::from_utf8_lossy(&bytes[pos..iend]).into_owned();
1715        pos = iend;
1716        let (clen, u2) = read_uvarint(bytes.get(pos..).unwrap_or(&[]))
1717            .ok_or(FileError::Container("truncated container len"))?;
1718        pos += u2;
1719        let cend = bound(pos, clen)?;
1720        let index = decode_index_container(&bytes[pos..cend], codec, perms)?;
1721        out.push((iri, index));
1722        pos = cend;
1723    }
1724    Ok(out)
1725}
1726
1727/// Serialize a full RDF *dataset*: the default-graph index plus zero or more
1728/// named graphs `(iri, index)`, all sharing one dictionary.
1729pub fn write_dataset(
1730    dict: &Dictionary,
1731    default_index: &GraphIndex,
1732    named: &[(String, GraphIndex)],
1733    has_quads: bool,
1734    pyramid_meta: &[u8],
1735    pyramid_levels: u16,
1736) -> Vec<u8> {
1737    write_dataset_with_metadata(
1738        dict,
1739        default_index,
1740        named,
1741        has_quads,
1742        pyramid_meta,
1743        pyramid_levels,
1744        &[],
1745        &[],
1746    )
1747}
1748
1749/// Serialize a dictionary to its on-file container bytes (4 front-coded, chunked
1750/// sections). Exposed so a low-RAM build can serialize **and drop** the live
1751/// `Dictionary` before building the permutation index — the index build works on
1752/// id-triples and never needs the dictionary.
1753pub(crate) fn encode_dict_container(dict: &Dictionary, codec: u8) -> Vec<u8> {
1754    let raw_sections = dict.sections();
1755    let dict_payloads: Vec<Vec<u8>> = raw_sections
1756        .iter()
1757        .map(|raw| encode_chunked_dict_section(raw, codec))
1758        .collect();
1759    encode_container(
1760        &[
1761            dict_payloads[0].as_slice(),
1762            dict_payloads[1].as_slice(),
1763            dict_payloads[2].as_slice(),
1764            dict_payloads[3].as_slice(),
1765        ],
1766        CODEC_NONE,
1767    )
1768}
1769
1770/// Serialize a dataset with an opaque **metadata** payload occupying the file's
1771/// metadata section (the application layer defines its meaning — the CLI stores a
1772/// JSON Dataset Card there). The section sits immediately after the header and
1773/// before the dictionary, so `metadata_offset` stays at `HEADER_LEN` and every
1774/// downstream section shifts by `metadata.len()`. The payload is folded into the
1775/// `content_hash`, so `verify` covers it and it is tamper-evident.
1776///
1777/// Passing an empty `metadata` is byte-identical to [`write_dataset`]: the section
1778/// is omitted (`metadata_len = 0`, `dictionary_offset = HEADER_LEN`) and the hash
1779/// is computed over exactly the same parts (a zero-length hash update is a no-op).
1780#[allow(clippy::too_many_arguments)]
1781pub fn write_dataset_with_metadata(
1782    dict: &Dictionary,
1783    default_index: &GraphIndex,
1784    named: &[(String, GraphIndex)],
1785    has_quads: bool,
1786    pyramid_meta: &[u8],
1787    pyramid_levels: u16,
1788    metadata: &[u8],
1789    text_index: &[u8],
1790) -> Vec<u8> {
1791    let codec = writer_codec();
1792    let dict_container = encode_dict_container(dict, codec);
1793    write_dataset_from_parts(
1794        &dict_container,
1795        dict.term_count() as u64,
1796        default_index,
1797        named,
1798        has_quads,
1799        dict.has_quoted_triples(),
1800        pyramid_meta,
1801        pyramid_levels,
1802        metadata,
1803        text_index,
1804        codec,
1805    )
1806}
1807
1808/// Assemble the final file image from an **already-serialized** dictionary
1809/// container (so the caller can drop the live `Dictionary` before calling this)
1810/// plus the permutation index and optional sections. The byte output is identical
1811/// to serializing the dictionary inline.
1812#[allow(clippy::too_many_arguments)]
1813pub(crate) fn write_dataset_from_parts(
1814    dict_container: &[u8],
1815    term_count: u64,
1816    default_index: &GraphIndex,
1817    named: &[(String, GraphIndex)],
1818    has_quads: bool,
1819    has_quoted_triples: bool,
1820    pyramid_meta: &[u8],
1821    pyramid_levels: u16,
1822    metadata: &[u8],
1823    text_index: &[u8],
1824    codec: u8,
1825) -> Vec<u8> {
1826    let index_container = encode_index_container(default_index, codec);
1827    let named_section = encode_named_graphs(named, codec);
1828
1829    // The metadata section (if any) sits between the header and the dictionary,
1830    // so the dictionary — and everything after it — shifts forward by its length.
1831    let meta_section_len = metadata.len() as u64;
1832    let dict_offset = HEADER_LEN as u64 + meta_section_len;
1833    let dict_len = dict_container.len() as u64;
1834    let index_offset = dict_offset + dict_len;
1835    let index_len = index_container.len() as u64;
1836    let pyr_offset = index_offset + index_len;
1837    let pyr_len = pyramid_meta.len() as u64;
1838    // Optional full-text index between the pyramid and the named graphs.
1839    let text_offset = pyr_offset + pyr_len;
1840    let text_len = text_index.len() as u64;
1841    let named_offset = text_offset + text_len;
1842    let named_len = if named.is_empty() {
1843        0
1844    } else {
1845        named_section.len() as u64
1846    };
1847
1848    // Hash parts in physical order, with the metadata payload prepended when
1849    // present. Omitting it entirely (rather than hashing an empty slice) keeps the
1850    // no-metadata output's hash byte-identical to the pre-metadata writer.
1851    // `verify()` rebuilds this exact list from the header — any section added
1852    // here must be added there too (and covered by a tamper test).
1853    let mut parts: Vec<&[u8]> = Vec::with_capacity(5);
1854    if meta_section_len > 0 {
1855        parts.push(metadata);
1856    }
1857    parts.push(dict_container);
1858    parts.push(&index_container);
1859    parts.push(pyramid_meta);
1860    if text_len > 0 {
1861        parts.push(text_index);
1862    }
1863    if named_len > 0 {
1864        parts.push(&named_section);
1865    }
1866
1867    // Length of the trailing schema-pyramid block (0 if none), so a reader can
1868    // fetch just that block for an index/dictionary/summary-free Tier-0 read.
1869    let schema_meta_len = crate::meta::schema_block_len(pyramid_meta);
1870
1871    let header = Header {
1872        version: crate::header::CURRENT_FORMAT_VERSION,
1873        flags: FLAG_TILE_SYNOPSIS
1874            | if has_quads { FLAG_HAS_QUADS } else { 0 }
1875            | if has_quoted_triples {
1876                FLAG_HAS_QUOTED_TRIPLES
1877            } else {
1878                0
1879            },
1880        metadata_offset: HEADER_LEN as u64,
1881        metadata_len: meta_section_len,
1882        dictionary_offset: dict_offset,
1883        dictionary_len: dict_len,
1884        root_dir_offset: index_offset,
1885        root_dir_len: index_len,
1886        pyramid_meta_offset: if pyr_len > 0 { pyr_offset } else { 0 },
1887        pyramid_meta_len: pyr_len,
1888        dict_codec: codec,
1889        block_codec: codec,
1890        pyramid_levels,
1891        // Every container in the file — default graph and named graphs alike —
1892        // is written from an index built with the same set, so the mask is a
1893        // file-level fact and the default index is its source of truth.
1894        perms: default_index.perms(),
1895        quad_count: default_index.triple_count() as u64
1896            + named
1897                .iter()
1898                .map(|(_, idx)| idx.triple_count() as u64)
1899                .sum::<u64>(),
1900        term_count,
1901        content_hash: content_hash(&parts),
1902        named_graphs_offset: if named_len > 0 { named_offset } else { 0 },
1903        named_graphs_len: named_len,
1904        schema_meta_len,
1905        text_index_offset: if text_len > 0 { text_offset } else { 0 },
1906        text_index_len: text_len,
1907        build_info_offset: 0,
1908        build_info_len: 0,
1909        extra_sections: Vec::new(),
1910    };
1911
1912    let mut out = Vec::with_capacity(
1913        HEADER_LEN
1914            + metadata.len()
1915            + dict_container.len()
1916            + index_container.len()
1917            + pyramid_meta.len()
1918            + text_index.len()
1919            + named_section.len()
1920            + MAGIC.len(),
1921    );
1922    out.extend_from_slice(&header.to_bytes());
1923    if meta_section_len > 0 {
1924        out.extend_from_slice(metadata);
1925    }
1926    out.extend_from_slice(dict_container);
1927    out.extend_from_slice(&index_container);
1928    out.extend_from_slice(pyramid_meta);
1929    if text_len > 0 {
1930        out.extend_from_slice(text_index);
1931    }
1932    if named_len > 0 {
1933        out.extend_from_slice(&named_section);
1934    }
1935    out.extend_from_slice(&MAGIC); // footer marker
1936    out
1937}
1938
1939/// `rdf:type` — the predicate that assigns a class to a resource.
1940pub const RDF_TYPE: &str = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
1941
1942/// An **ontology-aware** coarse graph: instead of structural communities, group
1943/// entities by their `rdf:type` class and aggregate relations between classes.
1944/// Returns `(subject_class, predicate, object_class, count)` over the default
1945/// graph. Entities with no type are `(untyped)`; literals are `(literal)`.
1946/// `rdf:type` triples themselves define the classes and are not counted as
1947/// relations. This is the dataset's effective schema with instance volumes.
1948pub fn schema_summary(rete: &Rete) -> Vec<(String, String, String, u32)> {
1949    use std::collections::{BTreeMap, HashMap};
1950    let triples = rete.dump(None);
1951
1952    let mut class_of: HashMap<&str, &str> = HashMap::new();
1953    for (s, p, o) in &triples {
1954        if p == RDF_TYPE {
1955            class_of.insert(s.as_str(), o.as_str());
1956        }
1957    }
1958    let classify = |t: &str| -> String {
1959        if let Some(c) = class_of.get(t) {
1960            (*c).to_string()
1961        } else if t.starts_with('"') {
1962            "(literal)".to_string()
1963        } else {
1964            "(untyped)".to_string()
1965        }
1966    };
1967
1968    let mut counts: BTreeMap<(String, String, String), u32> = BTreeMap::new();
1969    for (s, p, o) in &triples {
1970        if p == RDF_TYPE {
1971            continue; // type assertions define classes, not data relations
1972        }
1973        *counts
1974            .entry((classify(s), p.clone(), classify(o)))
1975            .or_default() += 1;
1976    }
1977    counts
1978        .into_iter()
1979        .map(|((a, p, b), c)| (a, p, b, c))
1980        .collect()
1981}
1982
1983/// Class populations: the number of resources of each `rdf:type` class in the
1984/// default graph, descending by count. The instance-count companion to
1985/// [`schema_summary`].
1986pub fn schema_classes(rete: &Rete) -> Vec<(String, u32)> {
1987    use std::collections::BTreeMap;
1988    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
1989    for (_s, p, o) in rete.dump(None) {
1990        if p == RDF_TYPE {
1991            *counts.entry(o).or_default() += 1;
1992        }
1993    }
1994    let mut out: Vec<(String, u32)> = counts.into_iter().collect();
1995    out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1996    out
1997}
1998
1999/// Fetch **only** the metadata section (the opaque Dataset Card blob) via a
2000/// [`RangeReader`]: read the 1 KiB header, then the metadata byte range —
2001/// nothing else. This is the index-free CARD tier of the exploration model: a
2002/// remote/S3 client learns the dataset's self-description in **two small range
2003/// requests**, never touching the dictionary, index, or pyramid. Returns `None`
2004/// when the file carries no metadata.
2005///
2006/// Companion to [`Rete::open_ranged`] (which deliberately *skips* the card to
2007/// keep the query path minimal); this is the explicit "I want the card" path.
2008pub fn read_metadata_ranged<R: RangeReader>(reader: &R) -> Result<Option<Vec<u8>>, FileError> {
2009    let head = reader.read_at(0, HEADER_LEN as u64)?;
2010    let header = Header::from_bytes(&head)?;
2011    if header.metadata_len == 0 {
2012        return Ok(None);
2013    }
2014    let bytes = reader.read_at(header.metadata_offset, header.metadata_len)?;
2015    Ok(Some(bytes))
2016}
2017
2018/// Fetch the metadata (Dataset Card) **and** build-info sections via a
2019/// [`RangeReader`] in the fewest requests: the 1 KiB header, then — because the
2020/// writer lays the build-info section immediately after the metadata — **one**
2021/// coalesced range covering both. A file with only one of the two costs the
2022/// same two requests; a file with neither costs just the header read. This
2023/// keeps "card + build conditions" within the CARD tier's 1 header + 1 range
2024/// budget instead of adding a third request.
2025///
2026/// Returns `(metadata, build_info)`, each `None` when absent.
2027#[allow(clippy::type_complexity)]
2028pub fn read_card_and_build_info_ranged<R: RangeReader>(
2029    reader: &R,
2030) -> Result<(Option<Vec<u8>>, Option<Vec<u8>>), FileError> {
2031    read_card_and_build_info_with_header(reader).map(|(_, m, b)| (m, b))
2032}
2033
2034/// [`read_card_and_build_info_ranged`], also returning the parsed [`Header`] it
2035/// had to read anyway.
2036///
2037/// The header is not a bonus: it carries the content hash and the section
2038/// directory — including whether the file has a TEXT_INDEX section, which the
2039/// card does not state (see
2040/// [`read_text_index_token_table_len_ranged`]). A caller that wants both and
2041/// used the tuple-only form would issue a **second** 1 KiB request for bytes it
2042/// already had, turning the CARD tier's documented "one header + one coalesced
2043/// range" into three requests.
2044#[allow(clippy::type_complexity)]
2045pub fn read_card_and_build_info_with_header<R: RangeReader>(
2046    reader: &R,
2047) -> Result<(Header, Option<Vec<u8>>, Option<Vec<u8>>), FileError> {
2048    let head = reader.read_at(0, HEADER_LEN as u64)?;
2049    let header = Header::from_bytes(&head)?;
2050    let meta = (header.metadata_offset, header.metadata_len);
2051    let build = (header.build_info_offset, header.build_info_len);
2052    if meta.1 > 0 && build.1 > 0 && build.0 == meta.0 + meta.1 {
2053        // Adjacent (the layout this crate writes): one read spans both.
2054        let both = reader.read_at(meta.0, meta.1 + build.1)?;
2055        let (m, b) = both.split_at(meta.1 as usize);
2056        return Ok((header, Some(m.to_vec()), Some(b.to_vec())));
2057    }
2058    let fetch = |off: u64, len: u64| -> Result<Option<Vec<u8>>, FileError> {
2059        if len == 0 {
2060            return Ok(None);
2061        }
2062        Ok(Some(reader.read_at(off, len)?))
2063    };
2064    Ok((header, fetch(meta.0, meta.1)?, fetch(build.0, build.1)?))
2065}
2066
2067/// The build-info section of a whole file image, or `None` if absent. The
2068/// payload is an opaque application-layer blob (the CLI stores build-conditions
2069/// JSON there); it is **not** covered by the content hash — see
2070/// [`attach_build_info`].
2071pub fn read_build_info(bytes: &[u8]) -> Result<Option<Vec<u8>>, FileError> {
2072    let header = Header::from_bytes(bytes)?;
2073    if header.build_info_len == 0 {
2074        return Ok(None);
2075    }
2076    let start = header.build_info_offset as usize;
2077    let end = start
2078        .checked_add(header.build_info_len as usize)
2079        .filter(|&e| e <= bytes.len())
2080        .ok_or(FileError::Container("build-info section overruns buffer"))?;
2081    Ok(Some(bytes[start..end].to_vec()))
2082}
2083
2084/// Everything needed to splice a build-info section into a finished file
2085/// **without holding the file in memory**: the rewritten header, where the
2086/// section goes, and where the bytes that do not move resume.
2087///
2088/// See [`plan_build_info`] for what the fields mean together; the layout is
2089/// always `[header][metadata][build info][everything else, verbatim]`.
2090#[derive(Debug, Clone, PartialEq, Eq)]
2091pub struct BuildInfoPlan {
2092    /// The header bytes to write at offset 0.
2093    pub header: [u8; HEADER_LEN],
2094    /// Offset the new section is written at — equivalently, the length of the
2095    /// unchanged `[header][metadata]` prefix.
2096    pub insert: u64,
2097    /// Offset in the **old** file where the bytes that do not move resume. The
2098    /// span `insert..tail_start` is the old build-info section, if any, and is
2099    /// the only part of the old file the new one drops.
2100    pub tail_start: u64,
2101    /// Length of the file this plan produces.
2102    pub new_len: u64,
2103}
2104
2105/// Plan the splice of a build-info section of `info_len` bytes into a file
2106/// whose first [`HEADER_LEN`] bytes are `head` and whose total length is
2107/// `file_len`.
2108///
2109/// The section goes immediately after the metadata section — adjacent, so
2110/// [`read_card_and_build_info_ranged`] fetches card + build info in one
2111/// coalesced range — and every later section's offset shifts by the length
2112/// delta. That shift is the whole of the arithmetic, and it lives here so the
2113/// in-memory splice ([`attach_build_info`]) and any streaming rewriter derive
2114/// the same header from the same rule instead of each carrying a copy that can
2115/// drift.
2116///
2117/// The content hash is **deliberately left untouched**: build info records the
2118/// facts that differ between two builds of identical data (timestamp, builder,
2119/// measured costs), and folding them into the hash would break the
2120/// reproducible-hash property. `verify` accordingly ignores this section, on an
2121/// old reader (which sees an unknown kind-7 entry) and a new one alike — so a
2122/// file that gains, loses or changes a build record **keeps its content
2123/// identity**.
2124///
2125/// `info_len` of 0 plans the removal of an existing section.
2126pub fn plan_build_info(
2127    head: &[u8],
2128    file_len: u64,
2129    info_len: u64,
2130) -> Result<BuildInfoPlan, FileError> {
2131    let mut header = Header::from_bytes(head)?;
2132    // The insert point: immediately after the metadata section. The writers in
2133    // this crate always place metadata (when present) at HEADER_LEN.
2134    let insert = HEADER_LEN as u64 + header.metadata_len;
2135    let old_len = header.build_info_len;
2136    if old_len > 0 && header.build_info_offset != insert {
2137        return Err(FileError::Container(
2138            "existing build-info section is not adjacent to the metadata",
2139        ));
2140    }
2141    let tail_start = insert.saturating_add(old_len);
2142    if tail_start > file_len || insert > file_len {
2143        return Err(FileError::Container("build-info splice out of bounds"));
2144    }
2145
2146    // Shift every section that lives at or after the old tail.
2147    let shift = |off: &mut u64, len: u64| {
2148        if len > 0 && *off >= tail_start {
2149            *off = *off - old_len + info_len;
2150        }
2151    };
2152    shift(&mut header.dictionary_offset, header.dictionary_len);
2153    shift(&mut header.root_dir_offset, header.root_dir_len);
2154    shift(&mut header.pyramid_meta_offset, header.pyramid_meta_len);
2155    shift(&mut header.named_graphs_offset, header.named_graphs_len);
2156    shift(&mut header.text_index_offset, header.text_index_len);
2157    for s in &mut header.extra_sections {
2158        if s.length > 0 && s.offset >= tail_start {
2159            s.offset = s.offset - old_len + info_len;
2160        }
2161    }
2162    if info_len == 0 {
2163        header.build_info_offset = 0;
2164        header.build_info_len = 0;
2165    } else {
2166        header.build_info_offset = insert;
2167        header.build_info_len = info_len;
2168    }
2169
2170    Ok(BuildInfoPlan {
2171        header: header.to_bytes(),
2172        insert,
2173        tail_start,
2174        new_len: file_len - old_len + info_len,
2175    })
2176}
2177
2178/// Attach (or replace) a **build-info** section in a finished file image,
2179/// returning the new image. Layout and hash semantics: [`plan_build_info`].
2180///
2181/// Passing an empty `info` removes an existing section (or returns the image
2182/// unchanged when there is none).
2183pub fn attach_build_info(image: &[u8], info: &[u8]) -> Result<Vec<u8>, FileError> {
2184    let plan = plan_build_info(image, image.len() as u64, info.len() as u64)?;
2185    let mut out = Vec::with_capacity(plan.new_len as usize);
2186    out.extend_from_slice(&plan.header);
2187    out.extend_from_slice(&image[HEADER_LEN..plan.insert as usize]);
2188    out.extend_from_slice(info);
2189    out.extend_from_slice(&image[plan.tail_start as usize..]);
2190    debug_assert_eq!(out.len() as u64, plan.new_len);
2191    Ok(out)
2192}
2193
2194/// Replace the **metadata** section (the Dataset Card blob) of a finished file
2195/// image, returning the new image. The section is the first one after the
2196/// header, so every later section's offset shifts by the length delta.
2197///
2198/// Unlike [`attach_build_info`], the metadata payload **is** inside the content
2199/// hash — it is part of what the file says about itself — so the hash is
2200/// recomputed here. The result is byte-identical to what the writer would have
2201/// produced had it been handed this payload in the first place
2202/// (`write_dataset_with_metadata`): the section sits at `HEADER_LEN`, every
2203/// later offset is derived from its length, and the hash folds the same parts
2204/// in the same order.
2205///
2206/// Why splice rather than rebuild: the only caller that needs this is a build
2207/// that has already assembled the file and then learned something about it by
2208/// **running** its own starter queries against it. Re-deriving the whole image
2209/// to correct a metadata blob would mean re-parsing the input; a splice costs
2210/// one memcpy and one rehash of an image that is already resident.
2211///
2212/// Passing an empty payload removes the section.
2213pub fn replace_metadata(image: &[u8], metadata: &[u8]) -> Result<Vec<u8>, FileError> {
2214    let mut header = Header::from_bytes(image)?;
2215    // The writers in this crate always place metadata (when present) at
2216    // HEADER_LEN; anything else is a file this function must not reshape.
2217    let insert = HEADER_LEN as u64;
2218    if header.metadata_len > 0 && header.metadata_offset != insert {
2219        return Err(FileError::Container(
2220            "metadata section does not sit immediately after the header",
2221        ));
2222    }
2223    let old_len = header.metadata_len;
2224    let tail_start = (insert + old_len) as usize;
2225    if tail_start > image.len() {
2226        return Err(FileError::Container("metadata splice out of bounds"));
2227    }
2228    let new_len = metadata.len() as u64;
2229
2230    let shift = |off: &mut u64, len: u64| {
2231        if len > 0 && *off >= insert + old_len {
2232            *off = *off - old_len + new_len;
2233        }
2234    };
2235    shift(&mut header.dictionary_offset, header.dictionary_len);
2236    shift(&mut header.root_dir_offset, header.root_dir_len);
2237    shift(&mut header.pyramid_meta_offset, header.pyramid_meta_len);
2238    shift(&mut header.named_graphs_offset, header.named_graphs_len);
2239    shift(&mut header.text_index_offset, header.text_index_len);
2240    shift(&mut header.build_info_offset, header.build_info_len);
2241    for s in &mut header.extra_sections {
2242        if s.length > 0 && s.offset >= insert + old_len {
2243            s.offset = s.offset - old_len + new_len;
2244        }
2245    }
2246    header.metadata_offset = insert;
2247    header.metadata_len = new_len;
2248
2249    let mut out = Vec::with_capacity(image.len() - old_len as usize + metadata.len());
2250    out.extend_from_slice(&header.to_bytes());
2251    out.extend_from_slice(metadata);
2252    out.extend_from_slice(&image[tail_start..]);
2253    // The payload changed, so the hash must: recompute it over the same part
2254    // list `verify` will rebuild from this very header, then re-serialize the
2255    // header carrying it.
2256    header.content_hash = hash_of_sections(&header, &out)?;
2257    out[..HEADER_LEN].copy_from_slice(&header.to_bytes());
2258    Ok(out)
2259}
2260
2261/// The content hash of a file image, folded over exactly the parts the writer
2262/// hashed (see `write_dataset_from_parts`): the metadata payload when present,
2263/// then dict, index, pyramid-meta, and — when present — the text index and the
2264/// named graphs. [`verify`] compares this against the header; [`replace_metadata`]
2265/// writes it.
2266fn hash_of_sections(header: &Header, bytes: &[u8]) -> Result<[u8; 16], FileError> {
2267    let slice = |off: u64, len: u64| -> Result<&[u8], FileError> {
2268        // `off` and `len` come STRAIGHT FROM THE FILE HEADER, so a crafted or
2269        // corrupt .rete can set them near u64::MAX and make `off + len` overflow
2270        // — which panics in debug and wraps in release, BEFORE `.get()` gets the
2271        // chance to reject the range. Found by the weekly fuzz run
2272        // (fuzz_targets/open.rs, "attempt to add with overflow"). Saturating the
2273        // sum turns both cases into the same clean Container error.
2274        let end = off.saturating_add(len);
2275        bytes
2276            .get(off as usize..end as usize)
2277            .ok_or(FileError::Container("section overruns buffer"))
2278    };
2279    let d = slice(header.dictionary_offset, header.dictionary_len)?;
2280    let i = slice(header.root_dir_offset, header.root_dir_len)?;
2281    let m = if header.pyramid_meta_len > 0 {
2282        slice(header.pyramid_meta_offset, header.pyramid_meta_len)?
2283    } else {
2284        &[]
2285    };
2286    let mut parts: Vec<&[u8]> = Vec::with_capacity(6);
2287    if header.metadata_len > 0 {
2288        parts.push(slice(header.metadata_offset, header.metadata_len)?);
2289    }
2290    parts.push(d);
2291    parts.push(i);
2292    parts.push(m);
2293    if header.text_index_len > 0 {
2294        parts.push(slice(header.text_index_offset, header.text_index_len)?);
2295    }
2296    if header.named_graphs_len > 0 {
2297        parts.push(slice(header.named_graphs_offset, header.named_graphs_len)?);
2298    }
2299    Ok(content_hash(&parts))
2300}
2301
2302/// Recompute the content hash from a file image and check it against the header
2303/// — detects corruption or truncation of the payload sections.
2304pub fn verify(bytes: &[u8]) -> Result<bool, FileError> {
2305    let header = Header::from_bytes(bytes)?;
2306    Ok(hash_of_sections(&header, bytes)? == header.content_hash)
2307}
2308
2309/// [`verify`] over a [`RangeReader`](crate::reader::RangeReader), streaming:
2310/// the hashed sections are read in bounded slices and folded into one BLAKE3
2311/// state, so peak memory is one slice, not the file. The same sections, in
2312/// the same order, as `hash_of_sections`; on a 49 GB file the in-memory form
2313/// needs 49 GB.
2314pub fn verify_ranged(reader: &dyn crate::reader::RangeReader) -> Result<bool, FileError> {
2315    /// Read size per step: large enough that a local or remote read is
2316    /// throughput-bound, small enough to be negligible next to any budget.
2317    const SLICE: u64 = 8 << 20;
2318    let len = reader.len();
2319    if len < HEADER_LEN as u64 {
2320        return Err(FileError::Container("file shorter than its header"));
2321    }
2322    let header = Header::from_bytes(&reader.read_at(0, HEADER_LEN as u64)?)?;
2323    let mut parts: Vec<(u64, u64)> = Vec::with_capacity(6);
2324    if header.metadata_len > 0 {
2325        parts.push((header.metadata_offset, header.metadata_len));
2326    }
2327    parts.push((header.dictionary_offset, header.dictionary_len));
2328    parts.push((header.root_dir_offset, header.root_dir_len));
2329    if header.pyramid_meta_len > 0 {
2330        parts.push((header.pyramid_meta_offset, header.pyramid_meta_len));
2331    }
2332    if header.text_index_len > 0 {
2333        parts.push((header.text_index_offset, header.text_index_len));
2334    }
2335    if header.named_graphs_len > 0 {
2336        parts.push((header.named_graphs_offset, header.named_graphs_len));
2337    }
2338    let mut h = blake3::Hasher::new();
2339    for (off, n) in parts {
2340        // Offsets come straight from the header: reject an overrun (or an
2341        // overflowing sum) before reading, as `hash_of_sections` does.
2342        if off.checked_add(n).is_none_or(|end| end > len) {
2343            return Err(FileError::Container("section overruns buffer"));
2344        }
2345        let mut at = off;
2346        while at < off + n {
2347            let take = (off + n - at).min(SLICE);
2348            h.update(&reader.read_at(at, take)?);
2349            at += take;
2350        }
2351    }
2352    let mut out = [0u8; 16];
2353    out.copy_from_slice(&h.finalize().as_bytes()[..16]);
2354    Ok(out == header.content_hash)
2355}
2356
2357/// Faults the pyramid meta in on first access. `None` = the fetch failed.
2358type PyramidLoader = Box<dyn Fn() -> Option<PyramidMeta> + Send + Sync>;
2359
2360/// The pyramid-meta section, held either resident (eager opens) or deferred
2361/// (the lazy remote open). SPARQL never touches the pyramid, but on a Wikidata
2362/// file it can be tens of MB (114k communities, millions of superedges), so a
2363/// remote SPARQL query must not pay to fetch it — it faults in only when a
2364/// community/pyramid query actually calls [`Rete::pyramid`].
2365enum PyramidSlot {
2366    Resident(Option<PyramidMeta>),
2367    Lazy {
2368        loader: PyramidLoader,
2369        cell: std::sync::OnceLock<Option<PyramidMeta>>,
2370    },
2371}
2372
2373/// Faults the text index in on first search. `None` = the file has none, or the
2374/// fetch/parse failed.
2375type TextIndexLoader = Box<dyn Fn() -> Option<crate::text_index::TextIndex> + Send + Sync>;
2376
2377/// Answers the token table's byte length from the section's first ≤10 bytes —
2378/// the length varint alone, never the table it measures.
2379type TokenTableProbe = Box<dyn Fn() -> Option<u64> + Send + Sync>;
2380
2381/// The TEXT_INDEX section, held either resident (eager opens decode the whole
2382/// thing) or deferred (the lazy remote open keeps only a loader that fetches the
2383/// token table on first search, then faults posting lists one at a time). SPARQL
2384/// never touches it, so the lazy remote path keeps its small range budget.
2385///
2386/// Both variants can also answer how long the section's leading **token table**
2387/// is without faulting it — the figure a caller needs to state what a first
2388/// search will cost, since `header.text_index_len` counts the postings blob too
2389/// and overstates it by several times on a literal-heavy graph.
2390enum TextIndexSlot {
2391    Resident {
2392        index: Option<crate::text_index::TextIndex>,
2393        /// Measured from the section bytes this open already held; `None` when
2394        /// the file carries no text index.
2395        token_table_len: Option<u64>,
2396    },
2397    Lazy {
2398        loader: TextIndexLoader,
2399        cell: std::sync::OnceLock<Option<crate::text_index::TextIndex>>,
2400        /// One ≤10-byte range read, memoized in `token_table_cell`.
2401        token_table: TokenTableProbe,
2402        token_table_cell: std::sync::OnceLock<Option<u64>>,
2403    },
2404}
2405
2406impl TextIndexSlot {
2407    /// The parsed index, faulting it in (token table first) on the lazy path.
2408    fn index(&self) -> Option<&crate::text_index::TextIndex> {
2409        match self {
2410            TextIndexSlot::Resident { index, .. } => index.as_ref(),
2411            TextIndexSlot::Lazy { loader, cell, .. } => cell.get_or_init(loader).as_ref(),
2412        }
2413    }
2414
2415    /// Byte length of the section's leading token table — see
2416    /// [`Rete::text_index_token_table_len`].
2417    fn token_table_len(&self) -> Option<u64> {
2418        match self {
2419            TextIndexSlot::Resident {
2420                token_table_len, ..
2421            } => *token_table_len,
2422            TextIndexSlot::Lazy {
2423                token_table,
2424                token_table_cell,
2425                ..
2426            } => *token_table_cell.get_or_init(token_table),
2427        }
2428    }
2429}
2430
2431/// Decode a whole TEXT_INDEX section held in memory, measuring its token table
2432/// on the way past. `section` is `None` when the file carries no text index.
2433fn resident_text_index_slot(section: Option<&[u8]>, codec: u8) -> Result<TextIndexSlot, FileError> {
2434    let Some(section) = section else {
2435        return Ok(TextIndexSlot::Resident {
2436            index: None,
2437            token_table_len: None,
2438        });
2439    };
2440    Ok(TextIndexSlot::Resident {
2441        index: Some(
2442            crate::text_index::TextIndex::from_section(section, codec)
2443                .map_err(|_| FileError::Container("malformed text index"))?,
2444        ),
2445        token_table_len: crate::text_index::TextIndex::postings_base(section).map(|b| b as u64),
2446    })
2447}
2448
2449/// Byte length of the TEXT_INDEX section's leading **token table**, from a
2450/// header a caller has already parsed — the prefix range a first
2451/// [`Rete::text_search`] fetches, and the honest figure to quote as its cost
2452/// (`header.text_index_len` counts the postings blob too and overstates it
2453/// several-fold). `None` when the file carries no text index, or its first
2454/// bytes could not be read.
2455///
2456/// Costs **one range read of ≤10 bytes** — the section's leading length varint
2457/// and nothing else, never the table it measures. This is the free-standing
2458/// companion to [`Rete::text_index_token_table_len`], for readers that hold a
2459/// [`Header`] and a [`RangeReader`] but never open the file (the CARD tier:
2460/// `rete card`, `rete card-url`).
2461pub fn read_text_index_token_table_len_ranged<R: RangeReader + ?Sized>(
2462    reader: &R,
2463    header: &Header,
2464) -> Option<u64> {
2465    if header.text_index_len == 0 {
2466        return None;
2467    }
2468    read_token_table_len(reader, header.text_index_offset, header.text_index_len)
2469}
2470
2471/// Byte length of the TEXT_INDEX section's leading token table — its length
2472/// varint plus the compressed table, i.e. the prefix range a first search
2473/// fetches. Reads the section's first ≤10 bytes and nothing else.
2474fn read_token_table_len<R: RangeReader + ?Sized>(reader: &R, off: u64, len: u64) -> Option<u64> {
2475    let head = reader.read_at(off, 10u64.min(len)).ok()?;
2476    let (ttlen, n) = crate::varint::read_uvarint(&head)?;
2477    Some((n as u64 + ttlen).min(len))
2478}
2479
2480/// A named graph's container decodes RESIDENT (one range read, one decode) up
2481/// to this size on the lazy path; a larger container opens as a remote-lazy
2482/// tile index instead, so one selective `GRAPH <g>` query over a multi-GB
2483/// graph faults only the tile directories plus the tiles it touches.
2484const NAMED_GRAPH_RESIDENT_MAX: u64 = 1 << 20; // 1 MiB
2485
2486/// Entries per lazily-allocated named-graph slab. The outer table is sized by
2487/// the section's leading count varint, which is UNTRUSTED — slabbing keeps a
2488/// hostile count from ballooning the allocation (the outer table costs a few
2489/// bytes per claimed slab; entry storage only materializes when touched).
2490const NAMED_SLAB: usize = 1024;
2491
2492/// First read of the named-graph walk, and the floor its ramp resets to. One
2493/// chunk covers a few dozen small-graph records, so a `LIMIT`ed `GRAPH ?g`
2494/// pays exactly one small read — the targeted-query win this size protects.
2495const NAMED_WALK_CHUNK: u64 = 64 * 1024;
2496
2497/// Ceiling of the walk's geometric read ramp — the read size an exhaustive
2498/// walk converges to (and starts at, when the demand is known exhaustive).
2499///
2500/// Sized from measurement, not taste. Against the nkod reference file
2501/// (67.9 MB, 31,974 named graphs, 65.4 MB section) the fixed 64 KiB walk cost
2502/// 262 requests / ~16.5 s in Chromium where the old eager whole-section fetch
2503/// cost ~8.5 s — i.e. ~31 ms of per-request overhead on ~8 s of transfer
2504/// (~7.7 MB/s). At 8 MiB per read a chunk transfers in ~1 s, putting the
2505/// request overhead near 3% of total time; halving the cap to 4 MiB doubles
2506/// that overhead, doubling the cap to 16 MiB buys back under 2% while doubling
2507/// both the walk buffer a wasm32 session must hold resident and the worst-case
2508/// over-read past the point a query stops. 8 MiB is the knee.
2509const NAMED_WALK_CHUNK_MAX: u64 = 8 * 1024 * 1024;
2510
2511/// One named graph, walked lazily out of the NAMED_GRAPHS section.
2512#[derive(Default)]
2513struct NamedEntry {
2514    /// `(iri, absolute byte range of the graph's index container)` — filled
2515    /// when the sequential directory walk reaches this slot.
2516    meta: std::sync::OnceLock<(String, ByteRange)>,
2517    /// The graph's permutation index, opened on first access. Boxed: most
2518    /// entries of a many-graph dataset are never touched by a given query.
2519    index: std::sync::OnceLock<Box<GraphIndex>>,
2520}
2521
2522/// Sequential position of the lazy named-graphs walk. The section has no
2523/// random-access directory — each entry's offset is the previous entry's end —
2524/// so entries are parsed strictly in order and memoised.
2525#[derive(Default)]
2526struct NamedWalk {
2527    /// Next entry index to parse.
2528    next: usize,
2529    /// Absolute file offset of that entry's header; `0` = the leading count
2530    /// varint has not been consumed yet (offset 0 is the file header, never
2531    /// inside a section).
2532    pos: u64,
2533    /// Read buffer carried ACROSS walk calls: `buf` holds the section bytes at
2534    /// `[buf_off, buf_off + buf.len())`. Entry-by-entry iteration (the
2535    /// `GRAPH ?g` path advances one entry per call) parses ~30 small-graph
2536    /// headers per 64 KiB chunk instead of re-reading a chunk per entry.
2537    /// [`LazyNamedGraphs::open_graph`] also serves whole small containers out
2538    /// of it, so a bulk chunk's ride-along payloads are never re-fetched.
2539    buf: Vec<u8>,
2540    buf_off: u64,
2541    /// Current read size of the geometric ramp (`0` = not started; treated as
2542    /// [`NAMED_WALK_CHUNK`]). Doubles on every buffer refill up to
2543    /// [`NAMED_WALK_CHUNK_MAX`], so a walk that keeps going converges to bulk
2544    /// reads after fetching at most as many bytes as it already used (the
2545    /// doubling bound: wasted read-ahead ≤ useful bytes). Reset to the floor
2546    /// whenever an oversized container is parsed — see `big_seen`.
2547    chunk: u64,
2548    /// A container larger than [`NAMED_GRAPH_RESIDENT_MAX`] has been parsed.
2549    /// Such graphs open tile-lazily (their payload bytes are NOT wanted by the
2550    /// walk), so ride-along prefetch stops paying off: the ramp resets and the
2551    /// exhaustive-demand hint stops seeding full-size reads. Without this, a
2552    /// header walk over a file of multi-MB containers would fetch the payloads
2553    /// it exists to hop over.
2554    big_seen: bool,
2555}
2556
2557/// The NAMED_GRAPHS section opened lazily over a [`RangeReader`]: nothing is
2558/// fetched at open. The section is a count varint followed by
2559/// `iri_len | iri | container_len | container` records, so the directory is
2560/// walked forward on demand — header bytes only, skipping container payloads —
2561/// and each touched graph's index is decoded (or opened tile-lazily) on first
2562/// access. A `LIMIT`ed `GRAPH ?g` query therefore reads a prefix of the
2563/// section; only a query that genuinely touches every graph (e.g. a full
2564/// `COUNT` over `GRAPH ?g`) walks it all — which is exactly what the eager
2565/// open used to fetch unconditionally.
2566///
2567/// **Failure contract** (same as lazy tiles/chunks): a failed read or a
2568/// malformed record sets a sticky flag and the accessor returns `None`;
2569/// nothing failed is ever memoised, so a later evaluation retries.
2570struct LazyNamedGraphs {
2571    reader: std::sync::Arc<dyn RangeReader + Send + Sync>,
2572    /// Absolute byte range of the NAMED_GRAPHS section.
2573    section: ByteRange,
2574    codec: u8,
2575    has_synopsis: bool,
2576    read_concurrency: usize,
2577    /// The file's permutation set — every graph container in a file carries
2578    /// the same one, so it comes from the header rather than per graph.
2579    perms: PermSet,
2580    /// `(count, slab table)` — set once the leading count varint is read.
2581    /// Slabs allocate on first touch; boxed slices never move, so `&` handed
2582    /// out to entries stay valid for `&self`'s lifetime with no unsafe.
2583    #[allow(clippy::type_complexity)]
2584    dir: std::sync::OnceLock<(usize, Box<[std::sync::OnceLock<Box<[NamedEntry]>>]>)>,
2585    walk: std::sync::Mutex<NamedWalk>,
2586    failed: std::sync::atomic::AtomicBool,
2587    /// Tile-cache byte cap applied to each LARGE (lazily-opened, loader-bearing)
2588    /// named graph as it is opened (bounded-export phase 2). `u64::MAX` =
2589    /// unlimited. Small resident graphs (no loader, ≤ 1 MiB) are never capped —
2590    /// an evicted tile could not be reloaded — but they are bounded by size and
2591    /// released per slot, so they need no cap.
2592    tile_cap: std::sync::atomic::AtomicU64,
2593}
2594
2595impl LazyNamedGraphs {
2596    fn new(
2597        reader: std::sync::Arc<dyn RangeReader + Send + Sync>,
2598        section: ByteRange,
2599        codec: u8,
2600        has_synopsis: bool,
2601        read_concurrency: usize,
2602        perms: PermSet,
2603    ) -> Self {
2604        LazyNamedGraphs {
2605            reader,
2606            section,
2607            codec,
2608            has_synopsis,
2609            read_concurrency,
2610            perms,
2611            dir: std::sync::OnceLock::new(),
2612            walk: std::sync::Mutex::new(NamedWalk::default()),
2613            failed: std::sync::atomic::AtomicBool::new(false),
2614            tile_cap: std::sync::atomic::AtomicU64::new(u64::MAX),
2615        }
2616    }
2617
2618    /// Set the tile-cache cap for large named graphs: records it for graphs
2619    /// opened later, and re-caps every large graph already opened. Small
2620    /// resident (loaderless) graphs are left unlimited.
2621    fn set_tile_cap(&self, cap: u64) {
2622        self.tile_cap
2623            .store(cap, std::sync::atomic::Ordering::Relaxed);
2624        self.for_each_opened(|g| {
2625            if g.is_lazy() {
2626                g.set_cache_cap(cap);
2627            }
2628        });
2629    }
2630
2631    /// Record a fetch/parse failure (checked by `index_incomplete`).
2632    fn fail(&self) {
2633        self.failed
2634            .store(true, std::sync::atomic::Ordering::Relaxed);
2635    }
2636
2637    /// The directory scaffold, reading the leading count varint on first call.
2638    #[allow(clippy::type_complexity)]
2639    fn directory(&self) -> Option<&(usize, Box<[std::sync::OnceLock<Box<[NamedEntry]>>]>)> {
2640        if let Some(d) = self.dir.get() {
2641            return Some(d);
2642        }
2643        let end = self.section.offset.checked_add(self.section.len)?;
2644        let (n, used) = match read_uvarint_at(&self.reader, self.section.offset, end) {
2645            Ok(v) => v,
2646            Err(_) => {
2647                self.fail();
2648                return None;
2649            }
2650        };
2651        // Each record needs at least 2 bytes of framing (two length varints),
2652        // so a count beyond len/2 is malformed — refuse before sizing by it.
2653        if n > self.section.len / 2 {
2654            self.fail();
2655            return None;
2656        }
2657        let n = n as usize;
2658        let slabs = n.div_ceil(NAMED_SLAB);
2659        let table: Box<[std::sync::OnceLock<Box<[NamedEntry]>>]> =
2660            (0..slabs).map(|_| std::sync::OnceLock::new()).collect();
2661        let _ = self.dir.set((n, table));
2662        {
2663            // Idempotent under an init race: both racers computed the same
2664            // `used` from the same bytes.
2665            let mut w = self.walk.lock().unwrap();
2666            if w.pos == 0 {
2667                w.pos = self.section.offset + used;
2668            }
2669        }
2670        self.dir.get()
2671    }
2672
2673    fn count(&self) -> usize {
2674        self.directory().map(|(n, _)| *n).unwrap_or(0)
2675    }
2676
2677    /// The `i`-th entry's cell (allocating its slab on first touch).
2678    fn entry(&self, i: usize) -> Option<&NamedEntry> {
2679        let (n, table) = self.directory()?;
2680        if i >= *n {
2681            return None;
2682        }
2683        let slab = table[i / NAMED_SLAB].get_or_init(|| {
2684            let len = NAMED_SLAB.min(n - (i / NAMED_SLAB) * NAMED_SLAB);
2685            (0..len).map(|_| NamedEntry::default()).collect()
2686        });
2687        slab.get(i % NAMED_SLAB)
2688    }
2689
2690    /// The next walk read's length, advancing the geometric ramp: at least
2691    /// `need` (a long IRI's header), at most the section remainder, and
2692    /// otherwise the current ramp value — which doubles per refill up to
2693    /// [`NAMED_WALK_CHUNK_MAX`]. The doubling keeps a stopping walk's wasted
2694    /// read-ahead bounded by the bytes it already used, while a walk that
2695    /// keeps going converges to bulk reads: the runtime safety net for
2696    /// exhaustive-looking queries the demand analysis could not prove.
2697    fn next_read_len(w: &mut NamedWalk, pos: u64, end: u64, need: u64) -> u64 {
2698        let cur = w.chunk.max(NAMED_WALK_CHUNK);
2699        w.chunk = (cur * 2).min(NAMED_WALK_CHUNK_MAX);
2700        cur.max(need).min(end - pos)
2701    }
2702
2703    /// Walk entry headers forward until `entries[upto].meta` is set. Reads the
2704    /// section in chunks and hops over container payloads, so the walk fetches
2705    /// O(headers) — not O(section) — where containers are large; small
2706    /// containers ride along in the same chunk and cost nothing extra.
2707    ///
2708    /// `exhaustive` is the demand hint from the query engine: the caller has
2709    /// PROVEN it will visit and open every named graph (an unrestricted
2710    /// `GRAPH ?g` under a consumer that drains the pipeline), so the walk
2711    /// starts at full-size reads instead of ramping up to them — the whole
2712    /// section arrives in `section/8 MiB` requests instead of hundreds. The
2713    /// hint is per-call, never memoised: a later targeted query on the same
2714    /// handle walks small again. It also defers to the layout: once an
2715    /// oversized (tile-lazy) container is seen, ride-along bytes stop being
2716    /// useful and the hint no longer seeds bulk reads.
2717    fn ensure_meta(&self, upto: usize, exhaustive: bool) -> Option<()> {
2718        let target = self.entry(upto)?;
2719        if target.meta.get().is_some() {
2720            return Some(());
2721        }
2722        let end = self.section.offset.checked_add(self.section.len)?;
2723        let mut w = self.walk.lock().unwrap();
2724        if exhaustive && !w.big_seen {
2725            w.chunk = NAMED_WALK_CHUNK_MAX;
2726        }
2727        while w.next <= upto {
2728            let pos = w.pos;
2729            if pos >= end {
2730                // The count varint promised more entries than the bytes hold.
2731                self.fail();
2732                return None;
2733            }
2734            // Make sure the whole header (two varints + the IRI, ≤ 20+iri_len
2735            // bytes) is buffered; refetch a larger window when the IRI is long.
2736            let have =
2737                |b: &[u8], off: u64, need: u64| pos >= off && pos + need <= off + b.len() as u64;
2738            if !have(&w.buf, w.buf_off, 20.min(end - pos)) {
2739                let len = Self::next_read_len(&mut w, pos, end, 20.min(end - pos));
2740                w.buf = match self.reader.read_at(pos, len) {
2741                    Ok(b) => b,
2742                    Err(_) => {
2743                        self.fail();
2744                        return None;
2745                    }
2746                };
2747                w.buf_off = pos;
2748            }
2749            let rel = (pos - w.buf_off) as usize;
2750            let Some((ilen, u1)) = read_uvarint(&w.buf[rel..]) else {
2751                self.fail();
2752                return None;
2753            };
2754            let header_need = u1 as u64 + ilen + 10; // iri_len + iri + container_len varint
2755            if pos + u1 as u64 + ilen > end {
2756                self.fail(); // IRI overruns the section: malformed
2757                return None;
2758            }
2759            if !have(&w.buf, w.buf_off, header_need.min(end - pos)) {
2760                let len = Self::next_read_len(&mut w, pos, end, header_need);
2761                w.buf = match self.reader.read_at(pos, len) {
2762                    Ok(b) => b,
2763                    Err(_) => {
2764                        self.fail();
2765                        return None;
2766                    }
2767                };
2768                w.buf_off = pos;
2769            }
2770            let rel = (pos - w.buf_off) as usize;
2771            let istart = rel + u1;
2772            let iend = istart + ilen as usize;
2773            let iri = String::from_utf8_lossy(&w.buf[istart..iend]).into_owned();
2774            let Some((clen, u2)) = read_uvarint(&w.buf[iend..]) else {
2775                self.fail();
2776                return None;
2777            };
2778            let cstart = pos + u1 as u64 + ilen + u2 as u64;
2779            let cend = match cstart.checked_add(clen) {
2780                Some(e) if e <= end => e,
2781                _ => {
2782                    self.fail(); // container overruns the section: malformed
2783                    return None;
2784                }
2785            };
2786            if clen > NAMED_GRAPH_RESIDENT_MAX {
2787                // This graph opens tile-lazily: the walk exists to HOP its
2788                // payload, so big read-ahead here is pure over-fetch. Drop the
2789                // ramp back to the floor and stop the exhaustive hint from
2790                // re-seeding it (headers stay cheap on payload-heavy files).
2791                w.big_seen = true;
2792                w.chunk = NAMED_WALK_CHUNK;
2793            }
2794            let range = ByteRange {
2795                offset: cstart,
2796                len: clen,
2797            };
2798            if let Some(e) = self.entry(w.next) {
2799                let _ = e.meta.set((iri, range));
2800            }
2801            w.pos = cend;
2802            w.next += 1;
2803        }
2804        Some(())
2805    }
2806
2807    /// The `i`-th graph's IRI — a header walk, never an index decode.
2808    /// `exhaustive`: see [`ensure_meta`](Self::ensure_meta).
2809    fn name_at(&self, i: usize, exhaustive: bool) -> Option<&str> {
2810        self.ensure_meta(i, exhaustive)?;
2811        self.entry(i)?.meta.get().map(|(iri, _)| iri.as_str())
2812    }
2813
2814    /// The `i`-th graph as `(iri, index)`, opening the index on first access.
2815    /// `exhaustive`: see [`ensure_meta`](Self::ensure_meta).
2816    fn graph_at(&self, i: usize, exhaustive: bool) -> Option<(&str, &GraphIndex)> {
2817        self.ensure_meta(i, exhaustive)?;
2818        let e = self.entry(i)?;
2819        let (iri, range) = e.meta.get()?;
2820        if e.index.get().is_none() {
2821            let opened = self.open_graph(*range)?;
2822            let _ = e.index.set(Box::new(opened));
2823        }
2824        Some((iri.as_str(), e.index.get()?.as_ref()))
2825    }
2826
2827    /// A small container's bytes: out of the walk buffer when the last chunk
2828    /// already carried them (bulk chunks always do — that is their point),
2829    /// else one targeted range read. Never called for tile-lazy containers.
2830    fn container_bytes(&self, range: ByteRange) -> Option<Vec<u8>> {
2831        {
2832            let w = self.walk.lock().unwrap();
2833            let buf_end = w.buf_off + w.buf.len() as u64;
2834            if range.offset >= w.buf_off && range.offset + range.len <= buf_end {
2835                let a = (range.offset - w.buf_off) as usize;
2836                return Some(w.buf[a..a + range.len as usize].to_vec());
2837            }
2838        }
2839        match self.reader.read_at(range.offset, range.len) {
2840            Ok(b) => Some(b),
2841            Err(_) => {
2842                self.fail();
2843                None
2844            }
2845        }
2846    }
2847
2848    /// Open one graph's index container: small ones resident (one read),
2849    /// large ones as a remote-lazy tile index — the same machinery as the
2850    /// default graph, so a selective query over a huge named graph fetches
2851    /// only the tile directories plus the tiles it touches.
2852    fn open_graph(&self, range: ByteRange) -> Option<GraphIndex> {
2853        if range.len <= NAMED_GRAPH_RESIDENT_MAX {
2854            let bytes = self.container_bytes(range)?;
2855            match decode_index_container(&bytes, self.codec, self.perms) {
2856                Ok(g) => Some(g),
2857                Err(_) => {
2858                    self.fail();
2859                    None
2860                }
2861            }
2862        } else {
2863            match open_index_container_lazy(
2864                &self.reader,
2865                range,
2866                self.codec,
2867                self.has_synopsis,
2868                self.read_concurrency,
2869                self.perms,
2870            ) {
2871                Ok((g, _, _)) => {
2872                    // A large graph faults its tiles through a loader, so bound
2873                    // its tile residency to the budget's share (an evicted tile
2874                    // simply re-faults). No-op when unlimited.
2875                    let cap = self.tile_cap.load(std::sync::atomic::Ordering::Relaxed);
2876                    if cap != u64::MAX {
2877                        g.set_cache_cap(cap);
2878                    }
2879                    Some(g)
2880                }
2881                Err(_) => {
2882                    self.fail();
2883                    None
2884                }
2885            }
2886        }
2887    }
2888
2889    /// Drop the decoded index of the graph named `iri`, if it has been opened,
2890    /// so its tiles and per-graph tile cache are freed. Takes `&mut self`:
2891    /// `OnceLock::take` needs unique access, which the export loop has between
2892    /// slots. Leaves the entry's `meta` (iri + range) so the graph re-opens
2893    /// lazily if touched again. A no-op if the graph is absent or never opened.
2894    fn release(&mut self, iri: &str) {
2895        let Some((_, table)) = self.dir.get_mut() else {
2896            return;
2897        };
2898        for slab in table.iter_mut() {
2899            let Some(entries) = slab.get_mut() else {
2900                continue;
2901            };
2902            for e in entries.iter_mut() {
2903                let matches = e
2904                    .meta
2905                    .get()
2906                    .map(|(name, _)| name.as_str() == iri)
2907                    .unwrap_or(false);
2908                if matches {
2909                    e.index.take();
2910                    return;
2911                }
2912            }
2913        }
2914    }
2915
2916    /// Visit every already-opened graph index (never triggers a fetch).
2917    fn for_each_opened(&self, mut f: impl FnMut(&GraphIndex)) {
2918        if let Some((_, table)) = self.dir.get() {
2919            for slab in table.iter().filter_map(|s| s.get()) {
2920                for e in slab.iter() {
2921                    if let Some(g) = e.index.get() {
2922                        f(g);
2923                    }
2924                }
2925            }
2926        }
2927    }
2928}
2929
2930/// The NAMED_GRAPHS section, held resident (in-memory and eager ranged opens —
2931/// unchanged) or walked lazily (the lazy ranged open, where fetching and
2932/// decoding every graph's index up front defeated remote laziness: 67 MB
2933/// fetched and ~32k indexes built before the first query on a many-graph file).
2934enum NamedGraphsSlot {
2935    Resident(Vec<(String, GraphIndex)>),
2936    Lazy(LazyNamedGraphs),
2937}
2938
2939impl NamedGraphsSlot {
2940    /// Heap bytes the named-graph directory holds outside any cache: every
2941    /// walked entry's IRI and range, the walk buffer, and the directories of
2942    /// the graph indexes currently open (see [`GraphIndex::directory_bytes`]).
2943    fn directory_bytes(&self) -> u64 {
2944        match self {
2945            NamedGraphsSlot::Resident(v) => v
2946                .iter()
2947                .map(|(iri, g)| iri.capacity() as u64 + g.directory_bytes())
2948                .sum(),
2949            NamedGraphsSlot::Lazy(l) => {
2950                let mut bytes = l.walk.lock().map(|w| w.buf.capacity() as u64).unwrap_or(0);
2951                if let Some((_, table)) = l.dir.get() {
2952                    bytes += table.len() as u64
2953                        * std::mem::size_of::<std::sync::OnceLock<Box<[NamedEntry]>>>() as u64;
2954                    for slab in table.iter().filter_map(|s| s.get()) {
2955                        bytes += slab.len() as u64 * std::mem::size_of::<NamedEntry>() as u64;
2956                        for e in slab.iter() {
2957                            if let Some((iri, _)) = e.meta.get() {
2958                                bytes += iri.capacity() as u64;
2959                            }
2960                            if let Some(g) = e.index.get() {
2961                                bytes += g.directory_bytes();
2962                            }
2963                        }
2964                    }
2965                }
2966                bytes
2967            }
2968        }
2969    }
2970
2971    fn count(&self) -> usize {
2972        match self {
2973            NamedGraphsSlot::Resident(v) => v.len(),
2974            NamedGraphsSlot::Lazy(l) => l.count(),
2975        }
2976    }
2977
2978    /// `exhaustive` is the walk-demand hint (resident opens ignore it): see
2979    /// [`LazyNamedGraphs::ensure_meta`].
2980    fn name_at(&self, i: usize, exhaustive: bool) -> Option<&str> {
2981        match self {
2982            NamedGraphsSlot::Resident(v) => v.get(i).map(|(iri, _)| iri.as_str()),
2983            NamedGraphsSlot::Lazy(l) => l.name_at(i, exhaustive),
2984        }
2985    }
2986
2987    fn graph_at(&self, i: usize, exhaustive: bool) -> Option<(&str, &GraphIndex)> {
2988        match self {
2989            NamedGraphsSlot::Resident(v) => v.get(i).map(|(iri, g)| (iri.as_str(), g)),
2990            NamedGraphsSlot::Lazy(l) => l.graph_at(i, exhaustive),
2991        }
2992    }
2993
2994    fn find(&self, iri: &str) -> Option<&GraphIndex> {
2995        match self {
2996            NamedGraphsSlot::Resident(v) => v.iter().find(|(name, _)| name == iri).map(|(_, g)| g),
2997            NamedGraphsSlot::Lazy(l) => {
2998                // Walk headers only until the IRI matches; decode just that
2999                // graph's index. An absent IRI costs a full header walk — the
3000                // layout has no by-name directory. A point lookup is targeted
3001                // demand: never hint the walk exhaustive.
3002                for i in 0..l.count() {
3003                    if l.name_at(i, false)? == iri {
3004                        return l.graph_at(i, false).map(|(_, g)| g);
3005                    }
3006                }
3007                None
3008            }
3009        }
3010    }
3011
3012    fn load_incomplete(&self) -> bool {
3013        match self {
3014            NamedGraphsSlot::Resident(v) => v.iter().any(|(_, g)| g.load_incomplete()),
3015            NamedGraphsSlot::Lazy(l) => {
3016                if l.failed.load(std::sync::atomic::Ordering::Relaxed) {
3017                    return true;
3018                }
3019                let mut bad = false;
3020                l.for_each_opened(|g| bad |= g.load_incomplete());
3021                bad
3022            }
3023        }
3024    }
3025
3026    fn reset_load_failures(&self) {
3027        match self {
3028            NamedGraphsSlot::Resident(v) => {
3029                for (_, g) in v {
3030                    g.reset_load_failure();
3031                }
3032            }
3033            NamedGraphsSlot::Lazy(l) => {
3034                l.failed.store(false, std::sync::atomic::Ordering::Relaxed);
3035                l.for_each_opened(|g| g.reset_load_failure());
3036            }
3037        }
3038    }
3039}
3040
3041/// A read-only, in-memory view over a `.rete` file image.
3042pub struct Rete {
3043    header: Header,
3044    dict: Dictionary,
3045    index: GraphIndex,
3046    index_section_ranges: [ByteRange; NUM_PERMS],
3047    /// Per-permutation tile directories as absolute file ranges
3048    /// (`(min_a, max_a, compressed-tile range)`), for provenance. Empty for
3049    /// pre-tiling (v0.1) files.
3050    tile_ranges: [Vec<(u32, u32, ByteRange)>; NUM_PERMS],
3051    pyramid: PyramidSlot,
3052    text_index: TextIndexSlot,
3053    named_graphs: NamedGraphsSlot,
3054    /// Raw bytes of the metadata section (empty if the file has none). The
3055    /// application layer decodes this (the CLI stores a JSON Dataset Card here).
3056    /// Only [`Rete::open`] populates it; [`Rete::open_ranged`] leaves it empty to
3057    /// preserve its minimal-fetch budget.
3058    metadata: Vec<u8>,
3059    /// Executes remote `SERVICE` blocks (SPARQL 1.1 federated query) — attached
3060    /// by the host via [`Rete::set_service_client`]; `None` means a non-SILENT
3061    /// `SERVICE` fails the query. Like the range readers, the engine never does
3062    /// I/O itself.
3063    service_client: Option<Box<dyn crate::service::ServiceClient>>,
3064    /// First failed non-SILENT `SERVICE` call of the current query. The row
3065    /// pipeline is infallible (the same contract as lazy tile fetches), so the
3066    /// failure is recorded here and taken by the top-level eval entry points.
3067    service_error: std::sync::Mutex<Option<String>>,
3068    /// Bytes a query's `COUNT(DISTINCT …)` set may hold before it spills to
3069    /// disk — see [`Rete::spill_threshold`].
3070    spill_threshold: std::sync::atomic::AtomicU64,
3071}
3072
3073impl Rete {
3074    /// Parse a full file image (v0 loads everything; a range-reading client
3075    /// will fetch only the sections it needs — same container format).
3076    pub fn open(bytes: &[u8]) -> Result<Self, FileError> {
3077        let header = Header::from_bytes(bytes)?;
3078
3079        // Header offsets/lengths are untrusted (a `.rete` may be fetched truncated
3080        // or corrupt from an arbitrary URL). Slice through a checked helper so a
3081        // bad region yields an error instead of panicking on an OOB index.
3082        let region = |off: u64, len: u64| -> Result<&[u8], FileError> {
3083            let start = off as usize;
3084            let end = start
3085                .checked_add(len as usize)
3086                .filter(|&e| e <= bytes.len())
3087                .ok_or(FileError::Container("section range out of bounds"))?;
3088            Ok(&bytes[start..end])
3089        };
3090
3091        let dict = decode_dictionary_container(
3092            region(header.dictionary_offset, header.dictionary_len)?,
3093            header.dict_codec,
3094        )?;
3095
3096        let index_bytes = region(header.root_dir_offset, header.root_dir_len)?;
3097        let index = decode_index_container(index_bytes, header.block_codec, header.perms)?;
3098        let index_section_ranges =
3099            decode_index_section_ranges(index_bytes, header.root_dir_offset, header.perms)?;
3100
3101        let pyramid = PyramidSlot::Resident(if header.pyramid_meta_len > 0 {
3102            Some(
3103                PyramidMeta::decode(region(header.pyramid_meta_offset, header.pyramid_meta_len)?)
3104                    .map_err(|_| FileError::Container("malformed pyramid meta"))?,
3105            )
3106        } else {
3107            None
3108        });
3109
3110        // The TEXT_INDEX section (opt-in `--text-index`); decode the whole thing
3111        // resident on a full-image open.
3112        let text_index = resident_text_index_slot(
3113            if header.text_index_len > 0 {
3114                Some(region(header.text_index_offset, header.text_index_len)?)
3115            } else {
3116                None
3117            },
3118            header.block_codec,
3119        )?;
3120
3121        let named_graphs = NamedGraphsSlot::Resident(if header.named_graphs_len > 0 {
3122            decode_named_graphs(
3123                region(header.named_graphs_offset, header.named_graphs_len)?,
3124                header.block_codec,
3125                header.perms,
3126            )?
3127        } else {
3128            Vec::new()
3129        });
3130
3131        let metadata = if header.metadata_len > 0 {
3132            region(header.metadata_offset, header.metadata_len)?.to_vec()
3133        } else {
3134            Vec::new()
3135        };
3136
3137        let tile_ranges =
3138            tile_file_ranges(index_bytes, header.root_dir_offset, &index_section_ranges);
3139        Ok(Self {
3140            header,
3141            dict,
3142            index,
3143            index_section_ranges,
3144            tile_ranges,
3145            pyramid,
3146            text_index,
3147            named_graphs,
3148            metadata,
3149            service_client: None,
3150            service_error: std::sync::Mutex::new(None),
3151            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
3152        })
3153    }
3154
3155    /// Attach the client that executes `SERVICE <endpoint> { … }` blocks
3156    /// (SPARQL 1.1 federated query) against remote SPARQL endpoints. Without
3157    /// one, a non-SILENT `SERVICE` fails the query with a clear error and a
3158    /// `SERVICE SILENT` degrades to one empty solution, per the spec.
3159    pub fn set_service_client(&mut self, client: Box<dyn crate::service::ServiceClient>) {
3160        self.service_client = Some(client);
3161    }
3162
3163    pub(crate) fn service_client(&self) -> Option<&dyn crate::service::ServiceClient> {
3164        self.service_client.as_deref()
3165    }
3166
3167    /// Record a failed non-SILENT `SERVICE` call (first error wins).
3168    pub(crate) fn record_service_error(&self, msg: &str) {
3169        let mut e = self.service_error.lock().unwrap();
3170        if e.is_none() {
3171            *e = Some(msg.to_string());
3172        }
3173    }
3174
3175    /// Take (and clear) the pending `SERVICE` failure — called by every
3176    /// top-level eval entry so it can never leak into a later query.
3177    pub(crate) fn take_service_error(&self) -> Option<String> {
3178        self.service_error.lock().unwrap().take()
3179    }
3180
3181    pub fn header(&self) -> &Header {
3182        &self.header
3183    }
3184
3185    /// The file's byte layout, for visualization: header, metadata,
3186    /// dictionary, each index permutation's tile directory and individual
3187    /// tiles, pyramid summary, and named graphs — sorted by offset. Bytes not
3188    /// covered by any segment are container framing (section directories and
3189    /// length fields).
3190    pub fn file_layout(&self) -> Vec<LayoutSegment> {
3191        let h = &self.header;
3192        let seg = |kind: &'static str, label: String, offset: u64, len: u64| LayoutSegment {
3193            kind,
3194            label,
3195            offset,
3196            len,
3197        };
3198        // The size in the label is DERIVED from the constant that defines the
3199        // span, never typed out: this line read "fixed 128 bytes" — the
3200        // pre-v0.3 header size — while emitting a 1024-byte span, and it is
3201        // what the file explorer shows a reader inspecting a real file.
3202        // `layout_header_label_matches_its_span` pins the two together.
3203        let mut out = vec![seg(
3204            "header",
3205            format!("header (fixed {} bytes)", crate::header::HEADER_LEN),
3206            0,
3207            crate::header::HEADER_LEN as u64,
3208        )];
3209        if h.metadata_len > 0 {
3210            out.push(seg(
3211                "metadata",
3212                "metadata (dataset card)".into(),
3213                h.metadata_offset,
3214                h.metadata_len,
3215            ));
3216        }
3217        out.push(seg(
3218            "dictionary",
3219            "dictionary (4 front-coded term sections)".into(),
3220            h.dictionary_offset,
3221            h.dictionary_len,
3222        ));
3223        for (si, perm) in crate::index::ALL_PERMS.into_iter().enumerate() {
3224            let sec = self.index_section_ranges[si];
3225            if sec.len == 0 {
3226                continue;
3227            }
3228            let first_tile = self.tile_ranges[si]
3229                .first()
3230                .map(|&(_, _, r)| r.offset)
3231                .unwrap_or(sec.offset + sec.len);
3232            if first_tile > sec.offset {
3233                out.push(seg(
3234                    "directory",
3235                    format!("{} tile directory", perm.name()),
3236                    sec.offset,
3237                    first_tile - sec.offset,
3238                ));
3239            }
3240            for (ti, &(min_a, max_a, r)) in self.tile_ranges[si].iter().enumerate() {
3241                out.push(seg(
3242                    "tile",
3243                    format!("{} tile {ti} (leading ids {min_a}..{max_a})", perm.name()),
3244                    r.offset,
3245                    r.len,
3246                ));
3247            }
3248        }
3249        if h.pyramid_meta_len > 0 {
3250            out.push(seg(
3251                "pyramid",
3252                "pyramid summary (communities + superedges)".into(),
3253                h.pyramid_meta_offset,
3254                h.pyramid_meta_len,
3255            ));
3256        }
3257        if h.named_graphs_len > 0 {
3258            out.push(seg(
3259                "named-graphs",
3260                format!("named graphs ({})", self.named_graphs.count()),
3261                h.named_graphs_offset,
3262                h.named_graphs_len,
3263            ));
3264        }
3265        out.sort_by_key(|s| s.offset);
3266        out
3267    }
3268
3269    /// Raw bytes of the file's metadata section, or `None` if it has none. The
3270    /// CLI stores a JSON Dataset Card here; `rete-core` treats it as opaque.
3271    /// Populated by [`Rete::open`] only — an [`Rete::open_ranged`] view returns
3272    /// `None` here (the card is not fetched on the minimal query path).
3273    pub fn metadata(&self) -> Option<&[u8]> {
3274        if self.metadata.is_empty() {
3275            None
3276        } else {
3277            Some(&self.metadata)
3278        }
3279    }
3280
3281    pub fn dictionary(&self) -> &Dictionary {
3282        &self.dict
3283    }
3284
3285    /// The pyramid metadata (summary graph + tiles), if the file has a pyramid.
3286    pub fn pyramid(&self) -> Option<&PyramidMeta> {
3287        match &self.pyramid {
3288            PyramidSlot::Resident(p) => p.as_ref(),
3289            // Faults the (possibly large) pyramid section on first access only.
3290            PyramidSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
3291        }
3292    }
3293
3294    /// The pyramid metadata **only if already resident or previously faulted** —
3295    /// never triggers a lazy range read. The query planner uses this for
3296    /// cardinality estimation so it is free for an in-memory file and never adds
3297    /// a fetch on the lazy remote path (which defers the pyramid by design).
3298    pub fn pyramid_if_loaded(&self) -> Option<&PyramidMeta> {
3299        match &self.pyramid {
3300            PyramidSlot::Resident(p) => p.as_ref(),
3301            PyramidSlot::Lazy { cell, .. } => cell.get().and_then(|o| o.as_ref()),
3302        }
3303    }
3304
3305    /// Per-predicate planner statistics from the query-stats block — empty when
3306    /// the file has none or the pyramid isn't resident (the lazy path doesn't
3307    /// fault it just for stats). See [`crate::meta::PredStat`].
3308    pub fn predicate_stats(&self) -> &[crate::meta::PredStat] {
3309        self.pyramid_if_loaded()
3310            .map(|p| p.predicate_stats.as_slice())
3311            .unwrap_or(&[])
3312    }
3313
3314    /// The entity shapes (characteristic sets) from the pyramid — empty when the
3315    /// file has none or the pyramid isn't resident. See [`crate::meta::CharSet`].
3316    pub fn char_sets(&self) -> &[crate::meta::CharSet] {
3317        self.pyramid_if_loaded()
3318            .map(|p| p.char_sets.as_slice())
3319            .unwrap_or(&[])
3320    }
3321
3322    /// The label index from the pyramid — empty when the file has none or the
3323    /// pyramid isn't resident. See [`crate::meta::LabelEntry`].
3324    pub fn label_index(&self) -> &[crate::meta::LabelEntry] {
3325        self.pyramid_if_loaded()
3326            .map(|p| p.label_index.as_slice())
3327            .unwrap_or(&[])
3328    }
3329
3330    /// Prefix-search the label index: the subjects whose label starts with
3331    /// `prefix` (case-insensitive), as `(label, subject_iri)`, capped at `limit`.
3332    /// Unlike the planner accessors, this **faults the pyramid** (where the index
3333    /// lives) on the lazy path — a prefix search is an explicit read, not a free
3334    /// estimate. Returns an empty vec when the file carries no label index.
3335    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<(String, String)> {
3336        let Some(pyr) = self.pyramid() else {
3337            return Vec::new();
3338        };
3339        pyr.prefix_search(prefix, limit)
3340            .into_iter()
3341            .filter_map(|e| {
3342                self.dict
3343                    .subject_term(e.subject)
3344                    .map(|iri| (e.label.clone(), iri))
3345            })
3346            .collect()
3347    }
3348
3349    /// The full-text index (TEXT_INDEX section), faulting it in on first access
3350    /// on the lazy remote path. `None` when the file carries no text index.
3351    pub(crate) fn text_index(&self) -> Option<&crate::text_index::TextIndex> {
3352        self.text_index.index()
3353    }
3354
3355    /// Whether this file carries a full-text (TEXT_INDEX) section, i.e. it was
3356    /// built with `--text-index`. Cheap — reads the header, never faults.
3357    pub fn has_text_index(&self) -> bool {
3358        self.header.text_index_len > 0
3359    }
3360
3361    /// Byte length of the TEXT_INDEX section's leading **token table** — the
3362    /// length varint plus the compressed table, which is exactly what a first
3363    /// [`text_search`](Self::text_search) faults. `None` when the file carries
3364    /// no text index (or its head could not be read).
3365    ///
3366    /// This is the number to quote as the cost of a first search, NOT
3367    /// `header().text_index_len`: that counts the postings blob too, which is
3368    /// the bulk of the section and is only ever fetched one posting list at a
3369    /// time. On the published `epfl-infoscience.rete` the section is 195 MB and
3370    /// its token table 29 MB — a 6.5× difference.
3371    ///
3372    /// Costs nothing on a resident open (measured while the bytes were in hand);
3373    /// a lazy/ranged open pays one ≤10-byte range read, memoized, and still
3374    /// never faults the table itself.
3375    pub fn text_index_token_table_len(&self) -> Option<u64> {
3376        self.text_index.token_table_len()
3377    }
3378
3379    /// Full-text search over the literals: subject IRIs that carry **every** word
3380    /// in `words` (whole-word, case-insensitive — AND semantics), optionally also
3381    /// requiring a word that **starts with** `prefix` (token-prefix). Results are
3382    /// ordered by subject id and capped at `limit` (0 = uncapped). Empty when the
3383    /// file has no text index or nothing matches.
3384    ///
3385    /// Like [`prefix_search`](Self::prefix_search), this **faults** the index on
3386    /// the lazy remote path — a search is an explicit read, and only the queried
3387    /// posting lists are fetched, not the whole index.
3388    pub fn text_search(&self, words: &[&str], prefix: Option<&str>, limit: usize) -> Vec<String> {
3389        text_search_in(self.text_index(), &self.dict, words, prefix, limit)
3390    }
3391
3392    /// The default-graph permutation index.
3393    pub fn default_index(&self) -> &GraphIndex {
3394        &self.index
3395    }
3396
3397    /// Resolve every triple of a graph (`None` = default graph) back to terms.
3398    pub fn dump(&self, graph: Option<&str>) -> Vec<TermTriple> {
3399        let index = match graph {
3400            None => &self.index,
3401            Some(g) => match self.graph_index(g) {
3402                Some(i) => i,
3403                None => return Vec::new(),
3404            },
3405        };
3406        index
3407            .match_pattern((None, None, None))
3408            .into_iter()
3409            .filter_map(|(s, p, o)| {
3410                Some((
3411                    self.dict.subject_term(s)?,
3412                    self.dict.predicate_term(p)?,
3413                    self.dict.object_term(o)?,
3414                ))
3415            })
3416            .collect()
3417    }
3418
3419    /// Stream every triple of a graph (`None` = default) to `f`, resolving terms
3420    /// a batch at a time — no full `Vec` materialization, so it is safe on graphs far
3421    /// larger than RAM. `rete export` uses this to serialize 100M+ triple files
3422    /// that `dump()` (which collects every term into a `Vec<String>`) would OOM on.
3423    ///
3424    /// On a lazy/ranged open the dictionary is **not** prefetched whole: each
3425    /// batch faults only the chunks its own terms live in. Dumping ONE named
3426    /// graph therefore costs that graph's terms, not the file's. Dumping
3427    /// *everything* still reads every dictionary byte and leaves every chunk
3428    /// resident — that is inherent, and it, not the prefetch, is what sets the
3429    /// peak of a full export. See the comment in the body for both measurements.
3430    ///
3431    /// To dump a *slice* — one predicate, one subject, one object — call
3432    /// [`dump_filtered_each`](Self::dump_filtered_each), which is this method
3433    /// with a triple pattern and prunes tiles instead of filtering rows.
3434    pub fn dump_each<F: FnMut(&str, &str, &str)>(&self, graph: Option<&str>, f: F) {
3435        self.dump_filtered_each(graph, None, None, None, f)
3436    }
3437
3438    /// [`dump_each`](Self::dump_each) restricted to a triple **pattern** — the
3439    /// filtered dump. Any of `s` / `p` / `o` may be bound (canonical N-Triples
3440    /// term tokens); all three unbound is exactly `dump_each`.
3441    ///
3442    /// # Why this is cheap, and how much
3443    ///
3444    /// It is the same access path a routed *query* takes, not a full scan with a
3445    /// predicate test bolted on: [`GraphIndex::scan_iter`] picks the permutation
3446    /// with the longest bound prefix, binary-searches that section's tile
3447    /// directory down to the span a bound leading id can live in, and then drops
3448    /// every remaining tile whose recorded synopsis proves it cannot hold a
3449    /// bound secondary component — **all from directories, without fetching a
3450    /// tile**. So a filtered dump pays for the slice, which is the whole point
3451    /// of issue #117.
3452    ///
3453    /// Measured on `cordis.rete` (801 MB, a 417 MB dictionary, 26.4M quads,
3454    /// six named graphs), lazily opened; "before" is the only way to get a
3455    /// predicate slice out of a dump previously — dump the graph and throw away
3456    /// what does not match, in the consumer:
3457    ///
3458    /// ```text
3459    ///   graph=results  p=s66#doi     -> 337,811 rows
3460    ///     before  375.8 MB · 1797 req · 2105.8 MB RSS · 12.8 s · first row 409 ms
3461    ///     after    16.0 MB ·  105 req ·  182.6 MB RSS ·  0.41 s · first row  14 ms
3462    ///   graph=results  p=s66#isbn    ->  40,761 rows
3463    ///     before  375.8 MB · 1797 req · 2105.7 MB RSS · 12.8 s
3464    ///     after    15.5 MB ·  104 req ·  155.2 MB RSS ·  0.23 s
3465    /// ```
3466    ///
3467    /// # Where it does NOT help
3468    ///
3469    /// The index is pruned; the **dictionary is not**. Term resolution still
3470    /// faults whichever chunks the surviving rows' terms live in, and on a file
3471    /// whose payload *is* the literals those chunks are most of the file. Same
3472    /// file, a predicate whose objects are the long abstracts:
3473    ///
3474    /// ```text
3475    ///   graph=projects p=s66#abstract ->  80,206 rows
3476    ///     before  260.7 MB · 1142 req · 1496.9 MB RSS · 5.97 s
3477    ///     after   213.4 MB · 1606 req · 1144.4 MB RSS · 2.91 s   (1.2x, not 23x)
3478    /// ```
3479    ///
3480    /// An *unfiltered* dump is unchanged by construction — it is the floor, and
3481    /// its peak is the resident dictionary, not this. Two ceilings sit above
3482    /// both: a faulted dictionary chunk is a `OnceCell` nothing ever evicts, and
3483    /// on a literal-heavy file the chunk directory alone can be a third of the
3484    /// file before any dump work at all (#198).
3485    ///
3486    /// # Permutations
3487    ///
3488    /// Every one of the eight bound/unbound shapes routes inside
3489    /// [`PermSet::CORE`] — `{SPO, POS, OSP}`, the minimum a legal file carries
3490    /// (see `perm_routing_never_leaves_core`). A file built with
3491    /// `--permutations 3` therefore prunes *identically* to a six-permutation
3492    /// one: same tiles, same rows, no fallback path to get wrong.
3493    ///
3494    /// A bound term the dictionary does not know, or an absent graph IRI, yields
3495    /// nothing without touching the index.
3496    ///
3497    /// # Order
3498    ///
3499    /// Rows arrive in the **routed permutation's** order, which is the price of
3500    /// routing at all. Unfiltered (and subject-bound) that is SPO — canonical
3501    /// `(s, p, o)`, unchanged from [`dump_each`](Self::dump_each). A bound
3502    /// predicate routes to POS and streams `(p, o, s)`; a bound object routes to
3503    /// OSP. The *set* is identical to the unfiltered dump filtered; the order is
3504    /// not, so a consumer that needs canonical order must sort. N-Quads, the
3505    /// format `rete export` writes, is order-independent.
3506    ///
3507    /// [`PermSet::CORE`]: crate::index::PermSet::CORE
3508    pub fn dump_filtered_each<F: FnMut(&str, &str, &str)>(
3509        &self,
3510        graph: Option<&str>,
3511        s: Option<&str>,
3512        p: Option<&str>,
3513        o: Option<&str>,
3514        mut f: F,
3515    ) {
3516        let index = match graph {
3517            None => Some(&self.index),
3518            Some(g) => self.graph_index(g),
3519        };
3520        let Some(index) = index else { return };
3521        // A bound term the dictionary does not know can match nothing, and the
3522        // index is never touched — same contract as `query_iter`/`query_batch`.
3523        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
3524            return;
3525        };
3526        let dict = &self.dict;
3527        // Resolve in fixed-size batches with ONE coalesced dictionary fault per
3528        // batch — the same `prefetch_terms` call `dump_batch` makes, for the
3529        // same reason.
3530        //
3531        // This used to be `Dictionary::prefetch_all` up front, on the argument
3532        // that a full dump reaches every term anyway so the bytes are owed
3533        // either way. That argument only holds for a dump of EVERYTHING, and
3534        // this takes a graph AND a pattern. Measured on `cordis.rete` (801 MB,
3535        // a 417 MB dictionary, six named graphs), lazily opened:
3536        //
3537        //   dump_each(Some(fundingschemes))  417 MB read, 2510 MB peak RSS
3538        //                              →     173 MB read,  619 MB peak RSS
3539        //   dump_each(None) — the EMPTY default graph, which is the first thing
3540        //   `rete export` calls on a quads file:
3541        //                                    415 MB read, 2508 MB peak RSS
3542        //                              →       0 MB read,   95 MB peak RSS
3543        //
3544        // The 2.5 GB comes from asking for every chunk at once: the bulk loader
3545        // coalesces adjacent chunks into one span, so the whole dictionary
3546        // section is materialized three times over before the first triple —
3547        // the fetched span, the per-chunk copies sliced out of it, and the
3548        // decompressed bodies.
3549        //
3550        // Be clear about what this does NOT buy. A dump of the whole default
3551        // graph still faults every chunk, and chunks stay resident once
3552        // faulted, so `rete export` of a single-graph file is unchanged:
3553        // `figshare.rete` (222 MB, a 126 MB dictionary) peaks at 874 MB lazily
3554        // either way. The resident dictionary is the floor there, not this.
3555        //
3556        // Within each window, term resolution is CHUNK-ORDERED, not row-ordered
3557        // (`Dictionary::resolve_window`, bounded-export phase 1): the window's
3558        // ids are grouped by the `(section, chunk)` they live in and each chunk
3559        // is decoded once and its runs walked once, filling every slot that
3560        // chunk owns, then the rows are emitted in the original scan order. Two
3561        // things follow. First, a window's objects — which scatter across the
3562        // object-only section for one subject — touch each chunk a single time
3563        // instead of re-faulting it per row. Second, the per-id re-walk from a
3564        // run's start that the old row-order path did for *every* id collapses
3565        // to one monotonic forward pass per run. The output is byte-for-byte
3566        // identical (same bytes per id, same emission order, same
3567        // all-three-resolved emit rule); the cap is still unlimited, so this is
3568        // structural preparation for the real bound (phase 2), plus a small
3569        // free speedup. The coalesced bulk fault (`prefetch_terms`) is
3570        // unchanged, so the read pattern and peak residency are as before.
3571        const WINDOW: usize = 4096;
3572        // The whole-window coalesced prefetch (`prefetch_terms`) materializes
3573        // every distinct chunk the window touches at once — up to ~2·WINDOW
3574        // chunks for scattered objects. Under a GENEROUS budget that transient
3575        // fits and the coalesced reads are the fast path (small/normal files,
3576        // `--in-memory`, unlimited). Under a TIGHT budget it would blow the cap
3577        // and thrash (insert-then-evict every chunk, then re-fault it in
3578        // `resolve_window`), so skip it: `resolve_window` is chunk-ordered and
3579        // holds one chunk `Arc` at a time (plan §3.5), keeping the window's
3580        // working set to a single chunk regardless of how the objects scatter.
3581        // Output is byte-identical either way — prefetch only warms the cache.
3582        let cap = dict.cache_cap();
3583        let prefetch_window =
3584            cap == u64::MAX || cap >= (WINDOW as u64) * 2 * (DICT_CHUNK_BUDGET as u64);
3585        // Under a finite budget the prefetch also goes in batches of
3586        // PREFETCH_BATCH chunks: one bulk read holds everything it fetched
3587        // (span, slices, decoded bodies) until it is all cached, which for a
3588        // scattered window was thousands of chunks — up to ~1 GB on
3589        // openaire-relation — outside the cache cap. Unlimited keeps one read
3590        // per section (the latency-optimal shape for a remote file).
3591        const PREFETCH_BATCH: usize = 64;
3592        let prefetch_batch = if cap == u64::MAX {
3593            usize::MAX
3594        } else {
3595            PREFETCH_BATCH
3596        };
3597        let mut ids: Vec<(u32, u32, u32)> = Vec::with_capacity(WINDOW);
3598        let mut nodes: Vec<u32> = Vec::with_capacity(WINDOW * 2);
3599        let mut preds: Vec<u32> = Vec::with_capacity(WINDOW);
3600        let mut resolver = crate::dictionary::WindowResolver::default();
3601        let mut flush = |ids: &mut Vec<(u32, u32, u32)>,
3602                         resolver: &mut crate::dictionary::WindowResolver,
3603                         f: &mut F| {
3604            if ids.is_empty() {
3605                return;
3606            }
3607            // Coalesced bulk fault of exactly the chunks this window needs —
3608            // only under a generous budget (see `prefetch_window` above); a
3609            // tight budget faults chunk-by-chunk inside `resolve_window`.
3610            if prefetch_window {
3611                nodes.clear();
3612                preds.clear();
3613                for &(s, p, o) in ids.iter() {
3614                    nodes.push(dict.subject_node(s));
3615                    nodes.push(dict.object_node(o));
3616                    preds.push(p);
3617                }
3618                dict.prefetch_terms_batched(&nodes, &preds, prefetch_batch);
3619            }
3620            dict.resolve_window(ids, resolver, |s, p, o| f(s, p, o));
3621            ids.clear();
3622        };
3623        for t in index.scan_iter(pattern) {
3624            ids.push(t);
3625            if ids.len() == WINDOW {
3626                flush(&mut ids, &mut resolver, &mut f);
3627            }
3628        }
3629        flush(&mut ids, &mut resolver, &mut f);
3630    }
3631
3632    /// What a [`dump_filtered_each`](Self::dump_filtered_each) over this graph
3633    /// and pattern will fetch, **before** it starts — the dump twin of
3634    /// `rete cost`'s query preview.
3635    ///
3636    /// Costed from the tile directories, so it fetches no tile. It does read the
3637    /// dictionary far enough to resolve the bound terms (that is a real,
3638    /// unavoidable cost of the dump too) and to learn whether the graph exists.
3639    pub fn dump_plan(
3640        &self,
3641        graph: Option<&str>,
3642        s: Option<&str>,
3643        p: Option<&str>,
3644        o: Option<&str>,
3645    ) -> DumpPlan {
3646        let index = match graph {
3647            None => Some(&self.index),
3648            Some(g) => self.graph_index(g),
3649        };
3650        let scan = index
3651            .zip(self.resolve_query_pattern(s, p, o))
3652            .map(|(ix, pattern)| ix.scan_plan(pattern));
3653        DumpPlan {
3654            scan,
3655            dictionary_bytes: self.header.dictionary_len,
3656        }
3657    }
3658
3659    /// The **pull** twin of [`Rete::dump_each`](Self::dump_each): every triple of a
3660    /// graph (`None` = default) as a lazy iterator of resolved terms.
3661    ///
3662    /// Same constant-memory scan — one triple decoded and resolved per `next()`,
3663    /// never a `Vec` of the whole graph — but the caller drives it, so it can
3664    /// stop early or be **suspended and resumed** across a foreign-function
3665    /// boundary. That is what the wasm/JS client's batched quad cursor needs:
3666    /// a callback cannot be paused mid-scan to hand control back to JavaScript,
3667    /// an iterator can.
3668    ///
3669    /// The dictionary is NOT prefetched: terms fault in as they are resolved, so
3670    /// stopping after a handful of triples costs a handful of dictionary reads
3671    /// rather than the whole dictionary — which on a lazy remote open of a large
3672    /// file would be gigabytes before the first triple, defeating the point of a
3673    /// pull API. [`dump_each`](Self::dump_each) does not prefetch it whole
3674    /// either; it batches the same per-term faults. Peak memory is
3675    /// O(faulted dictionary chunks + faulted index tiles), *not* O(triples).
3676    pub fn dump_iter(&self, graph: Option<&str>) -> impl Iterator<Item = TermTriple> + '_ {
3677        self.query_iter(graph, None, None, None)
3678    }
3679
3680    /// [`dump_iter`](Self::dump_iter) generalized to a triple **pattern**: the
3681    /// lazy pull form of [`query_in_graph`](Self::query_in_graph). A bound term
3682    /// unknown to the dictionary, or an unknown graph IRI, yields nothing.
3683    ///
3684    /// Same constant-memory guarantee — one triple decoded and resolved per
3685    /// `next()`, never a `Vec` of every match — and the same absence of a
3686    /// dictionary prefetch, so stopping after a handful of matches costs a
3687    /// handful of dictionary reads. `query_in_graph` is the eager twin; prefer
3688    /// this one wherever the consumer can stop early (`ASK`, `LIMIT`, an RDF4J
3689    /// `getStatements` that is about to be sliced).
3690    pub fn query_iter(
3691        &self,
3692        graph: Option<&str>,
3693        s: Option<&str>,
3694        p: Option<&str>,
3695        o: Option<&str>,
3696    ) -> impl Iterator<Item = TermTriple> + '_ {
3697        let index = match graph {
3698            None => Some(&self.index),
3699            Some(g) => self.graph_index(g),
3700        };
3701        index
3702            .zip(self.resolve_query_pattern(s, p, o))
3703            .into_iter()
3704            .flat_map(|(ix, pattern)| ix.scan_iter(pattern))
3705            .filter_map(move |(s, p, o)| {
3706                Some((
3707                    self.dict.subject_term(s)?,
3708                    self.dict.predicate_term(p)?,
3709                    self.dict.object_term(o)?,
3710                ))
3711            })
3712    }
3713
3714    /// One bounded, **resumable** slice of a pattern's matches inside one graph
3715    /// (`None` = default): `(triples, next_cursor, done)`. Start at `cursor = 0`
3716    /// and feed the returned cursor back until `done`.
3717    ///
3718    /// This is [`query_in_graph`](Self::query_in_graph) made streamable across a
3719    /// boundary that cannot hold a Rust borrow — the wasm/JVM one. The engine
3720    /// keeps no iterator alive between calls; the entire resume state is the
3721    /// opaque `u64` (see [`GraphIndex::scan_batch`]), so a client's cursor
3722    /// survives being suspended, and a cursor that is abandoned leaks nothing
3723    /// but a `u64`.
3724    ///
3725    /// The dictionary is faulted **per batch**, not whole: taking ten matches
3726    /// off the front of a 9.8-billion-quad file reads ten matches' worth of
3727    /// terms. That is the difference between `LIMIT 1` answering in bounded time
3728    /// and it reading a 30 GB dictionary first.
3729    ///
3730    /// `max_quads` is a floor (batches end on an a-group boundary), and every
3731    /// call either returns at least one row or reports `done`.
3732    pub fn query_batch(
3733        &self,
3734        graph: Option<&str>,
3735        s: Option<&str>,
3736        p: Option<&str>,
3737        o: Option<&str>,
3738        cursor: u64,
3739        max_quads: usize,
3740    ) -> (Vec<TermTriple>, u64, bool) {
3741        let index = match graph {
3742            None => &self.index,
3743            // An unknown graph IRI is an empty scan, not an error — same as
3744            // `query_in_graph` and `dump_batch`.
3745            Some(g) => match self.graph_index(g) {
3746                Some(i) => i,
3747                None => return (Vec::new(), cursor, true),
3748            },
3749        };
3750        // A bound term the dictionary does not know can match nothing, and the
3751        // index is never touched.
3752        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
3753            return (Vec::new(), cursor, true);
3754        };
3755        let (ids, next, done) = index.scan_batch(pattern, cursor, max_quads);
3756        // One coalesced dictionary fault for THIS batch — the same call
3757        // `query_in_graph` and `dump_batch` make, and the reason a partial walk
3758        // costs in proportion to what it actually read.
3759        if !ids.is_empty() {
3760            let mut nodes = Vec::with_capacity(ids.len() * 2);
3761            let mut preds = Vec::with_capacity(ids.len());
3762            for &(s, p, o) in &ids {
3763                nodes.push(self.dict.subject_node(s));
3764                nodes.push(self.dict.object_node(o));
3765                preds.push(p);
3766            }
3767            self.dict.prefetch_terms(&nodes, &preds);
3768        }
3769        let triples = ids
3770            .into_iter()
3771            .filter_map(|(s, p, o)| {
3772                Some((
3773                    self.dict.subject_term(s)?,
3774                    self.dict.predicate_term(p)?,
3775                    self.dict.object_term(o)?,
3776                ))
3777            })
3778            .collect();
3779        (triples, next, done)
3780    }
3781
3782    /// Resolve one bounded slice of a graph's triples. Returns
3783    /// `(triples, next_cursor, done)`; start with `cursor = 0` and feed the
3784    /// returned cursor back until `done`.
3785    ///
3786    /// This is the PULL half of the dump, and it exists because [`Rete::dump_each`] is
3787    /// push-based: it drives the scan itself and hands each triple to a
3788    /// callback. A client that wants to pull (a Python generator, a JS async
3789    /// iterator) has to hold the scan's stack between calls — which means a
3790    /// thread (unavailable on Pyodide and in wasm) or a self-referential struct
3791    /// holding an iterator that borrows the `Rete` stored next to it, whose
3792    /// soundness rests on drop order plus an unstated promise that this crate's
3793    /// lazily-faulted caches stay write-once. Neither is worth it for a dump.
3794    ///
3795    /// Instead the scan is made RESUMABLE: SPO tiles are ordered by subject id,
3796    /// so "every triple of subject `sid`, for ascending `sid`" visits exactly the
3797    /// tiles a full scan visits, in the same order, and the whole resume state
3798    /// collapses to one `u32`. No thread, no `unsafe`, no borrow held across
3799    /// calls — and unlike `(offset, limit)` it is O(n) overall rather than
3800    /// O(n²/limit), because nothing is ever re-scanned.
3801    ///
3802    /// `max_quads` is the FLOOR of a batch, not a hard cut: a batch always ends
3803    /// on a subject boundary, so no subject is split across two calls. Work per
3804    /// call is bounded by a probe budget too — a sparse named graph whose few
3805    /// subjects are spread over a huge id space returns early with `done = false`
3806    /// rather than grinding through absent ids, and the caller just asks again.
3807    pub fn dump_batch(
3808        &self,
3809        graph: Option<&str>,
3810        cursor: u32,
3811        max_quads: usize,
3812    ) -> (Vec<TermTriple>, u32, bool) {
3813        /// The first subject id at or after `sid` that any SPO tile can hold, or
3814        /// `None` when `sid` is past the last tile. Tiles ascend by leading
3815        /// range, so this is a binary search; it returns `sid` itself when a tile
3816        /// already covers it (an ordinary "this subject has no triples" step).
3817        fn next_tile_start(tiles: &[crate::index::Tile], sid: u32) -> Option<u32> {
3818            let i = tiles.partition_point(|t| t.leading_range().1 < sid);
3819            tiles.get(i).map(|t| t.leading_range().0.max(sid))
3820        }
3821
3822        let index = match graph {
3823            None => self.default_index(),
3824            // An unknown graph IRI is an empty dump, not an error — same as
3825            // `dump_each`.
3826            Some(g) => match self.graph_index(g) {
3827                Some(i) => i,
3828                None => return (Vec::new(), cursor, true),
3829            },
3830        };
3831        // Tile leading ranges live in the section directory, so both the span and
3832        // the gap jumps below are free on a lazy/remote open — no tile is fetched
3833        // to decide where to look.
3834        let tiles = index.tile_sections()[IndexPermutation::Spo.section_index()];
3835        let (Some(first), Some(last)) = (
3836            tiles.first().map(|t| t.leading_range().0),
3837            tiles.last().map(|t| t.leading_range().1),
3838        ) else {
3839            return (Vec::new(), cursor, true); // empty graph
3840        };
3841
3842        let mut sid = cursor.max(first);
3843        let mut probes = max_quads.saturating_mul(4).max(1 << 16);
3844        let mut ids: Vec<(u32, u32, u32)> = Vec::new();
3845        let mut exhausted = false;
3846        while sid <= last && probes > 0 {
3847            probes -= 1;
3848            let hits = index.match_pattern((Some(sid), None, None));
3849            if hits.is_empty() {
3850                // No triples at this id. If it also falls in a hole BETWEEN
3851                // tiles, jump straight to the next tile's first id instead of
3852                // stepping through the gap one absent id at a time.
3853                match next_tile_start(tiles, sid) {
3854                    None => {
3855                        exhausted = true;
3856                        break;
3857                    }
3858                    Some(next) if next > sid => {
3859                        sid = next;
3860                        continue;
3861                    }
3862                    Some(_) => {}
3863                }
3864            } else {
3865                ids.extend(hits);
3866            }
3867            if sid == u32::MAX {
3868                exhausted = true;
3869                break;
3870            }
3871            sid += 1;
3872            if ids.len() >= max_quads {
3873                break;
3874            }
3875        }
3876        let done = exhausted || sid > last;
3877
3878        // One coalesced dictionary fault for this batch, then resolve.
3879        // Deliberately NOT `Dictionary::prefetch_all` (what `dump_each` uses):
3880        // on a lazy open that pulls the ENTIRE dictionary — gigabytes on a big
3881        // file — before the first quad, so taking five quads off the front would
3882        // cost as much as a full dump. Per-batch prefetch keeps a partial walk
3883        // proportional to what it actually read.
3884        let dict = self.dictionary();
3885        if !ids.is_empty() {
3886            let mut nodes = Vec::with_capacity(ids.len() * 2);
3887            let mut preds = Vec::with_capacity(ids.len());
3888            for &(s, p, o) in &ids {
3889                nodes.push(dict.subject_node(s));
3890                nodes.push(dict.object_node(o));
3891                preds.push(p);
3892            }
3893            dict.prefetch_terms(&nodes, &preds);
3894        }
3895        let triples = ids
3896            .into_iter()
3897            .filter_map(|(s, p, o)| {
3898                Some((
3899                    dict.subject_term(s)?,
3900                    dict.predicate_term(p)?,
3901                    dict.object_term(o)?,
3902                ))
3903            })
3904            .collect();
3905        (triples, sid, done)
3906    }
3907
3908    /// How many named graphs this dataset has. On a lazy ranged open this
3909    /// reads only the section's leading count varint — never the graphs.
3910    pub fn named_graph_count(&self) -> usize {
3911        self.named_graphs.count()
3912    }
3913
3914    /// The `i`-th named graph's IRI (stored order). On a lazy ranged open this
3915    /// walks entry HEADERS up to `i` — it never decodes a graph's index.
3916    pub fn named_graph_name_at(&self, i: usize) -> Option<&str> {
3917        self.named_graphs.name_at(i, false)
3918    }
3919
3920    /// The `i`-th named graph as `(iri, index)`. On a lazy ranged open the
3921    /// index is fetched and decoded on first access and memoised; check
3922    /// [`index_incomplete`](Self::index_incomplete) after evaluating.
3923    pub fn named_graph_at(&self, i: usize) -> Option<(&str, &GraphIndex)> {
3924        self.named_graphs.graph_at(i, false)
3925    }
3926
3927    /// [`named_graph_name_at`](Self::named_graph_name_at) under a declared
3928    /// demand: `exhaustive = true` tells a lazy walk the caller will visit
3929    /// every graph, so it may read the section in bulk chunks. Purely a fetch
3930    /// strategy — results are identical either way. Only the SPARQL evaluator
3931    /// sets it, from shapes where full consumption is provable.
3932    pub(crate) fn named_graph_name_at_demand(&self, i: usize, exhaustive: bool) -> Option<&str> {
3933        self.named_graphs.name_at(i, exhaustive)
3934    }
3935
3936    /// [`named_graph_at`](Self::named_graph_at) under a declared demand: see
3937    /// [`named_graph_name_at_demand`](Self::named_graph_name_at_demand).
3938    pub(crate) fn named_graph_at_demand(
3939        &self,
3940        i: usize,
3941        exhaustive: bool,
3942    ) -> Option<(&str, &GraphIndex)> {
3943        self.named_graphs.graph_at(i, exhaustive)
3944    }
3945
3946    /// IRIs of the named graphs in this dataset (the default graph is unnamed).
3947    /// On a lazy ranged open this walks the whole directory (headers only) —
3948    /// prefer [`named_graph_count`](Self::named_graph_count) when the number
3949    /// is all that's needed.
3950    pub fn graph_names(&self) -> Vec<&str> {
3951        (0..self.named_graphs.count())
3952            .filter_map(|i| self.named_graphs.name_at(i, false))
3953            .collect()
3954    }
3955
3956    /// The permutation index of a named graph, or `None` if absent.
3957    pub fn graph_index(&self, iri: &str) -> Option<&GraphIndex> {
3958        self.named_graphs.find(iri)
3959    }
3960
3961    /// Match a triple pattern in dictionary-ID space (subject/predicate/object
3962    /// IDs), returning integer triples — the fast path used by the BGP engine.
3963    pub fn match_ids(
3964        &self,
3965        pattern: (Option<u32>, Option<u32>, Option<u32>),
3966    ) -> Vec<(u32, u32, u32)> {
3967        self.index.match_pattern(pattern)
3968    }
3969
3970    /// All `(subject_node, object_node)` pairs for a predicate, as unified node
3971    /// IDs — no term resolution. The fast path for graph traversal.
3972    pub fn predicate_pairs(&self, predicate: &str) -> Vec<(u32, u32)> {
3973        let pid = match self.dict.predicate_id(predicate) {
3974            Some(p) => p,
3975            None => return Vec::new(),
3976        };
3977        self.index
3978            .match_pattern((None, Some(pid), None))
3979            .into_iter()
3980            .map(|(s, _p, o)| (self.dict.subject_node(s), self.dict.object_node(o)))
3981            .collect()
3982    }
3983
3984    /// Open via a [`RangeReader`], fetching only the header and the named
3985    /// section ranges — never a linear scan of the whole resource. A full query
3986    /// open touches at most 4 ranges (header, dictionary, index, pyramid-meta).
3987    pub fn open_ranged<R: RangeReader>(reader: &R) -> Result<Self, FileError> {
3988        let head = reader.read_at(0, HEADER_LEN as u64)?;
3989        let header = Header::from_bytes(&head)?;
3990
3991        let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
3992        let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
3993
3994        let index_bytes = reader.read_at(header.root_dir_offset, header.root_dir_len)?;
3995        let index = decode_index_container(&index_bytes, header.block_codec, header.perms)?;
3996        let index_section_ranges =
3997            decode_index_section_ranges(&index_bytes, header.root_dir_offset, header.perms)?;
3998
3999        let pyramid = PyramidSlot::Resident(if header.pyramid_meta_len > 0 {
4000            let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
4001            Some(
4002                PyramidMeta::decode(&mb)
4003                    .map_err(|_| FileError::Container("malformed pyramid meta"))?,
4004            )
4005        } else {
4006            None
4007        });
4008
4009        // Fetch the whole TEXT_INDEX section resident (this opener does one range
4010        // read per section; the lazy opener below is the one that defers it).
4011        let text_index_bytes = if header.text_index_len > 0 {
4012            Some(reader.read_at(header.text_index_offset, header.text_index_len)?)
4013        } else {
4014            None
4015        };
4016        let text_index = resident_text_index_slot(text_index_bytes.as_deref(), header.block_codec)?;
4017
4018        let named_graphs = NamedGraphsSlot::Resident(if header.named_graphs_len > 0 {
4019            let nb = reader.read_at(header.named_graphs_offset, header.named_graphs_len)?;
4020            decode_named_graphs(&nb, header.block_codec, header.perms)?
4021        } else {
4022            Vec::new()
4023        });
4024
4025        // The metadata section (Dataset Card) is deliberately NOT fetched here:
4026        // a ranged query open keeps to its small range budget. Use `Rete::open`
4027        // (or a dedicated card fetch) when the card is actually needed.
4028        let tile_ranges =
4029            tile_file_ranges(&index_bytes, header.root_dir_offset, &index_section_ranges);
4030        Ok(Self {
4031            header,
4032            dict,
4033            index,
4034            index_section_ranges,
4035            tile_ranges,
4036            pyramid,
4037            text_index,
4038            named_graphs,
4039            metadata: Vec::new(),
4040            service_client: None,
4041            service_error: std::sync::Mutex::new(None),
4042            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
4043        })
4044    }
4045
4046    /// Open via an **owned** [`RangeReader`] with lazy tile faulting (tiled
4047    /// v0.2 files): fetches the header, dictionary, pyramid meta, named graphs,
4048    /// and each permutation's tile **directory** — but no default-graph tile
4049    /// payloads. Tiles fault in (one range request each) the first time a scan
4050    /// touches them, so a selective SPARQL query fetches O(touched tiles)
4051    /// bytes instead of the whole index.
4052    ///
4053    /// **Failure contract:** scans are infallible by design, so a failed tile
4054    /// fetch yields an empty tile and sets a sticky flag — after evaluating,
4055    /// callers MUST check [`index_incomplete`](Self::index_incomplete) and
4056    /// surface an error instead of the (possibly partial) results.
4057    pub fn open_ranged_lazy<R: RangeReader + Send + Sync + 'static>(
4058        reader: R,
4059    ) -> Result<Self, FileError> {
4060        let head = reader.read_at(0, HEADER_LEN as u64)?;
4061        let header = Header::from_bytes(&head)?;
4062        let reader = std::sync::Arc::new(reader);
4063        // Captured before the loader closures take the Arc: the reader's
4064        // concurrent-range fan-out, stamped onto the index for the planner.
4065        let read_concurrency = reader.concurrency();
4066
4067        // Lazily-chunked dictionary: locate the four sections, fetch each
4068        // section's header + restart table + chunk directory (small), and
4069        // fault the chunk bodies in on first term lookup.
4070        let dict = ranged_chunked_dictionary(&reader, &header, [true; 4])?;
4071
4072        // Locate the six index section payloads (container framing only) and
4073        // fetch just their tile directories — shared with the per-named-graph
4074        // lazy opener, which opens a large graph's container the same way.
4075        let reader_dyn: std::sync::Arc<dyn RangeReader + Send + Sync> = reader.clone();
4076        let (index, index_section_ranges, tile_ranges) = open_index_container_lazy(
4077            &reader_dyn,
4078            ByteRange {
4079                offset: header.root_dir_offset,
4080                len: header.root_dir_len,
4081            },
4082            header.block_codec,
4083            header.has_tile_synopsis(),
4084            read_concurrency,
4085            header.perms,
4086        )?;
4087
4088        // The pyramid meta is large on real graphs (tens of MB) and SPARQL never
4089        // reads it, so defer its fetch: it faults in only if `pyramid()` is
4090        // called (community / pyramid_tree / inspect queries).
4091        let pyramid = ranged_pyramid_slot(&reader, &header);
4092
4093        // The TEXT_INDEX section is also deferred: a text search faults the token
4094        // table on first call (the leading varint then its compressed bytes), then
4095        // fetches individual posting lists by `(offset, len)` — never the whole
4096        // postings blob. A SPARQL query, which never searches, pays nothing.
4097        let text_index = ranged_text_index_slot(&reader, &header);
4098
4099        // Named graphs are the last eagerly-fetched section standing on this
4100        // path — and on a many-graph file they dwarf everything else (67 MB
4101        // fetched and ~32k graph indexes decoded before the first query, on a
4102        // file whose queries then touch a handful of graphs). Defer them like
4103        // the pyramid and text index: nothing is read at open; a query walks
4104        // the directory and decodes only the graphs it touches.
4105        let named_graphs = if header.named_graphs_len > 0 {
4106            NamedGraphsSlot::Lazy(LazyNamedGraphs::new(
4107                reader_dyn,
4108                ByteRange {
4109                    offset: header.named_graphs_offset,
4110                    len: header.named_graphs_len,
4111                },
4112                header.block_codec,
4113                header.has_tile_synopsis(),
4114                read_concurrency,
4115                header.perms,
4116            ))
4117        } else {
4118            NamedGraphsSlot::Resident(Vec::new())
4119        };
4120
4121        let rete = Self {
4122            header,
4123            dict,
4124            index,
4125            index_section_ranges,
4126            tile_ranges,
4127            pyramid,
4128            text_index,
4129            named_graphs,
4130            metadata: Vec::new(),
4131            service_client: None,
4132            service_error: std::sync::Mutex::new(None),
4133            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
4134        };
4135        // A lazy open is the path for files too big to read whole, and the
4136        // one a long-lived session (relay, Python/R/Java clients, `rete serve`)
4137        // keeps resident across queries. Unbounded, its dictionary and tile
4138        // caches grow with everything any query ever touched: a GROUP BY over
4139        // a 9.5-billion-triple file ran a 16 GB container out of memory. Cap
4140        // them by default; a caller that wants another figure (or unlimited)
4141        // calls `set_memory_budget` again. Not on wasm32, whose 4 GiB address
4142        // space makes the default meaningless and whose callers bound their own
4143        // range budgets.
4144        #[cfg(not(target_arch = "wasm32"))]
4145        rete.set_memory_budget(Some(DEFAULT_QUERY_BUDGET_MB << 20));
4146        Ok(rete)
4147    }
4148
4149    /// Did any lazy fetch (index tile or dictionary chunk) fail since this
4150    /// `Rete` was opened? When true, query results may be silently incomplete —
4151    /// callers using [`Rete::open_ranged_lazy`] must check this after
4152    /// evaluating and turn it into an error.
4153    pub fn index_incomplete(&self) -> bool {
4154        self.index.load_incomplete()
4155            || self.dict.load_incomplete()
4156            || self.named_graphs.load_incomplete()
4157    }
4158
4159    /// Forget recorded lazy-fetch failures — the start-of-evaluation reset for
4160    /// a RESIDENT session (a browser worker holding one `Rete` across many
4161    /// queries): it makes [`index_incomplete`](Self::index_incomplete) a
4162    /// per-query verdict instead of a per-open one, so a single transient
4163    /// network failure no longer fails every subsequent query on the session.
4164    /// Sound because failed tiles/chunks are never cached — the next
4165    /// evaluation simply retries the fetch.
4166    pub fn reset_load_failures(&self) {
4167        self.index.reset_load_failure();
4168        self.dict.reset_load_failure();
4169        self.named_graphs.reset_load_failures();
4170    }
4171
4172    /// Bound peak resident memory to roughly `budget` bytes by capping the
4173    /// evictable dictionary chunk cache and the default-graph index tile cache
4174    /// (bounded-export phase 2). `None` or `u64::MAX` means unlimited — nothing
4175    /// is evicted and residency is identical to a plain open, which is what
4176    /// `--in-memory` and every query/wasm path use.
4177    ///
4178    /// Sound at any cap: the dictionary and the default-graph index both fault
4179    /// their chunks/tiles through a loader, so an evicted body is simply
4180    /// re-faulted on its next touch; the export dump reads each needed body
4181    /// while holding it (the cache `Arc` handout invariant), so eviction never
4182    /// drops bytes in use. Named-graph indexes are **not** capped here — a small
4183    /// resident graph has no loader and could never reload an evicted tile — so
4184    /// they stay unlimited and are released whole per slot instead (see
4185    /// [`release_named_graph`](Self::release_named_graph)).
4186    ///
4187    /// The open's **fixed** heap — chunk and tile directories, the named-graph
4188    /// directory walked so far ([`fixed_overhead`](Self::fixed_overhead)) — is
4189    /// paid whatever the caches hold, so it comes out of the dictionary share:
4190    /// the caps plus the fixed heap stay within `budget`. It grows with the
4191    /// file's chunk/tile/graph count (~18 MB of directories on the 14.75 GiB
4192    /// openaire-relation, 88 MB with its tile directories; 224 MB on a pre-#198
4193    /// epfl-infoscience whose chunk keys are whole boundary terms). Calling
4194    /// this again after a walk of the named-graph directory (as `rete export`
4195    /// does once it has listed the graph slots) re-subtracts the larger figure.
4196    /// The dictionary share never drops below its 64 MiB floor, which is the
4197    /// only way the total can be exceeded: a file whose directories alone
4198    /// approach the budget.
4199    ///
4200    /// Returns the split actually applied (dictionary share net of the fixed
4201    /// heap), for `RETE_OPEN_DEBUG` logging.
4202    pub fn set_memory_budget(&self, budget: Option<u64>) -> MemoryBudget {
4203        let total = budget.unwrap_or(u64::MAX);
4204        self.set_spill_threshold(budget.map(spill_threshold_for));
4205        let mut split = split_memory_budget(total);
4206        let fixed = if total == u64::MAX {
4207            0
4208        } else {
4209            self.fixed_overhead().total()
4210        };
4211        if split.dict_cache != u64::MAX {
4212            split.dict_cache = split
4213                .dict_cache
4214                .saturating_sub(fixed)
4215                .max(MIN_DICT_CACHE_CAP);
4216        }
4217        self.dict.set_cache_cap(split.dict_cache);
4218        self.index.set_cache_cap(split.tile_cache);
4219        // Large named graphs (opened lazily, loader-bearing) get the same tile
4220        // cap; small resident ones stay unlimited (bounded by size + per-slot
4221        // release). Combined with `release_named_graph`, a many-graph dump holds
4222        // one graph's tiles at a time, each capped.
4223        if let NamedGraphsSlot::Lazy(lazy) = &self.named_graphs {
4224            lazy.set_tile_cap(split.tile_cache);
4225        }
4226        if std::env::var("RETE_OPEN_DEBUG").is_ok() {
4227            eprintln!(
4228                "[memory-budget] total={} dict_cache={} tile_cache={} block_cache={} fixed={:.1}MiB",
4229                if total == u64::MAX {
4230                    "unlimited".to_string()
4231                } else {
4232                    format!("{}MiB", total >> 20)
4233                },
4234                fmt_cap(split.dict_cache),
4235                fmt_cap(split.tile_cache),
4236                fmt_cap(split.block_cache),
4237                fixed as f64 / (1u64 << 20) as f64,
4238            );
4239        }
4240        split
4241    }
4242
4243    /// How many bytes a query's `COUNT(DISTINCT …)` keeps in memory before it
4244    /// writes sorted runs to the temporary directory (`TMPDIR`) and counts by
4245    /// merging them; the count is exact either way. `u64::MAX` (the default of
4246    /// an eager open) never spills. [`set_memory_budget`](Self::set_memory_budget)
4247    /// sets it to an eighth of the budget, so a lazy open spills past 512 MiB
4248    /// by default.
4249    pub fn spill_threshold(&self) -> u64 {
4250        self.spill_threshold
4251            .load(std::sync::atomic::Ordering::Relaxed)
4252    }
4253
4254    /// Set [`spill_threshold`](Self::spill_threshold) without touching the
4255    /// caches — for an eager (resident) open, whose caches cannot be capped.
4256    /// `None` = never spill.
4257    pub fn set_spill_threshold(&self, bytes: Option<u64>) {
4258        self.spill_threshold.store(
4259            bytes.unwrap_or(u64::MAX),
4260            std::sync::atomic::Ordering::Relaxed,
4261        );
4262    }
4263
4264    /// The dictionary chunk cache's observability counters (decoded chunks and
4265    /// bytes, hits/misses, evictions, resident bytes) — for the bounded-export
4266    /// work-accounting tests and `RETE_OPEN_DEBUG`.
4267    pub fn dict_cache_stats(&self) -> crate::chunk_cache::CacheStats {
4268        self.dict.cache_stats()
4269    }
4270
4271    /// Heap bytes this open holds outside the bounded caches — the dictionary
4272    /// chunk directories, the index tile directories and the named-graph
4273    /// directory — for `RETE_OPEN_DEBUG` / `RETE_MEM_TRACE` accounting.
4274    pub fn fixed_overhead(&self) -> FixedOverhead {
4275        FixedOverhead {
4276            dict_directory: self.dict.directory_bytes(),
4277            index_directory: self.index.directory_bytes(),
4278            named_graphs: self.named_graphs.directory_bytes(),
4279        }
4280    }
4281
4282    /// The default-graph index tile cache's observability counters.
4283    pub fn index_cache_stats(&self) -> crate::chunk_cache::CacheStats {
4284        self.index.cache_stats()
4285    }
4286
4287    /// The current dictionary chunk-cache byte cap (`u64::MAX` = unlimited).
4288    pub fn dict_cache_cap(&self) -> u64 {
4289        self.dict.cache_cap()
4290    }
4291
4292    /// Release a named graph's decoded index after its slot has been exported,
4293    /// dropping its `GraphIndex` (and, with it, that graph's decoded tiles and
4294    /// its own tile cache) so a many-graph dump does not accumulate one resident
4295    /// index per graph — the next linear floor on many-graph files once the
4296    /// dictionary is bounded (plan §6). A no-op for the default graph, an
4297    /// unknown IRI, a resident (in-memory/eager) open, or a graph never opened.
4298    ///
4299    /// Takes `&mut self`: the export loop finishes one slot's dump (which only
4300    /// borrows `&self`) before releasing, so no outstanding borrow of the index
4301    /// exists. Correct because a released index is simply re-opened (headers +
4302    /// tile directory) on a later access — the same lazy contract as a chunk
4303    /// that was never faulted.
4304    pub fn release_named_graph(&mut self, iri: &str) {
4305        if let NamedGraphsSlot::Lazy(lazy) = &mut self.named_graphs {
4306            lazy.release(iri);
4307        }
4308    }
4309
4310    fn resolve_query_pattern(
4311        &self,
4312        s: Option<&str>,
4313        p: Option<&str>,
4314        o: Option<&str>,
4315    ) -> Option<Pattern> {
4316        let sid = match s {
4317            Some(t) => match self.dict.subject_id(t) {
4318                Some(id) => Some(id),
4319                None => return None,
4320            },
4321            None => None,
4322        };
4323        let pid = match p {
4324            Some(t) => match self.dict.predicate_id(t) {
4325                Some(id) => Some(id),
4326                None => return None,
4327            },
4328            None => None,
4329        };
4330        let oid = match o {
4331            Some(t) => match self.dict.object_id(t) {
4332                Some(id) => Some(id),
4333                None => return None,
4334            },
4335            None => None,
4336        };
4337        Some((sid, pid, oid))
4338    }
4339
4340    /// Evaluate a triple pattern and include the file/index provenance for every
4341    /// matched result. A bound term that is unknown to the dictionary yields no
4342    /// matches.
4343    pub fn query_with_provenance(
4344        &self,
4345        s: Option<&str>,
4346        p: Option<&str>,
4347        o: Option<&str>,
4348    ) -> Vec<TripleProvenance> {
4349        let pattern = match self.resolve_query_pattern(s, p, o) {
4350            Some(pattern) => pattern,
4351            None => return Vec::new(),
4352        };
4353
4354        let index_permutation = GraphIndex::best_permutation_in(self.header.perms, pattern);
4355        let dictionary_range = ByteRange {
4356            offset: self.header.dictionary_offset,
4357            len: self.header.dictionary_len,
4358        };
4359        let index_range = ByteRange {
4360            offset: self.header.root_dir_offset,
4361            len: self.header.root_dir_len,
4362        };
4363        let index_section_range = self.index_section_ranges[index_permutation.section_index()];
4364        let pyramid_range = (self.header.pyramid_meta_len > 0).then_some(ByteRange {
4365            offset: self.header.pyramid_meta_offset,
4366            len: self.header.pyramid_meta_len,
4367        });
4368
4369        let tiles = &self.tile_ranges[index_permutation.section_index()];
4370        self.index
4371            .match_pattern(pattern)
4372            .into_iter()
4373            .filter_map(|(s, p, o)| {
4374                let terms = (
4375                    self.dict.subject_term(s)?,
4376                    self.dict.predicate_term(p)?,
4377                    self.dict.object_term(o)?,
4378                );
4379                // The physical tile holding this match: the one whose
4380                // leading-id range covers the match's permuted leading id.
4381                let a = index_permutation.forward((s, p, o)).0;
4382                let ti = tiles.partition_point(|&(_, max_a, _)| max_a < a);
4383                let (tile, tile_range) = match tiles.get(ti) {
4384                    Some(&(min_a, _, range)) if min_a <= a => (
4385                        Some(format!("{}/{ti}", index_permutation.name())),
4386                        Some(range),
4387                    ),
4388                    _ => (None, None),
4389                };
4390                Some(TripleProvenance {
4391                    terms,
4392                    ids: (s, p, o),
4393                    graph: None,
4394                    matched_pattern: pattern,
4395                    index_permutation,
4396                    dictionary_range,
4397                    index_range,
4398                    index_section_range,
4399                    pyramid_range,
4400                    tile,
4401                    tile_range,
4402                })
4403            })
4404            .collect()
4405    }
4406
4407    /// Evaluate a triple pattern given as optional term strings, returning
4408    /// matching triples resolved back to terms. A bound term that is unknown to
4409    /// the dictionary yields no matches.
4410    pub fn query(&self, s: Option<&str>, p: Option<&str>, o: Option<&str>) -> Vec<TermTriple> {
4411        self.query_with_provenance(s, p, o)
4412            .into_iter()
4413            .map(|m| m.terms)
4414            .collect()
4415    }
4416
4417    /// Match a triple pattern **within a single graph** — `None` is the default
4418    /// graph, `Some(iri)` a named graph — resolving matches to canonical terms.
4419    /// This is [`Rete::query`] (default-graph only) generalized to any graph: the
4420    /// graph-scoped primitive a quad-aware consumer (e.g. an RDF4J `Sail`'s
4421    /// `getStatements`) needs. An unknown graph IRI, or a bound term absent from
4422    /// the shared dictionary, yields an empty result. All graphs share one
4423    /// dictionary, so the pattern resolves once against that ID space.
4424    pub fn query_in_graph(
4425        &self,
4426        graph: Option<&str>,
4427        s: Option<&str>,
4428        p: Option<&str>,
4429        o: Option<&str>,
4430    ) -> Vec<TermTriple> {
4431        let pattern = match self.resolve_query_pattern(s, p, o) {
4432            Some(pattern) => pattern,
4433            None => return Vec::new(),
4434        };
4435        let index = match graph {
4436            None => &self.index,
4437            Some(g) => match self.graph_index(g) {
4438                Some(i) => i,
4439                None => return Vec::new(),
4440            },
4441        };
4442        let ids = index.match_pattern(pattern);
4443        // One coalesced dictionary fault for THIS pattern's output, not the
4444        // whole dictionary. `prefetch_all` used to sit here, which made a
4445        // bounded `(s, p, o, g)` lookup — the primitive an RDF4J `Sail`'s
4446        // `getStatements` calls, once per named graph via `query_quads` — pay
4447        // for every term in the file: on `cordis.rete` (801 MB, a 417 MB
4448        // dictionary) a 15-row answer read 416 MB and peaked at 2.5 GB RSS.
4449        // Same call `dump_batch` makes, for the same reason.
4450        if !ids.is_empty() {
4451            let mut nodes = Vec::with_capacity(ids.len() * 2);
4452            let mut preds = Vec::with_capacity(ids.len());
4453            for &(s, p, o) in &ids {
4454                nodes.push(self.dict.subject_node(s));
4455                nodes.push(self.dict.object_node(o));
4456                preds.push(p);
4457            }
4458            self.dict.prefetch_terms(&nodes, &preds);
4459        }
4460        ids.into_iter()
4461            .filter_map(|(s, p, o)| {
4462                Some((
4463                    self.dict.subject_term(s)?,
4464                    self.dict.predicate_term(p)?,
4465                    self.dict.object_term(o)?,
4466                ))
4467            })
4468            .collect()
4469    }
4470
4471    /// Match a triple pattern across the default graph **and every named graph**,
4472    /// tagging each match with its graph (`None` = default). The quad-level
4473    /// companion to [`Rete::query`]; default-graph matches come first, then each
4474    /// named graph in stored order.
4475    pub fn query_quads(
4476        &self,
4477        s: Option<&str>,
4478        p: Option<&str>,
4479        o: Option<&str>,
4480    ) -> Vec<(TermTriple, Option<String>)> {
4481        let mut out: Vec<(TermTriple, Option<String>)> = self
4482            .query_in_graph(None, s, p, o)
4483            .into_iter()
4484            .map(|t| (t, None))
4485            .collect();
4486        for i in 0..self.named_graphs.count() {
4487            // A quad dump visits every graph by construction — exhaustive
4488            // demand, so a lazy walk may read the section in bulk chunks.
4489            let Some(iri) = self.named_graphs.name_at(i, true) else {
4490                continue;
4491            };
4492            for triple in self.query_in_graph(Some(iri), s, p, o) {
4493                out.push((triple, Some(iri.to_string())));
4494            }
4495        }
4496        out
4497    }
4498
4499    /// Evaluate one triple pattern through a [`RangeReader`] by fetching only
4500    /// the header, the dictionary, and — for a tiled (v0.2) file — the
4501    /// selected permutation section's tile **directory** plus the tile(s) the
4502    /// bound leading id routes to; an unbound leading id fetches the section's
4503    /// tile body in one request. v0.1 files fetch the whole selected section.
4504    /// Unknown bound terms return an empty result before touching the index.
4505    pub fn query_ranged<R: RangeReader>(
4506        reader: &R,
4507        s: Option<&str>,
4508        p: Option<&str>,
4509        o: Option<&str>,
4510    ) -> Result<Vec<TermTriple>, FileError> {
4511        let routed = match route_pattern(reader, s, p, o)? {
4512            Some(routed) => routed,
4513            None => return Ok(Vec::new()),
4514        };
4515        let matches = fetch_routed_matches(reader, &routed)?;
4516        Ok(matches
4517            .into_iter()
4518            .filter_map(|(s, p, o)| {
4519                Some((
4520                    routed.dict.subject_term(s)?,
4521                    routed.dict.predicate_term(p)?,
4522                    routed.dict.object_term(o)?,
4523                ))
4524            })
4525            .collect())
4526    }
4527
4528    /// Route one triple pattern to its permutation section without fetching
4529    /// any payload bytes. Returns `false` when a bound term is unknown and the
4530    /// index was skipped.
4531    pub fn route_pattern_ranged<R: RangeReader>(
4532        reader: &R,
4533        s: Option<&str>,
4534        p: Option<&str>,
4535        o: Option<&str>,
4536    ) -> Result<bool, FileError> {
4537        Ok(route_pattern(reader, s, p, o)?.is_some())
4538    }
4539}
4540
4541/// A pattern routed to its permutation section: everything needed to fetch
4542/// matches, with no payload bytes read yet.
4543struct RoutedPattern {
4544    dict: Dictionary,
4545    pattern: Pattern,
4546    permutation: IndexPermutation,
4547    header: Header,
4548    /// Absolute byte range of the selected section's payload.
4549    section: ByteRange,
4550}
4551
4552/// Resolve a pattern against the remote dictionary and locate its permutation
4553/// section (header + dictionary + container framing only).
4554fn route_pattern<R: RangeReader>(
4555    reader: &R,
4556    s: Option<&str>,
4557    p: Option<&str>,
4558    o: Option<&str>,
4559) -> Result<Option<RoutedPattern>, FileError> {
4560    let head = reader.read_at(0, HEADER_LEN as u64)?;
4561    let header = Header::from_bytes(&head)?;
4562
4563    let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
4564    let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
4565
4566    let Some(pattern) = resolve_query_pattern(&dict, s, p, o) else {
4567        return Ok(None);
4568    };
4569    let permutation = GraphIndex::best_permutation_in(header.perms, pattern);
4570    let section = locate_container_section_ranged(
4571        reader,
4572        header.root_dir_offset,
4573        header.root_dir_len,
4574        header
4575            .perms
4576            .position(permutation)
4577            .ok_or(FileError::Container("routed to an absent permutation"))?,
4578        header.perms.len() as u64,
4579    )?;
4580    Ok(Some(RoutedPattern {
4581        dict,
4582        pattern,
4583        permutation,
4584        header,
4585        section,
4586    }))
4587}
4588
4589/// Fetch and scan a routed pattern's matches: read the tile directory, then only
4590/// the matching tile byte ranges (the run of covering tiles for a bound leading
4591/// id — one for ordinary groups, several for a split mega-group — the
4592/// O(matching bytes) promise).
4593fn fetch_routed_matches<R: RangeReader>(
4594    reader: &R,
4595    routed: &RoutedPattern,
4596) -> Result<Vec<Triple>, FileError> {
4597    let dir = read_tile_directory_ranged(reader, routed.section)?;
4598    let [pa, _, _] = routed.permutation.order_pattern(routed.pattern);
4599    let codec = routed.header.block_codec;
4600    let mut out = Vec::new();
4601    match pa {
4602        // Bound leading id: the run of covering tiles (several for a split
4603        // mega-group; one otherwise).
4604        Some(a) => {
4605            for e in dir.iter().filter(|e| e.min_a <= a && a <= e.max_a) {
4606                let bytes = reader.read_at(routed.section.offset + e.start, e.end - e.start)?;
4607                let tile = decompress(codec, &bytes)?;
4608                out.extend(GraphIndex::match_serialized_block(
4609                    &tile,
4610                    routed.permutation,
4611                    routed.pattern,
4612                ));
4613            }
4614        }
4615        // Unbound leading id: every tile matters — fetch the contiguous tile
4616        // body in one request and slice it.
4617        None => {
4618            if let (Some(first), Some(last)) = (dir.first(), dir.last()) {
4619                let base = first.start;
4620                let body = reader.read_at(routed.section.offset + base, last.end - base)?;
4621                for e in &dir {
4622                    let tile = decompress(
4623                        codec,
4624                        &body[(e.start - base) as usize..(e.end - base) as usize],
4625                    )?;
4626                    out.extend(GraphIndex::match_serialized_block(
4627                        &tile,
4628                        routed.permutation,
4629                        routed.pattern,
4630                    ));
4631                }
4632            }
4633        }
4634    }
4635    out.sort_unstable();
4636    Ok(out)
4637}
4638
4639fn resolve_query_pattern(
4640    dict: &Dictionary,
4641    s: Option<&str>,
4642    p: Option<&str>,
4643    o: Option<&str>,
4644) -> Option<Pattern> {
4645    let sid = match s {
4646        Some(t) => Some(dict.subject_id(t)?),
4647        None => None,
4648    };
4649    let pid = match p {
4650        Some(t) => Some(dict.predicate_id(t)?),
4651        None => None,
4652    };
4653    let oid = match o {
4654        Some(t) => Some(dict.object_id(t)?),
4655        None => None,
4656    };
4657    Some((sid, pid, oid))
4658}
4659
4660/// The lazily-chunked dictionary of a ranged open: per section, fetch the
4661/// header + chunk directory and fault the chunk bodies in on first term lookup.
4662///
4663/// `want` selects which of the four sections (0 shared, 1 subject-only,
4664/// 2 object-only, 3 predicates) are really read; a skipped one becomes an empty
4665/// section whose terms resolve to `None`. On a file written before #198 a
4666/// **directory is not small**: it carried each chunk's first term verbatim, so
4667/// the object-only section of a dataset that stores abstracts can run to
4668/// hundreds of megabytes, and fetching it is most of what a remote open costs.
4669/// Current builds key it by the shortest separator instead (a few bytes per
4670/// chunk), but that is write-side, so every already-published file still pays.
4671/// A reader that only ever resolves subjects (see [`SearchView`]) skips
4672/// sections 2 and 3 and pays none of it. [`Rete::open_ranged_lazy`] wants all
4673/// four.
4674fn ranged_chunked_dictionary<R: RangeReader + Send + Sync + 'static>(
4675    reader: &std::sync::Arc<R>,
4676    header: &Header,
4677    want: [bool; 4],
4678) -> Result<Dictionary, FileError> {
4679    // One (unlimited) cache shared across the four sections, keyed by
4680    // (section_index, chunk_index).
4681    let cache = crate::chunk_cache::ChunkCache::unlimited_arc();
4682    let mut dict_sections: Vec<crate::dict::ChunkedSection> = Vec::with_capacity(4);
4683    for si in 0..4 {
4684        if !want[si] {
4685            dict_sections.push(crate::dict::ChunkedSection::from_parts(
4686                crate::dict::SectionMeta {
4687                    term_count: 0,
4688                    restart_interval: 1,
4689                    restart_offsets: Vec::new(),
4690                },
4691                Vec::new(),
4692                None,
4693                cache.clone(),
4694                si as u8,
4695            ));
4696            continue;
4697        }
4698        let section = locate_container_section_ranged(
4699            reader.as_ref(),
4700            header.dictionary_offset,
4701            header.dictionary_len,
4702            si,
4703            4,
4704        )?;
4705        let (meta, entries) = read_dict_dir_ranged(reader.as_ref(), section)?;
4706        let ranges: Vec<ByteRange> = entries
4707            .iter()
4708            .map(|e| ByteRange {
4709                offset: section.offset + e.start,
4710                len: (e.end - e.start),
4711            })
4712            .collect();
4713        let chunks: Vec<crate::dict::SectionChunk> = entries
4714            .into_iter()
4715            .map(|e| crate::dict::SectionChunk::new(e.first_run, e.key, e.body_start))
4716            .collect();
4717        let chunk_reader = reader.clone();
4718        let codec = header.dict_codec;
4719        let loader_ranges = ranges.clone();
4720        let loader: crate::dict::ChunkLoader = Box::new(move |ci| {
4721            let range = loader_ranges.get(ci)?;
4722            let bytes = chunk_reader.read_at(range.offset, range.len).ok()?;
4723            decompress(codec, &bytes).ok()
4724        });
4725        // Full-section sweeps (export/dump) batch their chunk fetches:
4726        // adjacent chunk ranges coalesce into a handful of range reads.
4727        let bulk_reader = reader.clone();
4728        let bulk: crate::dict::ChunkBulkLoader = Box::new(move |cis| {
4729            let want: Option<Vec<ByteRange>> =
4730                cis.iter().map(|&ci| ranges.get(ci).copied()).collect();
4731            let blobs = read_coalesced(bulk_reader.as_ref(), &want?, DICT_COALESCE_GAP)?;
4732            blobs.iter().map(|b| decompress(codec, b).ok()).collect()
4733        });
4734        dict_sections.push(
4735            crate::dict::ChunkedSection::from_parts(
4736                meta,
4737                chunks,
4738                Some(loader),
4739                cache.clone(),
4740                si as u8,
4741            )
4742            .with_bulk_loader(bulk),
4743        );
4744    }
4745    let dict_arr: [crate::dict::ChunkedSection; 4] = dict_sections
4746        .try_into()
4747        .map_err(|_| FileError::Container("expected 4 dictionary sections"))?;
4748    Ok(Dictionary::from_chunked_sections(dict_arr))
4749}
4750
4751/// The pyramid meta is large on real graphs (tens of MB) and SPARQL never reads
4752/// it, so a ranged open defers its fetch: it faults in only if `pyramid()` is
4753/// called (community / pyramid_tree / label-prefix queries).
4754fn ranged_pyramid_slot<R: RangeReader + Send + Sync + 'static>(
4755    reader: &std::sync::Arc<R>,
4756    header: &Header,
4757) -> PyramidSlot {
4758    if header.pyramid_meta_len == 0 {
4759        return PyramidSlot::Resident(None);
4760    }
4761    let pyr_reader = reader.clone();
4762    let pyr_off = header.pyramid_meta_offset;
4763    let pyr_len = header.pyramid_meta_len;
4764    PyramidSlot::Lazy {
4765        loader: Box::new(move || {
4766            let mb = pyr_reader.read_at(pyr_off, pyr_len).ok()?;
4767            PyramidMeta::decode(&mb).ok()
4768        }),
4769        cell: std::sync::OnceLock::new(),
4770    }
4771}
4772
4773/// The deferred TEXT_INDEX of a ranged open: a text search faults the token
4774/// table on first call (the leading varint then its compressed bytes), then
4775/// fetches individual posting lists by `(offset, len)` — never the whole
4776/// postings blob. A caller that never searches pays nothing.
4777fn ranged_text_index_slot<R: RangeReader + Send + Sync + 'static>(
4778    reader: &std::sync::Arc<R>,
4779    header: &Header,
4780) -> TextIndexSlot {
4781    if header.text_index_len == 0 {
4782        return TextIndexSlot::Resident {
4783            index: None,
4784            token_table_len: None,
4785        };
4786    }
4787    let ti_reader = reader.clone();
4788    let probe_reader = reader.clone();
4789    let ti_off = header.text_index_offset;
4790    let ti_len = header.text_index_len;
4791    let codec = header.block_codec;
4792    TextIndexSlot::Lazy {
4793        loader: Box::new(move || {
4794            // The section opens with `varint token_table_len`; read enough to
4795            // decode it (a uvarint is ≤ 10 bytes), then fetch the varint + the
4796            // compressed token table as one prefix range.
4797            let prefix_len = read_token_table_len(&*ti_reader, ti_off, ti_len)?;
4798            let prefix = ti_reader.read_at(ti_off, prefix_len).ok()?;
4799            let postings_base = crate::text_index::TextIndex::postings_base(&prefix)? as u64;
4800            let postings_abs = ti_off + postings_base;
4801            let pr = ti_reader.clone();
4802            let posting_loader =
4803                Box::new(move |off: u64, len: u64| pr.read_at(postings_abs + off, len).ok());
4804            crate::text_index::TextIndex::from_token_table(&prefix, codec, posting_loader).ok()
4805        }),
4806        cell: std::sync::OnceLock::new(),
4807        // The same leading varint the loader starts from, asked on its own so a
4808        // caller can *state* the cost of a first search without paying it.
4809        token_table: Box::new(move || read_token_table_len(&*probe_reader, ti_off, ti_len)),
4810        token_table_cell: std::sync::OnceLock::new(),
4811    }
4812}
4813
4814/// Full-text search over `ti`, resolving the matched subject ids through `dict`.
4815/// The engine behind [`Rete::text_search`] and [`SearchView::text_search`].
4816fn text_search_in(
4817    ti: Option<&crate::text_index::TextIndex>,
4818    dict: &Dictionary,
4819    words: &[&str],
4820    prefix: Option<&str>,
4821    limit: usize,
4822) -> Vec<String> {
4823    let Some(ti) = ti else {
4824        return Vec::new();
4825    };
4826    // Each query word is tokenized exactly as at build time (so "Glucose"
4827    // matches the stored "glucose"); a word that splits into several tokens
4828    // requires all of them. AND across every required token + the prefix.
4829    let mut acc: Option<Vec<u32>> = None;
4830    if let Some(p) = prefix {
4831        acc = Some(ti.prefix(&p.to_lowercase()));
4832    }
4833    for w in words {
4834        for tok in crate::text_index::tokenize(w) {
4835            let posting = ti.lookup(&tok);
4836            acc = Some(match acc {
4837                Some(a) => intersect_sorted(&a, &posting),
4838                None => posting,
4839            });
4840            if acc.as_ref().is_some_and(|a| a.is_empty()) {
4841                return Vec::new();
4842            }
4843        }
4844    }
4845    let ids = acc.unwrap_or_default();
4846    let mut out = Vec::with_capacity(if limit > 0 {
4847        limit.min(ids.len())
4848    } else {
4849        ids.len()
4850    });
4851    for id in ids {
4852        if let Some(iri) = dict.subject_term(id) {
4853            out.push(iri);
4854            if limit > 0 && out.len() >= limit {
4855                break;
4856            }
4857        }
4858    }
4859    out
4860}
4861
4862/// A `.rete` opened for **search only**: the dictionary's chunk directories, and
4863/// the TEXT_INDEX / pyramid deferred behind their lazy slots. Nothing else.
4864///
4865/// What makes this cheap is skipping dictionary sections 2 and 3 (object-only
4866/// and predicates) — NOT skipping the index. Measured on the published
4867/// `epfl-infoscience.rete` (1.64 GB), a full [`Rete::open_ranged_lazy`] reads
4868/// 536,947,344 B, of which the six permutation tile directories are **49,940 B
4869/// in 40 reads** and the dictionary is 536,896,380 B in 36 reads: the
4870/// object-only chunk directory alone is 234,400,728 B, because that file stores
4871/// every chunk's first term verbatim. Files built since #198 key it by the
4872/// shortest separator (~600 KB on this graph); the published one still does
4873/// not, and only a rebuild changes that. This view opens sections 0 and 1
4874/// only — both search modes return subject IRIs — and costs **21,554 B in 9
4875/// reads** on the same file. It also skips the index container and defers the
4876/// TEXT_INDEX and pyramid, so it pays only for what it reads: the token table
4877/// on the first search, then one range per posting list and per dictionary
4878/// chunk holding a matched subject. This is what backs `rete search-url`.
4879pub struct SearchView {
4880    header: Header,
4881    dict: Dictionary,
4882    pyramid: PyramidSlot,
4883    text_index: TextIndexSlot,
4884}
4885
4886impl SearchView {
4887    /// Open for search over any [`RangeReader`] (HTTP or local): header +
4888    /// dictionary chunk directories only, with the text index and pyramid
4889    /// faulting in on first use.
4890    pub fn open_ranged<R: RangeReader + Send + Sync + 'static>(
4891        reader: R,
4892    ) -> Result<Self, FileError> {
4893        let head = reader.read_at(0, HEADER_LEN as u64)?;
4894        let header = Header::from_bytes(&head)?;
4895        let reader = std::sync::Arc::new(reader);
4896        // Sections 0 (shared) and 1 (subject-only) are the only ones a search
4897        // resolves — both modes return subject IRIs. Skipping 2 and 3 is what
4898        // makes this open cheap where a full one is not.
4899        let dict = ranged_chunked_dictionary(&reader, &header, [true, true, false, false])?;
4900        let pyramid = ranged_pyramid_slot(&reader, &header);
4901        let text_index = ranged_text_index_slot(&reader, &header);
4902        Ok(Self {
4903            header,
4904            dict,
4905            pyramid,
4906            text_index,
4907        })
4908    }
4909
4910    /// The file header (already fetched by [`open_ranged`](Self::open_ranged)).
4911    pub fn header(&self) -> &Header {
4912        &self.header
4913    }
4914
4915    /// Whether the file carries a TEXT_INDEX section — a header read, no fault.
4916    pub fn has_text_index(&self) -> bool {
4917        self.header.text_index_len > 0
4918    }
4919
4920    /// Whether the file carries a pyramid (where the label index lives) — a
4921    /// header read, no fault.
4922    pub fn has_pyramid(&self) -> bool {
4923        self.header.pyramid_meta_len > 0
4924    }
4925
4926    /// Full-text search — see [`Rete::text_search`] for the semantics. Faults
4927    /// the token table on the first call, then only the queried postings.
4928    pub fn text_search(&self, words: &[&str], prefix: Option<&str>, limit: usize) -> Vec<String> {
4929        text_search_in(self.text_index.index(), &self.dict, words, prefix, limit)
4930    }
4931
4932    /// What that first search will cost — see
4933    /// [`Rete::text_index_token_table_len`]. One ≤10-byte range read, so a
4934    /// caller can quote the figure before deciding to search.
4935    pub fn text_index_token_table_len(&self) -> Option<u64> {
4936        self.text_index.token_table_len()
4937    }
4938
4939    /// Label-prefix search — see [`Rete::prefix_search`]. Faults the pyramid
4940    /// (which carries the label index), so it is the pricier of the two.
4941    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<(String, String)> {
4942        let pyr = match &self.pyramid {
4943            PyramidSlot::Resident(p) => p.as_ref(),
4944            PyramidSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
4945        };
4946        let Some(pyr) = pyr else {
4947            return Vec::new();
4948        };
4949        pyr.prefix_search(prefix, limit)
4950            .into_iter()
4951            .filter_map(|e| {
4952                self.dict
4953                    .subject_term(e.subject)
4954                    .map(|iri| (e.label.clone(), iri))
4955            })
4956            .collect()
4957    }
4958}
4959
4960/// A lightweight, overview-only view of a file: the pyramid summary graph plus
4961/// just enough dictionary to label predicates. Fetched via ranges *without*
4962/// touching the (large) triple index — the "load the coarse graph first" path
4963/// from SPEC.md §7.2.
4964#[must_use]
4965pub struct SummaryView {
4966    pub round: u32,
4967    pub summary: Vec<SuperEdge>,
4968    /// The shipped `subClassOf` hierarchy (v2 schema pyramid; empty on v1 files).
4969    pub class_hierarchy: Vec<ClassNode>,
4970    /// Per-level type rollups — the leveled legend, read index-free.
4971    pub level_rollups: Vec<LevelRollup>,
4972    /// Per-level lateral class-relation graph (the non-`is-a` connections).
4973    pub level_links: Vec<LevelLinks>,
4974    /// Per-community descriptors (Phase 4 progressive refinement; may be empty).
4975    pub descriptors: Vec<CommunityDescriptor>,
4976    /// `subClassOf` cycles (v2.1; empty on older files).
4977    pub subclass_cycles: Vec<Vec<String>>,
4978    /// `owl:disjointWith` class pairs (v2.1; empty on older files).
4979    pub disjoint_pairs: Vec<(String, String)>,
4980    /// `owl:equivalentClass` class pairs (v2.1; empty on older files).
4981    pub equivalent_pairs: Vec<(String, String)>,
4982    dict: Dictionary,
4983}
4984
4985impl SummaryView {
4986    /// Read header → dictionary → pyramid-meta only (skips the index container).
4987    pub fn open_ranged<R: RangeReader>(reader: &R) -> Result<Option<Self>, FileError> {
4988        let head = reader.read_at(0, HEADER_LEN as u64)?;
4989        let header = Header::from_bytes(&head)?;
4990        if header.pyramid_meta_len == 0 {
4991            return Ok(None);
4992        }
4993
4994        let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
4995        let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
4996
4997        let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
4998        let meta =
4999            PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
5000
5001        Ok(Some(SummaryView {
5002            round: meta.round,
5003            summary: meta.summary,
5004            class_hierarchy: meta.class_hierarchy,
5005            level_rollups: meta.level_rollups,
5006            level_links: meta.level_links,
5007            descriptors: meta.descriptors,
5008            subclass_cycles: meta.subclass_cycles,
5009            disjoint_pairs: meta.disjoint_pairs,
5010            equivalent_pairs: meta.equivalent_pairs,
5011            dict,
5012        }))
5013    }
5014
5015    /// Number of semantic-zoom levels in the schema pyramid (0 if none shipped).
5016    pub fn level_count(&self) -> usize {
5017        self.level_rollups.len()
5018    }
5019
5020    /// The type rollup at semantic level `k` (0 = coarsest/most abstract), or
5021    /// `None` if `k` is out of range. Index-free — answered from the pyramid-meta.
5022    pub fn level_rollup(&self, k: usize) -> Option<&LevelRollup> {
5023        self.level_rollups.get(k)
5024    }
5025
5026    /// Resolve a predicate ID in the summary to its term.
5027    pub fn predicate_term(&self, id: u32) -> Option<String> {
5028        self.dict.predicate_term(id)
5029    }
5030
5031    /// Exact number of triples using `predicate`, summed from the summary's
5032    /// superedge counts — answered without ever reading the triple index.
5033    pub fn predicate_total(&self, predicate: &str) -> u32 {
5034        match self.dict.predicate_id(predicate) {
5035            Some(pid) => self
5036                .summary
5037                .iter()
5038                .filter(|e| e.predicate == pid)
5039                .map(|e| e.count)
5040                .sum(),
5041            None => 0,
5042        }
5043    }
5044
5045    /// All predicates with their exact triple totals, descending by count.
5046    pub fn predicate_totals(&self) -> Vec<(String, u32)> {
5047        let mut by_pred: std::collections::BTreeMap<u32, u32> = std::collections::BTreeMap::new();
5048        for e in &self.summary {
5049            *by_pred.entry(e.predicate).or_default() += e.count;
5050        }
5051        let mut out: Vec<(String, u32)> = by_pred
5052            .into_iter()
5053            .filter_map(|(pid, c)| self.dict.predicate_term(pid).map(|t| (t, c)))
5054            .collect();
5055        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
5056        out
5057    }
5058
5059    /// Number of communities the summary spans (distinct supernode endpoints).
5060    pub fn community_count(&self) -> usize {
5061        let mut comms = std::collections::BTreeSet::new();
5062        for e in &self.summary {
5063            comms.insert(e.s_comm);
5064            comms.insert(e.o_comm);
5065        }
5066        comms.len()
5067    }
5068
5069    /// **Index-free T-Box coherence (Tier-0).** Detect schema-level incoherent
5070    /// points purely from the shipped schema pyramid — no triple index, no
5071    /// instance data, O(ontology) regardless of graph size:
5072    /// - `subclass-cycle`: a set of classes that are mutually `rdfs:subClassOf`.
5073    /// - `unsatisfiable-class`: a class whose ancestor closure (over all parents,
5074    ///   folded through `owl:equivalentClass`) contains both ends of an
5075    ///   `owl:disjointWith` pair, so no individual can ever be one.
5076    ///
5077    /// Soundness is bounded by what the pyramid ships: the `subClassOf` hierarchy
5078    /// is capped (`MAX_HIERARCHY` in `schema_pyramid`), so on a very large ontology
5079    /// a pruned ancestor can hide an unsatisfiable class (a false *coherent*, never
5080    /// a false *incoherent*). Instance-level clashes (a node typed into disjoint
5081    /// classes, functional-property clashes) are NOT visible here — they need the
5082    /// A-Box (Tier-1/Tier-2 `reason`).
5083    pub fn tbox_coherence(&self) -> Vec<crate::reason::Inconsistency> {
5084        schema_coherence(
5085            &self.class_hierarchy,
5086            &self.subclass_cycles,
5087            &self.disjoint_pairs,
5088            &self.equivalent_pairs,
5089        )
5090    }
5091
5092    /// True when [`tbox_coherence`](Self::tbox_coherence) finds no schema-level
5093    /// incoherent point.
5094    pub fn tbox_is_coherent(&self) -> bool {
5095        self.tbox_coherence().is_empty()
5096    }
5097}
5098
5099/// Compute T-Box coherence points from the schema-pyramid fields alone — no
5100/// dictionary, no index, no instance data. Shared by [`SummaryView::tbox_coherence`]
5101/// and the dictionary-free [`read_schema_coherence_ranged`]. Emits `subclass-cycle`
5102/// and `unsatisfiable-class` (a class whose ancestor closure — over all parents,
5103/// folded through `owl:equivalentClass` — contains both ends of a disjoint pair).
5104pub fn schema_coherence(
5105    class_hierarchy: &[ClassNode],
5106    subclass_cycles: &[Vec<String>],
5107    disjoint_pairs: &[(String, String)],
5108    equivalent_pairs: &[(String, String)],
5109) -> Vec<crate::reason::Inconsistency> {
5110    use crate::reason::Inconsistency;
5111    use std::collections::{BTreeMap, BTreeSet, VecDeque};
5112    const MAX_REACH: usize = 100_000;
5113
5114    let mut out: Vec<Inconsistency> = Vec::new();
5115
5116    for cyc in subclass_cycles {
5117        let detail = if cyc.len() == 1 {
5118            format!("{} is rdfs:subClassOf itself (a cycle)", cyc[0])
5119        } else {
5120            format!(
5121                "classes {{{}}} are mutually rdfs:subClassOf (a cycle)",
5122                cyc.join(", ")
5123            )
5124        };
5125        out.push(Inconsistency {
5126            kind: "subclass-cycle",
5127            detail,
5128        });
5129    }
5130
5131    if !disjoint_pairs.is_empty() {
5132        // Upward adjacency: subClassOf parents + bidirectional equivalence.
5133        let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
5134        for n in class_hierarchy {
5135            let e = adj.entry(n.class.as_str()).or_default();
5136            for p in &n.parents {
5137                e.push(p.as_str());
5138            }
5139        }
5140        for (a, b) in equivalent_pairs {
5141            adj.entry(a.as_str()).or_default().push(b.as_str());
5142            adj.entry(b.as_str()).or_default().push(a.as_str());
5143        }
5144
5145        // Candidate focus classes: every class named anywhere in the schema.
5146        let mut focuses: BTreeSet<&str> =
5147            class_hierarchy.iter().map(|n| n.class.as_str()).collect();
5148        for (a, b) in disjoint_pairs.iter().chain(equivalent_pairs) {
5149            focuses.insert(a.as_str());
5150            focuses.insert(b.as_str());
5151        }
5152
5153        let mut seen: BTreeSet<&str> = BTreeSet::new();
5154        for &c in &focuses {
5155            // reach(c) = {c} ∪ ancestors (capped BFS over `adj`).
5156            let mut reach: BTreeSet<&str> = BTreeSet::new();
5157            let mut q: VecDeque<&str> = VecDeque::new();
5158            reach.insert(c);
5159            q.push_back(c);
5160            while let Some(x) = q.pop_front() {
5161                if reach.len() > MAX_REACH {
5162                    break;
5163                }
5164                if let Some(ns) = adj.get(x) {
5165                    for &p in ns {
5166                        if reach.insert(p) {
5167                            q.push_back(p);
5168                        }
5169                    }
5170                }
5171            }
5172            for (x, y) in disjoint_pairs {
5173                if reach.contains(x.as_str()) && reach.contains(y.as_str()) && seen.insert(c) {
5174                    out.push(Inconsistency {
5175                        kind: "unsatisfiable-class",
5176                        detail: format!(
5177                            "{c} is a subclass of both {x} and {y}, which are \
5178                             owl:disjointWith — no individual can be a {c}"
5179                        ),
5180                    });
5181                    break;
5182                }
5183            }
5184        }
5185    }
5186
5187    out.sort_by(|a, b| (a.kind, &a.detail).cmp(&(b.kind, &b.detail)));
5188    out
5189}
5190
5191/// **Dictionary-free Tier-0 coherence read.** Fetch only the header and the
5192/// pyramid-meta range (2 small range reads) and run [`schema_coherence`] over the
5193/// schema pyramid — never touching the **dictionary** (which a literal-heavy file
5194/// makes large) or the triple index. `Ok(None)` if the file ships no pyramid.
5195///
5196/// This is what makes the Tier-0 check cheap on big graphs: the schema pyramid
5197/// carries its own class-string table, so coherence needs none of the dictionary.
5198pub fn read_schema_coherence_ranged<R: RangeReader>(
5199    reader: &R,
5200) -> Result<Option<Vec<crate::reason::Inconsistency>>, FileError> {
5201    let head = reader.read_at(0, HEADER_LEN as u64)?;
5202    let header = Header::from_bytes(&head)?;
5203    if header.pyramid_meta_len == 0 {
5204        return Ok(None);
5205    }
5206    // Fast path: the header records the trailing schema block's length, so read ONLY
5207    // that block (at the end of pyramid-meta) — never the community summary, the
5208    // dictionary, or the index. This is what makes Tier-0 flat at any graph size.
5209    if header.schema_meta_len > 0 && (header.schema_meta_len as u64) <= header.pyramid_meta_len {
5210        let off =
5211            header.pyramid_meta_offset + header.pyramid_meta_len - header.schema_meta_len as u64;
5212        let block = reader.read_at(off, header.schema_meta_len as u64)?;
5213        let (hierarchy, cycles, disjoint, equivalent) = crate::meta::decode_schema_block(&block)
5214            .map_err(|_| FileError::Container("malformed schema block"))?;
5215        return Ok(Some(schema_coherence(
5216            &hierarchy,
5217            &cycles,
5218            &disjoint,
5219            &equivalent,
5220        )));
5221    }
5222    // Fallback (pre-v0.2.1 files with no header field): decode the whole pyramid-meta.
5223    let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
5224    let meta =
5225        PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
5226    Ok(Some(schema_coherence(
5227        &meta.class_hierarchy,
5228        &meta.subclass_cycles,
5229        &meta.disjoint_pairs,
5230        &meta.equivalent_pairs,
5231    )))
5232}
5233
5234/// The **schema summary** (per-class histogram + class relations at the finest
5235/// level) read over a [`RangeReader`] from the schema pyramid alone — the
5236/// index-free, range-readable source for a Schema view of a remote graph. Returns
5237/// `(classes, relations)` with `classes = [(class_iri, count)]` and `relations =
5238/// [(s_class, predicate, o_class, count)]`; `None` when the file has no schema
5239/// pyramid. Like [`read_schema_coherence_ranged`], it reads only the trailing
5240/// schema block, so it stays flat at any graph size.
5241#[allow(clippy::type_complexity)]
5242pub fn read_schema_summary_ranged<R: RangeReader>(
5243    reader: &R,
5244) -> Result<Option<(Vec<(String, u64)>, Vec<(String, String, String, u64)>)>, FileError> {
5245    let head = reader.read_at(0, HEADER_LEN as u64)?;
5246    let header = Header::from_bytes(&head)?;
5247    if header.pyramid_meta_len == 0 {
5248        return Ok(None);
5249    }
5250    if header.schema_meta_len > 0 && (header.schema_meta_len as u64) <= header.pyramid_meta_len {
5251        let off =
5252            header.pyramid_meta_offset + header.pyramid_meta_len - header.schema_meta_len as u64;
5253        let block = reader.read_at(off, header.schema_meta_len as u64)?;
5254        let summary = crate::meta::decode_schema_block_summary(&block)
5255            .map_err(|_| FileError::Container("malformed schema block"))?;
5256        return Ok(Some(summary));
5257    }
5258    // Fallback (pre-v0.2.1 files): decode the whole pyramid-meta, pull finest levels.
5259    let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
5260    let meta =
5261        PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
5262    if meta.level_rollups.is_empty() && meta.level_links.is_empty() {
5263        return Ok(None);
5264    }
5265    let classes = meta
5266        .level_rollups
5267        .iter()
5268        .max_by_key(|r| r.depth)
5269        .map(|r| r.classes.clone())
5270        .unwrap_or_default();
5271    let relations = meta
5272        .level_links
5273        .iter()
5274        .max_by_key(|l| l.depth)
5275        .map(|l| {
5276            l.links
5277                .iter()
5278                .map(|c| {
5279                    (
5280                        c.s_class.clone(),
5281                        c.predicate.clone(),
5282                        c.o_class.clone(),
5283                        c.count,
5284                    )
5285                })
5286                .collect()
5287        })
5288        .unwrap_or_default();
5289    Ok(Some((classes, relations)))
5290}
5291
5292/// Every dictionary section's chunk **routing keys**, exactly as stored, from a
5293/// finished file image. Test-only: it exists so a test in another module of
5294/// this crate (`extbuild`) can assert on the directory the writers produce
5295/// without any of this becoming public surface.
5296#[cfg(test)]
5297pub(crate) fn dict_chunk_keys_for_test(image: &[u8]) -> Vec<Vec<Vec<u8>>> {
5298    let header = Header::from_bytes(&image[..HEADER_LEN]).unwrap();
5299    let s = header.dictionary_offset as usize;
5300    let e = s + header.dictionary_len as usize;
5301    decode_container(&image[s..e], CODEC_NONE)
5302        .unwrap()
5303        .iter()
5304        .map(|payload| {
5305            let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
5306            entries.into_iter().map(|c| c.key).collect()
5307        })
5308        .collect()
5309}
5310
5311#[cfg(test)]
5312mod tests {
5313    use super::*;
5314    use crate::dictionary::DictionaryBuilder;
5315    use crate::index::GraphIndexBuilder;
5316
5317    /// The pure-Rust decode path (wasm, `--no-default-features`) reads a zstd
5318    /// frame back byte for byte. Guards the `ruzstd` API across upgrades.
5319    #[cfg(not(feature = "compression"))]
5320    #[test]
5321    fn ruzstd_decodes_a_zstd_frame() {
5322        let data: Vec<u8> = (0..50_000u32)
5323            .flat_map(|i| (i % 251).to_le_bytes())
5324            .collect();
5325        let frame = ruzstd::encoding::compress_to_vec(
5326            &data[..],
5327            ruzstd::encoding::CompressionLevel::Fastest,
5328        );
5329        assert!(frame.len() < data.len());
5330        assert_eq!(decompress(CODEC_ZSTD, &frame).unwrap(), data);
5331    }
5332
5333    /// A three-permutation file must survive every opener — resident, ranged,
5334    /// ranged-lazy — and answer every pattern shape with the rows the
5335    /// six-permutation twin gives, including through a named graph's own
5336    /// container. The mask is a file-level fact, so getting it to the named
5337    /// graphs' decoder is a separate thing that can be forgotten.
5338    #[test]
5339    fn three_permutation_file_round_trips_every_open_path() {
5340        use crate::index::PermSet;
5341        use crate::reader::SliceReader;
5342
5343        let build = |perms: PermSet| {
5344            let mut db = crate::DictionaryBuilder::new();
5345            let mut triples = Vec::new();
5346            for i in 0..60u32 {
5347                let (s, p, o) = (
5348                    format!("<http://ex/s{}>", i % 11),
5349                    format!("<http://ex/p{}>", i % 3),
5350                    format!("<http://ex/o{}>", i % 7),
5351                );
5352                db.observe(&s, &p, &o);
5353                triples.push((s, p, o));
5354            }
5355            let dict = db.build();
5356            let ids: Vec<(u32, u32, u32)> = triples
5357                .iter()
5358                .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
5359                .collect();
5360            let def = GraphIndexBuilder::from_triples(ids.clone())
5361                .with_tile_budget(96)
5362                .with_perms(perms)
5363                .build();
5364            let named = GraphIndexBuilder::from_triples(ids)
5365                .with_tile_budget(96)
5366                .with_perms(perms)
5367                .build();
5368            write_dataset(
5369                &dict,
5370                &def,
5371                &[("<http://ex/g>".to_string(), named)],
5372                true,
5373                &[],
5374                0,
5375            )
5376        };
5377
5378        let six = build(PermSet::ALL);
5379        let three = build(PermSet::CORE);
5380        assert!(three.len() < six.len());
5381        assert_eq!(Header::from_bytes(&three).unwrap().perms, PermSet::CORE);
5382        assert_eq!(Header::from_bytes(&six).unwrap().perms, PermSet::ALL);
5383
5384        let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>)> = vec![
5385            (None, None, None),
5386            (Some("<http://ex/s3>"), None, None),
5387            (None, Some("<http://ex/p1>"), None),
5388            (None, None, Some("<http://ex/o5>")),
5389            (Some("<http://ex/s3>"), Some("<http://ex/p1>"), None),
5390            (Some("<http://ex/s3>"), None, Some("<http://ex/o5>")),
5391            (None, Some("<http://ex/p1>"), Some("<http://ex/o5>")),
5392            (
5393                Some("<http://ex/s3>"),
5394                Some("<http://ex/p1>"),
5395                Some("<http://ex/o5>"),
5396            ),
5397        ];
5398
5399        for (image, tag) in [(&six, "six"), (&three, "three")] {
5400            let resident = Rete::open(image).unwrap();
5401            let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
5402            let ranged = Rete::open_ranged(&SliceReader::new(leaked)).unwrap();
5403            let lazy = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
5404            for (s, p, o) in &shapes {
5405                let want: Vec<_> = {
5406                    let mut v = Rete::open(&six).unwrap().query(*s, *p, *o);
5407                    v.sort();
5408                    v
5409                };
5410                for (got, path) in [
5411                    (resident.query(*s, *p, *o), "resident"),
5412                    (ranged.query(*s, *p, *o), "ranged"),
5413                    (lazy.query(*s, *p, *o), "lazy"),
5414                ] {
5415                    let mut got = got;
5416                    got.sort();
5417                    assert_eq!(got, want, "{tag}/{path} disagreed on {:?}", (s, p, o));
5418                }
5419                // The named graph's container carries the same mask.
5420                let mut g = resident.query_in_graph(Some("<http://ex/g>"), *s, *p, *o);
5421                g.sort();
5422                assert_eq!(g, want, "{tag}/named disagreed on {:?}", (s, p, o));
5423            }
5424        }
5425
5426        // And the single-pattern ROUTED read (the `query-url` path) locates its
5427        // section by container position, not by the format's fixed six-wide
5428        // slot — the one place the two indexes differ.
5429        let leaked: &'static [u8] = Box::leak(three.clone().into_boxed_slice());
5430        let reader = SliceReader::new(leaked);
5431        let routed = Rete::query_ranged(&reader, None, None, Some("<http://ex/o5>")).unwrap();
5432        let mut want = Rete::open(&six)
5433            .unwrap()
5434            .query(None, None, Some("<http://ex/o5>"));
5435        want.sort();
5436        let mut got = routed;
5437        got.sort();
5438        assert_eq!(got, want, "routed single-pattern read on a lean file");
5439    }
5440
5441    #[test]
5442    fn read_coalesced_merges_within_gap_and_splits_beyond() {
5443        use crate::reader::{CountingReader, SliceReader};
5444        let bytes = vec![0u8; 4096];
5445        // Three 16-byte ranges: A..B gap = 32, B..C gap = 1024.
5446        let ranges = [
5447            ByteRange { offset: 0, len: 16 },
5448            ByteRange {
5449                offset: 48,
5450                len: 16,
5451            },
5452            ByteRange {
5453                offset: 1088,
5454                len: 16,
5455            },
5456        ];
5457        // Tight gap (16): nothing merges → one read per range.
5458        let r = CountingReader::new(SliceReader::new(&bytes));
5459        let out = read_coalesced(&r, &ranges, 16).unwrap();
5460        assert_eq!(out.len(), 3);
5461        assert_eq!(r.requests(), 3);
5462        // Gap 64 merges A+B (gap 32) but not C (gap 1024) → two reads.
5463        let r = CountingReader::new(SliceReader::new(&bytes));
5464        read_coalesced(&r, &ranges, 64).unwrap();
5465        assert_eq!(r.requests(), 2);
5466        // Gap 4096 merges all three into one read, over-fetching the gaps.
5467        let r = CountingReader::new(SliceReader::new(&bytes));
5468        read_coalesced(&r, &ranges, 4096).unwrap();
5469        assert_eq!(r.requests(), 1);
5470    }
5471
5472    fn build_image() -> Vec<u8> {
5473        let triples = [
5474            ("Alice", "knows", "Bob"),
5475            ("Bob", "knows", "Carol"),
5476            ("Alice", "age", "30"),
5477        ];
5478        let mut db = DictionaryBuilder::new();
5479        for (s, p, o) in triples {
5480            db.observe(s, p, o);
5481        }
5482        let dict = db.build();
5483
5484        let mut ib = GraphIndexBuilder::new();
5485        for (s, p, o) in triples {
5486            ib.push(dict.encode(s, p, o).unwrap());
5487        }
5488        let index = ib.build();
5489
5490        let (meta, levels) = build_pyramid_meta(&dict, &triples_ids(&dict), DEFAULT_TILE_BUDGET);
5491        write_file(&dict, &index, false, &meta, levels)
5492    }
5493
5494    fn triples_ids(dict: &Dictionary) -> Vec<(u32, u32, u32)> {
5495        [
5496            ("Alice", "knows", "Bob"),
5497            ("Bob", "knows", "Carol"),
5498            ("Alice", "age", "30"),
5499        ]
5500        .iter()
5501        .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
5502        .collect()
5503    }
5504
5505    #[test]
5506    fn file_round_trips_header_and_counts() {
5507        let bytes = build_image();
5508        let rete = Rete::open(&bytes).unwrap();
5509        assert_eq!(rete.header().quad_count, 3);
5510        assert!(rete.header().term_count >= 5);
5511        let expected_codec = writer_codec();
5512        assert_eq!(rete.header().dict_codec, expected_codec);
5513        assert_eq!(rete.header().block_codec, expected_codec);
5514        assert_eq!(&bytes[bytes.len() - 4..], &MAGIC); // footer marker
5515    }
5516
5517    /// A file whose index was built with a tiny tile budget (forcing many
5518    /// tiles per permutation) must round-trip through write/open and answer
5519    /// every query shape identically — through both the in-memory and the
5520    /// routed ranged read paths.
5521    #[test]
5522    fn multi_tile_file_round_trips_and_routes() {
5523        let triples: Vec<(String, String, String)> = (0..200)
5524            .map(|i| {
5525                (
5526                    format!("<http://ex/s/{i}>"),
5527                    format!("<http://ex/p/{}>", i % 5),
5528                    format!("<http://ex/o/{}>", i % 23),
5529                )
5530            })
5531            .collect();
5532        let mut db = DictionaryBuilder::new();
5533        for (s, p, o) in &triples {
5534            db.observe(s, p, o);
5535        }
5536        let dict = db.build();
5537        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
5538        for (s, p, o) in &triples {
5539            ib.push(dict.encode(s, p, o).unwrap());
5540        }
5541        let index = ib.build();
5542        assert!(
5543            index.tile_sections()[0].len() > 3,
5544            "tiny budget must force many tiles"
5545        );
5546        let bytes = write_file(&dict, &index, false, &[], 0);
5547
5548        let rete = Rete::open(&bytes).unwrap();
5549        assert_eq!(rete.header().version, crate::header::CURRENT_FORMAT_VERSION);
5550        assert_eq!(rete.query(None, None, None).len(), 200);
5551        assert_eq!(rete.query(Some("<http://ex/s/7>"), None, None).len(), 1);
5552        assert_eq!(
5553            rete.query(None, Some("<http://ex/p/3>"), None).len(),
5554            40,
5555            "predicate extent spans tiles"
5556        );
5557        assert_eq!(
5558            rete.query(None, None, Some("<http://ex/o/22>")).len(),
5559            8 // 22, 45, 68, ... < 200
5560        );
5561
5562        // Routed ranged read must agree (and only decompress matching tiles).
5563        use crate::reader::SliceReader;
5564        let reader = SliceReader::new(&bytes);
5565        let routed = Rete::query_ranged(&reader, Some("<http://ex/s/7>"), None, None).unwrap();
5566        assert_eq!(routed.len(), 1);
5567        let routed = Rete::query_ranged(&reader, None, Some("<http://ex/p/3>"), None).unwrap();
5568        assert_eq!(routed.len(), 40);
5569        let routed = Rete::query_ranged(&reader, None, None, Some("<http://ex/o/22>")).unwrap();
5570        assert_eq!(routed.len(), 8);
5571    }
5572
5573    /// The tile-synopsis trailer round-trips through encode/parse, and each parsed
5574    /// synopsis is **exactly** the tile block's own b/c zone — so the directory
5575    /// can never prune a tile the tile itself would have matched.
5576    /// Section-internal byte offsets are u64: a directory whose tiles sit past
5577    /// 4 GiB must parse with exact offsets on EVERY platform. On wasm32 (32-bit
5578    /// usize) the old parse truncated a >4 GiB section length and rejected the
5579    /// tail ("dict chunk overruns section" on the first >4 GiB dictionary —
5580    /// crossref's 5.2 GB g.obj — the playground regression this guards).
5581    #[test]
5582    fn tile_directory_offsets_survive_past_4gib() {
5583        let mut dir = Vec::new();
5584        write_uvarint(&mut dir, 2); // two tiles
5585        write_uvarint(&mut dir, 5); // tile 1: Δmin_a
5586        write_uvarint(&mut dir, 0); //         span
5587        write_uvarint(&mut dir, 3 << 30); //   len = 3 GiB
5588        write_uvarint(&mut dir, 1); // tile 2: Δmin_a
5589        write_uvarint(&mut dir, 0);
5590        write_uvarint(&mut dir, 2 << 30); //   len = 2 GiB
5591        let total = dir.len() as u64 + (3u64 << 30) + (2u64 << 30) + 64;
5592        let entries = parse_tile_directory(&dir, total).unwrap();
5593        assert_eq!(entries.len(), 2);
5594        assert_eq!(entries[1].start, dir.len() as u64 + (3u64 << 30));
5595        assert!(
5596            entries[1].end > u32::MAX as u64,
5597            "tail tile sits past 4 GiB"
5598        );
5599        // a total smaller than the tiles must still reject the directory
5600        assert!(parse_tile_directory(&dir, 1 << 20).is_err());
5601    }
5602
5603    #[test]
5604    fn tile_synopsis_trailer_round_trips() {
5605        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
5606        for i in 0..200u32 {
5607            ib.push((i, i % 7, i % 13));
5608        }
5609        let index = ib.build();
5610        let tile_count = index.tile_sections()[0].len();
5611        assert!(tile_count > 3, "tiny budget forces many tiles");
5612
5613        let payload = encode_tiled_section(&index, 0, CODEC_NONE);
5614        let dir = parse_tile_directory(&payload, payload.len() as u64).unwrap();
5615        assert_eq!(dir.len(), tile_count);
5616        // The trailer sits past the last tile; the old directory parse stops there.
5617        let trailer_start = dir.iter().map(|e| e.end).max().unwrap();
5618        assert!(
5619            trailer_start < payload.len() as u64,
5620            "a trailer follows the tiles"
5621        );
5622        for e in &dir {
5623            assert!(
5624                e.end <= payload.len() as u64,
5625                "tiles still located within the payload"
5626            );
5627        }
5628        let syn = parse_tile_synopsis(&payload, trailer_start as usize, dir.len()).unwrap();
5629        for (e, (min_b, max_b, min_c, max_c)) in dir.iter().zip(syn) {
5630            let block = decompress(CODEC_NONE, &payload[e.start as usize..e.end as usize]).unwrap();
5631            let z = *crate::triples::TripleBlock::parse(&block).unwrap().zone();
5632            assert_eq!(
5633                (min_b, max_b, min_c, max_c),
5634                (z.min_b, z.max_b, z.min_c, z.max_c),
5635                "synopsis equals the tile's own zone"
5636            );
5637        }
5638    }
5639
5640    /// End-to-end safety: a synopsis-carrying file, opened **lazily** (range
5641    /// reads), must return exactly the brute-force answer for every pattern shape
5642    /// — the synopsis prune may never drop a real match.
5643    #[test]
5644    fn tile_synopsis_lazy_matches_reference_every_shape() {
5645        use crate::reader::{CountingReader, SliceReader};
5646        let triples: Vec<(String, String, String)> = (0..200u32)
5647            .map(|i| {
5648                (
5649                    format!("<http://ex/s/{i:04}>"),
5650                    format!("<http://ex/p/{}>", i % 7),
5651                    format!("<http://ex/o/{:04}>", i % 13),
5652                )
5653            })
5654            .collect();
5655        let mut db = DictionaryBuilder::new();
5656        for (s, p, o) in &triples {
5657            db.observe(s, p, o);
5658        }
5659        let dict = db.build();
5660        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
5661        for (s, p, o) in &triples {
5662            ib.push(dict.encode(s, p, o).unwrap());
5663        }
5664        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
5665
5666        let eager = Rete::open(&bytes).unwrap();
5667        assert!(
5668            eager.header().has_tile_synopsis(),
5669            "new files set the synopsis flag"
5670        );
5671
5672        // `open_ranged_lazy` needs a `'static` reader; leak the image (the test
5673        // process exits straight after).
5674        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
5675        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
5676        let lazy = Rete::open_ranged_lazy(reader).unwrap();
5677
5678        let brute = |s: Option<&str>, p: Option<&str>, o: Option<&str>| {
5679            let mut v: Vec<(String, String, String)> = triples
5680                .iter()
5681                .filter(|(a, b, c)| {
5682                    s.is_none_or(|x| x == a) && p.is_none_or(|x| x == b) && o.is_none_or(|x| x == c)
5683                })
5684                .cloned()
5685                .collect();
5686            v.sort();
5687            v
5688        };
5689        // Existing + absent terms in every position (and unbound) — 4×4×4 shapes.
5690        let sv = [
5691            None,
5692            Some("<http://ex/s/0007>"),
5693            Some("<http://ex/s/0130>"),
5694            Some("<http://ex/s/9999>"),
5695        ];
5696        let pv = [
5697            None,
5698            Some("<http://ex/p/3>"),
5699            Some("<http://ex/p/6>"),
5700            Some("<http://ex/p/999>"),
5701        ];
5702        let ov = [
5703            None,
5704            Some("<http://ex/o/0000>"),
5705            Some("<http://ex/o/0012>"),
5706            Some("<http://ex/o/9999>"),
5707        ];
5708        for &s in &sv {
5709            for &p in &pv {
5710                for &o in &ov {
5711                    let mut e = eager.query(s, p, o);
5712                    e.sort();
5713                    let mut l = lazy.query(s, p, o);
5714                    l.sort();
5715                    let r = brute(s, p, o);
5716                    assert_eq!(e, r, "eager {s:?} {p:?} {o:?}");
5717                    assert_eq!(l, r, "lazy {s:?} {p:?} {o:?} — synopsis over-pruned");
5718                }
5719            }
5720        }
5721        assert!(!lazy.index_incomplete(), "no lazy fetch failed");
5722    }
5723
5724    /// Byte-0 / polyglot experiment: a real `.rete` embedded behind a large HTML
5725    /// shell (so byte 0 is `<`, not `RETE`) still opens and queries LAZILY through
5726    /// an `OffsetReader`, touching only the graph's bytes and never the shell.
5727    #[test]
5728    fn polyglot_offset_reads_lazily() {
5729        use crate::reader::{
5730            detect_polyglot_base, CountingReader, OffsetReader, SliceReader, POLYGLOT_DIGITS,
5731            POLYGLOT_MARKER,
5732        };
5733
5734        // A real .rete image, hidden behind a 50 KB HTML shell that carries the
5735        // polyglot base-offset marker in its first bytes (a browser-ignored
5736        // comment). The .rete begins right after the shell.
5737        let image = build_image();
5738        let mut shell = Vec::new();
5739        shell.extend_from_slice(b"<!DOCTYPE html><html><head><!--");
5740        shell.extend_from_slice(POLYGLOT_MARKER);
5741        let digits_at = shell.len();
5742        shell.extend_from_slice(&[b'0'; POLYGLOT_DIGITS]); // patched below
5743        shell.extend_from_slice(b"--></head><body>a web page</body></html>\n");
5744        shell.resize(50_000, b' '); // >> the .rete, to prove the shell is untouched
5745        let base = shell.len() as u64;
5746        let digits = format!("{base:0width$}", width = POLYGLOT_DIGITS);
5747        shell[digits_at..digits_at + POLYGLOT_DIGITS].copy_from_slice(digits.as_bytes());
5748
5749        let mut poly = shell;
5750        poly.extend_from_slice(&image);
5751
5752        // byte 0 is a web page, not a .rete...
5753        assert_ne!(
5754            &poly[0..4],
5755            b"RETE",
5756            "polyglot must not start with the magic"
5757        );
5758        // ...but the marker in the first header window points at the embedded .rete.
5759        let detected = detect_polyglot_base(&poly[..crate::header::HEADER_LEN]).unwrap();
5760        assert_eq!(detected, base);
5761
5762        // Open it LAZILY through the offset shim and query it.
5763        let leaked: &'static [u8] = Box::leak(poly.into_boxed_slice());
5764        let counting = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
5765        let lazy = Rete::open_ranged_lazy(OffsetReader::new(counting.clone(), detected)).unwrap();
5766
5767        // The polyglot must answer exactly like the plain .rete it embeds.
5768        let plain = Rete::open(&image).unwrap();
5769        let mut want = plain.query(None, None, None);
5770        want.sort();
5771        let mut got = lazy.query(None, None, None);
5772        got.sort();
5773        assert_eq!(
5774            got, want,
5775            "polyglot lazy query differs from the plain .rete"
5776        );
5777        assert!(!got.is_empty(), "expected some triples");
5778        assert!(!lazy.index_incomplete(), "a lazy fetch failed");
5779
5780        // It was LAZY: it read fewer bytes than the HTML shell alone, i.e. it
5781        // never touched the prefix — only the embedded .rete's own bytes.
5782        let read = counting.bytes_read();
5783        eprintln!(
5784            "polyglot lazy read: HTML shell {base} B + .rete image {} B; \
5785             the query touched only {read} B (never the shell).",
5786            image.len()
5787        );
5788        assert!(
5789            read < base,
5790            "read {read} bytes; the HTML shell alone is {base} — the reader \
5791             touched the prefix instead of only the embedded .rete"
5792        );
5793    }
5794
5795    /// Build a small file whose objects are string literals, **with** a text
5796    /// index, and return `(image, triples)`. Shared by the text-index tests.
5797    #[cfg(test)]
5798    fn build_text_indexed(triples: &[(String, String, String)]) -> Vec<u8> {
5799        let mut db = DictionaryBuilder::new();
5800        for (s, p, o) in triples {
5801            db.observe(s, p, o);
5802        }
5803        let dict = db.build();
5804        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
5805        let mut id_triples: Vec<(u32, u32, u32)> = Vec::with_capacity(triples.len());
5806        for (s, p, o) in triples {
5807            let t = dict.encode(s, p, o).unwrap();
5808            ib.push(t);
5809            id_triples.push(t);
5810        }
5811        let index = ib.build();
5812        let text_index = compute_text_index(&dict, &id_triples);
5813        assert!(
5814            !text_index.is_empty(),
5815            "literals should produce a text index"
5816        );
5817        write_dataset_with_metadata(&dict, &index, &[], false, &[], 0, &[], &text_index)
5818    }
5819
5820    /// A `--text-index` build round-trips: `text_search` returns exactly the
5821    /// subjects whose literals contain the queried word(s), with AND across words
5822    /// and token-prefix — matching a brute-force scan of the literals.
5823    #[test]
5824    fn text_index_eager_matches_brute_force() {
5825        let triples: Vec<(String, String, String)> = vec![
5826            (
5827                "<http://ex/s0>",
5828                "<http://ex/label>",
5829                "\"alpha glucose phosphate\"",
5830            ),
5831            ("<http://ex/s1>", "<http://ex/label>", "\"beta Glucose\""),
5832            ("<http://ex/s2>", "<http://ex/label>", "\"gamma fructose\""),
5833            (
5834                "<http://ex/s3>",
5835                "<http://ex/note>",
5836                "\"einstein relativity\"",
5837            ),
5838            (
5839                "<http://ex/s4>",
5840                "<http://ex/ref>",
5841                "<http://ex/not-a-literal>",
5842            ),
5843        ]
5844        .into_iter()
5845        .map(|(s, p, o)| (s.to_string(), p.to_string(), o.to_string()))
5846        .collect();
5847        let bytes = build_text_indexed(&triples);
5848        let rete = Rete::open(&bytes).unwrap();
5849        assert!(rete.has_text_index());
5850
5851        // Brute-force reference: subjects whose literal objects contain all words.
5852        let brute = |words: &[&str]| -> Vec<String> {
5853            let mut v: Vec<String> = triples
5854                .iter()
5855                .filter(|(_, _, o)| {
5856                    crate::terms::is_literal(o)
5857                        && words.iter().all(|w| {
5858                            let wl = w.to_lowercase();
5859                            crate::terms::literal_lexical(o)
5860                                .unwrap()
5861                                .split(|c: char| !c.is_alphanumeric())
5862                                .any(|t| t.to_lowercase() == wl)
5863                        })
5864                })
5865                .map(|(s, _, _)| s.clone())
5866                .collect();
5867            v.sort();
5868            v.dedup();
5869            v
5870        };
5871
5872        let mut got = rete.text_search(&["glucose"], None, 0);
5873        got.sort();
5874        assert_eq!(got, brute(&["glucose"]), "case-insensitive single word");
5875
5876        // AND across two words: only s0 has both.
5877        let mut got = rete.text_search(&["glucose", "phosphate"], None, 0);
5878        got.sort();
5879        assert_eq!(got, brute(&["glucose", "phosphate"]));
5880
5881        // A word nobody has → empty.
5882        assert!(rete.text_search(&["zzznope"], None, 0).is_empty());
5883
5884        // Token-prefix: "ein…" matches "einstein".
5885        let got = rete.text_search(&[], Some("ein"), 0);
5886        assert_eq!(got, vec!["<http://ex/s3>".to_string()]);
5887
5888        // No text index → empty, has_text_index() false.
5889        let mut db = DictionaryBuilder::new();
5890        for (s, p, o) in &triples {
5891            db.observe(s, p, o);
5892        }
5893        let dict = db.build();
5894        let mut ib = GraphIndexBuilder::new();
5895        for (s, p, o) in &triples {
5896            ib.push(dict.encode(s, p, o).unwrap());
5897        }
5898        let plain = write_dataset(&dict, &ib.build(), &[], false, &[], 0);
5899        let plain_rete = Rete::open(&plain).unwrap();
5900        assert!(!plain_rete.has_text_index());
5901        assert!(plain_rete.text_search(&["glucose"], None, 0).is_empty());
5902    }
5903
5904    /// The lazy/remote path returns the same subjects as the eager path **and**
5905    /// faults only the token table + the queried posting list — never the whole
5906    /// postings blob. A `CountingReader` proves the byte budget stays small.
5907    #[test]
5908    fn text_index_lazy_faults_only_queried_postings() {
5909        use crate::reader::{CountingReader, SliceReader};
5910        // Many subjects so the postings blob is large relative to one posting:
5911        // every subject carries "common", but only a few carry "rare".
5912        let mut triples: Vec<(String, String, String)> = (0..300u32)
5913            .map(|i| {
5914                (
5915                    format!("<http://ex/s/{i:04}>"),
5916                    "<http://ex/label>".to_string(),
5917                    format!("\"common word number {i}\""),
5918                )
5919            })
5920            .collect();
5921        for i in [3u32, 77, 250] {
5922            triples.push((
5923                format!("<http://ex/s/{i:04}>"),
5924                "<http://ex/tag>".to_string(),
5925                "\"raretoken\"".to_string(),
5926            ));
5927        }
5928        let bytes = build_text_indexed(&triples);
5929        let eager = Rete::open(&bytes).unwrap();
5930        let mut want = eager.text_search(&["raretoken"], None, 0);
5931        want.sort();
5932        assert_eq!(want.len(), 3);
5933
5934        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
5935        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
5936        let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
5937        // Bytes pulled by the open itself (dict dirs, index dirs, named graphs) —
5938        // the text index is deferred and not touched yet.
5939        let before = reader.bytes_read();
5940        let mut got = lazy.text_search(&["raretoken"], None, 0);
5941        got.sort();
5942        assert_eq!(got, want, "lazy search matches eager");
5943        let pulled = reader.bytes_read() - before;
5944        // The search faulted the token table + the one "raretoken" posting; it must
5945        // be far less than the whole text-index section (300 "common" postings).
5946        let ti_len = eager.header().text_index_len;
5947        assert!(
5948            pulled < ti_len,
5949            "search pulled {pulled} B but the section is {ti_len} B — faulted too much"
5950        );
5951        assert!(!lazy.index_incomplete());
5952    }
5953
5954    /// `SearchView` answers exactly what a full open answers, having read
5955    /// strictly less: it skips the permutation tile directories and — the part
5956    /// that actually costs on a literal-heavy graph — the object-only
5957    /// dictionary's chunk directory.
5958    #[test]
5959    fn search_view_matches_full_open_for_far_fewer_bytes() {
5960        use crate::reader::{CountingReader, SliceReader};
5961        // Long object literals so the object-only dictionary (and its chunk
5962        // directory) dominates the file, as on a graph that stores abstracts.
5963        let filler = "lorem ipsum dolor sit amet consectetur adipiscing elit ".repeat(20);
5964        let mut triples: Vec<(String, String, String)> = (0..300u32)
5965            .map(|i| {
5966                (
5967                    format!("<http://ex/s/{i:04}>"),
5968                    "<http://ex/abstract>".to_string(),
5969                    format!("\"{filler} number {i}\""),
5970                )
5971            })
5972            .collect();
5973        for i in [3u32, 77, 250] {
5974            triples.push((
5975                format!("<http://ex/s/{i:04}>"),
5976                "<http://ex/tag>".to_string(),
5977                "\"raretoken\"".to_string(),
5978            ));
5979        }
5980        let bytes = build_text_indexed(&triples);
5981        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
5982
5983        let full_reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
5984        let full = Rete::open_ranged_lazy(full_reader.clone()).unwrap();
5985        let mut want = full.text_search(&["raretoken"], None, 0);
5986        want.sort();
5987        assert_eq!(want.len(), 3);
5988
5989        let search_reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
5990        let view = SearchView::open_ranged(search_reader.clone()).unwrap();
5991        assert!(view.has_text_index());
5992        let mut got = view.text_search(&["raretoken"], None, 0);
5993        got.sort();
5994        assert_eq!(got, want, "search view matches the full open");
5995        assert!(
5996            search_reader.bytes_read() < full_reader.bytes_read(),
5997            "search read {} B, full open read {} B — the narrow open bought nothing",
5998            search_reader.bytes_read(),
5999            full_reader.bytes_read()
6000        );
6001
6002        // A file without a text index says so from the header alone, and the
6003        // label-prefix mode still works through the same view.
6004        let mut prefix_hits = view.prefix_search("", 5);
6005        prefix_hits.sort();
6006        assert!(!prefix_hits.is_empty() || !view.has_pyramid());
6007    }
6008
6009    /// Nothing records a dictionary chunk directory's byte length, so a ranged
6010    /// open probes for it. That probe must cost about ONE directory, not several
6011    /// — it used to re-read the whole prefix on every round, so the sum was
6012    /// ~2x the last (already over-sized) read: 537 MB of range reads to fetch
6013    /// epfl-infoscience's 234 MB object-only directory. Here the same probe runs
6014    /// against a file whose object literals are long enough that its directory
6015    /// is well past the 8 KiB header prefix, and must (a) stay inside the
6016    /// append-only bound of 2x the directory and (b) beat what the old
6017    /// re-reading loop would have spent.
6018    #[test]
6019    fn dict_chunk_directory_probe_costs_about_one_directory() {
6020        use crate::reader::{CountingReader, SliceReader};
6021        // Long literals that share a 3 KiB prefix with their neighbour, each
6022        // ending in a 1 KiB tail of its own: the WKT-polygon / JSON-blob shape
6023        // (#198's weak case). The directory stores the SHORTEST SEPARATOR per
6024        // chunk, and a separator has to reproduce the whole shared prefix — so
6025        // these keys are still ~3 KiB and the directory still runs well past
6026        // the 8 KiB header prefix, which is what gives the probe something to
6027        // measure.
6028        //
6029        // This fixture used to put the varying bytes FIRST (`"{i:04} xxxx…"`).
6030        // That shape is the one separators annihilate: its keys collapse to ~4
6031        // bytes, the whole directory to 192 B, it fits the header prefix, and
6032        // the probe never runs — the assertion below then fails naming itself.
6033        // Keep the shared prefix.
6034        let head = "x".repeat(3000);
6035        let triples: Vec<(String, String, String)> = (0..2000u32)
6036            .map(|i| {
6037                let tail = char::from(b'a' + (i % 26) as u8).to_string().repeat(1000);
6038                (
6039                    format!("<http://ex/s/{i:04}>"),
6040                    "<http://ex/abstract>".to_string(),
6041                    format!("\"{head}{i:04}{tail}\""),
6042                )
6043            })
6044            .collect();
6045        let mut db = DictionaryBuilder::new();
6046        for (s, p, o) in &triples {
6047            db.observe(s, p, o);
6048        }
6049        let dict = db.build();
6050        let mut ib = GraphIndexBuilder::new();
6051        for (s, p, o) in &triples {
6052            ib.push(dict.encode(s, p, o).unwrap());
6053        }
6054        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
6055
6056        let reader = CountingReader::new(SliceReader::new(&bytes));
6057        let header = Header::from_bytes(&reader.read_at(0, HEADER_LEN as u64).unwrap()).unwrap();
6058        // Section 2 is object-only: the one carrying the long literals.
6059        let section = locate_container_section_ranged(
6060            &reader,
6061            header.dictionary_offset,
6062            header.dictionary_len,
6063            2,
6064            4,
6065        )
6066        .unwrap();
6067
6068        let before = reader.bytes_read();
6069        let (_meta, entries) = read_dict_dir_ranged(&reader, section).unwrap();
6070        let spent = reader.bytes_read() - before;
6071
6072        // The first chunk body starts where the directory ends.
6073        let dir_end = entries[0].start;
6074        let head = reader.read_at(section.offset, 64).unwrap();
6075        let (header_len, n0) = read_uvarint(&head).unwrap();
6076        let dir_start = n0 as u64 + header_len;
6077        let dir_len = dir_end - dir_start;
6078        let dir_total = section.len - dir_start;
6079        assert!(
6080            dir_start + dir_len > 8192,
6081            "directory fits the header prefix ({dir_len} B) — the probe never runs"
6082        );
6083
6084        // What the previous loop spent: every round re-read from the start.
6085        let mut old = 0u64;
6086        let mut p = 4096u64.min(dir_total).max(1);
6087        loop {
6088            let got = p.min(dir_total);
6089            old += got;
6090            if got >= dir_len || p >= dir_total {
6091                break;
6092            }
6093            p = p.saturating_mul(2).min(dir_total);
6094        }
6095        assert!(
6096            spent <= dir_len * 2,
6097            "probe read {spent} B for a {dir_len} B directory — past the append-only bound"
6098        );
6099        assert!(
6100            spent < old,
6101            "probe read {spent} B; the re-reading loop it replaced spent {old} B"
6102        );
6103    }
6104
6105    // ---------------------------------------------------------------------
6106    // Separator routing keys (#198 option (d))
6107    // ---------------------------------------------------------------------
6108
6109    /// A verbatim-keyed twin of a chunked dictionary section: same header, same
6110    /// chunking, byte-identical compressed bodies — only every directory key
6111    /// replaced by the chunk's actual first term. That is exactly what
6112    /// `encode_chunked_dict_section` wrote before separator keys, so it is the
6113    /// control for every comparison below (and for the proptest).
6114    fn verbatim_keyed_twin(payload: &[u8], codec: u8) -> Vec<u8> {
6115        let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
6116        let (header_len, n0) = read_uvarint(payload).unwrap();
6117        let mut out = Vec::new();
6118        write_uvarint(&mut out, header_len);
6119        out.extend_from_slice(&payload[n0..n0 + header_len as usize]);
6120        write_uvarint(&mut out, entries.len() as u64);
6121        let mut prev_run = 0usize;
6122        for e in &entries {
6123            let body = decompress(codec, &payload[e.start as usize..e.end as usize]).unwrap();
6124            let first = crate::dict::run_first_term(&body, 0).unwrap();
6125            write_uvarint(&mut out, (e.first_run - prev_run) as u64);
6126            write_uvarint(&mut out, first.len() as u64);
6127            out.extend_from_slice(&first);
6128            write_uvarint(&mut out, e.end - e.start);
6129            prev_run = e.first_run;
6130        }
6131        for e in &entries {
6132            out.extend_from_slice(&payload[e.start as usize..e.end as usize]);
6133        }
6134        out
6135    }
6136
6137    /// Each chunk's (first term, last term), decoded from the bodies.
6138    fn chunk_bounds_of(payload: &[u8], codec: u8) -> Vec<(Vec<u8>, Vec<u8>)> {
6139        let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
6140        entries
6141            .iter()
6142            .map(|e| {
6143                let body = decompress(codec, &payload[e.start as usize..e.end as usize]).unwrap();
6144                (
6145                    crate::dict::run_first_term(&body, 0).unwrap(),
6146                    crate::dict::run_last_term(&body, 0, body.len()).unwrap(),
6147                )
6148            })
6149            .collect()
6150    }
6151
6152    /// A term set built to hit every shape the corpus has: plain IRIs, literals
6153    /// sharing a long prefix with their neighbour (separators are LONG there —
6154    /// the WKT/`ohm-full` weak case), literals that diverge in their first bytes
6155    /// (separators are tiny — the `epfl-infoscience` case), literals larger than
6156    /// `DICT_CHUNK_BUDGET` on their own, and multibyte UTF-8.
6157    fn adversarial_terms() -> Vec<String> {
6158        let mut terms: Vec<String> = Vec::new();
6159        for i in 0..6_000 {
6160            terms.push(format!("<http://example.org/work/{i:06}>"));
6161        }
6162        let shared = "X".repeat(900);
6163        for i in 0..4_000 {
6164            terms.push(format!("\"{shared}-{i:05}-tail\""));
6165        }
6166        let deep = "Y".repeat(3_000); // shared prefix far past any short key
6167        for i in 0..120 {
6168            terms.push(format!("\"{deep}{i:03}\""));
6169        }
6170        for i in 0..6 {
6171            // one literal larger than the 64 KiB budget: a chunk of one run
6172            terms.push(format!("\"BIG-{i:02}-{}\"", "Z".repeat(90_000)));
6173        }
6174        for i in 0..800 {
6175            terms.push(format!(
6176                "\"h\u{e9}llo-{}-\u{1F600}{i:04}\"",
6177                "\u{e9}".repeat(40)
6178            ));
6179        }
6180        for i in 0..2_000 {
6181            terms.push(format!("\"{i:05}-{}\"", "T".repeat(1_800)));
6182        }
6183        terms.sort();
6184        terms.dedup();
6185        terms
6186    }
6187
6188    fn chunked_section_of(terms: &[String]) -> Vec<u8> {
6189        let mut b = crate::dict::DictSectionBuilder::new().with_restart_interval(16);
6190        for t in terms {
6191            b.push(t.clone());
6192        }
6193        b.build()
6194    }
6195
6196    /// The invariant, asserted against the real chunk contents: the stored key
6197    /// of chunk `i` is strictly above chunk `i-1`'s LAST term and at most chunk
6198    /// `i`'s own first term, chunk 0's key is empty, and the keys ascend — the
6199    /// three facts `ChunkedSection::id`'s `partition_point` relies on.
6200    ///
6201    /// Plus the anti-regression the rename exists for: on a section like this
6202    /// the key must NOT be the first term. A writer that quietly went back to
6203    /// storing terms would pass every other test in this file — dumps and
6204    /// `term(id)` route by `first_run` and never read the key.
6205    #[test]
6206    fn chunk_directory_keys_are_separators_not_terms() {
6207        let terms = adversarial_terms();
6208        let raw = chunked_section_of(&terms);
6209        let payload = encode_chunked_dict_section(&raw, CODEC_NONE);
6210        let (_meta, entries) = parse_chunked_dict_dir(&payload, payload.len() as u64).unwrap();
6211        let bounds = chunk_bounds_of(&payload, CODEC_NONE);
6212        assert!(
6213            entries.len() > 20,
6214            "need a many-chunk section, got {}",
6215            entries.len()
6216        );
6217
6218        assert!(entries[0].key.is_empty(), "chunk 0 needs no separator");
6219        let mut key_bytes = 0usize;
6220        let mut term_bytes = 0usize;
6221        let mut differ = 0usize;
6222        for (i, e) in entries.iter().enumerate() {
6223            key_bytes += e.key.len();
6224            term_bytes += bounds[i].0.len();
6225            if e.key != bounds[i].0 {
6226                differ += 1;
6227            }
6228            assert!(
6229                e.key <= bounds[i].0,
6230                "chunk {i}: key must be <= its own first term"
6231            );
6232            if i > 0 {
6233                assert!(
6234                    e.key.as_slice() > bounds[i - 1].1.as_slice(),
6235                    "chunk {i}: key must be > the previous chunk's last term"
6236                );
6237                assert!(
6238                    e.key > entries[i - 1].key,
6239                    "chunk {i}: keys must ascend for partition_point"
6240                );
6241                // shortest: one byte past the divergence from the predecessor
6242                assert_eq!(
6243                    e.key,
6244                    crate::dict::shortest_separator(&bounds[i - 1].1, &bounds[i].0),
6245                    "chunk {i}: key is not the SHORTEST separator"
6246                );
6247            }
6248        }
6249        assert!(
6250            differ > 0,
6251            "every key equalled its first term — the writer stores terms again"
6252        );
6253        assert!(
6254            key_bytes * 4 < term_bytes,
6255            "separators bought almost nothing: {key_bytes} B vs {term_bytes} B of first terms"
6256        );
6257        eprintln!(
6258            "chunks={} first-term keys={term_bytes} B  separator keys={key_bytes} B ({:.1}x)",
6259            entries.len(),
6260            term_bytes as f64 / key_bytes.max(1) as f64
6261        );
6262    }
6263
6264    /// A separator-keyed section answers **identically** to the verbatim-keyed
6265    /// one built over the same bodies, under the same unmodified reader: every
6266    /// id, every term, and every boundary probe — the first and last term of
6267    /// each chunk, one byte either side of each, strings that fall strictly
6268    /// *between* two chunks, truncations, the empty string, and probes below
6269    /// and above the whole section.
6270    ///
6271    /// Probing `id(term)` is the entire point. A broken key (a truncation, a
6272    /// hash) leaves `term(id)`, `dump` and `export` byte-perfect — they route by
6273    /// `first_run` — and only lookups lie.
6274    #[test]
6275    fn separator_keys_route_identically_to_verbatim_first_terms() {
6276        let terms = adversarial_terms();
6277        let raw = chunked_section_of(&terms);
6278        let sep_payload = encode_chunked_dict_section(&raw, CODEC_NONE);
6279        let ver_payload = verbatim_keyed_twin(&sep_payload, CODEC_NONE);
6280
6281        // Same chunking, byte-identical bodies — only the keys differ.
6282        let (_ms, es) = parse_chunked_dict_dir(&sep_payload, sep_payload.len() as u64).unwrap();
6283        let (_mv, ev) = parse_chunked_dict_dir(&ver_payload, ver_payload.len() as u64).unwrap();
6284        assert_eq!(es.len(), ev.len());
6285        for (a, b) in es.iter().zip(ev.iter()) {
6286            assert_eq!(a.first_run, b.first_run);
6287            assert_eq!(
6288                &sep_payload[a.start as usize..a.end as usize],
6289                &ver_payload[b.start as usize..b.end as usize],
6290                "chunk bodies must be identical"
6291            );
6292        }
6293        assert!(
6294            sep_payload.len() < ver_payload.len(),
6295            "separator payload is not smaller"
6296        );
6297
6298        let sec_s = decode_chunked_dict_section(
6299            &sep_payload,
6300            CODEC_NONE,
6301            crate::chunk_cache::ChunkCache::unlimited_arc(),
6302            0,
6303        )
6304        .unwrap();
6305        let sec_v = decode_chunked_dict_section(
6306            &ver_payload,
6307            CODEC_NONE,
6308            crate::chunk_cache::ChunkCache::unlimited_arc(),
6309            0,
6310        )
6311        .unwrap();
6312
6313        // 1. every present term → the same, correct id
6314        for (i, t) in terms.iter().enumerate() {
6315            let want = Some(i as u32 + 1);
6316            assert_eq!(sec_v.id(t), want, "verbatim id() lost a term");
6317            assert_eq!(sec_s.id(t), want, "separator id() lost a term");
6318        }
6319        // 2. every id → the same term
6320        for i in 0..terms.len() as u32 {
6321            assert_eq!(sec_s.term(i + 1), sec_v.term(i + 1));
6322            assert_eq!(
6323                sec_s.term(i + 1).as_deref(),
6324                Some(terms[i as usize].as_str())
6325            );
6326        }
6327        // 3. boundary probes, from the real chunk bounds
6328        let bounds = chunk_bounds_of(&sep_payload, CODEC_NONE);
6329        let mut probes: Vec<String> = Vec::new();
6330        let push = |bytes: &[u8], probes: &mut Vec<String>| {
6331            if let Ok(s) = std::str::from_utf8(bytes) {
6332                probes.push(s.to_string());
6333            }
6334        };
6335        for (first, last) in &bounds {
6336            for base in [first, last] {
6337                push(base, &mut probes); // present
6338                let mut after = base.clone();
6339                after.push(0x01); // immediately after — between chunks when base = last
6340                push(&after, &mut probes);
6341                let mut far = base.clone();
6342                far.push(b'~');
6343                push(&far, &mut probes);
6344                if !base.is_empty() {
6345                    push(&base[..base.len() - 1], &mut probes); // truncated
6346                    let mut down = base.clone();
6347                    *down.last_mut().unwrap() = down.last().unwrap().wrapping_sub(1);
6348                    push(&down, &mut probes);
6349                    let mut up = base.clone();
6350                    *up.last_mut().unwrap() = up.last().unwrap().wrapping_add(1);
6351                    push(&up, &mut probes);
6352                }
6353            }
6354        }
6355        // 4. the stored separators themselves, and the ends of the world
6356        for e in &es {
6357            push(&e.key, &mut probes);
6358            let mut plus = e.key.clone();
6359            plus.push(0);
6360            push(&plus, &mut probes);
6361        }
6362        probes.push(String::new()); // the empty key, i.e. chunk 0's
6363        probes.push("\u{1}".to_string()); // below every term
6364        probes.push("\u{10FFFF}\u{10FFFF}".to_string()); // above every term
6365        probes.push(terms.first().unwrap().clone());
6366        probes.push(terms.last().unwrap().clone());
6367
6368        let mut hits = 0usize;
6369        for p in &probes {
6370            let (a, b) = (sec_v.id(p), sec_s.id(p));
6371            assert_eq!(
6372                a,
6373                b,
6374                "probe diverged: {:?}",
6375                p.get(..p.len().min(48)).unwrap_or(p.as_str())
6376            );
6377            hits += usize::from(a.is_some());
6378        }
6379        assert!(
6380            probes.len() > 500 && hits > 50,
6381            "probe set is too thin: {} probes, {hits} of them present",
6382            probes.len()
6383        );
6384        eprintln!(
6385            "{} boundary probes, {hits} present, all identical",
6386            probes.len()
6387        );
6388    }
6389
6390    /// The same claim as a **property**: for arbitrary term sets and arbitrary
6391    /// probes, a separator-keyed section and the verbatim-keyed twin built from
6392    /// the same input are indistinguishable through the reader. The fixed test
6393    /// above picks the shapes I thought of; this one shrinks a counterexample
6394    /// out of the ones I did not.
6395    mod separator_props {
6396        use super::*;
6397        use proptest::prelude::*;
6398
6399        /// Sorted, deduplicated term sets mixing the three shapes that decide a
6400        /// separator's length: short IRIs, literals whose payload comes BEFORE
6401        /// the discriminating bytes (long shared prefixes ⇒ long separators),
6402        /// and literals that diverge immediately (⇒ one-byte separators). Sizes
6403        /// straddle `DICT_CHUNK_BUDGET`, so single- and many-chunk sections both
6404        /// occur.
6405        fn term_set() -> impl Strategy<Value = Vec<String>> {
6406            prop::collection::vec(("[a-c]{1,6}", 0u8..3, 300usize..1200), 60..250).prop_map(
6407                |specs| {
6408                    let mut terms: Vec<String> = specs
6409                        .into_iter()
6410                        .enumerate()
6411                        .map(|(i, (core, kind, pad))| {
6412                            let block = "z".repeat(pad);
6413                            match kind {
6414                                0 => format!("<http://ex/{core}/{i:05}>"),
6415                                1 => format!("\"{block}{core}{i:05}\""),
6416                                _ => format!("\"{core}{i:05}{block}\""),
6417                            }
6418                        })
6419                        .collect();
6420                    terms.sort();
6421                    terms.dedup();
6422                    terms
6423                },
6424            )
6425        }
6426
6427        proptest! {
6428            #[test]
6429            fn prop_separator_keyed_section_answers_like_verbatim(
6430                terms in term_set(),
6431                extra in prop::collection::vec(any::<String>(), 0..8),
6432            ) {
6433                let raw = chunked_section_of(&terms);
6434                let sep = encode_chunked_dict_section(&raw, CODEC_NONE);
6435                let ver = verbatim_keyed_twin(&sep, CODEC_NONE);
6436                prop_assert!(sep.len() <= ver.len());
6437
6438                // The separator invariant, against the real chunk contents.
6439                let (_m, es) = parse_chunked_dict_dir(&sep, sep.len() as u64).unwrap();
6440                let bounds = chunk_bounds_of(&sep, CODEC_NONE);
6441                prop_assert!(es[0].key.is_empty());
6442                for i in 1..es.len() {
6443                    prop_assert!(es[i].key.as_slice() > bounds[i - 1].1.as_slice());
6444                    prop_assert!(es[i].key <= bounds[i].0);
6445                    prop_assert!(es[i].key > es[i - 1].key);
6446                }
6447
6448                let sec_s = decode_chunked_dict_section(
6449                    &sep,
6450                    CODEC_NONE,
6451                    crate::chunk_cache::ChunkCache::unlimited_arc(),
6452                    0,
6453                )
6454                .unwrap();
6455                let sec_v = decode_chunked_dict_section(
6456                    &ver,
6457                    CODEC_NONE,
6458                    crate::chunk_cache::ChunkCache::unlimited_arc(),
6459                    0,
6460                )
6461                .unwrap();
6462
6463                for (i, t) in terms.iter().enumerate() {
6464                    prop_assert_eq!(sec_s.id(t), Some(i as u32 + 1));
6465                    prop_assert_eq!(sec_v.id(t), Some(i as u32 + 1));
6466                    prop_assert_eq!(sec_s.term(i as u32 + 1), sec_v.term(i as u32 + 1));
6467                }
6468
6469                // Probes: arbitrary strings, plus mutations of every boundary.
6470                let mut probes: Vec<String> = extra;
6471                probes.push(String::new());
6472                for (first, last) in &bounds {
6473                    for base in [first, last] {
6474                        if let Ok(s) = std::str::from_utf8(base) {
6475                            probes.push(s.to_string());
6476                            probes.push(format!("{s}\u{1}"));
6477                            probes.push(s[..s.len() - s.chars().next_back()
6478                                .map(char::len_utf8).unwrap_or(0)].to_string());
6479                        }
6480                    }
6481                }
6482                for e in &es {
6483                    if let Ok(s) = std::str::from_utf8(&e.key) {
6484                        probes.push(s.to_string());
6485                    }
6486                }
6487                for p in &probes {
6488                    prop_assert_eq!(sec_s.id(p), sec_v.id(p), "probe diverged: {:?}", p);
6489                }
6490            }
6491        }
6492    }
6493
6494    /// The TEXT_INDEX section is inside the content hash: a freshly built
6495    /// text-indexed file must pass `verify()`, and flipping a byte inside the
6496    /// section must break it. (Regression: `verify()` once rebuilt the hash
6497    /// without the text index, so every `--text-index` file failed as corrupt.)
6498    #[test]
6499    fn text_index_is_tamper_evident_and_verifies() {
6500        let triples: Vec<(String, String, String)> = vec![(
6501            "<http://ex/s0>".to_string(),
6502            "<http://ex/label>".to_string(),
6503            "\"alpha glucose phosphate\"".to_string(),
6504        )];
6505        let bytes = build_text_indexed(&triples);
6506        let header = Rete::open(&bytes).unwrap().header().clone();
6507        assert!(header.text_index_len > 0);
6508        assert!(verify(&bytes).unwrap(), "a text-indexed build must verify");
6509
6510        let mut tampered = bytes.clone();
6511        tampered[header.text_index_offset as usize] ^= 0xff;
6512        assert!(
6513            !verify(&tampered).unwrap(),
6514            "tampering with the text index must break verify()"
6515        );
6516    }
6517
6518    /// End-to-end win: on a remote (range-read) file, a lookup whose routed tile
6519    /// is ruled out by a bound secondary fetches **fewer bytes** with the synopsis
6520    /// than without it — and the answer is identical (empty) either way.
6521    #[test]
6522    fn synopsis_cuts_remote_fetch_bytes() {
6523        use crate::header::FLAG_TILE_SYNOPSIS;
6524        use crate::reader::{CountingReader, SliceReader};
6525
6526        // Zero-padded terms ⇒ dictionary ids are monotonic in i; subject s_i pairs
6527        // only with object o_i, so an OSP tile (routed by object) holds a
6528        // contiguous subject range — a subject from a far tile is provably absent.
6529        let triples: Vec<(String, String, String)> = (0..400u32)
6530            .map(|i| {
6531                (
6532                    format!("<http://ex/s/{i:04}>"),
6533                    "<http://ex/p>".to_string(),
6534                    format!("<http://ex/o/{i:04}>"),
6535                )
6536            })
6537            .collect();
6538        let mut db = DictionaryBuilder::new();
6539        for (s, p, o) in &triples {
6540            db.observe(s, p, o);
6541        }
6542        let dict = db.build();
6543        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6544        for (s, p, o) in &triples {
6545            ib.push(dict.encode(s, p, o).unwrap());
6546        }
6547        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
6548
6549        // (s_0395, ?, o_0005): routes OSP by the (early) object, secondary = the
6550        // (late) subject — outside that tile's subject range, so the synopsis
6551        // prunes the one routed tile.
6552        let q = (Some("<http://ex/s/0395>"), None, Some("<http://ex/o/0005>"));
6553        // Measure the bytes the QUERY pulls (after open) — isolating the per-query
6554        // saving from the one-time synopsis trailer reads done at open, which a
6555        // persistent remote session amortizes over many queries.
6556        let query_bytes = |image: &[u8]| -> (u64, usize) {
6557            let leaked: &'static [u8] = Box::leak(image.to_vec().into_boxed_slice());
6558            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
6559            let rete = Rete::open_ranged_lazy(reader.clone()).unwrap();
6560            let before = reader.bytes_read(); // after open (incl. trailer reads)
6561            let n = rete.query(q.0, q.1, q.2).len();
6562            assert!(!rete.index_incomplete());
6563            (reader.bytes_read() - before, n)
6564        };
6565
6566        let (on_bytes, on_n) = query_bytes(&bytes);
6567        // Same file with the synopsis flag cleared = an older reader's behavior.
6568        let mut off = bytes.clone();
6569        off[5] &= !FLAG_TILE_SYNOPSIS;
6570        let (off_bytes, off_n) = query_bytes(&off);
6571
6572        assert_eq!(on_n, 0, "the pair never co-occurs");
6573        assert_eq!(off_n, 0, "same answer without the synopsis");
6574        // Both pay the same dictionary-resolution bytes; the difference is the one
6575        // routed index tile that the synopsis skips (and the no-synopsis path
6576        // fetches only to have its zone map reject it).
6577        assert!(
6578            on_bytes < off_bytes,
6579            "synopsis skips the routed tile fetch: {on_bytes} < {off_bytes}"
6580        );
6581    }
6582
6583    /// A double-bound-object intersection (`?p P o1 ; P o2 ; label ?l`) — the
6584    /// shape whose REMOTE join strategy changed (scan + hash-join instead of
6585    /// probing each prefix row) — must return the SAME rows opened eagerly (in
6586    /// memory) and lazily (remote-style, `is_remote()` true). Strategy is a
6587    /// performance choice; the result multiset is invariant.
6588    #[test]
6589    fn double_bound_object_join_eager_matches_lazy() {
6590        use crate::reader::SliceReader;
6591        let occ = "<http://ex/occ>";
6592        let phys = "<http://ex/physicist>";
6593        let phil = "<http://ex/philosopher>";
6594        let label = "<http://www.w3.org/2000/01/rdf-schema#label>";
6595        // p00..p19 are physicists; p00..p09 are also philosophers (the answer).
6596        let mut triples: Vec<(String, String, String)> = Vec::new();
6597        for i in 0..20u32 {
6598            triples.push((format!("<http://ex/p/{i:02}>"), occ.into(), phys.into()));
6599            if i < 10 {
6600                triples.push((format!("<http://ex/p/{i:02}>"), occ.into(), phil.into()));
6601            }
6602            triples.push((
6603                format!("<http://ex/p/{i:02}>"),
6604                label.into(),
6605                format!("\"Name {i:02}\""),
6606            ));
6607        }
6608        let mut db = DictionaryBuilder::new();
6609        for (s, p, o) in &triples {
6610            db.observe(s, p, o);
6611        }
6612        let dict = db.build();
6613        let mut ib = GraphIndexBuilder::new().with_tile_budget(16);
6614        for (s, p, o) in &triples {
6615            ib.push(dict.encode(s, p, o).unwrap());
6616        }
6617        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
6618
6619        let q = "SELECT ?l WHERE { \
6620            ?p <http://ex/occ> <http://ex/physicist> ; \
6621               <http://ex/occ> <http://ex/philosopher> ; \
6622               <http://www.w3.org/2000/01/rdf-schema#label> ?l }";
6623        let run = |rete: &Rete| -> Vec<String> {
6624            let (_, sols) = crate::eval_sparql(rete, q).unwrap();
6625            let mut v: Vec<String> = sols.iter().filter_map(|b| b.get("l").cloned()).collect();
6626            v.sort();
6627            v
6628        };
6629
6630        let eager_rows = run(&Rete::open(&bytes).unwrap());
6631        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
6632        let lazy = Rete::open_ranged_lazy(std::sync::Arc::new(SliceReader::new(leaked))).unwrap();
6633        let lazy_rows = run(&lazy);
6634        assert!(!lazy.index_incomplete());
6635
6636        assert_eq!(eager_rows.len(), 10, "the 10 physicist∩philosopher labels");
6637        assert_eq!(eager_rows, lazy_rows, "eager and lazy must agree exactly");
6638    }
6639
6640    /// A dictionary big enough to split into multiple chunks per section must
6641    /// round-trip every id↔term mapping through the chunked (v0.2) encoding —
6642    /// including terms at chunk boundaries and absent near-misses.
6643    #[test]
6644    fn multi_chunk_dictionary_round_trips() {
6645        let mut db = DictionaryBuilder::new();
6646        let term = |i: u32| format!("<http://example.org/some/long/prefix/entity/{i:06}>");
6647        for i in 0..6000u32 {
6648            db.observe(&term(i), "<http://ex/p>", &term(i + 1));
6649        }
6650        let dict = db.build();
6651        let mut ib = GraphIndexBuilder::new();
6652        for i in 0..6000u32 {
6653            ib.push(
6654                dict.encode(&term(i), "<http://ex/p>", &term(i + 1))
6655                    .unwrap(),
6656            );
6657        }
6658        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
6659        let rete = Rete::open(&bytes).unwrap();
6660        let d = rete.dictionary();
6661        assert_eq!(d.term_count(), dict.term_count());
6662        for i in (0..6000).step_by(97).chain([0, 1, 5999, 6000]) {
6663            let t = term(i);
6664            let sid = dict.subject_id(&t);
6665            assert_eq!(d.subject_id(&t), sid, "subject_id({t})");
6666            if let Some(id) = sid {
6667                assert_eq!(d.subject_term(id).as_deref(), Some(t.as_str()));
6668            }
6669            let oid = dict.object_id(&t);
6670            assert_eq!(d.object_id(&t), oid, "object_id({t})");
6671        }
6672        assert_eq!(d.subject_id("<http://example.org/absent>"), None);
6673        assert_eq!(d.predicate_id("<http://ex/p>"), Some(1));
6674        assert_eq!(d.predicate_term(1).as_deref(), Some("<http://ex/p>"));
6675    }
6676
6677    #[test]
6678    #[cfg(feature = "compression")]
6679    fn compression_shrinks_repetitive_data() {
6680        // Many triples sharing IRI prefixes — exactly what front-coding + zstd
6681        // should crush. The compressed file must be far smaller than the raw
6682        // term bytes, and still query correctly.
6683        let mut db = DictionaryBuilder::new();
6684        let triples: Vec<(String, String, String)> = (0..500)
6685            .map(|i| {
6686                (
6687                    format!("<http://example.org/entity/{i}>"),
6688                    "<http://example.org/p/relatedTo>".to_string(),
6689                    format!("<http://example.org/entity/{}>", (i + 1) % 500),
6690                )
6691            })
6692            .collect();
6693        for (s, p, o) in &triples {
6694            db.observe(s, p, o);
6695        }
6696        let dict = db.build();
6697        let mut ib = GraphIndexBuilder::new();
6698        for (s, p, o) in &triples {
6699            ib.push(dict.encode(s, p, o).unwrap());
6700        }
6701        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
6702
6703        let raw: usize = triples
6704            .iter()
6705            .map(|(s, p, o)| s.len() + p.len() + o.len())
6706            .sum();
6707        assert!(
6708            bytes.len() < raw / 2,
6709            "expected strong compression: file {} vs raw terms {raw}",
6710            bytes.len()
6711        );
6712
6713        // Still queryable after compression.
6714        let rete = Rete::open(&bytes).unwrap();
6715        let r = rete.query(Some("<http://example.org/entity/0>"), None, None);
6716        assert_eq!(r.len(), 1);
6717        assert_eq!(r[0].2, "<http://example.org/entity/1>");
6718    }
6719
6720    fn big_file_with_pyramid() -> Vec<u8> {
6721        // A ring of 300 entities -> index dwarfs dict+meta.
6722        let triples: Vec<(String, String, String)> = (0..300)
6723            .map(|i| {
6724                (
6725                    format!("<http://ex/e{i}>"),
6726                    "<http://ex/next>".to_string(),
6727                    format!("<http://ex/e{}>", (i + 1) % 300),
6728                )
6729            })
6730            .collect();
6731        let mut db = DictionaryBuilder::new();
6732        for (s, p, o) in &triples {
6733            db.observe(s, p, o);
6734        }
6735        let dict = db.build();
6736        let ids: Vec<_> = triples
6737            .iter()
6738            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
6739            .collect();
6740        let mut ib = GraphIndexBuilder::new();
6741        for &t in &ids {
6742            ib.push(t);
6743        }
6744        let (meta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
6745        write_file(&dict, &ib.build(), false, &meta, levels)
6746    }
6747
6748    #[test]
6749    fn ranged_open_is_minimal_and_correct() {
6750        use crate::reader::{CountingReader, SliceReader};
6751        let bytes = big_file_with_pyramid();
6752
6753        let full = CountingReader::new(SliceReader::new(&bytes));
6754        let rete = Rete::open_ranged(&full).unwrap();
6755        // Full open touches at most 4 ranges (header, dict, index, meta).
6756        assert!(full.requests() <= 4, "requests = {}", full.requests());
6757        assert_eq!(
6758            rete.query(Some("<http://ex/e0>"), None, None)[0].2,
6759            "<http://ex/e1>"
6760        );
6761
6762        // Summary-only open skips the index → strictly fewer bytes than the file.
6763        let summ_reader = CountingReader::new(SliceReader::new(&bytes));
6764        let view = SummaryView::open_ranged(&summ_reader).unwrap().unwrap();
6765        assert!(!view.summary.is_empty());
6766        assert!(
6767            summ_reader.bytes_read() < bytes.len() as u64,
6768            "summary read {} of {} bytes",
6769            summ_reader.bytes_read(),
6770            bytes.len()
6771        );
6772        // And fewer than a full open, since it never fetched the index.
6773        assert!(summ_reader.bytes_read() < full.bytes_read());
6774    }
6775
6776    #[test]
6777    fn content_hash_is_set_and_verifies() {
6778        let bytes = build_image();
6779        let rete = Rete::open(&bytes).unwrap();
6780        assert_ne!(
6781            rete.header().content_hash,
6782            [0u8; 16],
6783            "hash must be populated"
6784        );
6785        assert!(verify(&bytes).unwrap(), "freshly built file verifies");
6786        assert!(
6787            verify_ranged(&crate::reader::SliceReader::new(&bytes)).unwrap(),
6788            "the streaming verify agrees"
6789        );
6790
6791        // Same data builds an identical hash (deterministic).
6792        assert_eq!(
6793            Rete::open(&build_image()).unwrap().header().content_hash,
6794            rete.header().content_hash
6795        );
6796
6797        // Corrupting a payload byte breaks verification.
6798        let mut tampered = bytes.clone();
6799        let last = tampered.len() - 5; // inside payload, before footer magic
6800        tampered[last] ^= 0xff;
6801        assert!(!verify(&tampered).unwrap());
6802        assert!(!verify_ranged(&crate::reader::SliceReader::new(&tampered)).unwrap());
6803        // A truncated file is an error, never a pass.
6804        let short = &bytes[..bytes.len() / 2];
6805        assert!(verify_ranged(&crate::reader::SliceReader::new(short)).is_err());
6806        assert!(verify_ranged(&crate::reader::SliceReader::new(&bytes[..10])).is_err());
6807    }
6808
6809    /// Build the standard 3-triple image with an opaque metadata payload.
6810    fn build_with_metadata(meta: &[u8]) -> Vec<u8> {
6811        let triples = [
6812            ("Alice", "knows", "Bob"),
6813            ("Bob", "knows", "Carol"),
6814            ("Alice", "age", "30"),
6815        ];
6816        let mut db = DictionaryBuilder::new();
6817        for (s, p, o) in triples {
6818            db.observe(s, p, o);
6819        }
6820        let dict = db.build();
6821        let ids: Vec<_> = triples
6822            .iter()
6823            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
6824            .collect();
6825        let mut ib = GraphIndexBuilder::new();
6826        for &t in &ids {
6827            ib.push(t);
6828        }
6829        let (pmeta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
6830        write_dataset_with_metadata(&dict, &ib.build(), &[], false, &pmeta, levels, meta, &[])
6831    }
6832
6833    #[test]
6834    fn metadata_round_trips_and_shifts_offsets() {
6835        let card = br#"{"title":"My Dataset"}"#;
6836        let bytes = build_with_metadata(card);
6837        let rete = Rete::open(&bytes).unwrap();
6838
6839        // The opaque payload reads back verbatim.
6840        assert_eq!(rete.metadata(), Some(card.as_slice()));
6841        let h = rete.header();
6842        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
6843        assert_eq!(h.metadata_len, card.len() as u64);
6844        // The dictionary (and everything after it) shifted forward by the card.
6845        assert_eq!(h.dictionary_offset, HEADER_LEN as u64 + card.len() as u64);
6846
6847        // The index still decodes correctly at its shifted offset.
6848        assert_eq!(
6849            rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
6850            1
6851        );
6852        // The card is inside the content hash, so the file still verifies.
6853        assert!(verify(&bytes).unwrap());
6854    }
6855
6856    #[test]
6857    fn empty_metadata_is_byte_identical_to_plain_writer() {
6858        // The `&[]` path must produce exactly the bytes of the metadata-free
6859        // writer for identical inputs — old files and outputs are unchanged.
6860        assert_eq!(
6861            build_with_metadata(&[]),
6862            build_image(),
6863            "empty-metadata output must equal the plain writer byte-for-byte"
6864        );
6865    }
6866
6867    /// The contract `replace_metadata` has to meet to be usable at the end of a
6868    /// build: swapping the card must land on **exactly** the bytes the writer
6869    /// would have produced with that card in the first place — shorter, longer,
6870    /// and gone — so a spliced file and a rebuilt one are the same file.
6871    #[test]
6872    fn replace_metadata_matches_a_direct_write() {
6873        let first = br#"{"title":"My Dataset","queries":[1,2,3]}"#;
6874        let bytes = build_with_metadata(first);
6875        for target in [
6876            br#"{"title":"My Dataset","queries":[1,2]}"#.as_slice(), // shorter
6877            br#"{"title":"My Dataset","queries":[1,2,3,4,5,6,7]}"#.as_slice(), // longer
6878            b"".as_slice(),                                          // removed
6879            first.as_slice(),                                        // unchanged
6880        ] {
6881            let spliced = replace_metadata(&bytes, target).unwrap();
6882            assert_eq!(
6883                spliced,
6884                build_with_metadata(target),
6885                "splicing {} bytes of metadata must equal a direct write",
6886                target.len()
6887            );
6888            assert!(
6889                verify(&spliced).unwrap(),
6890                "the new hash covers the new card"
6891            );
6892            let rete = Rete::open(&spliced).unwrap();
6893            assert_eq!(
6894                rete.metadata(),
6895                (!target.is_empty()).then_some(target),
6896                "the new payload reads back verbatim"
6897            );
6898            // The data is untouched by the surgery.
6899            assert_eq!(
6900                rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
6901                1
6902            );
6903        }
6904    }
6905
6906    /// A build-info section survives the splice, still adjacent to the card, and
6907    /// still outside the content hash.
6908    #[test]
6909    fn replace_metadata_keeps_build_info_adjacent() {
6910        let with_info =
6911            attach_build_info(&build_with_metadata(br#"{"a":1}"#), br#"{"builder":"x"}"#).unwrap();
6912        let spliced = replace_metadata(&with_info, br#"{"a":1,"b":2}"#).unwrap();
6913        let h = Header::from_bytes(&spliced).unwrap();
6914        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
6915        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + h.metadata_len);
6916        assert_eq!(
6917            read_build_info(&spliced).unwrap().as_deref(),
6918            Some(br#"{"builder":"x"}"#.as_slice())
6919        );
6920        assert!(verify(&spliced).unwrap());
6921        // Same card, same content hash, whatever the build-info says.
6922        assert_eq!(
6923            h.content_hash,
6924            Header::from_bytes(&build_with_metadata(br#"{"a":1,"b":2}"#))
6925                .unwrap()
6926                .content_hash
6927        );
6928    }
6929
6930    #[test]
6931    fn metadata_is_tamper_evident() {
6932        let card = br#"{"title":"x"}"#;
6933        let mut bytes = build_with_metadata(card);
6934        assert!(verify(&bytes).unwrap());
6935        // The card occupies [HEADER_LEN .. HEADER_LEN+card_len); flip a byte in it.
6936        bytes[HEADER_LEN + 2] ^= 0xff;
6937        assert!(
6938            !verify(&bytes).unwrap(),
6939            "tampering with the card must break verify()"
6940        );
6941    }
6942
6943    #[test]
6944    fn ranged_opens_do_not_fetch_metadata() {
6945        use crate::reader::{CountingReader, SliceReader};
6946        let card = vec![0xABu8; 512]; // distinctive and sizable
6947        let bytes = build_with_metadata(&card);
6948        let total = bytes.len() as u64;
6949
6950        // A full ranged open never loads the card and never reads its byte range.
6951        let r = CountingReader::new(SliceReader::new(&bytes));
6952        let rete = Rete::open_ranged(&r).unwrap();
6953        assert!(
6954            rete.metadata().is_none(),
6955            "open_ranged must not load the card"
6956        );
6957        assert!(r.requests() <= 4, "requests = {}", r.requests());
6958        assert!(
6959            r.bytes_read() <= total - card.len() as u64,
6960            "read {} of {} bytes; the {}-byte card must be skipped",
6961            r.bytes_read(),
6962            total,
6963            card.len()
6964        );
6965
6966        // Summary-only open likewise ignores the card and still summarizes.
6967        let rs = CountingReader::new(SliceReader::new(&bytes));
6968        let view = SummaryView::open_ranged(&rs).unwrap().unwrap();
6969        assert!(!view.summary.is_empty());
6970        assert!(rs.bytes_read() <= total - card.len() as u64);
6971    }
6972
6973    #[test]
6974    fn metadata_ranged_fetches_only_header_and_card() {
6975        use crate::reader::{CountingReader, SliceReader};
6976        // The CARD tier: fetch the self-description over a RangeReader touching
6977        // only the header + metadata range — never the dictionary/index/pyramid.
6978        let card = vec![0xCDu8; 384];
6979        let bytes = build_with_metadata(&card);
6980
6981        let r = CountingReader::new(SliceReader::new(&bytes));
6982        let got = read_metadata_ranged(&r).unwrap().unwrap();
6983        assert_eq!(got, card, "the card reads back verbatim");
6984        assert_eq!(r.requests(), 2, "exactly header + metadata ranges");
6985        assert_eq!(
6986            r.bytes_read(),
6987            HEADER_LEN as u64 + card.len() as u64,
6988            "no dictionary/index/pyramid bytes are touched"
6989        );
6990
6991        // A cardless file resolves to None after a single header read.
6992        let plain = build_image();
6993        let rp = CountingReader::new(SliceReader::new(&plain));
6994        assert!(read_metadata_ranged(&rp).unwrap().is_none());
6995        assert_eq!(rp.requests(), 1, "header only for a cardless file");
6996        assert_eq!(rp.bytes_read(), HEADER_LEN as u64);
6997    }
6998
6999    #[test]
7000    fn build_info_attaches_outside_the_hash() {
7001        let card = br#"{"title":"My Dataset"}"#;
7002        let base = build_with_metadata(card);
7003        let base_hash = Header::from_bytes(&base).unwrap().content_hash;
7004
7005        let info = br#"{"built_at":"2026-08-04T00:00:00Z","builder":"rete-cli 0.3.2"}"#;
7006        let with = attach_build_info(&base, info).unwrap();
7007
7008        // The section reads back verbatim, adjacent to the metadata.
7009        assert_eq!(read_build_info(&with).unwrap().unwrap(), info);
7010        let h = Header::from_bytes(&with).unwrap();
7011        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + card.len() as u64);
7012        assert_eq!(h.build_info_len, info.len() as u64);
7013        assert_eq!(h.expected_file_len(), Some(with.len() as u64));
7014
7015        // OUTSIDE the hash: the content hash is unchanged and the file still
7016        // verifies — two builds of identical data stay hash-equal even though
7017        // their build-info differs.
7018        assert_eq!(h.content_hash, base_hash);
7019        assert!(verify(&with).unwrap());
7020        let other = attach_build_info(&base, br#"{"built_at":"1999-01-01T00:00:00Z"}"#).unwrap();
7021        assert_eq!(Header::from_bytes(&other).unwrap().content_hash, base_hash);
7022
7023        // The graph still opens and answers at its shifted offsets.
7024        let rete = Rete::open(&with).unwrap();
7025        assert_eq!(
7026            rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
7027            1
7028        );
7029        assert_eq!(rete.metadata(), Some(card.as_slice()));
7030
7031        // Replacing an existing section works and removing it restores the
7032        // original image byte-for-byte (the strip-equality determinism proof).
7033        let replaced = attach_build_info(&with, br#"{"builder":"other"}"#).unwrap();
7034        assert_eq!(
7035            read_build_info(&replaced).unwrap().unwrap(),
7036            br#"{"builder":"other"}"#
7037        );
7038        assert!(verify(&replaced).unwrap());
7039        let stripped = attach_build_info(&replaced, &[]).unwrap();
7040        assert_eq!(stripped, base, "stripping build-info restores the image");
7041    }
7042
7043    #[test]
7044    fn build_info_attaches_on_a_metadata_free_file() {
7045        // No card: the section slots in right after the header.
7046        let base = build_image();
7047        let info = b"{\"builder\":\"x\"}";
7048        let with = attach_build_info(&base, info).unwrap();
7049        let h = Header::from_bytes(&with).unwrap();
7050        assert_eq!(h.build_info_offset, HEADER_LEN as u64);
7051        assert_eq!(h.metadata_len, 0);
7052        assert_eq!(read_build_info(&with).unwrap().unwrap(), info);
7053        assert!(verify(&with).unwrap());
7054        assert_eq!(
7055            h.content_hash,
7056            Header::from_bytes(&base).unwrap().content_hash
7057        );
7058        Rete::open(&with).unwrap();
7059    }
7060
7061    /// The contract a **streaming** rewriter needs: a plan is enough to rebuild
7062    /// the file from three spans — the header it hands back, the untouched
7063    /// `[HEADER_LEN, insert)` prefix, the new section, and the untouched
7064    /// `[tail_start, len)` tail — and the result is byte-identical to the
7065    /// in-memory splice. That is what lets a 17 GB file gain a build record
7066    /// with a 4 MiB buffer instead of 34 GB of RAM.
7067    #[test]
7068    fn a_plan_rebuilds_exactly_what_the_in_memory_splice_produces() {
7069        let card = br#"{"title":"My Dataset"}"#;
7070        let base = build_with_metadata(card);
7071        let base_hash = Header::from_bytes(&base).unwrap().content_hash;
7072        for start in [
7073            base.clone(),
7074            attach_build_info(&base, b"{\"a\":1}").unwrap(),
7075        ] {
7076            for info in [
7077                br#"{"builder":"rete-cli 0.3.2","query_costs":{}}"#.as_slice(), // longer
7078                b"{}".as_slice(),                                               // shorter
7079                b"".as_slice(),                                                 // removed
7080            ] {
7081                let plan = plan_build_info(&start, start.len() as u64, info.len() as u64).unwrap();
7082                let mut streamed = Vec::new();
7083                streamed.extend_from_slice(&plan.header);
7084                streamed.extend_from_slice(&start[HEADER_LEN..plan.insert as usize]);
7085                streamed.extend_from_slice(info);
7086                streamed.extend_from_slice(&start[plan.tail_start as usize..]);
7087                assert_eq!(streamed.len() as u64, plan.new_len);
7088                assert_eq!(
7089                    streamed,
7090                    attach_build_info(&start, info).unwrap(),
7091                    "a {}-byte section streamed must equal the same section spliced",
7092                    info.len()
7093                );
7094                // The point of the exercise: the file's identity is untouched.
7095                assert!(verify(&streamed).unwrap());
7096                let h = Header::from_bytes(&streamed).unwrap();
7097                assert_eq!(h.content_hash, base_hash);
7098                assert_eq!(h.expected_file_len(), Some(plan.new_len));
7099                let rete = Rete::open(&streamed).unwrap();
7100                assert_eq!(rete.metadata(), Some(card.as_slice()));
7101                assert_eq!(
7102                    rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
7103                    1
7104                );
7105            }
7106        }
7107    }
7108
7109    /// A plan is refused rather than trusted when the header's own numbers put
7110    /// the section outside the file — the header is attacker-controlled input.
7111    #[test]
7112    fn a_plan_refuses_a_header_that_overruns_the_file() {
7113        let base = build_with_metadata(br#"{"title":"x"}"#);
7114        assert!(plan_build_info(&base, 8, 16).is_err());
7115        assert!(plan_build_info(&base[..HEADER_LEN], base.len() as u64, 16).is_ok());
7116    }
7117
7118    #[test]
7119    fn card_and_build_info_ranged_is_one_header_plus_one_range() {
7120        use crate::reader::{CountingReader, SliceReader};
7121        let card = vec![0xCDu8; 384];
7122        let info = vec![0xEFu8; 200];
7123        let bytes = attach_build_info(&build_with_metadata(&card), &info).unwrap();
7124
7125        // Both sections in ONE coalesced range after the header — the CARD
7126        // tier's 1 header + 1 range budget holds with build info included.
7127        let r = CountingReader::new(SliceReader::new(&bytes));
7128        let (m, b) = read_card_and_build_info_ranged(&r).unwrap();
7129        assert_eq!(m.unwrap(), card);
7130        assert_eq!(b.unwrap(), info);
7131        assert_eq!(r.requests(), 2, "header + one coalesced range");
7132        assert_eq!(
7133            r.bytes_read(),
7134            HEADER_LEN as u64 + card.len() as u64 + info.len() as u64
7135        );
7136
7137        // Card only: same two requests, no build info.
7138        let plain = build_with_metadata(&card);
7139        let rp = CountingReader::new(SliceReader::new(&plain));
7140        let (m, b) = read_card_and_build_info_ranged(&rp).unwrap();
7141        assert_eq!(m.unwrap(), card);
7142        assert!(b.is_none());
7143        assert_eq!(rp.requests(), 2);
7144        // Stated as the comparison every client depends on: carrying a build
7145        // record costs a reader NOTHING in requests. Any client that shows the
7146        // build record — the CLI, the browser card modal — can therefore do it
7147        // in the card's own budget, and does not need a second call.
7148        assert_eq!(
7149            r.requests(),
7150            rp.requests(),
7151            "build info must not cost an extra request",
7152        );
7153
7154        // Neither: the header read alone answers.
7155        let none = build_image();
7156        let rn = CountingReader::new(SliceReader::new(&none));
7157        let (m, b) = read_card_and_build_info_ranged(&rn).unwrap();
7158        assert!(m.is_none() && b.is_none());
7159        assert_eq!(rn.requests(), 1);
7160    }
7161
7162    #[test]
7163    fn schema_summary_groups_by_type() {
7164        let rt = RDF_TYPE;
7165        let bytes = build_from(&[
7166            ("Alice", rt, "Person"),
7167            ("Bob", rt, "Person"),
7168            ("NYC", rt, "City"),
7169            ("Alice", "knows", "Bob"),
7170            ("Alice", "livesIn", "NYC"),
7171            ("Alice", "name", "\"Alice\""),
7172        ]);
7173        let rete = Rete::open(&bytes).unwrap();
7174        let summary = schema_summary(&rete);
7175        // Expect class-level relations, rdf:type excluded.
7176        assert!(summary.contains(&("Person".into(), "knows".into(), "Person".into(), 1)));
7177        assert!(summary.contains(&("Person".into(), "livesIn".into(), "City".into(), 1)));
7178        assert!(summary.contains(&("Person".into(), "name".into(), "(literal)".into(), 1)));
7179        // No rdf:type relations in the summary.
7180        assert!(!summary.iter().any(|(_, p, _, _)| p == RDF_TYPE));
7181
7182        // Class populations: 2 People, 1 City, sorted by count desc.
7183        let classes = schema_classes(&rete);
7184        assert_eq!(
7185            classes,
7186            vec![("Person".into(), 2u32), ("City".into(), 1u32)]
7187        );
7188    }
7189
7190    fn build_from(triples: &[(&str, &str, &str)]) -> Vec<u8> {
7191        let mut db = DictionaryBuilder::new();
7192        for (s, p, o) in triples {
7193            db.observe(s, p, o);
7194        }
7195        let dict = db.build();
7196        let mut ib = GraphIndexBuilder::new();
7197        for (s, p, o) in triples {
7198            ib.push(dict.encode(s, p, o).unwrap());
7199        }
7200        write_file(&dict, &ib.build(), false, &[], 0)
7201    }
7202
7203    /// Build a file WITH a pyramid (so the schema pyramid + coherence axioms ship).
7204    fn build_with_pyramid(triples: &[(&str, &str, &str)]) -> Vec<u8> {
7205        let mut db = DictionaryBuilder::new();
7206        for (s, p, o) in triples {
7207            db.observe(s, p, o);
7208        }
7209        let dict = db.build();
7210        let encoded: Vec<_> = triples
7211            .iter()
7212            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
7213            .collect();
7214        let mut ib = GraphIndexBuilder::new();
7215        for t in &encoded {
7216            ib.push(*t);
7217        }
7218        let (meta, levels) = build_pyramid_meta(&dict, &encoded, DEFAULT_TILE_BUDGET);
7219        write_dataset(&dict, &ib.build(), &[], false, &meta, levels)
7220    }
7221
7222    #[test]
7223    fn tbox_coherence_flags_unsatisfiable_class_index_free() {
7224        use crate::reader::{CountingReader, SliceReader};
7225        let rt = RDF_TYPE;
7226        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
7227        let disj = "<http://www.w3.org/2002/07/owl#disjointWith>";
7228        // C ⊑ D, C ⊑ E, D disjointWith E ⇒ C unsatisfiable — schema-only, with one
7229        // instance present just so the schema pyramid gets built.
7230        let bytes = build_with_pyramid(&[
7231            ("<http://ex/C>", sub, "<http://ex/D>"),
7232            ("<http://ex/C>", sub, "<http://ex/E>"),
7233            ("<http://ex/D>", disj, "<http://ex/E>"),
7234            ("<http://ex/x>", rt, "<http://ex/C>"),
7235        ]);
7236
7237        let r = CountingReader::new(SliceReader::new(&bytes));
7238        let view = SummaryView::open_ranged(&r).unwrap().unwrap();
7239        let points = view.tbox_coherence();
7240        assert!(
7241            points
7242                .iter()
7243                .any(|i| i.kind == "unsatisfiable-class" && i.detail.contains("http://ex/C>")),
7244            "expected C unsatisfiable from the schema alone, got {points:?}"
7245        );
7246
7247        // Proven index-free: bytes read never reach the (root_dir) index section,
7248        // mirroring `schema_pyramid_round_trips_through_file_index_free`.
7249        let header = Header::from_bytes(&bytes[..HEADER_LEN]).unwrap();
7250        assert!(
7251            r.bytes_read() <= bytes.len() as u64 - header.root_dir_len,
7252            "tbox_coherence must not read the triple index"
7253        );
7254    }
7255
7256    #[test]
7257    fn schema_coherence_reads_only_the_schema_block() {
7258        use crate::reader::{CountingReader, SliceReader};
7259        let rt = RDF_TYPE;
7260        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
7261        let disj = "<http://www.w3.org/2002/07/owl#disjointWith>";
7262        // 500 instances with unique literals → a sizable dictionary + community
7263        // summary, so a whole-pyramid-meta read would be large; the schema block
7264        // (bounded by the tiny ontology) stays small.
7265        let mut triples: Vec<(String, String, String)> = vec![
7266            ("<http://ex/C>".into(), sub.into(), "<http://ex/D>".into()),
7267            ("<http://ex/C>".into(), sub.into(), "<http://ex/E>".into()),
7268            ("<http://ex/D>".into(), disj.into(), "<http://ex/E>".into()),
7269        ];
7270        for i in 0..500 {
7271            let s = format!("<http://ex/x{i}>");
7272            triples.push((s.clone(), rt.into(), "<http://ex/C>".into()));
7273            triples.push((
7274                s,
7275                "<http://ex/label>".into(),
7276                format!("\"unique label {i}\""),
7277            ));
7278        }
7279        let trefs: Vec<(&str, &str, &str)> = triples
7280            .iter()
7281            .map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
7282            .collect();
7283        let bytes = build_with_pyramid(&trefs);
7284
7285        let header = Header::from_bytes(&bytes[..HEADER_LEN]).unwrap();
7286        assert!(
7287            header.schema_meta_len > 0,
7288            "the writer recorded a schema-block length"
7289        );
7290        assert!(
7291            (header.schema_meta_len as u64) < header.pyramid_meta_len,
7292            "schema block ({}) should be far smaller than the whole pyramid-meta ({})",
7293            header.schema_meta_len,
7294            header.pyramid_meta_len
7295        );
7296
7297        let r = CountingReader::new(SliceReader::new(&bytes));
7298        let points = read_schema_coherence_ranged(&r).unwrap().unwrap();
7299        assert!(points.iter().any(|i| i.kind == "unsatisfiable-class"));
7300        // It read only the header + the schema block — not the summary or dictionary.
7301        assert!(
7302            r.bytes_read() <= HEADER_LEN as u64 + header.schema_meta_len as u64,
7303            "read {} bytes; expected <= header + schema block ({})",
7304            r.bytes_read(),
7305            HEADER_LEN as u64 + header.schema_meta_len as u64
7306        );
7307    }
7308
7309    #[test]
7310    fn tbox_coherence_clean_schema_is_coherent() {
7311        use crate::reader::SliceReader;
7312        let rt = RDF_TYPE;
7313        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
7314        let bytes = build_with_pyramid(&[
7315            ("<http://ex/Dog>", sub, "<http://ex/Animal>"),
7316            ("<http://ex/x>", rt, "<http://ex/Dog>"),
7317        ]);
7318        let view = SummaryView::open_ranged(&SliceReader::new(&bytes))
7319            .unwrap()
7320            .unwrap();
7321        assert!(view.tbox_is_coherent(), "a plain hierarchy is coherent");
7322    }
7323
7324    #[test]
7325    fn named_graphs_round_trip() {
7326        // One shared dictionary; default graph + a named graph "g1".
7327        let all = [
7328            ("Alice", "knows", "Bob"), // default
7329            ("Bob", "age", "30"),      // named g1
7330        ];
7331        let mut db = DictionaryBuilder::new();
7332        for (s, p, o) in all {
7333            db.observe(s, p, o);
7334        }
7335        let dict = db.build();
7336
7337        let mut def = GraphIndexBuilder::new();
7338        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
7339        let mut g1 = GraphIndexBuilder::new();
7340        g1.push(dict.encode("Bob", "age", "30").unwrap());
7341
7342        let named = vec![("http://ex/g1".to_string(), g1.build())];
7343        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
7344
7345        assert!(verify(&bytes).unwrap());
7346        let rete = Rete::open(&bytes).unwrap();
7347        assert_eq!(rete.graph_names(), vec!["http://ex/g1"]);
7348        // The named graph contains Bob age 30, not the default-graph triple.
7349        let gi = rete.graph_index("http://ex/g1").unwrap();
7350        assert_eq!(gi.triple_count(), 1);
7351        assert!(rete.graph_index("http://ex/missing").is_none());
7352
7353        // quad_count counts ALL quads — default graph + named graphs (1 + 1),
7354        // not just the default index (which would report 1).
7355        assert_eq!(rete.header().quad_count, 2);
7356
7357        // Default-graph query path is unchanged.
7358        assert_eq!(rete.query(Some("Alice"), None, None).len(), 1);
7359
7360        // dump() round-trips each graph back to terms.
7361        assert_eq!(
7362            rete.dump(None),
7363            vec![("Alice".into(), "knows".into(), "Bob".into())]
7364        );
7365        assert_eq!(
7366            rete.dump(Some("http://ex/g1")),
7367            vec![("Bob".into(), "age".into(), "30".into())]
7368        );
7369    }
7370
7371    /// A named graph whose container exceeds [`NAMED_GRAPH_RESIDENT_MAX`]
7372    /// opens through [`open_index_container_lazy`] — the default graph's own
7373    /// machinery, at the container's offset. Drive that helper directly over a
7374    /// named graph's container range (the threshold itself is too large to
7375    /// cross cheaply in a unit test) and require identical matches.
7376    #[test]
7377    fn named_graph_container_opens_tile_lazily_like_the_root() {
7378        let mut db = DictionaryBuilder::new();
7379        let node = |n: u32| format!("<http://ex/n{n}>");
7380        let p = "<http://ex/p>".to_string();
7381        db.observe(&node(0), &p, &node(1));
7382        for i in 0..500u32 {
7383            db.observe(&node(1000 + i), &p, &node(2000 + i));
7384        }
7385        let dict = db.build();
7386        let mut def = GraphIndexBuilder::new();
7387        def.push(dict.encode(&node(0), &p, &node(1)).unwrap());
7388        let mut g = GraphIndexBuilder::new().with_tile_budget(256); // many tiles
7389        for i in 0..500u32 {
7390            g.push(dict.encode(&node(1000 + i), &p, &node(2000 + i)).unwrap());
7391        }
7392        let named = vec![("<http://ex/g>".to_string(), g.build())];
7393        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
7394        let rete = Rete::open(&bytes).unwrap();
7395        let header = rete.header();
7396
7397        // The single entry's container range: count varint, iri_len varint,
7398        // iri, container_len varint, then the container.
7399        let soff = header.named_graphs_offset as usize;
7400        let send = soff + header.named_graphs_len as usize;
7401        let sec = &bytes[soff..send];
7402        let (n, mut pos) = read_uvarint(sec).unwrap();
7403        assert_eq!(n, 1);
7404        let (ilen, u1) = read_uvarint(&sec[pos..]).unwrap();
7405        pos += u1 + ilen as usize;
7406        let (clen, u2) = read_uvarint(&sec[pos..]).unwrap();
7407        pos += u2;
7408        let container = ByteRange {
7409            offset: (soff + pos) as u64,
7410            len: clen,
7411        };
7412
7413        use crate::reader::SliceReader;
7414        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7415        let reader: std::sync::Arc<dyn RangeReader + Send + Sync> =
7416            std::sync::Arc::new(SliceReader::new(leaked));
7417        let (lazy_idx, _, _) = open_index_container_lazy(
7418            &reader,
7419            container,
7420            header.block_codec,
7421            header.has_tile_synopsis(),
7422            1,
7423            header.perms,
7424        )
7425        .unwrap();
7426        let mut want = rete
7427            .graph_index("<http://ex/g>")
7428            .unwrap()
7429            .match_pattern((None, None, None));
7430        let mut got = lazy_idx.match_pattern((None, None, None));
7431        want.sort_unstable();
7432        got.sort_unstable();
7433        assert_eq!(got.len(), 500);
7434        assert_eq!(got, want);
7435        assert!(!lazy_idx.load_incomplete());
7436    }
7437
7438    /// `dump_iter` must agree with `dump` exactly — and must be *lazy*: taking
7439    /// the first triple of a lazily range-read file may not drag the whole
7440    /// index across the reader, or the JS client's streaming quad cursor (which
7441    /// is this iterator, suspended between wasm calls) would be a materializing
7442    /// dump wearing a cursor's clothes.
7443    #[test]
7444    fn dump_iter_matches_dump_and_stops_early() {
7445        use crate::reader::{CountingReader, SliceReader};
7446
7447        // Enough triples over tiny tiles to force many tiles per permutation.
7448        let triples: Vec<(String, String, String)> = (0..2000u32)
7449            .map(|i| {
7450                (
7451                    format!("<http://ex/s/{i:04}>"),
7452                    "<http://ex/p>".to_string(),
7453                    format!("<http://ex/o/{i:04}>"),
7454                )
7455            })
7456            .collect();
7457        let mut db = DictionaryBuilder::new();
7458        for (s, p, o) in &triples {
7459            db.observe(s, p, o);
7460        }
7461        let dict = db.build();
7462        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
7463        for (s, p, o) in &triples {
7464            ib.push(dict.encode(s, p, o).unwrap());
7465        }
7466        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7467
7468        // Same triples, same order, as the eager dump.
7469        let rete = Rete::open(&bytes).unwrap();
7470        assert_eq!(rete.dump_iter(None).collect::<Vec<_>>(), rete.dump(None));
7471        // A missing named graph yields nothing rather than the default graph.
7472        assert_eq!(rete.dump_iter(Some("http://ex/nope")).count(), 0);
7473
7474        // Lazily: taking one triple must cost far fewer bytes than draining.
7475        let read_bytes = |take: usize| -> u64 {
7476            let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7477            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7478            let rete = Rete::open_ranged_lazy(reader.clone()).unwrap();
7479            let before = reader.bytes_read();
7480            let n = rete.dump_iter(None).take(take).count();
7481            assert_eq!(n, take.min(triples.len()));
7482            assert!(!rete.index_incomplete());
7483            reader.bytes_read() - before
7484        };
7485        let first_only = read_bytes(1);
7486        let everything = read_bytes(triples.len());
7487        // Always true: stopping after one triple cannot cost a full scan.
7488        assert!(
7489            first_only < everything,
7490            "dump_iter is not lazy: one triple read {first_only} B, a full scan {everything} B"
7491        );
7492        // The STRONG claim — under half — only holds once the payload dwarfs the
7493        // fixed open cost. This fixture is ~32 KB, and with `compression` off the
7494        // dictionary is essentially the whole file, so opening it at all already
7495        // pays most of `everything` (21 KB of 33 KB) and the ratio says nothing
7496        // about laziness. Asserting it unconditionally is how this test failed on
7497        // `--no-default-features` while passing everywhere else.
7498        #[cfg(feature = "compression")]
7499        assert!(
7500            first_only * 2 < everything,
7501            "dump_iter is not lazy: one triple read {first_only} B of the {everything} B a full scan reads"
7502        );
7503
7504        // A FRESH open: these assertions fault tiles and the dictionary in,
7505        // and the laziness measurement above counts bytes on a COLD reader —
7506        // sharing one `rete` makes first_only and everything both ~0, so the
7507        // comparison silently stops testing anything.
7508        let fresh = Rete::open(&bytes).unwrap();
7509        // `dump_batch` is the resumable form the language clients pull through.
7510        // Driven to exhaustion it must reproduce the eager dump EXACTLY — same
7511        // triples, same order — for any batch size, including one so small that
7512        // every call ends on a subject boundary before reaching it.
7513        let drain = |max_quads: usize| {
7514            let (mut out, mut cursor, mut calls) = (Vec::new(), 0u32, 0);
7515            loop {
7516                let (batch, next, done) = fresh.dump_batch(None, cursor, max_quads);
7517                out.extend(batch);
7518                cursor = next;
7519                calls += 1;
7520                assert!(calls < 10_000, "cursor failed to advance");
7521                if done {
7522                    break out;
7523                }
7524            }
7525        };
7526        let eager = fresh.dump(None);
7527        for max in [1usize, 7, 128, 100_000] {
7528            assert_eq!(drain(max), eager, "batch size {max} changed the dump");
7529        }
7530        // An unknown graph is an empty, already-finished dump — not an error and
7531        // not the default graph leaking through.
7532        let (t, _, done) = fresh.dump_batch(Some("http://ex/nope"), 0, 16);
7533        assert!(t.is_empty() && done);
7534    }
7535
7536    /// `query_batch` is `dump_batch` generalized to a PATTERN — the primitive the
7537    /// Java client's RDF4J cursor pulls through, one bounded batch per wasm call.
7538    /// Three things have to hold or the cursor is a materializing scan wearing a
7539    /// cursor's clothes: driven to exhaustion it must reproduce the eager
7540    /// `query_in_graph` for every pattern shape and every batch size; taking one
7541    /// row must cost far fewer bytes than draining; and every call must either
7542    /// yield a row or say `done`, so the caller can never spin.
7543    #[test]
7544    fn query_batch_matches_query_in_graph_and_stops_early() {
7545        use crate::reader::{CountingReader, SliceReader};
7546
7547        // Enough triples over tiny tiles that a scan spans many tiles, with two
7548        // predicates and a repeated subject so bound patterns have real fanout.
7549        let triples: Vec<(String, String, String)> = (0..2000u32)
7550            .map(|i| {
7551                (
7552                    format!("<http://ex/s/{:04}>", i / 2),
7553                    format!("<http://ex/p/{}>", i % 2),
7554                    format!("<http://ex/o/{i:04}>"),
7555                )
7556            })
7557            .collect();
7558        let mut db = DictionaryBuilder::new();
7559        for (s, p, o) in &triples {
7560            db.observe(s, p, o);
7561        }
7562        let dict = db.build();
7563        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
7564        for (s, p, o) in &triples {
7565            ib.push(dict.encode(s, p, o).unwrap());
7566        }
7567        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7568        let rete = Rete::open(&bytes).unwrap();
7569
7570        let drain = |s: Option<&str>, p: Option<&str>, o: Option<&str>, max: usize| {
7571            let (mut out, mut cursor, mut calls) = (Vec::new(), 0u64, 0);
7572            loop {
7573                let (batch, next, done) = rete.query_batch(None, s, p, o, cursor, max);
7574                // The contract the client relies on: rows, or done. Never
7575                // "nothing yet, call again" — that is an unbounded spin.
7576                assert!(!batch.is_empty() || done, "empty non-final batch");
7577                out.extend(batch);
7578                cursor = next;
7579                calls += 1;
7580                assert!(calls < 20_000, "cursor failed to advance");
7581                if done {
7582                    break out;
7583                }
7584            }
7585        };
7586        let shapes: [(Option<&str>, Option<&str>, Option<&str>); 6] = [
7587            (None, None, None),
7588            (Some("<http://ex/s/0007>"), None, None),
7589            (None, Some("<http://ex/p/1>"), None),
7590            (None, None, Some("<http://ex/o/0042>")),
7591            (Some("<http://ex/s/0007>"), Some("<http://ex/p/0>"), None),
7592            (
7593                Some("<http://ex/s/0007>"),
7594                Some("<http://ex/p/0>"),
7595                Some("<http://ex/o/0014>"),
7596            ),
7597        ];
7598        for (s, p, o) in shapes {
7599            let eager = rete.query_in_graph(None, s, p, o);
7600            assert!(
7601                !eager.is_empty(),
7602                "fixture shape {s:?} {p:?} {o:?} matches nothing"
7603            );
7604            // The lazy pull form agrees exactly, order included.
7605            assert_eq!(
7606                rete.query_iter(None, s, p, o).collect::<Vec<_>>(),
7607                eager,
7608                "query_iter disagrees for {s:?} {p:?} {o:?}"
7609            );
7610            for max in [1usize, 3, 64, 100_000] {
7611                let mut got = drain(s, p, o, max);
7612                let mut want = eager.clone();
7613                // A batch is re-sorted canonically per batch, not globally, so
7614                // a bound pattern's batches can arrive in permutation order.
7615                got.sort();
7616                want.sort();
7617                assert_eq!(got, want, "batch size {max} changed {s:?} {p:?} {o:?}");
7618            }
7619            // The fully unbound scan routes to SPO, so batching is order-exact.
7620            if s.is_none() && p.is_none() && o.is_none() {
7621                assert_eq!(drain(s, p, o, 7), eager, "unbound batching reordered rows");
7622            }
7623        }
7624
7625        // A bound term the dictionary has never seen, and an unknown graph, are
7626        // both finished-and-empty rather than errors or a leak of everything.
7627        let (t, _, done) = rete.query_batch(None, Some("<http://ex/nope>"), None, None, 0, 16);
7628        assert!(t.is_empty() && done);
7629        let (t, _, done) = rete.query_batch(Some("http://ex/nope"), None, None, None, 0, 16);
7630        assert!(t.is_empty() && done);
7631        assert_eq!(
7632            rete.query_iter(None, Some("<http://ex/nope>"), None, None)
7633                .count(),
7634            0
7635        );
7636
7637        // Lazily: one row must not drag the whole scan across the reader. This
7638        // is the property that makes `LIMIT 1` bounded on a 48 GiB file.
7639        let read_bytes = |take: usize| -> u64 {
7640            let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7641            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7642            let rete = Rete::open_ranged_lazy(reader.clone()).unwrap();
7643            let before = reader.bytes_read();
7644            let (mut n, mut cursor) = (0usize, 0u64);
7645            loop {
7646                let (batch, next, done) = rete.query_batch(None, None, None, None, cursor, 1);
7647                n += batch.len();
7648                cursor = next;
7649                if done || n >= take {
7650                    break;
7651                }
7652            }
7653            assert!(n >= take.min(triples.len()));
7654            assert!(!rete.index_incomplete());
7655            reader.bytes_read() - before
7656        };
7657        let first_only = read_bytes(1);
7658        let everything = read_bytes(triples.len());
7659        assert!(
7660            first_only < everything,
7661            "query_batch is not lazy: one row read {first_only} B, a full scan {everything} B"
7662        );
7663        #[cfg(feature = "compression")]
7664        assert!(
7665            first_only * 2 < everything,
7666            "query_batch is not lazy: one row read {first_only} B of the {everything} B a full scan reads"
7667        );
7668    }
7669
7670    /// A batched cursor over a QUADS file must stay inside the graph it was
7671    /// given: the default graph and each named graph resume independently.
7672    #[test]
7673    fn query_batch_is_graph_scoped() {
7674        let mut db = DictionaryBuilder::new();
7675        for (s, p, o) in [
7676            ("Alice", "knows", "Bob"),
7677            ("Alice", "knows", "Carol"),
7678            ("Alice", "knows", "Dave"),
7679            ("Erin", "knows", "Frank"),
7680            ("Alice", "knows", "Zoe"),
7681        ] {
7682            db.observe(s, p, o);
7683        }
7684        let dict = db.build();
7685        let mut def = GraphIndexBuilder::new();
7686        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
7687        def.push(dict.encode("Alice", "knows", "Carol").unwrap());
7688        let mut g1 = GraphIndexBuilder::new();
7689        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
7690        g1.push(dict.encode("Erin", "knows", "Frank").unwrap());
7691        let mut g2 = GraphIndexBuilder::new();
7692        g2.push(dict.encode("Alice", "knows", "Zoe").unwrap());
7693        let named = vec![
7694            ("http://ex/g1".to_string(), g1.build()),
7695            ("http://ex/g2".to_string(), g2.build()),
7696        ];
7697        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
7698        let rete = Rete::open(&bytes).unwrap();
7699        for graph in [None, Some("http://ex/g1"), Some("http://ex/g2")] {
7700            let eager = rete.query_in_graph(graph, None, None, None);
7701            let (mut out, mut cursor) = (Vec::new(), 0u64);
7702            loop {
7703                let (batch, next, done) = rete.query_batch(graph, None, None, None, cursor, 1);
7704                out.extend(batch);
7705                cursor = next;
7706                if done {
7707                    break;
7708                }
7709            }
7710            assert_eq!(out, eager, "graph {graph:?} batched differently");
7711        }
7712    }
7713
7714    #[test]
7715    fn query_in_graph_is_graph_scoped() {
7716        // Default graph: Alice knows Bob, Alice knows Carol.
7717        // Named g1: Alice knows Dave (same predicate, different graph).
7718        let mut db = DictionaryBuilder::new();
7719        for (s, p, o) in [
7720            ("Alice", "knows", "Bob"),
7721            ("Alice", "knows", "Carol"),
7722            ("Alice", "knows", "Dave"),
7723        ] {
7724            db.observe(s, p, o);
7725        }
7726        let dict = db.build();
7727
7728        let mut def = GraphIndexBuilder::new();
7729        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
7730        def.push(dict.encode("Alice", "knows", "Carol").unwrap());
7731        let mut g1 = GraphIndexBuilder::new();
7732        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
7733
7734        let named = vec![("http://ex/g1".to_string(), g1.build())];
7735        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
7736        let rete = Rete::open(&bytes).unwrap();
7737
7738        // Default graph only: the two default-graph objects, not Dave.
7739        let mut def_objs: Vec<String> = rete
7740            .query_in_graph(None, Some("Alice"), Some("knows"), None)
7741            .into_iter()
7742            .map(|(_, _, o)| o)
7743            .collect();
7744        def_objs.sort();
7745        assert_eq!(def_objs, vec!["Bob".to_string(), "Carol".to_string()]);
7746
7747        // Named graph only: just Dave.
7748        assert_eq!(
7749            rete.query_in_graph(Some("http://ex/g1"), Some("Alice"), None, None),
7750            vec![("Alice".into(), "knows".into(), "Dave".into())]
7751        );
7752
7753        // A wildcard-everything scan is scoped to its graph.
7754        assert_eq!(rete.query_in_graph(None, None, None, None).len(), 2);
7755        assert_eq!(
7756            rete.query_in_graph(Some("http://ex/g1"), None, None, None)
7757                .len(),
7758            1
7759        );
7760
7761        // An unknown graph IRI is empty, not an error.
7762        assert!(rete
7763            .query_in_graph(Some("http://ex/missing"), None, None, None)
7764            .is_empty());
7765    }
7766
7767    /// A **bounded** `query_in_graph` — and a dump of one named graph — must not
7768    /// fault the whole dictionary on a lazily range-read file.
7769    ///
7770    /// Both used to call `Dictionary::prefetch_all`, so the graph-scoped pattern
7771    /// primitive an RDF4J `Sail` calls once per named graph paid for every term
7772    /// in the file to answer a handful of rows. Measured on `cordis.rete`
7773    /// (801 MB, a 417 MB dictionary) before the fix: a 15-row answer read
7774    /// 416 MB and peaked at 2.5 GB RSS. The fixture below is the same shape in
7775    /// miniature — long literals so the dictionary dominates the file — and the
7776    /// assertion is the invariant that regressed: bytes read must stay well
7777    /// under the dictionary section. Lazy answers are compared against eager
7778    /// throughout: cheaper is only interesting if it is also correct.
7779    #[test]
7780    fn bounded_query_in_graph_does_not_fault_the_whole_dictionary() {
7781        use crate::reader::{CountingReader, SliceReader};
7782
7783        // Long, distinct, poorly-compressible object literals: the dictionary is
7784        // the payload here, exactly as on a graph that stores abstracts or
7785        // embedded media. Repeating one filler string would not do — front
7786        // coding plus zstd shrink 2000 near-identical literals to ~6 KB, which
7787        // is smaller than the index and makes the measurement meaningless.
7788        let payload = |i: u32| -> String {
7789            let mut s = String::with_capacity(800);
7790            let mut x = i as u64 ^ 0x9e37_79b9_7f4a_7c15;
7791            while s.len() < 800 {
7792                x = x
7793                    .wrapping_mul(6364136223846793005)
7794                    .wrapping_add(1442695040888963407);
7795                s.push_str(&format!("{:016x}", x ^ (x >> 29)));
7796            }
7797            s
7798        };
7799        // Half the quads go to the named graph, and the two halves are kept
7800        // lexically apart (`a…` / `b…`) so each lands in its own dictionary
7801        // chunks — the real layout on a quads file whose graphs use different
7802        // IRI namespaces, e.g. `cordis.rete`. Interleaving them instead would
7803        // put a term of both graphs in every chunk, and then no per-graph
7804        // access pattern could avoid faulting the whole dictionary.
7805        let quads: Vec<(String, String, String, bool)> = (0..2000u32)
7806            .map(|i| {
7807                let named = i % 2 == 0;
7808                let ns = if named { 'a' } else { 'b' };
7809                (
7810                    format!("<http://ex/{ns}/s/{i:04}>"),
7811                    "<http://ex/abstract>".to_string(),
7812                    format!("\"{ns}{} number {i}\"", payload(i)),
7813                    named,
7814                )
7815            })
7816            .collect();
7817        let mut db = DictionaryBuilder::new();
7818        for (s, p, o, _) in &quads {
7819            db.observe(s, p, o);
7820        }
7821        let dict = db.build();
7822        let mut def = GraphIndexBuilder::new().with_tile_budget(256);
7823        let mut g1 = GraphIndexBuilder::new().with_tile_budget(256);
7824        for (s, p, o, named) in &quads {
7825            let t = dict.encode(s, p, o).unwrap();
7826            if *named {
7827                g1.push(t);
7828            } else {
7829                def.push(t);
7830            }
7831        }
7832        let named = vec![("http://ex/g1".to_string(), g1.build())];
7833        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
7834
7835        let eager = Rete::open(&bytes).unwrap();
7836        let dict_len = eager.header().dictionary_len;
7837
7838        // `probe` runs one closure against a COLD lazy open and reports the
7839        // bytes it faulted beyond the open itself.
7840        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7841        let probe = |f: &dyn Fn(&Rete) -> Vec<TermTriple>| -> (Vec<TermTriple>, u64) {
7842            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7843            let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
7844            let before = reader.bytes_read();
7845            let out = f(&lazy);
7846            assert!(!lazy.index_incomplete(), "a chunk fetch failed");
7847            (out, reader.bytes_read() - before)
7848        };
7849
7850        // 1. One bound subject in the named graph.
7851        let want = eager.query_in_graph(
7852            Some("http://ex/g1"),
7853            Some("<http://ex/a/s/0100>"),
7854            None,
7855            None,
7856        );
7857        assert_eq!(want.len(), 1, "fixture: expected exactly one match");
7858        let (got, pulled) = probe(&|r| {
7859            r.query_in_graph(
7860                Some("http://ex/g1"),
7861                Some("<http://ex/a/s/0100>"),
7862                None,
7863                None,
7864            )
7865        });
7866        assert_eq!(got, want, "lazy query_in_graph disagrees with eager");
7867        assert!(
7868            pulled * 4 < dict_len,
7869            "a 1-row query_in_graph faulted {pulled} B of a {dict_len} B dictionary — \
7870             it is prefetching the whole thing again"
7871        );
7872
7873        // 2. A graph with NOTHING to resolve must not fault the dictionary.
7874        //    Both shapes below used to pull every chunk before returning an
7875        //    empty answer, and `rete export` hits the second one first on a
7876        //    quads file whose default graph is empty — on `cordis.rete` that
7877        //    was 415 MB read and 2.5 GB of peak RSS to emit zero triples. What
7878        //    is left is the named-graph directory walk it takes to learn the
7879        //    graph is absent at all; that is index, not dictionary.
7880        let (empty, q_pulled) =
7881            probe(&|r| r.query_in_graph(Some("http://ex/nope"), None, None, None));
7882        assert!(empty.is_empty());
7883        let (_, d_pulled) = probe(&|r| {
7884            let mut n = 0usize;
7885            r.dump_each(Some("http://ex/nope"), |_, _, _| n += 1);
7886            assert_eq!(n, 0, "an absent graph yielded triples");
7887            Vec::new()
7888        });
7889        assert_eq!(
7890            q_pulled, d_pulled,
7891            "query_in_graph and dump_each disagree on what an absent graph costs"
7892        );
7893        assert!(
7894            q_pulled * 4 < dict_len,
7895            "an absent graph faulted {q_pulled} B of a {dict_len} B dictionary"
7896        );
7897
7898        // 3. Dumping ONE named graph costs that graph, not the whole file: the
7899        //    default graph's half of the dictionary must stay unfaulted.
7900        let want: Vec<TermTriple> = eager.dump(Some("http://ex/g1"));
7901        assert_eq!(want.len(), 1000);
7902        let (got, pulled) = probe(&|r| {
7903            let mut out = Vec::new();
7904            r.dump_each(Some("http://ex/g1"), |s, p, o| {
7905                out.push((s.to_string(), p.to_string(), o.to_string()))
7906            });
7907            out
7908        });
7909        assert_eq!(got, want, "lazy dump_each disagrees with eager dump");
7910        assert!(
7911            pulled * 3 < dict_len * 2,
7912            "dumping half the graph faulted {pulled} B of a {dict_len} B dictionary — \
7913             the other graph's chunks are being pulled in too"
7914        );
7915    }
7916
7917    /// Build a multi-chunk single-graph ranged file image for the eviction
7918    /// tests: 4000 distinct ~70-byte object literals overflow the 64 KiB
7919    /// dictionary chunk budget into several chunks, and one multi-KiB literal
7920    /// forces a large run/chunk boundary — the shape a tiny budget must survive.
7921    #[cfg(test)]
7922    fn build_multichunk_image() -> Vec<u8> {
7923        use crate::index::PermSet;
7924        let mut db = DictionaryBuilder::new();
7925        let mut triples: Vec<(String, String, String)> = (0..4000u32)
7926            .map(|i| {
7927                (
7928                    format!("<http://ex/s/{i:07}>"),
7929                    format!("<http://ex/p/{}>", i % 8),
7930                    format!("\"object literal number {i:07} with padding text to widen the term\""),
7931                )
7932            })
7933            .collect();
7934        triples.push((
7935            "<http://ex/s/big>".to_string(),
7936            "<http://ex/p/big>".to_string(),
7937            format!("\"{}\"", "Z".repeat(8000)),
7938        ));
7939        for (s, p, o) in &triples {
7940            db.observe(s, p, o);
7941        }
7942        let dict = db.build();
7943        let mut idx = GraphIndexBuilder::new()
7944            .with_tile_budget(64)
7945            .with_perms(PermSet::ALL);
7946        for (s, p, o) in &triples {
7947            idx.push(dict.encode(s, p, o).unwrap());
7948        }
7949        write_dataset(&dict, &idx.build(), &[], true, &[], 0)
7950    }
7951
7952    /// **Bounded-export phase 2: eviction is byte-transparent, and progress is
7953    /// guaranteed at any cap.** A cap far below the decompressed dictionary
7954    /// forces the chunk cache to evict during a full dump, yet the dump is
7955    /// byte-for-byte the unlimited-cap dump — the `Arc` handout keeps every
7956    /// chunk alive across its one decode, so eviction never drops bytes in use.
7957    /// A 1-byte cap (smaller than any single chunk, so every insert evicts
7958    /// itself immediately) still completes with identical output: it is the
7959    /// handout invariant, not residency, that makes a window make progress.
7960    #[test]
7961    fn tiny_dict_cache_evicts_yet_dump_is_byte_identical() {
7962        use crate::reader::SliceReader;
7963        let leaked: &'static [u8] = Box::leak(build_multichunk_image().into_boxed_slice());
7964        let dump = |cap: u64| -> (
7965            Vec<(String, String, String)>,
7966            crate::chunk_cache::CacheStats,
7967        ) {
7968            let rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
7969            rete.dict.set_cache_cap(cap);
7970            let mut out = Vec::new();
7971            rete.dump_filtered_each(None, None, None, None, |s, p, o| {
7972                out.push((s.to_string(), p.to_string(), o.to_string()))
7973            });
7974            assert!(
7975                !rete.index_incomplete(),
7976                "no lazy fault should fail at cap {cap}"
7977            );
7978            let stats = rete.dict_cache_stats();
7979            (out, stats)
7980        };
7981
7982        let (unlimited, us) = dump(u64::MAX);
7983        assert_eq!(unlimited.len(), 4001, "every triple dumped");
7984        assert_eq!(us.evictions, 0, "unlimited cap evicts nothing");
7985
7986        // A cap far below the decompressed dictionary: eviction happens, output
7987        // unchanged.
7988        let (tiny, ts) = dump(128 * 1024);
7989        assert_eq!(tiny, unlimited, "128 KiB-cap dump differs from unlimited");
7990        assert!(ts.evictions > 0, "a tiny cap must evict (stats {ts:?})");
7991
7992        // Progress guarantee: a 1-byte cap still completes, byte-identical.
7993        let (starved, ss) = dump(1);
7994        assert_eq!(starved, unlimited, "1-byte-cap dump differs from unlimited");
7995        assert!(
7996            ss.evictions > 0,
7997            "1-byte cap evicts every insert (stats {ss:?})"
7998        );
7999    }
8000
8001    /// **The budget pays for the open's fixed directories.** A ranged open holds
8002    /// one descriptor per dictionary chunk and per index tile for its whole
8003    /// life, outside every cache — 88 MB on the 14.75 GiB openaire-relation,
8004    /// 224 MB on epfl-infoscience. `set_memory_budget` used to give the caches
8005    /// the whole budget on top of that; it now measures the directories
8006    /// ([`Rete::fixed_overhead`]) and takes them out of the dictionary share, so
8007    /// caps + directories stay within the total. This checks the arithmetic on
8008    /// a many-chunk file, the 64 MiB floor, the unlimited pass-through, and that
8009    /// a dump under the capped budget keeps the dictionary cache within its
8010    /// (net) share.
8011    #[test]
8012    fn memory_budget_charges_the_fixed_directories() {
8013        use crate::reader::SliceReader;
8014        let leaked: &'static [u8] = Box::leak(build_multichunk_image().into_boxed_slice());
8015        let rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
8016
8017        let fixed = rete.fixed_overhead();
8018        // Every chunk and every tile has a descriptor; a multi-chunk dictionary
8019        // and a 64-triple tile budget make both directories non-trivial.
8020        assert!(
8021            fixed.dict_directory > 0 && fixed.index_directory > 0,
8022            "{fixed:?}"
8023        );
8024        assert_eq!(fixed.named_graphs, 0, "no named graphs in this file");
8025        assert_eq!(
8026            fixed.total(),
8027            fixed.dict_directory + fixed.index_directory + fixed.named_graphs
8028        );
8029
8030        // A budget well above the floor: the dictionary share is the plain
8031        // split less the directories, exactly.
8032        let total: u64 = 512 << 20;
8033        let applied = rete.set_memory_budget(Some(total));
8034        let plain = split_memory_budget(total);
8035        assert_eq!(applied.dict_cache, plain.dict_cache - fixed.total());
8036        assert_eq!(rete.dict_cache_cap(), applied.dict_cache);
8037        assert_eq!(applied.tile_cache, plain.tile_cache);
8038        assert_eq!(applied.block_cache, plain.block_cache);
8039        assert!(
8040            applied.dict_cache + applied.tile_cache + applied.block_cache + fixed.total() <= total,
8041            "caps + fixed directories exceed the budget: {applied:?} + {fixed:?} > {total}"
8042        );
8043
8044        // A budget whose share would fall under the floor keeps the floor.
8045        let applied = rete.set_memory_budget(Some(1 << 20));
8046        assert_eq!(applied.dict_cache, MIN_DICT_CACHE_CAP);
8047
8048        // Unlimited stays unlimited: nothing is subtracted from u64::MAX.
8049        let applied = rete.set_memory_budget(None);
8050        assert_eq!(applied.dict_cache, u64::MAX);
8051        assert_eq!(rete.dict_cache_cap(), u64::MAX);
8052
8053        // Under a cap smaller than the decompressed dictionary, a full dump
8054        // keeps the cache within it.
8055        let rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
8056        rete.dict.set_cache_cap(128 * 1024);
8057        let mut rows = 0usize;
8058        rete.dump_filtered_each(None, None, None, None, |_, _, _| rows += 1);
8059        assert_eq!(rows, 4001);
8060        let s = rete.dict_cache_stats();
8061        assert!(s.evictions > 0, "the cap must bind: {s:?}");
8062        // One oversized chunk may sit alone above the cap (the handout
8063        // invariant); otherwise residency is within it.
8064        assert!(
8065            s.entries <= 1 || s.resident_bytes <= 128 * 1024,
8066            "dictionary cache over its cap after the dump: {s:?}"
8067        );
8068    }
8069
8070    /// **Bounded-export phase 2: per-graph index release.** After a named
8071    /// graph's slot is exported, [`Rete::release_named_graph`] drops its decoded
8072    /// index (and its tiles) so a many-graph dump holds one graph's index at a
8073    /// time, not all of them. The graph re-opens lazily if touched again.
8074    #[test]
8075    fn releasing_a_named_graph_drops_and_reopens_its_index() {
8076        use crate::index::PermSet;
8077        use crate::reader::SliceReader;
8078        let mut db = DictionaryBuilder::new();
8079        let all: Vec<(String, String, String)> = (0..40u32)
8080            .map(|i| {
8081                (
8082                    format!("<http://ex/s{}>", i % 7),
8083                    format!("<http://ex/p{}>", i % 3),
8084                    format!("<http://ex/o{}>", i % 5),
8085                )
8086            })
8087            .collect();
8088        for (s, p, o) in &all {
8089            db.observe(s, p, o);
8090        }
8091        let dict = db.build();
8092        let mk = |take: fn(u32) -> bool| {
8093            let mut g = GraphIndexBuilder::new()
8094                .with_tile_budget(16)
8095                .with_perms(PermSet::ALL);
8096            for (i, (s, p, o)) in all.iter().enumerate() {
8097                if take(i as u32) {
8098                    g.push(dict.encode(s, p, o).unwrap());
8099                }
8100            }
8101            g.build()
8102        };
8103        let def = mk(|_| true);
8104        let g1 = mk(|i| i % 2 == 0);
8105        let g2 = mk(|i| i % 3 == 0);
8106        let image = write_dataset(
8107            &dict,
8108            &def,
8109            &[
8110                ("<http://ex/g1>".to_string(), g1),
8111                ("<http://ex/g2>".to_string(), g2),
8112            ],
8113            true,
8114            &[],
8115            0,
8116        );
8117        let leaked: &'static [u8] = Box::leak(image.into_boxed_slice());
8118        let mut rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
8119
8120        let opened = |r: &Rete| -> usize {
8121            match &r.named_graphs {
8122                NamedGraphsSlot::Lazy(l) => {
8123                    let mut n = 0;
8124                    l.for_each_opened(|_| n += 1);
8125                    n
8126                }
8127                _ => 0,
8128            }
8129        };
8130
8131        assert_eq!(opened(&rete), 0, "nothing opened before first touch");
8132        // Touching g1 opens its index.
8133        assert!(rete.graph_index("<http://ex/g1>").is_some());
8134        assert_eq!(opened(&rete), 1, "g1 opened");
8135        // Release it: the index is dropped.
8136        rete.release_named_graph("<http://ex/g1>");
8137        assert_eq!(opened(&rete), 0, "g1 released");
8138        // Releasing an unknown / already-released graph is a no-op.
8139        rete.release_named_graph("<http://ex/absent>");
8140        rete.release_named_graph("<http://ex/g1>");
8141        assert_eq!(opened(&rete), 0);
8142        // The graph re-opens lazily on the next access.
8143        assert!(rete.graph_index("<http://ex/g1>").is_some());
8144        assert_eq!(opened(&rete), 1, "g1 re-opened after release");
8145    }
8146
8147    /// **The filtered-dump correctness invariant** (#117 point 1): for every
8148    /// graph and every one of the eight bound/unbound shapes,
8149    /// `dump_filtered_each` must return *exactly* the quads `dump_each` returns,
8150    /// filtered — no more, and above all no fewer.
8151    ///
8152    /// "No fewer" is the whole risk. The speedup comes from *not fetching* tiles
8153    /// a synopsis says cannot match, and a pruning bug does not crash, it
8154    /// silently returns a shorter answer. So the oracle here is the unfiltered
8155    /// dump with a plain Rust `retain` over it — the thing the filter is meant
8156    /// to be a cheaper spelling of — and it is checked on every open path, since
8157    /// the eager path prunes with a different code path than the lazy one.
8158    ///
8159    /// It runs on a `PermSet::CORE` (`--permutations 3`) file as well as a
8160    /// six-permutation one. Every shape routes inside CORE — `perm_routing_never_leaves_core`
8161    /// proves the routing table does — so the two must agree row for row, and if
8162    /// a future shape ever routed outside CORE this test fails on the three-perm
8163    /// build rather than silently returning nothing from an absent section.
8164    #[test]
8165    fn filtered_dump_is_exactly_the_unfiltered_dump_filtered() {
8166        use crate::index::PermSet;
8167        use crate::reader::SliceReader;
8168
8169        let terms = |i: u32| {
8170            (
8171                format!("<http://ex/s{}>", i % 11),
8172                format!("<http://ex/p{}>", i % 3),
8173                // Mix IRIs and literals in the object column: a literal lives in
8174                // a different dictionary section than a node, and the object
8175                // filter has to resolve through the right one.
8176                if i.is_multiple_of(4) {
8177                    format!("\"lit {}\"@en", i % 5)
8178                } else {
8179                    format!("<http://ex/o{}>", i % 7)
8180                },
8181            )
8182        };
8183        let build = |perms: PermSet| {
8184            let mut db = DictionaryBuilder::new();
8185            let all: Vec<_> = (0..90u32).map(terms).collect();
8186            for (s, p, o) in &all {
8187                db.observe(s, p, o);
8188            }
8189            let dict = db.build();
8190            let mut def = GraphIndexBuilder::new()
8191                .with_tile_budget(16)
8192                .with_perms(perms);
8193            let mut g1 = GraphIndexBuilder::new()
8194                .with_tile_budget(16)
8195                .with_perms(perms);
8196            for (i, (s, p, o)) in all.iter().enumerate() {
8197                let t = dict.encode(s, p, o).unwrap();
8198                // Deliberately overlapping, not disjoint: every second triple is
8199                // in both graphs, so a filter that leaked across graphs would
8200                // still look plausible on counts alone.
8201                if i % 2 == 0 {
8202                    g1.push(t);
8203                }
8204                def.push(t);
8205            }
8206            write_dataset(
8207                &dict,
8208                &def.build(),
8209                &[("<http://ex/g1>".to_string(), g1.build())],
8210                true,
8211                &[],
8212                0,
8213            )
8214        };
8215
8216        let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>)> = vec![
8217            (None, None, None),
8218            (Some("<http://ex/s3>"), None, None),
8219            (None, Some("<http://ex/p1>"), None),
8220            (None, None, Some("<http://ex/o5>")),
8221            (None, None, Some("\"lit 0\"@en")),
8222            (Some("<http://ex/s3>"), Some("<http://ex/p0>"), None),
8223            (Some("<http://ex/s4>"), None, Some("<http://ex/o4>")),
8224            (None, Some("<http://ex/p1>"), Some("<http://ex/o5>")),
8225            (
8226                Some("<http://ex/s3>"),
8227                Some("<http://ex/p0>"),
8228                Some("<http://ex/o3>"),
8229            ),
8230            // Terms the dictionary has never seen, in each position: these must
8231            // return nothing, not everything (an unresolvable bound term must
8232            // not silently degrade to "unbound").
8233            (Some("<http://ex/nope>"), None, None),
8234            (None, Some("<http://ex/nope>"), None),
8235            (None, None, Some("<http://ex/nope>")),
8236        ];
8237
8238        let mut any_pruned = false;
8239        for perms in [PermSet::ALL, PermSet::CORE] {
8240            let image = build(perms);
8241            let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
8242            let opens: Vec<(&str, Rete)> = vec![
8243                ("resident", Rete::open(&image).unwrap()),
8244                (
8245                    "ranged",
8246                    Rete::open_ranged(&SliceReader::new(leaked)).unwrap(),
8247                ),
8248                (
8249                    "lazy",
8250                    Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap(),
8251                ),
8252            ];
8253            for graph in [None, Some("<http://ex/g1>"), Some("<http://ex/absent>")] {
8254                // The oracle: the unfiltered dump, filtered in Rust.
8255                let full: Vec<TermTriple> = {
8256                    let mut v = Vec::new();
8257                    Rete::open(&image).unwrap().dump_each(graph, |s, p, o| {
8258                        v.push((s.to_string(), p.to_string(), o.to_string()))
8259                    });
8260                    v.sort();
8261                    v
8262                };
8263                for &(s, p, o) in &shapes {
8264                    let mut want = full.clone();
8265                    want.retain(|(ts, tp, to)| {
8266                        s.is_none_or(|x| x == ts)
8267                            && p.is_none_or(|x| x == tp)
8268                            && o.is_none_or(|x| x == to)
8269                    });
8270                    for (path, rete) in &opens {
8271                        let mut got = Vec::new();
8272                        rete.dump_filtered_each(graph, s, p, o, |s, p, o| {
8273                            got.push((s.to_string(), p.to_string(), o.to_string()))
8274                        });
8275                        got.sort();
8276                        assert_eq!(
8277                            got,
8278                            want,
8279                            "{path}/{perms:?} graph={graph:?} pattern={:?} — filtered dump \
8280                             is not the unfiltered dump filtered",
8281                            (s, p, o)
8282                        );
8283                        assert!(!rete.index_incomplete(), "{path}: a fetch failed");
8284                    }
8285                    // …and the plan must agree with what the dump actually did:
8286                    // an empty answer means either no scan at all, or a scan
8287                    // whose admitted tiles simply held nothing.
8288                    let plan = opens[2].1.dump_plan(graph, s, p, o);
8289                    if graph == Some("<http://ex/absent>")
8290                        || [s, p, o].contains(&Some("<http://ex/nope>"))
8291                    {
8292                        assert!(
8293                            plan.scan.is_none(),
8294                            "an unmatchable dump still planned a scan: {:?}",
8295                            (graph, s, p, o)
8296                        );
8297                    } else {
8298                        let scan = plan.scan.expect("a matchable dump plans a scan");
8299                        assert!(
8300                            PermSet::CORE.contains(scan.permutation),
8301                            "a dump routed to {:?}, outside PermSet::CORE — a \
8302                             --permutations 3 file has no such section",
8303                            scan.permutation
8304                        );
8305                        assert!(scan.tiles_admitted <= scan.tiles_routed);
8306                        assert!(scan.tiles_routed <= scan.tiles_total);
8307                        assert!(scan.tile_bytes <= scan.section_bytes);
8308                        if scan.tiles_admitted < scan.tiles_total {
8309                            any_pruned = true;
8310                        }
8311                    }
8312                }
8313            }
8314        }
8315        assert!(
8316            any_pruned,
8317            "no shape pruned a single tile — the fixture is too small to be \
8318             measuring anything"
8319        );
8320    }
8321
8322    /// The point of the filter: a predicate-scoped dump of a lazily range-read
8323    /// file must **fetch less** than the same graph's full dump, not merely
8324    /// return less.
8325    ///
8326    /// The fixture is the shape the win shows on — many tiles, and a predicate
8327    /// whose triples live in a minority of them — so the assertion can be about
8328    /// bytes rather than about the code path being taken on faith. Measured on
8329    /// the real thing (`cordis.rete`, 801 MB): one predicate of one named graph
8330    /// read 16.0 MB where the graph's full dump read 375.8 MB.
8331    #[test]
8332    fn a_predicate_scoped_dump_fetches_less_than_the_graph() {
8333        use crate::reader::{CountingReader, SliceReader};
8334
8335        // 200 subjects × 20 predicates, tiled small so there are many tiles, and
8336        // one distinct object per cell so that a predicate slice needs a
8337        // twentieth of the object dictionary rather than all of it — otherwise
8338        // the dictionary, which this change does not prune, swamps the index
8339        // saving the test is about.
8340        let mut db = DictionaryBuilder::new();
8341        let mut all = Vec::new();
8342        for s in 0..200u32 {
8343            for p in 0..20u32 {
8344                let t = (
8345                    format!("<http://ex/s/{s:04}>"),
8346                    format!("<http://ex/p/{p:02}>"),
8347                    format!("\"object {s:04}/{p:02}\""),
8348                );
8349                db.observe(&t.0, &t.1, &t.2);
8350                all.push(t);
8351            }
8352        }
8353        let dict = db.build();
8354        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
8355        for (s, p, o) in &all {
8356            ib.push(dict.encode(s, p, o).unwrap());
8357        }
8358        let image = write_dataset(&dict, &ib.build(), &[], false, &[], 0);
8359        let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
8360
8361        // Bytes faulted past the open, for one dump.
8362        let probe = |s: Option<&str>, p: Option<&str>, o: Option<&str>| -> (usize, u64) {
8363            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8364            let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
8365            let before = reader.bytes_read();
8366            let mut n = 0usize;
8367            lazy.dump_filtered_each(None, s, p, o, |_, _, _| n += 1);
8368            assert!(!lazy.index_incomplete());
8369            (n, reader.bytes_read() - before)
8370        };
8371
8372        let (full_rows, full_bytes) = probe(None, None, None);
8373        let (pred_rows, pred_bytes) = probe(None, Some("<http://ex/p/07>"), None);
8374        let (subj_rows, subj_bytes) = probe(Some("<http://ex/s/0021>"), None, None);
8375        assert_eq!(full_rows, 4000);
8376        assert_eq!(pred_rows, 200, "one predicate covers every subject once");
8377        assert_eq!(subj_rows, 20);
8378        // True in any build: a slice fetches strictly less than the graph.
8379        assert!(
8380            pred_bytes < full_bytes && subj_bytes < full_bytes,
8381            "a slice fetched {pred_bytes} / {subj_bytes} B where the whole graph fetched \
8382             {full_bytes} B — the scan is not being pruned at all"
8383        );
8384        // The SIZE of the win is a property of the FILE, not of this change: it is
8385        // the index share of what a dump reads, and the dictionary — which no
8386        // filter prunes — is the rest. With the default `compression` feature the
8387        // dictionary chunk bodies are zstd-compressed and the slice costs about a
8388        // tenth of the graph (measured here: 1,565 B for the predicate and 864 B
8389        // for the subject, against 15,714 B). Built `--no-default-features` those
8390        // same chunks are stored RAW, so resolving 200 rows' terms dominates and
8391        // the identical, identically-pruned scan costs 22,225 B of 40,165 — 55%.
8392        // Asserting the strong ratio unconditionally would be asserting that zstd
8393        // is enabled; the index-side prune is checked below and holds either way.
8394        #[cfg(feature = "compression")]
8395        assert!(
8396            pred_bytes * 4 < full_bytes && subj_bytes * 4 < full_bytes,
8397            "a 1-in-20 predicate slice fetched {pred_bytes} B and one subject \
8398             {subj_bytes} B where the whole graph fetched {full_bytes} B"
8399        );
8400
8401        // And the plan predicted the index side of it without fetching a tile.
8402        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8403        let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
8404        let before = reader.bytes_read();
8405        let plan = lazy
8406            .dump_plan(None, None, Some("<http://ex/p/07>"), None)
8407            .scan
8408            .expect("a known predicate plans a scan");
8409        assert!(
8410            reader.bytes_read() - before < 64 * 1024,
8411            "planning fetched {} B — it is supposed to read directories, not tiles",
8412            reader.bytes_read() - before
8413        );
8414        assert!(plan.tiles_admitted < plan.tiles_total);
8415        // The index side on its own, with the dictionary's share taken out: one
8416        // predicate of twenty routes to a handful of POS tiles. (Measured here:
8417        // the whole dump faults 15,714 B, the predicate slice 1,565 B and one
8418        // subject 864 B — a 10x and an 18x, most of what is left being the
8419        // dictionary this change does not prune.)
8420        assert!(
8421            plan.tile_bytes * 5 < plan.section_bytes,
8422            "one predicate of twenty planned {} B of a {} B section",
8423            plan.tile_bytes,
8424            plan.section_bytes
8425        );
8426    }
8427
8428    #[test]
8429    fn query_quads_tags_every_graph() {
8430        let mut db = DictionaryBuilder::new();
8431        for (s, p, o) in [("Alice", "knows", "Bob"), ("Alice", "knows", "Dave")] {
8432            db.observe(s, p, o);
8433        }
8434        let dict = db.build();
8435        let mut def = GraphIndexBuilder::new();
8436        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
8437        let mut g1 = GraphIndexBuilder::new();
8438        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
8439        let named = vec![("http://ex/g1".to_string(), g1.build())];
8440        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8441        let rete = Rete::open(&bytes).unwrap();
8442
8443        // `Alice knows ?` spans both graphs; each match carries its graph tag.
8444        let quads = rete.query_quads(Some("Alice"), Some("knows"), None);
8445        assert_eq!(quads.len(), 2);
8446        assert_eq!(
8447            quads[0],
8448            (("Alice".into(), "knows".into(), "Bob".into()), None)
8449        );
8450        assert_eq!(
8451            quads[1],
8452            (
8453                ("Alice".into(), "knows".into(), "Dave".into()),
8454                Some("http://ex/g1".to_string())
8455            )
8456        );
8457
8458        // A bound term absent from the dictionary yields nothing, in any graph.
8459        assert!(rete.query_quads(Some("Nobody"), None, None).is_empty());
8460    }
8461
8462    #[test]
8463    fn pyramid_meta_round_trips_in_file() {
8464        let rete = Rete::open(&build_image()).unwrap();
8465        let pyr = rete.pyramid().expect("file has a pyramid");
8466        // Summary covers all 3 triples by count; tiles are not stored in v0.
8467        let total: u32 = pyr.summary.iter().map(|e| e.count).sum();
8468        assert_eq!(total, 3);
8469        assert!(!pyr.summary.is_empty());
8470        assert!(pyr.tiles.is_empty());
8471    }
8472
8473    #[test]
8474    fn schema_pyramid_round_trips_through_file_index_free() {
8475        use crate::reader::{CountingReader, SliceReader};
8476        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
8477        let q = |s: &str, p: &str, o: &str| {
8478            (s.to_string(), p.to_string(), o.to_string(), None::<String>)
8479        };
8480        // Astronomer ⊑ Scientist ⊑ Person ⊑ Agent, instances at the leaves.
8481        let quads = vec![
8482            q("<a>", RDF_TYPE, "<Astronomer>"),
8483            q("<b>", RDF_TYPE, "<Astronomer>"),
8484            q("<c>", RDF_TYPE, "<Person>"),
8485            q("<Astronomer>", sub, "<Scientist>"),
8486            q("<Scientist>", sub, "<Person>"),
8487            q("<Person>", sub, "<Agent>"),
8488            q("<a>", "<knows>", "<b>"),
8489            q("<b>", "<knows>", "<c>"),
8490        ];
8491        let (bytes, _) =
8492            crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
8493
8494        // The v2 schema pyramid round-trips through the built file.
8495        let rete = Rete::open(&bytes).unwrap();
8496        let pyr = rete.pyramid().expect("pyramid present");
8497        assert!(!pyr.level_rollups.is_empty(), "schema pyramid shipped");
8498        assert!(pyr
8499            .class_hierarchy
8500            .iter()
8501            .any(|n| n.class == "<Agent>" && n.depth == 0));
8502
8503        // It reads index-free: a SummaryView open never touches the index section.
8504        let r = CountingReader::new(SliceReader::new(&bytes));
8505        let view = SummaryView::open_ranged(&r).unwrap().unwrap();
8506        assert!(view.level_count() >= 2, "multi-level pyramid");
8507        let coarse = view.level_rollup(0).unwrap();
8508        assert!(
8509            coarse.classes.iter().any(|(c, _)| c == "<Agent>"),
8510            "coarsest level rolls up to the root Agent"
8511        );
8512        let h = Header::from_bytes(&bytes).unwrap();
8513        assert!(
8514            r.bytes_read() <= bytes.len() as u64 - h.root_dir_len,
8515            "summary read {} bytes; the {}-byte index section must be skipped",
8516            r.bytes_read(),
8517            h.root_dir_len
8518        );
8519    }
8520
8521    #[test]
8522    fn predicate_totals_from_summary_only() {
8523        use crate::reader::SliceReader;
8524        // build_image: 2 `knows` triples + 1 `age` triple.
8525        let bytes = build_image();
8526        let reader = SliceReader::new(&bytes);
8527        let view = SummaryView::open_ranged(&reader).unwrap().unwrap();
8528        assert_eq!(view.predicate_total("knows"), 2);
8529        assert_eq!(view.predicate_total("age"), 1);
8530        assert_eq!(view.predicate_total("missing"), 0);
8531        let totals = view.predicate_totals();
8532        assert_eq!(totals[0], ("knows".to_string(), 2)); // sorted by count desc
8533    }
8534
8535    #[test]
8536    fn query_patterns_resolve_to_terms() {
8537        let rete = Rete::open(&build_image()).unwrap();
8538
8539        // All triples.
8540        assert_eq!(rete.query(None, None, None).len(), 3);
8541
8542        // Subject bound.
8543        let mut alice = rete.query(Some("Alice"), None, None);
8544        alice.sort();
8545        assert_eq!(
8546            alice,
8547            vec![
8548                ("Alice".into(), "age".into(), "30".into()),
8549                ("Alice".into(), "knows".into(), "Bob".into()),
8550            ]
8551        );
8552
8553        // Predicate bound.
8554        assert_eq!(rete.query(None, Some("knows"), None).len(), 2);
8555
8556        // Full triple, present and absent.
8557        assert_eq!(
8558            rete.query(Some("Bob"), Some("knows"), Some("Carol")),
8559            vec![("Bob".into(), "knows".into(), "Carol".into())]
8560        );
8561        assert!(rete.query(Some("Nobody"), None, None).is_empty());
8562        assert!(rete.query(None, Some("likes"), None).is_empty());
8563    }
8564
8565    #[test]
8566    fn query_provenance_reports_terms_ids_sections_and_index_choice() {
8567        let bytes = build_image();
8568        let rete = Rete::open(&bytes).unwrap();
8569
8570        let mut matches = rete.query_with_provenance(None, Some("knows"), None);
8571        matches.sort_by(|a, b| a.terms.cmp(&b.terms));
8572
8573        assert_eq!(matches.len(), 2);
8574        assert_eq!(
8575            matches[0].terms,
8576            ("Alice".into(), "knows".into(), "Bob".into())
8577        );
8578        assert_eq!(
8579            matches[0].ids,
8580            rete.dictionary().encode("Alice", "knows", "Bob").unwrap()
8581        );
8582        assert_eq!(matches[0].graph.as_deref(), None);
8583        assert_eq!(
8584            matches[0].matched_pattern,
8585            (None, Some(matches[0].ids.1), None)
8586        );
8587        assert_eq!(
8588            matches[0].index_permutation,
8589            crate::index::IndexPermutation::Pos
8590        );
8591
8592        let h = rete.header();
8593        assert_eq!(matches[0].dictionary_range.offset, h.dictionary_offset);
8594        assert_eq!(matches[0].dictionary_range.len, h.dictionary_len);
8595        assert_eq!(matches[0].index_range.offset, h.root_dir_offset);
8596        assert_eq!(matches[0].index_range.len, h.root_dir_len);
8597        assert!(
8598            matches[0].index_section_range.offset > h.root_dir_offset,
8599            "POS is section 1, so its payload starts after the container header and SPO payload"
8600        );
8601        assert!(matches[0].index_section_range.len > 0);
8602        assert!(matches[0].index_section_range.end() <= matches[0].index_range.end());
8603        assert!(matches[0].index_section_range.len < matches[0].index_range.len);
8604        assert_eq!(
8605            matches[0].pyramid_range.as_ref().map(|r| (r.offset, r.len)),
8606            Some((h.pyramid_meta_offset, h.pyramid_meta_len))
8607        );
8608        // Tiled (v0.2) files report the physical tile holding the match; its
8609        // compressed byte range nests inside the selected section payload.
8610        let tile_range = matches[0].tile_range.expect("tiled file reports a tile");
8611        assert!(matches[0]
8612            .tile
8613            .as_deref()
8614            .unwrap()
8615            .starts_with(matches[0].index_permutation.name()));
8616        assert!(matches[0].index_section_range.offset <= tile_range.offset);
8617        assert!(tile_range.end() <= matches[0].index_section_range.end());
8618    }
8619
8620    /// Build an in-memory `.rete` with `n` labeled subjects: each carries an
8621    /// `rdfs:label` literal drawn from `WORDS` (a word prefix selects ~1/|WORDS|
8622    /// of them) plus one extra edge so the subject has a degree to rank by.
8623    fn build_labeled(n: usize) -> Vec<u8> {
8624        const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
8625        const WORDS: &[&str] = &[
8626            "alanine",
8627            "benzene",
8628            "glucose",
8629            "dextrose",
8630            "ethanol",
8631            "formate",
8632            "heptane",
8633            "isoleucine",
8634        ];
8635        let triples: Vec<(String, String, String)> = (0..n)
8636            .flat_map(|i| {
8637                let s = format!("<http://ex/e{i}>");
8638                let w = WORDS[i % WORDS.len()];
8639                [
8640                    (s.clone(), LABEL.to_string(), format!("\"{w}-{i:06}\"")),
8641                    (
8642                        s,
8643                        "<http://ex/p>".to_string(),
8644                        format!("<http://ex/c{}>", i % 64),
8645                    ),
8646                ]
8647            })
8648            .collect();
8649        let mut db = DictionaryBuilder::new();
8650        for (s, p, o) in &triples {
8651            db.observe(s, p, o);
8652        }
8653        let dict = db.build();
8654        let ids: Vec<(u32, u32, u32)> = triples
8655            .iter()
8656            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
8657            .collect();
8658        let mut ib = GraphIndexBuilder::new();
8659        for &t in &ids {
8660            ib.push(t);
8661        }
8662        let (meta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
8663        write_file(&dict, &ib.build(), false, &meta, levels)
8664    }
8665
8666    #[test]
8667    fn prefix_search_matches_a_filter_scan() {
8668        // 800 < the 8192 label-index cap, so the index is COMPLETE — every label
8669        // is present and the two paths must return the exact same subject set.
8670        let bytes = build_labeled(800);
8671        let rete = Rete::open(&bytes).unwrap();
8672        let idx_subjects: std::collections::BTreeSet<String> = rete
8673            .prefix_search("glucose", 10_000)
8674            .into_iter()
8675            .map(|(_label, subject)| subject)
8676            .collect();
8677        // The same selection via a SPARQL FILTER scan over every label literal.
8678        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
8679                 FILTER(STRSTARTS(LCASE(?l), \"glucose\")) }";
8680        let crate::QueryOutput::Select(_, rows) = crate::eval_query(&rete, q).unwrap() else {
8681            panic!("expected SELECT");
8682        };
8683        let scan_subjects: std::collections::BTreeSet<String> =
8684            rows.iter().map(|r| r.get("s").cloned().unwrap()).collect();
8685        assert_eq!(idx_subjects, scan_subjects, "index agrees with the scan");
8686        assert_eq!(
8687            idx_subjects.len(),
8688            100,
8689            "800/8 words = 100 glucose-* labels"
8690        );
8691    }
8692
8693    /// Latency: the binary-search label index vs the FILTER scan it replaces.
8694    /// Ignored by default (timing-sensitive); run with
8695    /// `cargo test -p rete-core -- --ignored --nocapture bench_prefix_search`.
8696    #[test]
8697    #[ignore]
8698    fn bench_prefix_search_vs_filter_scan() {
8699        use std::time::Instant;
8700        let n = 6000; // < the 8192 cap, so both paths return identical sets
8701        let bytes = build_labeled(n);
8702        let rete = Rete::open(&bytes).unwrap();
8703        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
8704                 FILTER(STRSTARTS(LCASE(?l), \"glucose\")) }";
8705        let reps = 200;
8706        let idx_n = rete.prefix_search("glucose", 100_000).len();
8707        let t = Instant::now();
8708        for _ in 0..reps {
8709            std::hint::black_box(rete.prefix_search("glucose", 100_000));
8710        }
8711        let idx_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
8712        let t = Instant::now();
8713        for _ in 0..reps {
8714            let _ = std::hint::black_box(crate::eval_query(&rete, q).unwrap());
8715        }
8716        let scan_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
8717        println!(
8718            "label prefix search over {n} labeled subjects ({idx_n} matches): \
8719             index {idx_ms:.4} ms vs FILTER scan {scan_ms:.3} ms ({:.0}× faster)",
8720            scan_ms / idx_ms
8721        );
8722    }
8723
8724    /// Latency: the TEXT_INDEX word search vs the `FILTER(CONTAINS(?l, …))` scan
8725    /// it replaces. Ignored by default (timing-sensitive); run with
8726    /// `cargo test -p rete-core -- --ignored --nocapture bench_text_search`.
8727    #[test]
8728    #[ignore]
8729    fn bench_text_search_vs_contains_scan() {
8730        use std::time::Instant;
8731        const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
8732        const WORDS: &[&str] = &[
8733            "alanine",
8734            "benzene",
8735            "glucose",
8736            "dextrose",
8737            "ethanol",
8738            "formate",
8739            "heptane",
8740            "isoleucine",
8741        ];
8742        let n = 6000;
8743        let triples: Vec<(String, String, String)> = (0..n)
8744            .map(|i| {
8745                (
8746                    format!("<http://ex/e{i}>"),
8747                    LABEL.to_string(),
8748                    format!("\"{} sample number {i:06}\"", WORDS[i % WORDS.len()]),
8749                )
8750            })
8751            .collect();
8752        let mut db = DictionaryBuilder::new();
8753        for (s, p, o) in &triples {
8754            db.observe(s, p, o);
8755        }
8756        let dict = db.build();
8757        let ids: Vec<(u32, u32, u32)> = triples
8758            .iter()
8759            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
8760            .collect();
8761        let mut ib = GraphIndexBuilder::new();
8762        for &t in &ids {
8763            ib.push(t);
8764        }
8765        let ti = compute_text_index(&dict, &ids);
8766        let bytes = write_dataset_with_metadata(&dict, &ib.build(), &[], false, &[], 0, &[], &ti);
8767        let rete = Rete::open(&bytes).unwrap();
8768
8769        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
8770                 FILTER(CONTAINS(LCASE(?l), \"glucose\")) }";
8771        let reps = 200;
8772        let idx_n = rete.text_search(&["glucose"], None, 100_000).len();
8773        let t = Instant::now();
8774        for _ in 0..reps {
8775            std::hint::black_box(rete.text_search(&["glucose"], None, 100_000));
8776        }
8777        let idx_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
8778        let t = Instant::now();
8779        for _ in 0..reps {
8780            let _ = std::hint::black_box(crate::eval_query(&rete, q).unwrap());
8781        }
8782        let scan_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
8783        println!(
8784            "text search over {n} literals ({idx_n} matches): \
8785             index {idx_ms:.4} ms vs FILTER(CONTAINS) scan {scan_ms:.3} ms ({:.0}× faster)",
8786            scan_ms / idx_ms
8787        );
8788    }
8789
8790    /// Operational debugging harness for a REAL on-disk file (ignored; driven
8791    /// by env vars): step-by-step dump of a bound (p, o) POS routing.
8792    ///   RETE_DEBUG_FILE=<path.rete> RETE_DEBUG_P=<iri> RETE_DEBUG_O=<iri>
8793    #[test]
8794    #[ignore = "operational tool, driven by RETE_DEBUG_* env vars"]
8795    fn debug_bound_po_routing() {
8796        struct FR(std::fs::File);
8797        impl crate::RangeReader for FR {
8798            fn len(&self) -> u64 {
8799                self.0.metadata().map(|m| m.len()).unwrap_or(0)
8800            }
8801            fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
8802                use std::os::unix::fs::FileExt;
8803                let mut buf = vec![0u8; len as usize];
8804                self.0.read_exact_at(&mut buf, offset)?;
8805                Ok(buf)
8806            }
8807        }
8808        let path = std::env::var("RETE_DEBUG_FILE").expect("RETE_DEBUG_FILE");
8809        let p_iri = std::env::var("RETE_DEBUG_P").expect("RETE_DEBUG_P");
8810        let o_iri = std::env::var("RETE_DEBUG_O").expect("RETE_DEBUG_O");
8811        let rete =
8812            Rete::open_ranged_lazy(std::sync::Arc::new(FR(std::fs::File::open(&path).unwrap())))
8813                .unwrap();
8814        let pid = rete.dict.predicate_id(&p_iri).expect("p resolves");
8815        let oid = rete.dict.object_id(&o_iri).expect("o resolves");
8816        eprintln!("pid={pid} oid={oid}");
8817        let pattern = (None, Some(pid), Some(oid));
8818        let perm = GraphIndex::best_permutation(pattern);
8819        eprintln!("best_permutation = {}", perm.name());
8820        let si = perm.section_index();
8821        let tiles = &rete.index.sections[si];
8822        eprintln!("section {} tiles = {}", perm.name(), tiles.len());
8823        let [pa, pb, pc] = perm.order_pattern(pattern);
8824        eprintln!("permuted pattern pa={pa:?} pb={pb:?} pc={pc:?}");
8825        let (start, end) = rete.index.tile_span(si, pa);
8826        eprintln!("tile_span = [{start}, {end}) -> {} tiles", end - start);
8827        let mut admitted = 0usize;
8828        for (ti, t) in tiles.iter().enumerate().take(end).skip(start) {
8829            if t.syn_admits(pb, pc) {
8830                admitted += 1;
8831                if admitted <= 10 {
8832                    let (lo, hi) = t.leading_range();
8833                    eprintln!("  admit tile {ti}: a=[{lo},{hi}] syn={:?}", t.syn);
8834                }
8835            }
8836        }
8837        eprintln!("admitted {admitted} tile(s) by synopsis");
8838        let n = rete.index.scan_iter(pattern).count();
8839        eprintln!("scan_iter matches = {n}");
8840        let hi_res = rete.query(None, Some(&p_iri), Some(&o_iri));
8841        eprintln!("high-level query matches = {}", hi_res.len());
8842    }
8843
8844    /// The header segment's LABEL and the span it describes must agree. They
8845    /// did not from v0.3 (which grew the header to 1 KiB) until this test: the
8846    /// label still said "fixed 128 bytes", the pre-v0.3 size, while the segment
8847    /// covered `HEADER_LEN` = 1024 — and that label is exactly what the file
8848    /// explorer shows someone inspecting a real file. Reading the size back out
8849    /// of the string is the point: it fails if anyone retypes the number
8850    /// instead of deriving it from `HEADER_LEN`.
8851    #[test]
8852    fn layout_header_label_matches_its_span() {
8853        let mut db = DictionaryBuilder::new();
8854        let mut triples = Vec::new();
8855        for i in 0..24u32 {
8856            let (s, p, o) = (
8857                format!("<http://ex/s{}>", i % 5),
8858                format!("<http://ex/p{}>", i % 3),
8859                format!("<http://ex/o{i}>"),
8860            );
8861            db.observe(&s, &p, &o);
8862            triples.push((s, p, o));
8863        }
8864        let dict = db.build();
8865        let ids: Vec<(u32, u32, u32)> = triples
8866            .iter()
8867            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
8868            .collect();
8869        let index = GraphIndexBuilder::from_triples(ids).build();
8870        let image = write_dataset_with_metadata(
8871            &dict,
8872            &index,
8873            &[],
8874            false,
8875            &[],
8876            0,
8877            br#"{"name":"layout-label-fixture"}"#,
8878            &[],
8879        );
8880        let rete = Rete::open(&image).unwrap();
8881
8882        let layout = rete.file_layout();
8883        let header = layout
8884            .iter()
8885            .find(|s| s.kind == "header")
8886            .expect("every layout starts with a header segment");
8887
8888        assert_eq!(
8889            header.offset, 0,
8890            "the header is the first thing in the file"
8891        );
8892        assert_eq!(
8893            header.len, HEADER_LEN as u64,
8894            "the header segment must span exactly HEADER_LEN"
8895        );
8896
8897        // Pull the number back out of the human-readable label and hold it to
8898        // the span. A hard-coded literal that drifts from HEADER_LEN dies here.
8899        let digits: String = header
8900            .label
8901            .chars()
8902            .skip_while(|c| !c.is_ascii_digit())
8903            .take_while(|c| c.is_ascii_digit())
8904            .collect();
8905        assert!(
8906            !digits.is_empty(),
8907            "header label {:?} states no size at all",
8908            header.label
8909        );
8910        assert_eq!(
8911            digits.parse::<u64>().unwrap(),
8912            header.len,
8913            "header label {:?} disagrees with the {}-byte span it describes",
8914            header.label,
8915            header.len
8916        );
8917
8918        // The layout must also stay sorted and start at byte zero, which is
8919        // what makes "the first segment is the header" meaningful.
8920        assert!(
8921            layout.windows(2).all(|w| w[0].offset <= w[1].offset),
8922            "file_layout must return segments sorted by offset"
8923        );
8924    }
8925}