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, Section, SectionKind, FLAG_HAS_QUADS, FLAG_HAS_QUOTED_TRIPLES, FLAG_TILE_SYNOPSIS,
20    HEADER_LEN, MAGIC,
21};
22use crate::index::{GraphIndex, IndexPermutation, Pattern, PermSet, NUM_PERMS};
23use crate::meta::{ClassNode, CommunityDescriptor, LevelLinks, LevelRollup, PyramidMeta};
24use crate::pyramid::{build_dendrogram, project_graph, PyramidAlgo};
25use crate::reader::RangeReader;
26use crate::tiling::{choose_round_for_budget, summarize, SuperEdge};
27use crate::triples::Triple;
28use crate::varint::{read_uvarint, write_uvarint};
29
30/// Default per-tile byte budget `T` (SPEC.md §7.1).
31pub const DEFAULT_TILE_BUDGET: usize = 64 * 1024;
32
33/// Build the encoded pyramid-meta section for a graph: cluster, pick a round
34/// sized to `budget`, then emit the **summary** (quotient) graph. Returns
35/// `(encoded_meta, pyramid_levels)`.
36///
37/// Per-community tiles are *not* stored: they would duplicate every triple, and
38/// the exact ranged single-pattern path now routes into one permutation section
39/// without that fourth copy. Physical community-tile directories are the next
40/// storage step (SPEC §7.2).
41pub fn build_pyramid_meta(
42    dict: &Dictionary,
43    triples: &[(u32, u32, u32)],
44    budget: usize,
45) -> (Vec<u8>, u16) {
46    build_pyramid_meta_with(dict, triples, budget, None)
47}
48
49/// Like [`build_pyramid_meta`], but `type_override` forces the schema-pyramid's
50/// type predicate (e.g. `wdt:P31`) instead of auto-detection. Uses the default
51/// [`PyramidAlgo::Louvain`] community algorithm — byte-identical to before.
52pub fn build_pyramid_meta_with(
53    dict: &Dictionary,
54    triples: &[(u32, u32, u32)],
55    budget: usize,
56    type_override: Option<&str>,
57) -> (Vec<u8>, u16) {
58    build_pyramid_meta_algo(dict, triples, budget, type_override, PyramidAlgo::Louvain)
59}
60
61/// Like [`build_pyramid_meta_with`], but selects the community [`PyramidAlgo`].
62/// [`PyramidAlgo::Types`] partitions by `rdf:type` — the deterministic,
63/// parallelizable alternative to Louvain (one linear pass, no modularity) that
64/// still emits the full summary + `query_stats`; it falls back to Louvain when the
65/// graph has no usable typing. Everything downstream of the dendrogram (round
66/// choice, summary, schema pyramid, planner stats) is shared across algorithms.
67pub fn build_pyramid_meta_algo(
68    dict: &Dictionary,
69    triples: &[(u32, u32, u32)],
70    budget: usize,
71    type_override: Option<&str>,
72    algo: PyramidAlgo,
73) -> (Vec<u8>, u16) {
74    build_pyramid_meta_layout(
75        dict,
76        triples,
77        budget,
78        type_override,
79        algo,
80        PyramidLayout::Combined,
81    )
82}
83
84/// What a pyramid-meta section carries besides the community summary.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum PyramidLayout {
87    /// Everything, as every build up to 0.3.x wrote it: the summary, the
88    /// per-community descriptors, the schema pyramid, `query_stats`, the
89    /// characteristic sets and the bounded (8,192-entry) label block.
90    #[default]
91    Combined,
92    /// Only what needs the community partition: the summary and the
93    /// per-community descriptors. The schema, `query_stats` and the
94    /// characteristic sets live in the SCHEMA section (kind 9) and the labels
95    /// in the LABEL_INDEX section (kind 8) — what `rete build` writes.
96    CommunityOnly,
97}
98
99/// Like [`build_pyramid_meta_algo`], choosing the [`PyramidLayout`].
100pub fn build_pyramid_meta_layout(
101    dict: &Dictionary,
102    triples: &[(u32, u32, u32)],
103    budget: usize,
104    type_override: Option<&str>,
105    algo: PyramidAlgo,
106    layout: PyramidLayout,
107) -> (Vec<u8>, u16) {
108    // Optional sub-phase timing (set RETE_BUILD_TIMING=1) — the pyramid build is
109    // the dominant cost of a big `rete build`; this shows where inside it.
110    // `Instant::now()` must stay behind the flag: `std::time` is unsupported on
111    // `wasm32-unknown-unknown` and panics ("time not implemented"), so an
112    // unconditional clock read would break every in-browser `build()`.
113    let timing = std::env::var_os("RETE_BUILD_TIMING").is_some();
114    let mut t = timing.then(std::time::Instant::now);
115    let mut lap = |label: &str| {
116        if let Some(t0) = &mut t {
117            eprintln!(
118                "  [pyramid] {label}: {:.0} ms",
119                t0.elapsed().as_secs_f64() * 1000.0
120            );
121            *t0 = std::time::Instant::now();
122        }
123    };
124
125    // The community partition — the only step that differs by algorithm.
126    let louvain = |lap: &mut dyn FnMut(&str)| {
127        let g = project_graph(dict, triples);
128        lap("project_graph");
129        let d = build_dendrogram(&g);
130        lap("build_dendrogram (Louvain)");
131        d
132    };
133    let dend = match algo {
134        PyramidAlgo::Louvain => louvain(&mut lap),
135        PyramidAlgo::Types => {
136            match crate::schema_pyramid::build_type_dendrogram(dict, triples, type_override) {
137                Some(d) => {
138                    lap("build_type_dendrogram");
139                    d
140                }
141                None => {
142                    eprintln!(
143                        "  [pyramid] --pyramid-algo types: no usable rdf:type \
144                         predicate — falling back to louvain"
145                    );
146                    louvain(&mut lap)
147                }
148            }
149        }
150    };
151    let round = choose_round_for_budget(dict, triples, &dend, budget);
152    lap("choose_round_for_budget");
153    let summary = summarize(dict, triples, &dend, round);
154    lap("summarize");
155    // Attach the v2 schema pyramid (the non-exclusive subClassOf DAG + per-level
156    // type rollups + per-level lateral class relations + per-community
157    // descriptors). Empty when the graph has no usable typing, in which case the
158    // encoding stays byte-identical to a v1 pyramid-meta.
159    if layout == PyramidLayout::CommunityOnly {
160        // Descriptors only: the schema pyramid's other fields, the planner
161        // stats, the shapes and the labels have sections of their own.
162        let descriptors = crate::schema_pyramid::build_community_descriptors(
163            dict,
164            triples,
165            &dend,
166            round,
167            type_override,
168        );
169        lap("build_community_descriptors");
170        let meta = PyramidMeta::new(round as u32, summary, &[]).with_schema(
171            Vec::new(),
172            Vec::new(),
173            Vec::new(),
174            descriptors,
175            Vec::new(),
176            Vec::new(),
177            Vec::new(),
178        );
179        let out = (meta.encode(), dend.rounds() as u16);
180        lap("encode");
181        return out;
182    }
183    let sp = crate::schema_pyramid::build_schema_pyramid_with(
184        dict,
185        triples,
186        &dend,
187        round,
188        type_override,
189    );
190    lap("build_schema_pyramid");
191    let predicate_stats = compute_predicate_stats(triples);
192    lap("compute_predicate_stats");
193    let char_sets = compute_char_sets(triples);
194    lap("compute_char_sets");
195    let label_index = compute_label_index(dict, triples);
196    lap("compute_label_index");
197    let meta = PyramidMeta::new(round as u32, summary, &[])
198        .with_schema(
199            sp.class_hierarchy,
200            sp.level_rollups,
201            sp.level_links,
202            sp.descriptors,
203            sp.subclass_cycles,
204            sp.disjoint_pairs,
205            sp.equivalent_pairs,
206        )
207        .with_predicate_stats(predicate_stats)
208        .with_char_sets(char_sets)
209        .with_label_index(label_index);
210    let out = (meta.encode(), dend.rounds() as u16);
211    lap("encode");
212    out
213}
214
215/// The label predicates a [`compute_label_index`] entry can come from — the
216/// common "human-readable name of this subject" terms, angle-bracketed as the
217/// dictionary stores them. Order is irrelevant (we union their ids). The same
218/// list the LABEL_INDEX section's rule L1 uses.
219const LABEL_PREDICATES: &[&str] = crate::label_index::DIRECT_LABEL_PREDICATES;
220
221/// Build the bounded, **legacy** label block of a [`PyramidLayout::Combined`]
222/// pyramid-meta (the LABEL_INDEX section, kind 8, replaces it): the display labels of
223/// the most-connected labeled subjects, sorted by the label's lowercased form.
224/// Ranking keeps autocomplete useful on a huge graph (the prominent entities
225/// survive the bound); a graph with fewer than `MAX_LABELS` labels keeps them
226/// all. Deterministic (degree, then subject id, then label) so builds are
227/// reproducible. O(triples) transient memory, freed before the file is written.
228fn compute_label_index(
229    dict: &Dictionary,
230    triples: &[(u32, u32, u32)],
231) -> Vec<crate::meta::LabelEntry> {
232    use crate::terms::{is_literal, literal_lexical};
233    use std::collections::{HashMap, HashSet};
234    const MAX_LABELS: usize = 8192;
235
236    // Resolve the label predicates that actually occur in this graph.
237    let label_pids: HashSet<u32> = LABEL_PREDICATES
238        .iter()
239        .filter_map(|p| dict.predicate_id(p))
240        .collect();
241    if label_pids.is_empty() {
242        return Vec::new();
243    }
244    // Subject degree (triple count) — the ranking used to bound the index.
245    let mut degree: HashMap<u32, u32> = HashMap::new();
246    for &(s, _p, _o) in triples {
247        *degree.entry(s).or_insert(0) += 1;
248    }
249    // Candidate (subject, label) pairs, deduped on (subject, lowercased label).
250    let mut seen: HashSet<(u32, String)> = HashSet::new();
251    let mut candidates: Vec<(u32, String, u32)> = Vec::new(); // (degree, label, subject)
252    for &(s, p, o) in triples {
253        if !label_pids.contains(&p) {
254            continue;
255        }
256        let Some(term) = dict.object_term(o) else {
257            continue;
258        };
259        if !is_literal(&term) {
260            continue;
261        }
262        let Some(label) = literal_lexical(&term) else {
263            continue;
264        };
265        if label.is_empty() {
266            continue;
267        }
268        if seen.insert((s, label.to_lowercase())) {
269            candidates.push((*degree.get(&s).unwrap_or(&0), label, s));
270        }
271    }
272    // Keep the most-connected entities when over budget: rank by degree desc,
273    // then subject asc, then label asc (deterministic).
274    if candidates.len() > MAX_LABELS {
275        candidates.sort_by(|a, b| {
276            b.0.cmp(&a.0)
277                .then_with(|| a.2.cmp(&b.2))
278                .then_with(|| a.1.cmp(&b.1))
279        });
280        candidates.truncate(MAX_LABELS);
281    }
282    // Final order: by lowercased label (search key), then label, then subject.
283    candidates.sort_by(|a, b| {
284        a.1.to_lowercase()
285            .cmp(&b.1.to_lowercase())
286            .then_with(|| a.1.cmp(&b.1))
287            .then_with(|| a.2.cmp(&b.2))
288    });
289    candidates
290        .into_iter()
291        .map(|(_deg, label, subject)| crate::meta::LabelEntry { label, subject })
292        .collect()
293}
294
295/// Build the default-scope full-text index section (`token → subjects`) over
296/// every literal object of `triples`: tokenize each literal into words and
297/// record the subject that carries it. Empty when the graph has no literals.
298/// This is the pre-0.4.0 index, byte for byte; the builds call
299/// `text_index::build_section` with their scope (`--text-index-graphs`,
300/// `--text-index-include`/`-exclude`). O(literal bytes) transient. The token
301/// table is compressed with `writer_codec` (the reader decompresses with
302/// `block_codec`).
303pub fn compute_text_index(dict: &Dictionary, triples: &[(u32, u32, u32)]) -> Vec<u8> {
304    crate::text_index::build_section(
305        dict,
306        &crate::text_index::TextIndexScope::default(),
307        triples,
308        std::iter::empty(),
309        writer_codec(),
310    )
311}
312
313/// The top entity **shapes** (characteristic sets): group subjects by the exact
314/// set of predicates they carry, keep the most common. Bounded to `MAX_CHAR_SETS`
315/// and sorted deterministically (by subject count, then predicate list) so the
316/// encoding is reproducible. O(triples) transient memory.
317fn compute_char_sets(triples: &[(u32, u32, u32)]) -> Vec<crate::meta::CharSet> {
318    use std::collections::{BTreeSet, HashMap};
319    const MAX_CHAR_SETS: usize = 128;
320    let mut by_subject: HashMap<u32, BTreeSet<u32>> = HashMap::new();
321    for &(s, p, _o) in triples {
322        by_subject.entry(s).or_default().insert(p);
323    }
324    let mut shapes: HashMap<Vec<u32>, u64> = HashMap::new();
325    for set in by_subject.into_values() {
326        *shapes.entry(set.into_iter().collect()).or_insert(0) += 1;
327    }
328    let mut v: Vec<crate::meta::CharSet> = shapes
329        .into_iter()
330        .map(|(predicates, subjects)| crate::meta::CharSet {
331            predicates,
332            subjects,
333        })
334        .collect();
335    v.sort_by(|a, b| {
336        b.subjects
337            .cmp(&a.subjects)
338            .then_with(|| a.predicates.cmp(&b.predicates))
339    });
340    v.truncate(MAX_CHAR_SETS);
341    v
342}
343
344/// Per-predicate cardinality for the cost-based planner, in one pass over the
345/// triples (deduped, so a per-(subject,predicate) count is its distinct-object
346/// count). Returned sorted by predicate id for a reproducible encoding. Holds a
347/// transient `(subject -> count, object -> count)` map per predicate — O(triples)
348/// memory, freed before the file is written.
349fn compute_predicate_stats(triples: &[(u32, u32, u32)]) -> Vec<crate::meta::PredStat> {
350    use std::collections::HashMap;
351    #[allow(clippy::type_complexity)]
352    let mut acc: HashMap<u32, (HashMap<u32, u32>, HashMap<u32, u32>, u64)> = HashMap::new();
353    for &(s, p, o) in triples {
354        let e = acc.entry(p).or_default();
355        *e.0.entry(s).or_insert(0) += 1;
356        *e.1.entry(o).or_insert(0) += 1;
357        e.2 += 1;
358    }
359    let mut stats: Vec<crate::meta::PredStat> = acc
360        .into_iter()
361        .map(|(predicate, (subj, obj, count))| crate::meta::PredStat {
362            predicate,
363            count,
364            distinct_subjects: subj.len() as u64,
365            distinct_objects: obj.len() as u64,
366            max_objects_per_subject: subj.values().copied().max().unwrap_or(0),
367            max_subjects_per_object: obj.values().copied().max().unwrap_or(0),
368        })
369        .collect();
370    stats.sort_by_key(|p| p.predicate);
371    stats
372}
373
374/// No compression.
375pub const CODEC_NONE: u8 = 0;
376/// zstd compression (per section).
377pub const CODEC_ZSTD: u8 = 1;
378
379#[derive(Debug, thiserror::Error)]
380#[non_exhaustive]
381pub enum FileError {
382    #[error("header: {0}")]
383    Header(#[from] crate::header::HeaderError),
384    #[error("malformed container: {0}")]
385    Container(&'static str),
386    #[error("unknown codec: {0}")]
387    UnknownCodec(u8),
388    #[error("decompression failed: {0}")]
389    Decompress(std::io::Error),
390    #[error("io: {0}")]
391    Io(#[from] std::io::Error),
392}
393
394/// The codec the writer uses: zstd when the `compression` feature is on, else
395/// none. Reading honors whatever codec the header records (when supported).
396pub(crate) fn writer_codec() -> u8 {
397    if cfg!(feature = "compression") {
398        CODEC_ZSTD
399    } else {
400        CODEC_NONE
401    }
402}
403
404/// Intersection of two ascending-sorted, deduped id lists — the AND of two
405/// posting lists in a multi-word text search. Linear merge, output sorted.
406fn intersect_sorted(a: &[u32], b: &[u32]) -> Vec<u32> {
407    let mut out = Vec::with_capacity(a.len().min(b.len()));
408    let (mut i, mut j) = (0, 0);
409    while i < a.len() && j < b.len() {
410        match a[i].cmp(&b[j]) {
411            std::cmp::Ordering::Less => i += 1,
412            std::cmp::Ordering::Greater => j += 1,
413            std::cmp::Ordering::Equal => {
414                out.push(a[i]);
415                i += 1;
416                j += 1;
417            }
418        }
419    }
420    out
421}
422
423pub(crate) fn compress(codec: u8, bytes: &[u8]) -> Vec<u8> {
424    match codec {
425        // Level and frame shape are writer knobs (`crate::write_opts`); any
426        // combination decodes with every conformant zstd decoder.
427        #[cfg(feature = "compression")]
428        CODEC_ZSTD => {
429            let level = crate::write_opts::zstd_level();
430            match crate::write_opts::frame_shape() {
431                crate::write_opts::FrameShape::Stream => zstd::encode_all(bytes, level),
432                crate::write_opts::FrameShape::Sized => zstd::bulk::compress(bytes, level),
433            }
434            .expect("zstd encode is infallible in-memory")
435        }
436        _ => bytes.to_vec(),
437    }
438}
439
440/// The streaming twin of [`compress`]: the same codec, level and frame shape,
441/// read from `src` (exactly `src_len` bytes) and written to `dst`, for a
442/// payload the memory-bounded build keeps on disk (the TEXT_INDEX token
443/// table). A streaming frame is what `zstd::encode_all` writes, through the
444/// same `Encoder`. A sized frame is told the source size up front
445/// (`set_pledged_src_size`), so it picks the same small-input parameters and
446/// records the same content size as `zstd::bulk::compress`, without holding
447/// the payload. The encoder cuts blocks from its own input buffer, not from
448/// how the input was handed to it, so how `src` hands out its bytes never
449/// changes the output.
450///
451/// It equals [`compress`]'s one-shot output only while the payload fits the
452/// level's window (4 MiB at level 9): past it the streaming encoder's ring
453/// buffer wraps and it finds slightly different matches. A payload that one
454/// writer streams and another holds whole must therefore go through
455/// [`compress_streamed`] on the in-memory side — the token table does.
456/// `text_index_spill` pins both facts.
457#[cfg(not(target_arch = "wasm32"))]
458pub(crate) fn compress_to(
459    codec: u8,
460    src_len: u64,
461    src: &mut dyn std::io::Read,
462    dst: &mut dyn std::io::Write,
463) -> std::io::Result<()> {
464    match codec {
465        #[cfg(feature = "compression")]
466        CODEC_ZSTD => zstd_stream(src_len, src, dst),
467        _ => {
468            let _ = src_len;
469            std::io::copy(src, dst).map(|_| ())
470        }
471    }
472}
473
474/// [`compress`] through the streaming encoder: byte for byte what
475/// [`compress_to`] writes for the same payload, whatever its size. For the
476/// in-memory writer of a payload the memory-bounded build compresses from
477/// disk (the TEXT_INDEX token table), so the two builds write the same bytes
478/// past the zstd window too. Below it this is [`compress`]'s output.
479pub(crate) fn compress_streamed(codec: u8, bytes: &[u8]) -> Vec<u8> {
480    match codec {
481        #[cfg(feature = "compression")]
482        CODEC_ZSTD => {
483            let mut out = Vec::with_capacity(bytes.len() / 4 + 64);
484            zstd_stream(bytes.len() as u64, &mut &bytes[..], &mut out)
485                .expect("zstd encode is infallible in-memory");
486            out
487        }
488        _ => bytes.to_vec(),
489    }
490}
491
492/// The streaming zstd encoder at the writer's level and frame shape.
493#[cfg(feature = "compression")]
494fn zstd_stream(
495    src_len: u64,
496    src: &mut dyn std::io::Read,
497    dst: &mut dyn std::io::Write,
498) -> std::io::Result<()> {
499    let mut enc = zstd::stream::Encoder::new(dst, crate::write_opts::zstd_level())?;
500    if crate::write_opts::frame_shape() == crate::write_opts::FrameShape::Sized {
501        enc.set_pledged_src_size(Some(src_len))?;
502        enc.include_contentsize(true)?;
503    }
504    let copied = std::io::copy(src, &mut enc)?;
505    if copied != src_len {
506        return Err(std::io::Error::other(format!(
507            "zstd stream: {copied} bytes read, {src_len} announced"
508        )));
509    }
510    enc.finish()?;
511    Ok(())
512}
513
514pub(crate) fn decompress(codec: u8, bytes: &[u8]) -> Result<Vec<u8>, FileError> {
515    match codec {
516        CODEC_NONE => Ok(bytes.to_vec()),
517        // Native (the `compression` feature — always on for the CLI, off for
518        // wasm) decodes through the C `zstd` library, the same one
519        // [`compress`] encodes with. It is roughly 2× the pure-Rust decoder
520        // and speeds every read path, not just export; the multi-pass tight
521        // budget of a bounded export (`--memory-budget-mb`) re-decodes the
522        // dictionary, so a faster decoder is the single largest lever there.
523        // A valid zstd frame decodes to the same bytes under any conformant
524        // decoder, so the output is byte-for-byte what `ruzstd` produced.
525        #[cfg(feature = "compression")]
526        CODEC_ZSTD => zstd::decode_all(bytes).map_err(FileError::Decompress),
527        // Pure-Rust decode so a target WITHOUT a C toolchain (wasm, or a
528        // `--no-default-features` build) can still read compressed files.
529        // This is the ONLY decode path wasm ever compiles — the C `zstd`
530        // crate is gated behind `compression`, which wasm never enables.
531        #[cfg(not(feature = "compression"))]
532        CODEC_ZSTD => {
533            use std::io::Read;
534            let mut dec = ruzstd::decoding::StreamingDecoder::new(bytes)
535                .map_err(|e| FileError::Decompress(std::io::Error::other(e.to_string())))?;
536            let mut out = Vec::new();
537            dec.read_to_end(&mut out).map_err(FileError::Decompress)?;
538            Ok(out)
539        }
540        other => Err(FileError::UnknownCodec(other)),
541    }
542}
543
544/// Bytes this close are cheaper fetched as one read than as two round trips:
545/// tiles are laid back-to-back so this only ever bridges a tile already made
546/// resident by an earlier window — keep it tight to avoid re-fetching it.
547const TILE_COALESCE_GAP: u64 = 4096;
548
549/// The dictionary chunks a query's output terms touch are scattered across the
550/// section (terms are sorted, output ids are not), so byte-adjacency is rare.
551/// A wider gap trades a little over-fetch for far fewer round trips — the right
552/// call on a latency-bound remote read, where one skipped 64 KiB chunk is much
553/// cheaper than another request's RTT.
554const DICT_COALESCE_GAP: u64 = 64 * 1024;
555
556/// The default `rete export --memory-budget-mb` when the flag is omitted — the
557/// same number `rete build`/`merge` default to, so one figure governs both the
558/// build and the export of a file. Comfortably above a sub-4-GB-dictionary
559/// file's decompressed dictionary (those export in a single pass at today's
560/// speed), and far under a large VM's RAM so a 50-GB-class file is bounded
561/// rather than OOM. See [`split_memory_budget`].
562pub const DEFAULT_EXPORT_BUDGET_MB: u64 = 4096;
563
564/// The default memory budget, in MiB, of a lazily-opened file — the bound on
565/// its dictionary chunk cache, index tile cache and raw block cache, split as
566/// [`split_memory_budget`] describes. `rete sparql --memory-budget-mb` (and
567/// the other query commands) default to it too, and so does every client that
568/// opens through [`Rete::open_ranged_lazy`] (Python, R, Java, the relay), so a
569/// long-lived session no longer grows with every tile a query ever touched.
570/// The same figure as [`DEFAULT_EXPORT_BUDGET_MB`]: one number governs reading
571/// a big file whatever the command.
572pub const DEFAULT_QUERY_BUDGET_MB: u64 = DEFAULT_EXPORT_BUDGET_MB;
573
574/// The [`Rete::spill_threshold`] a memory budget implies: an eighth of it. A
575/// spilling `COUNT(DISTINCT …)` buffer can briefly occupy about twice its
576/// threshold while it sorts, so this keeps it within a quarter of the budget.
577pub fn spill_threshold_for(budget: u64) -> u64 {
578    budget / 8
579}
580
581/// The lowest byte cap the dictionary chunk cache is ever given, whatever the
582/// budget. It only has to guarantee forward progress and hold a handful of
583/// chunks (a single front-coded run can dwarf the 64 KiB chunk budget — a
584/// base64-embedded image, say). Correctness never depends on it: the cache's
585/// `Arc` handout keeps a chunk alive across its one decode even if it is
586/// evicted the instant it is inserted, so a full dump completes at *any* cap.
587/// This floor only keeps a pathologically small `--memory-budget-mb` from
588/// thrashing the whole dictionary chunk-by-chunk.
589const MIN_DICT_CACHE_CAP: u64 = 64 << 20; // 64 MiB
590
591/// Upper bound of the budget share [`split_memory_budget`] holds back for
592/// transient, uncached allocations (see the `reserve` there).
593const TRANSIENT_RESERVE_MAX: u64 = 128 << 20; // 128 MiB
594
595/// How a total `--memory-budget-mb` splits across the export's three bounded
596/// consumers. The three caps sum to the total less a small reserve (≤ 128 MiB)
597/// for the window's buffers and one window's in-flight reads, which no cache
598/// owns. [`Rete::set_memory_budget`] further subtracts the open's fixed
599/// directories ([`Rete::fixed_overhead`]) from the dictionary share, so the
600/// live heap of a dump stays within the total.
601#[derive(Debug, Clone, Copy)]
602pub struct MemoryBudget {
603    /// Raw file-block LRU (the `BlockCacheReader` that backs the ranged reader).
604    pub block_cache: u64,
605    /// Decompressed dictionary chunk bodies (the old ~4.3 GB export floor).
606    pub dict_cache: u64,
607    /// Decoded index tiles of the scanned permutation.
608    pub tile_cache: u64,
609}
610
611/// Split a total byte budget across the block cache, the dictionary chunk cache
612/// and the index tile cache (bounded-export phase 2, plan §5.2). `u64::MAX`
613/// (unlimited, e.g. `--in-memory` or `--memory-budget-mb 0`) passes straight
614/// through to every consumer.
615///
616/// The dictionary is the dominant consumer on scholar files (literal-heavy
617/// object section), and its chunks recur across windows, so it gets the bulk of
618/// the budget; the tile cache is small (a scanned permutation is far smaller
619/// than the dictionary) and the block cache stays modest, capped like every
620/// other ranged reader at [`crate::block_cache::DEFAULT_CACHE_CAP`].
621pub fn split_memory_budget(total: u64) -> MemoryBudget {
622    if total == u64::MAX {
623        return MemoryBudget {
624            block_cache: u64::MAX,
625            dict_cache: u64::MAX,
626            tile_cache: u64::MAX,
627        };
628    }
629    // Raw file blocks: min(256 MiB, total/8). Repeated directory/header reads
630    // must stay cheap, but this cache is not where the floor lived.
631    let block_cache = (total / 8).min(crate::block_cache::DEFAULT_CACHE_CAP);
632    // Scanned-permutation tiles: modest — tens of MB on epfl-class files. Cap
633    // at total/16 (≤ 256 MiB) so the dictionary keeps the rest.
634    let tile_cache = (total / 16).min(256 << 20);
635    // Held back for what no cache owns: the window's id and string buffers,
636    // the coalesced read spans and freshly decoded bodies of one window's
637    // prefetch before they enter the cache, and the program itself. Measured at
638    // ~80-120 MB on epfl and openaire-relation (dev/export-memory), so 1/32 of
639    // the budget, capped at 128 MiB.
640    let reserve = (total / 32).min(TRANSIENT_RESERVE_MAX);
641    // The dictionary chunk cache gets everything else, floored so a tiny budget
642    // still holds a working set of chunks.
643    let dict_cache = total
644        .saturating_sub(block_cache)
645        .saturating_sub(tile_cache)
646        .saturating_sub(reserve)
647        .max(MIN_DICT_CACHE_CAP);
648    MemoryBudget {
649        block_cache,
650        dict_cache,
651        tile_cache,
652    }
653}
654
655/// Heap an open `Rete` holds outside its bounded caches, by structure — see
656/// [`Rete::fixed_overhead`].
657#[derive(Debug, Clone, Copy, Default)]
658pub struct FixedOverhead {
659    /// The four dictionary sections' chunk directories.
660    pub dict_directory: u64,
661    /// The default graph's tile directories (every carried permutation).
662    pub index_directory: u64,
663    /// The named-graph directory plus the open graphs' tile directories.
664    pub named_graphs: u64,
665}
666
667impl FixedOverhead {
668    /// The sum of every part.
669    pub fn total(&self) -> u64 {
670        self.dict_directory + self.index_directory + self.named_graphs
671    }
672}
673
674/// Format a cache byte cap for `RETE_OPEN_DEBUG` (`unlimited` for `u64::MAX`).
675fn fmt_cap(cap: u64) -> String {
676    if cap == u64::MAX {
677        "unlimited".to_string()
678    } else {
679        format!("{}MiB", cap >> 20)
680    }
681}
682
683/// Fetch a set of ascending, disjoint byte ranges, coalescing ranges whose gap
684/// is at most `gap` into one span, then fetching the spans through
685/// [`RangeReader::read_many`] (which a parallelizable reader issues
686/// concurrently). Returns each requested range's bytes in order; `None` if any
687/// read fails.
688fn read_coalesced<R: RangeReader + ?Sized>(
689    reader: &R,
690    ranges: &[ByteRange],
691    gap: u64,
692) -> Option<Vec<Vec<u8>>> {
693    // Build the coalesced spans and remember which span each input range maps
694    // into, so the fetched span blobs can be sliced back apart in order.
695    let mut spans: Vec<(u64, u64)> = Vec::new();
696    let mut span_of: Vec<usize> = Vec::with_capacity(ranges.len());
697    let mut i = 0;
698    while i < ranges.len() {
699        let start = ranges[i].offset;
700        let mut end = ranges[i].offset.checked_add(ranges[i].len)?;
701        let mut j = i + 1;
702        while j < ranges.len() {
703            let r = &ranges[j];
704            if r.offset < end || r.offset - end > gap {
705                break;
706            }
707            end = r.offset.checked_add(r.len)?;
708            j += 1;
709        }
710        let si = spans.len();
711        spans.push((start, end - start));
712        for _ in i..j {
713            span_of.push(si);
714        }
715        i = j;
716    }
717    let blobs = reader.read_many(&spans).ok()?;
718    if blobs.len() != spans.len() {
719        return None;
720    }
721    let mut out = Vec::with_capacity(ranges.len());
722    for (k, r) in ranges.iter().enumerate() {
723        let (span_start, _) = spans[span_of[k]];
724        let blob = &blobs[span_of[k]];
725        let lo = (r.offset - span_start) as usize;
726        let hi = lo.checked_add(r.len as usize)?;
727        out.push(blob.get(lo..hi)?.to_vec());
728    }
729    Some(out)
730}
731
732/// Content hash (first 16 bytes of blake3) over the file payload sections.
733/// Identifies the immutable content independent of the header.
734fn content_hash(parts: &[&[u8]]) -> [u8; 16] {
735    let mut h = blake3::Hasher::new();
736    for p in parts {
737        h.update(p);
738    }
739    let mut out = [0u8; 16];
740    out.copy_from_slice(&h.finalize().as_bytes()[..16]);
741    out
742}
743
744/// A resolved triple as terms.
745pub type TermTriple = (String, String, String);
746
747/// What a filtered dump will cost, before it runs — see
748/// [`Rete::dump_plan`](crate::Rete::dump_plan).
749#[derive(Debug, Clone, Copy, PartialEq, Eq)]
750pub struct DumpPlan {
751    /// The routed scan this dump will run. `None` when it provably matches
752    /// nothing without touching the index: an absent graph IRI, or a bound term
753    /// the dictionary does not contain.
754    pub scan: Option<crate::index::ScanPlan>,
755    /// The file's whole dictionary section — the **ceiling** on what resolving
756    /// the matched rows' terms can fault, and on a literal-heavy file the
757    /// dominant term in a dump's real cost.
758    ///
759    /// It is deliberately not an estimate. A dump that matches one row faults
760    /// only the chunks that row's three terms live in, but nothing in the tile
761    /// directories says which chunks those are or how big they are, and a
762    /// preview that guessed would be worse than one that states the bound.
763    pub dictionary_bytes: u64,
764}
765
766/// One labelled byte region of a `.rete` file image (see
767/// [`Rete::file_layout`]). `kind` is a stable machine tag: `header`,
768/// `metadata`, `dictionary`, `directory`, `tile`, `pyramid`, `named-graphs`.
769#[derive(Debug, Clone)]
770pub struct LayoutSegment {
771    pub kind: &'static str,
772    pub label: String,
773    pub offset: u64,
774    pub len: u64,
775}
776
777/// A byte range in the `.rete` file image.
778#[derive(Debug, Clone, Copy, PartialEq, Eq)]
779pub struct ByteRange {
780    pub offset: u64,
781    pub len: u64,
782}
783
784impl ByteRange {
785    pub fn end(self) -> u64 {
786        self.offset + self.len
787    }
788}
789
790/// Why a triple-pattern result is present in the file.
791#[derive(Debug, Clone, PartialEq, Eq)]
792pub struct TripleProvenance {
793    /// Matched triple resolved to canonical N-Triples tokens.
794    pub terms: TermTriple,
795    /// Matched triple in dictionary ID space.
796    pub ids: Triple,
797    /// Named graph IRI, or `None` for the default graph.
798    pub graph: Option<String>,
799    /// The resolved ID-space pattern that was matched.
800    pub matched_pattern: Pattern,
801    /// Permutation selected to answer the pattern.
802    pub index_permutation: IndexPermutation,
803    /// File byte range containing the dictionary container.
804    pub dictionary_range: ByteRange,
805    /// File byte range containing the permutation index container.
806    pub index_range: ByteRange,
807    /// File byte range containing the selected permutation payload inside the
808    /// index container.
809    pub index_section_range: ByteRange,
810    /// File byte range containing the pyramid metadata, when present.
811    pub pyramid_range: Option<ByteRange>,
812    /// Physical tile identifier, once tile directories are materialized.
813    /// Physical tile identifier (`PERM/index`, e.g. `POS/3`) for tiled (v0.2)
814    /// files; `None` for pre-tiling files.
815    pub tile: Option<String>,
816    /// File byte range of that (compressed) tile — the exact bytes a ranged
817    /// client would fetch to re-derive this match.
818    pub tile_range: Option<ByteRange>,
819}
820
821/// Encode a length-prefixed container of byte sections, each compressed with
822/// `codec` independently (so a range-reading client decompresses only the
823/// sections it fetches). Stored length is the *compressed* length.
824fn encode_container(sections: &[&[u8]], codec: u8) -> Vec<u8> {
825    let mut out = Vec::new();
826    write_uvarint(&mut out, sections.len() as u64);
827    for s in sections {
828        let payload = compress(codec, s);
829        write_uvarint(&mut out, payload.len() as u64);
830        out.extend_from_slice(&payload);
831    }
832    out
833}
834
835/// Decode a container into owned, decompressed sections.
836fn decode_container(bytes: &[u8], codec: u8) -> Result<Vec<Vec<u8>>, FileError> {
837    let (n, mut pos) = read_uvarint(bytes).ok_or(FileError::Container("truncated count"))?;
838    // `n` is untrusted; each section needs ≥1 byte, so cap the pre-allocation at
839    // the buffer length rather than trusting the count (avoids an OOM on a bogus
840    // header pointing at a small region).
841    let mut out = Vec::with_capacity((n as usize).min(bytes.len()));
842    for _ in 0..n {
843        let (len, used) =
844            read_uvarint(&bytes[pos..]).ok_or(FileError::Container("truncated length"))?;
845        pos += used;
846        let end = pos + len as usize;
847        if end > bytes.len() {
848            return Err(FileError::Container("section overruns buffer"));
849        }
850        out.push(decompress(codec, &bytes[pos..end])?);
851        pos = end;
852    }
853    Ok(out)
854}
855
856fn checked_end(off: u64, len: u64) -> Result<u64, FileError> {
857    off.checked_add(len)
858        .ok_or(FileError::Container("section range overflows"))
859}
860
861/// Per-chunk budget for dictionary section bodies — same reasoning as
862/// [`crate::index::INDEX_TILE_BUDGET`]: one fetch, one decompress per touch.
863const DICT_CHUNK_BUDGET: usize = crate::write_opts::DEFAULT_DICT_CHUNK_BYTES;
864
865/// Encode one dictionary section as a chunked payload (format v0.2):
866/// `[header_len, raw header (term_count/interval/restart table)]
867///  [num_chunks; per chunk: Δfirst_run, key_len, key, comp_len]
868///  [individually compressed run-aligned body slices]`.
869/// The header keeps its original encoding, so restart offsets stay valid in
870/// the section's coordinate space.
871///
872/// `key` is the chunk's **routing separator**, not its first term: the
873/// shortest string strictly above the previous chunk's last term and at most
874/// the chunk's own first term (empty for chunk 0). See
875/// [`crate::dict::SectionChunk::key`] for the invariant and
876/// [`crate::dict::shortest_separator`] for the choice. Storing the first term
877/// verbatim — what this wrote before — is the degenerate case of the same
878/// invariant, so readers of either vintage route both correctly; the
879/// separator is simply orders of magnitude smaller when the boundary term is a
880/// long literal.
881fn encode_chunked_dict_section(raw: &[u8], codec: u8) -> Vec<u8> {
882    let meta = crate::dict::parse_meta(raw).unwrap_or(crate::dict::SectionMeta {
883        term_count: 0,
884        restart_interval: 1,
885        restart_offsets: Vec::new(),
886    });
887    let body_start = meta
888        .restart_offsets
889        .first()
890        .copied()
891        .unwrap_or(raw.len() as u64);
892    let header = &raw[..(body_start.min(raw.len() as u64)) as usize];
893
894    // Split runs into chunks by body-byte budget (whole runs only).
895    let chunk_budget = crate::write_opts::dict_chunk_budget() as u64;
896    let n_runs = meta.restart_offsets.len();
897    let mut bounds: Vec<(usize, u64, u64)> = Vec::new(); // (first_run, start, end)
898    let mut r = 0;
899    while r < n_runs {
900        let start = meta.restart_offsets[r];
901        let mut r2 = r + 1;
902        while r2 < n_runs && meta.restart_offsets[r2] - start < chunk_budget {
903            r2 += 1;
904        }
905        let end = if r2 < n_runs {
906            meta.restart_offsets[r2]
907        } else {
908            raw.len() as u64
909        };
910        bounds.push((r, start, end));
911        r = r2;
912    }
913
914    // Chunks are independent: compress them across all cores, as the index
915    // tiles are (`par_iter` keeps the order, so the bytes are unchanged). At a
916    // high `--zstd-level` this serial loop was most of a build's wall time.
917    #[cfg(feature = "parallel")]
918    let compressed: Vec<Vec<u8>> = {
919        use rayon::prelude::*;
920        bounds
921            .par_iter()
922            .map(|&(_, s, e)| compress(codec, &raw[s as usize..e as usize]))
923            .collect()
924    };
925    #[cfg(not(feature = "parallel"))]
926    let compressed: Vec<Vec<u8>> = bounds
927        .iter()
928        .map(|&(_, s, e)| compress(codec, &raw[s as usize..e as usize]))
929        .collect();
930    let mut out = Vec::new();
931    write_uvarint(&mut out, header.len() as u64);
932    out.extend_from_slice(header);
933    write_uvarint(&mut out, bounds.len() as u64);
934    let mut prev_run = 0usize;
935    // The previous chunk's last term — the separator's lower bound. Decoding it
936    // walks at most `restart_interval` front-coded entries of one chunk, which
937    // is nothing beside the zstd pass over the same 64 KiB.
938    let mut prev_last: Option<Vec<u8>> = None;
939    for (i, (&(first_run, start, end), comp)) in bounds.iter().zip(&compressed).enumerate() {
940        let key = if i == 0 {
941            // Everything routes to chunk 0; it needs no separator at all.
942            Vec::new()
943        } else {
944            let first = crate::dict::run_first_term(raw, start as usize).unwrap_or_default();
945            match &prev_last {
946                // Undecodable predecessor: the verbatim first term is always a
947                // valid separator, just not a short one.
948                None => first,
949                Some(pl) => crate::dict::shortest_separator(pl, &first),
950            }
951        };
952        if i + 1 < bounds.len() {
953            // The last run of THIS chunk starts one run before the next chunk's.
954            let last_run_off = meta.restart_offsets[bounds[i + 1].0 - 1] as usize;
955            prev_last = crate::dict::run_last_term(raw, last_run_off, end as usize);
956        }
957        write_uvarint(&mut out, (first_run - prev_run) as u64);
958        write_uvarint(&mut out, key.len() as u64);
959        out.extend_from_slice(&key);
960        write_uvarint(&mut out, comp.len() as u64);
961        prev_run = first_run;
962    }
963    for comp in &compressed {
964        out.extend_from_slice(comp);
965    }
966    out
967}
968
969/// A parsed chunked-dict-section directory entry: the chunk's run/key/body
970/// coordinates plus its compressed byte range *within the payload*. `key` is a
971/// routing separator, not a term — see [`crate::dict::SectionChunk::key`].
972struct DictChunkEntry {
973    first_run: usize,
974    key: Vec<u8>,
975    body_start: u64,
976    start: u64,
977    end: u64,
978}
979
980/// Parse a chunked dictionary section's header + directory (not the chunks).
981/// `bytes` may be a prefix of the payload; compressed ranges validate against
982/// `total_len`.
983fn parse_chunked_dict_dir(
984    bytes: &[u8],
985    total_len: u64,
986) -> Result<(crate::dict::SectionMeta, Vec<DictChunkEntry>), FileError> {
987    let mut pos = 0usize;
988    let take = |pos: &mut usize| -> Result<u64, FileError> {
989        let (v, n) = read_uvarint(bytes.get(*pos..).unwrap_or(&[]))
990            .ok_or(FileError::Container("truncated dict chunk directory"))?;
991        *pos += n;
992        Ok(v)
993    };
994    let header_len = take(&mut pos)? as usize;
995    let header = bytes
996        .get(pos..pos.saturating_add(header_len))
997        .ok_or(FileError::Container("truncated dict header"))?;
998    let meta = crate::dict::parse_meta(header)
999        .map_err(|_| FileError::Container("malformed dict header"))?;
1000    pos += header_len;
1001
1002    let num_chunks = take(&mut pos)? as usize;
1003    let mut entries = Vec::with_capacity(num_chunks.min(bytes.len()));
1004    let mut lens = Vec::with_capacity(num_chunks.min(bytes.len()));
1005    let mut prev_run = 0usize;
1006    for _ in 0..num_chunks {
1007        let drun = take(&mut pos)? as usize;
1008        let klen = take(&mut pos)? as usize;
1009        let key = bytes
1010            .get(pos..pos.saturating_add(klen))
1011            .ok_or(FileError::Container("truncated dict chunk key"))?
1012            .to_vec();
1013        pos += klen;
1014        let clen = take(&mut pos)?;
1015        let first_run = prev_run + drun;
1016        let body_start = meta
1017            .restart_offsets
1018            .get(first_run)
1019            .copied()
1020            .ok_or(FileError::Container("dict chunk run out of range"))?;
1021        entries.push(DictChunkEntry {
1022            first_run,
1023            key,
1024            body_start,
1025            start: 0,
1026            end: 0,
1027        });
1028        lens.push(clen);
1029        prev_run = first_run;
1030    }
1031    let mut start = pos as u64;
1032    for (e, len) in entries.iter_mut().zip(lens) {
1033        let end = start
1034            .checked_add(len)
1035            .filter(|&e| e <= total_len)
1036            .ok_or(FileError::Container("dict chunk overruns section"))?;
1037        e.start = start;
1038        e.end = end;
1039        start = end;
1040    }
1041    Ok((meta, entries))
1042}
1043
1044/// Fetch and parse a remote chunked dict section's header + directory: read a
1045/// small prefix and grow it geometrically until it parses, never fetching past
1046/// the section.
1047/// Read a chunked dictionary section's directory over a range reader WITHOUT
1048/// materializing the section-wide restart table. That table is one offset per
1049/// restart run — a 50 M-term section has ~3 M of them (~24 MiB resident), and
1050/// holding it is an iOS-Safari OOM on a big remote file. We read only the tiny
1051/// header prefix (term_count / interval) and the chunk directory, skipping the
1052/// restart-table bytes entirely; per-run offsets are derived per chunk on fault
1053/// (`SectionChunk::run_offsets`). The returned meta has an empty
1054/// `restart_offsets`, which the chunked lookups read as "derive per chunk".
1055fn read_dict_dir_ranged<R: RangeReader>(
1056    reader: &R,
1057    section: ByteRange,
1058) -> Result<(crate::dict::SectionMeta, Vec<DictChunkEntry>), FileError> {
1059    let total = section.len;
1060    // Initial prefix: the header prefix ([header_len][term_count][interval]) and,
1061    // for a *small* section, the whole chunk directory too — so those still cost
1062    // a single read. A big section has a huge restart table between the header
1063    // and the directory; we detect that (dir_start past the prefix) and range-
1064    // read only the directory below, never fetching the table.
1065    let init = 8192.min(total); // never over-read past the section (a tiny/empty
1066                                // section holds only its header + a stub directory)
1067    let head = reader.read_at(section.offset, init)?;
1068    let (header_len, n0) =
1069        read_uvarint(&head).ok_or(FileError::Container("truncated dict header len"))?;
1070    let hbase = n0; // first byte of the header body
1071    let (term_count, n1) = read_uvarint(head.get(hbase..).unwrap_or(&[]))
1072        .ok_or(FileError::Container("truncated dict term_count"))?;
1073    let (restart_interval, _n2) = read_uvarint(head.get(hbase + n1..).unwrap_or(&[]))
1074        .ok_or(FileError::Container("truncated dict interval"))?;
1075    if restart_interval == 0 {
1076        return Err(FileError::Container("zero restart interval"));
1077    }
1078    // The chunk directory begins right after the header body — i.e. past the
1079    // `header_len` bytes, which include the restart table we never materialize.
1080    let dir_start = (hbase as u64)
1081        .checked_add(header_len)
1082        .filter(|&d| d <= total)
1083        .ok_or(FileError::Container("dict header overruns section"))?;
1084    let dir_total = total - dir_start;
1085    let meta = crate::dict::SectionMeta {
1086        term_count: term_count as u32,
1087        restart_interval: restart_interval as u32,
1088        restart_offsets: Vec::new(),
1089    };
1090    let finish = |mut entries: Vec<DictChunkEntry>| {
1091        for e in &mut entries {
1092            e.start += dir_start; // dir-relative → section-relative
1093            e.end += dir_start;
1094        }
1095        (meta.clone(), entries)
1096    };
1097    // Fast path: the directory already sits in the prefix we read (small section
1098    // — its restart table is tiny, so the ~few KiB over-read is negligible).
1099    // A short prefix is not wasted: those bytes seed the probe below.
1100    let mut have: Vec<u8> = Vec::new();
1101    if dir_start < head.len() as u64 {
1102        match parse_chunk_dir_only(&head[dir_start as usize..], dir_total)? {
1103            ChunkDirParse::Done(entries) => return Ok(finish(entries)),
1104            ChunkDirParse::Truncated { .. } => have = head[dir_start as usize..].to_vec(),
1105        }
1106    }
1107    // Big section: range-read the directory on its own, skipping the table.
1108    //
1109    // Nothing records the directory's byte length, so it has to be probed. Two
1110    // things keep that probe near ONE directory's worth of bytes. Each round
1111    // **appends** to the bytes already held instead of re-reading from the
1112    // start — the previous loop re-read the whole prefix every time, so a
1113    // 234 MB directory (epfl-infoscience's object section) cost 537 MB of range
1114    // reads to fetch. And a truncated parse reports how many entries fit in how
1115    // many bytes, which extrapolates the rest instead of blindly doubling. The
1116    // extrapolation may only quadruple a round, so a wild guess on a directory
1117    // with wildly uneven entries (one stored term can be hundreds of KB) can
1118    // never fetch the section body.
1119    let mut want = 4096u64.min(dir_total).max(1);
1120    loop {
1121        let held = have.len() as u64;
1122        if want > held {
1123            let extra = reader.read_at(section.offset + dir_start + held, want - held)?;
1124            if extra.is_empty() {
1125                return Err(FileError::Container("truncated dict chunk directory"));
1126            }
1127            have.extend_from_slice(&extra);
1128        }
1129        let held = have.len() as u64;
1130        match parse_chunk_dir_only(&have, dir_total)? {
1131            ChunkDirParse::Done(entries) => return Ok(finish(entries)),
1132            ChunkDirParse::Truncated { .. } if held >= dir_total => {
1133                return Err(FileError::Container("truncated dict chunk directory"));
1134            }
1135            ChunkDirParse::Truncated {
1136                parsed,
1137                used,
1138                total,
1139            } => {
1140                // Extrapolate from a sample big enough to mean something;
1141                // otherwise just double. Undershooting only costs another
1142                // (append-only) round, so the estimate carries a small margin
1143                // rather than a generous one.
1144                let est = if parsed >= 16 && used > 0 {
1145                    let whole = ((used as u64) / (parsed as u64)).saturating_mul(total as u64);
1146                    whole.saturating_add(whole / 8).saturating_add(64)
1147                } else {
1148                    // Too small a sample to extrapolate from: double.
1149                    held.saturating_mul(2)
1150                };
1151                want = est
1152                    .max(held.saturating_add(1))
1153                    .min(held.saturating_mul(4))
1154                    .min(dir_total);
1155            }
1156        }
1157    }
1158}
1159
1160/// What [`parse_chunk_dir_only`] made of a chunk-directory prefix: either the
1161/// whole directory, or how far a truncated one got (the probe's step hint).
1162/// Truncation is not an error here — the caller is deliberately reading a
1163/// prefix and deciding how much more to fetch.
1164enum ChunkDirParse {
1165    Done(Vec<DictChunkEntry>),
1166    Truncated {
1167        /// Entries fully decoded from the prefix.
1168        parsed: usize,
1169        /// Bytes those entries occupied (directory-relative).
1170        used: usize,
1171        /// Chunk count the directory declares.
1172        total: usize,
1173    },
1174}
1175
1176/// Parse just the chunk directory (the bytes after a section header):
1177/// `[num_chunks][per chunk: Δfirst_run, key_len, key, comp_len]`.
1178/// Chunk byte ranges (`start`/`end`) come back relative to the directory's own
1179/// start; `body_start` is 0 (a lite section never uses it — lookups derive run
1180/// offsets per chunk). Bodies aren't needed here, so `dir` may end at the first
1181/// body as long as it covers the whole directory. A `dir` that stops mid-entry
1182/// is reported as [`ChunkDirParse::Truncated`], not an error: only bytes that
1183/// cannot be a directory at any length (a chunk range overrunning the section)
1184/// fail.
1185fn parse_chunk_dir_only(dir: &[u8], dir_total: u64) -> Result<ChunkDirParse, FileError> {
1186    let mut pos = 0usize;
1187    let take = |pos: &mut usize| -> Option<u64> {
1188        let (v, n) = read_uvarint(dir.get(*pos..).unwrap_or(&[]))?;
1189        *pos += n;
1190        Some(v)
1191    };
1192    let Some(num_chunks) = take(&mut pos) else {
1193        return Ok(ChunkDirParse::Truncated {
1194            parsed: 0,
1195            used: 0,
1196            total: 0,
1197        });
1198    };
1199    let num_chunks = num_chunks as usize;
1200    let mut entries = Vec::with_capacity(num_chunks.min(dir.len()));
1201    let mut lens: Vec<u64> = Vec::with_capacity(num_chunks.min(dir.len()));
1202    let mut prev_run = 0usize;
1203    for _ in 0..num_chunks {
1204        let entry_start = pos;
1205        let short = ChunkDirParse::Truncated {
1206            parsed: entries.len(),
1207            used: entry_start,
1208            total: num_chunks,
1209        };
1210        let (Some(drun), Some(klen)) = (take(&mut pos), take(&mut pos)) else {
1211            return Ok(short);
1212        };
1213        let (drun, klen) = (drun as usize, klen as usize);
1214        let Some(key) = dir.get(pos..pos.saturating_add(klen)) else {
1215            return Ok(short);
1216        };
1217        let key = key.to_vec();
1218        pos += klen;
1219        let Some(clen) = take(&mut pos) else {
1220            return Ok(short);
1221        };
1222        let first_run = prev_run + drun;
1223        entries.push(DictChunkEntry {
1224            first_run,
1225            key,
1226            body_start: 0,
1227            start: 0,
1228            end: 0,
1229        });
1230        lens.push(clen);
1231        prev_run = first_run;
1232    }
1233    let mut start = pos as u64;
1234    for (e, len) in entries.iter_mut().zip(lens) {
1235        let end = start
1236            .checked_add(len)
1237            .filter(|&e| e <= dir_total)
1238            .ok_or(FileError::Container("dict chunk overruns section"))?;
1239        e.start = start;
1240        e.end = end;
1241        start = end;
1242    }
1243    Ok(ChunkDirParse::Done(entries))
1244}
1245
1246/// Decode one chunked dictionary section payload into a resident
1247/// [`crate::dict::ChunkedSection`] (chunks decompressed up front — the local
1248/// open path).
1249fn decode_chunked_dict_section(
1250    payload: &[u8],
1251    codec: u8,
1252    cache: std::sync::Arc<crate::chunk_cache::ChunkCache>,
1253    section_index: u8,
1254) -> Result<crate::dict::ChunkedSection, FileError> {
1255    let (meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64)?;
1256    let chunks = entries
1257        .into_iter()
1258        .map(|e| {
1259            let body = decompress(codec, &payload[e.start as usize..e.end as usize])?;
1260            Ok((
1261                crate::dict::SectionChunk::new(e.first_run, e.key, e.body_start),
1262                body,
1263            ))
1264        })
1265        .collect::<Result<Vec<_>, FileError>>()?;
1266    Ok(crate::dict::ChunkedSection::resident(
1267        meta,
1268        chunks,
1269        cache,
1270        section_index,
1271    ))
1272}
1273
1274fn decode_dictionary_container(bytes: &[u8], codec: u8) -> Result<Dictionary, FileError> {
1275    let dsecs = decode_container(bytes, CODEC_NONE)?;
1276    if dsecs.len() != 4 {
1277        return Err(FileError::Container("expected 4 dictionary sections"));
1278    }
1279    // The four sections share one (unlimited) cache — residency is identical to
1280    // the previous per-chunk `OnceLock`s.
1281    let cache = crate::chunk_cache::ChunkCache::unlimited_arc();
1282    let mut sections = Vec::with_capacity(4);
1283    for (si, sec) in dsecs.iter().enumerate() {
1284        sections.push(decode_chunked_dict_section(
1285            sec,
1286            codec,
1287            cache.clone(),
1288            si as u8,
1289        )?);
1290    }
1291    let arr: [crate::dict::ChunkedSection; 4] = sections
1292        .try_into()
1293        .map_err(|_| FileError::Container("expected 4 dictionary sections"))?;
1294    Ok(Dictionary::from_chunked_sections(arr))
1295}
1296
1297/// Encode one permutation's tiled section payload (format v0.2):
1298/// `[num_tiles][per tile: delta(min_a), max_a - min_a, compressed_len][tiles…]`,
1299/// each tile compressed independently with `codec` so a ranged reader can
1300/// fetch and decompress exactly the tiles a query routes to. The directory
1301/// itself is uncompressed (it must be readable before any tile).
1302fn encode_tiled_section(index: &GraphIndex, si: usize, codec: u8) -> Vec<u8> {
1303    let tiles = index.tile_sections()[si];
1304    let n = tiles.len();
1305    // Tile bodies live in the index's chunk cache (keyed by (section, tile));
1306    // fetch each as a shared handle. For a locally-built index every tile is
1307    // resident, so this is a cheap `Arc` clone under an uncontended lock.
1308    // Per-tile compression is the bulk of serialization time on a large graph and
1309    // the tiles are independent, so compress them across all cores. `par_iter`
1310    // preserves order, so the output is byte-identical to the serial map.
1311    #[cfg(feature = "parallel")]
1312    let compressed: Vec<Vec<u8>> = {
1313        use rayon::prelude::*;
1314        (0..n)
1315            .into_par_iter()
1316            .map(|ti| compress(codec, &index.tile_body(si, ti)))
1317            .collect()
1318    };
1319    #[cfg(not(feature = "parallel"))]
1320    let compressed: Vec<Vec<u8>> = (0..n)
1321        .map(|ti| compress(codec, &index.tile_body(si, ti)))
1322        .collect();
1323    let mut out = Vec::new();
1324    write_uvarint(&mut out, tiles.len() as u64);
1325    let mut prev_min = 0u32;
1326    for (tile, comp) in tiles.iter().zip(&compressed) {
1327        let (min_a, max_a) = tile.leading_range();
1328        write_uvarint(&mut out, (min_a - prev_min) as u64);
1329        write_uvarint(&mut out, (max_a - min_a) as u64);
1330        write_uvarint(&mut out, comp.len() as u64);
1331        prev_min = min_a;
1332    }
1333    for comp in &compressed {
1334        out.extend_from_slice(comp);
1335    }
1336    // Tile-synopsis trailer (FLAG_TILE_SYNOPSIS): per tile, the inclusive min/max
1337    // of the two non-leading columns `(min_b, span_b, min_c, span_c)`, derived
1338    // from each tile's zone map. Appended **after** the tile payloads so a reader
1339    // that predates the flag — which locates tiles by length and stops — never
1340    // reads it (backward-compatible). A reader honoring the flag reads it from the
1341    // section tail. On the (impossible for a built tile) parse failure, emit a
1342    // full range so nothing is ever wrongly pruned.
1343    for ti in 0..n {
1344        let body = index.tile_body(si, ti);
1345        let (min_b, max_b, min_c, max_c) = match crate::triples::TripleBlock::parse(&body) {
1346            Ok(b) => {
1347                let z = b.zone();
1348                (z.min_b, z.max_b, z.min_c, z.max_c)
1349            }
1350            Err(_) => (0, u32::MAX, 0, u32::MAX),
1351        };
1352        write_uvarint(&mut out, min_b as u64);
1353        write_uvarint(&mut out, (max_b - min_b) as u64);
1354        write_uvarint(&mut out, min_c as u64);
1355        write_uvarint(&mut out, (max_c - min_c) as u64);
1356    }
1357    out
1358}
1359
1360/// A parsed v0.2 tile-directory entry: leading-id range plus the tile's byte
1361/// range *within the section payload*.
1362struct TileDirEntry {
1363    min_a: u32,
1364    max_a: u32,
1365    start: u64,
1366    end: u64,
1367}
1368
1369/// One tile's synopsis: inclusive min/max of the two non-leading columns.
1370type TileSynopsis = (u32, u32, u32, u32);
1371
1372/// Parse the **tile-synopsis trailer** (when [`FLAG_TILE_SYNOPSIS`] is set): the
1373/// `num_tiles × (min_b, span_b, min_c, span_c)` records that follow the last tile
1374/// payload, starting at `trailer_start` within `payload`. Returns one synopsis per
1375/// tile, in directory order. `payload` may be just the trailer slice (remote) or
1376/// the whole section (local); `trailer_start` is the offset of the trailer within
1377/// it. A short/garbled trailer yields `None` (the caller keeps `None` synopses —
1378/// pruning simply doesn't fire, never a wrong result).
1379fn parse_tile_synopsis(
1380    payload: &[u8],
1381    trailer_start: usize,
1382    num_tiles: usize,
1383) -> Option<Vec<TileSynopsis>> {
1384    let mut pos = trailer_start;
1385    let take = |pos: &mut usize| -> Option<u32> {
1386        let (v, n) = read_uvarint(payload.get(*pos..)?)?;
1387        *pos += n;
1388        u32::try_from(v).ok()
1389    };
1390    let mut out = Vec::with_capacity(num_tiles.min(payload.len()));
1391    for _ in 0..num_tiles {
1392        let min_b = take(&mut pos)?;
1393        let max_b = min_b.checked_add(take(&mut pos)?)?;
1394        let min_c = take(&mut pos)?;
1395        let max_c = min_c.checked_add(take(&mut pos)?)?;
1396        out.push((min_b, max_b, min_c, max_c));
1397    }
1398    Some(out)
1399}
1400
1401/// Parse a tiled section payload's directory (not the tiles). `bytes` may be a
1402/// **prefix** of the payload (a ranged reader fetches the directory before any
1403/// tile); tile byte ranges are validated against `total_len`, the full payload
1404/// length. Every length is untrusted.
1405fn parse_tile_directory(bytes: &[u8], total_len: u64) -> Result<Vec<TileDirEntry>, FileError> {
1406    let mut pos = 0usize;
1407    let take = |pos: &mut usize| -> Result<u64, FileError> {
1408        let (v, n) = read_uvarint(bytes.get(*pos..).unwrap_or(&[]))
1409            .ok_or(FileError::Container("truncated tile directory"))?;
1410        *pos += n;
1411        Ok(v)
1412    };
1413    let num_tiles = take(&mut pos)? as usize;
1414    let mut entries = Vec::with_capacity(num_tiles.min(bytes.len()));
1415    let mut prev_min = 0u32;
1416    let mut lens = Vec::with_capacity(num_tiles.min(bytes.len()));
1417    for _ in 0..num_tiles {
1418        let dmin = take(&mut pos)? as u32;
1419        let span = take(&mut pos)? as u32;
1420        let len = take(&mut pos)?;
1421        let min_a = prev_min.wrapping_add(dmin);
1422        entries.push(TileDirEntry {
1423            min_a,
1424            max_a: min_a.wrapping_add(span),
1425            start: 0,
1426            end: 0,
1427        });
1428        lens.push(len);
1429        prev_min = min_a;
1430    }
1431    let mut start = pos as u64;
1432    for (e, len) in entries.iter_mut().zip(lens) {
1433        let end = start
1434            .checked_add(len)
1435            .filter(|&e| e <= total_len)
1436            .ok_or(FileError::Container("tile overruns section"))?;
1437        e.start = start;
1438        e.end = end;
1439        start = end;
1440    }
1441    Ok(entries)
1442}
1443
1444/// Fetch and parse a remote tiled section's directory: read a small prefix and
1445/// grow it geometrically until the directory parses, never fetching past the
1446/// section. A directory that still fails on the whole section is corrupt.
1447fn read_tile_directory_ranged<R: RangeReader>(
1448    reader: &R,
1449    section: ByteRange,
1450) -> Result<Vec<TileDirEntry>, FileError> {
1451    let total = section.len;
1452    let mut prefetch = 4096u64.min(total);
1453    loop {
1454        let prefix = reader.read_at(section.offset, prefetch)?;
1455        match parse_tile_directory(&prefix, total) {
1456            Ok(dir) => return Ok(dir),
1457            Err(_) if prefetch < total => prefetch = prefetch.saturating_mul(2).min(total),
1458            Err(e) => return Err(e),
1459        }
1460    }
1461}
1462
1463/// Fetch and parse a remote section's **tile-synopsis trailer** (only when the
1464/// header's [`FLAG_TILE_SYNOPSIS`] is set): one targeted range read of the bytes
1465/// past the last tile, parsed into one synopsis per tile (directory order). A
1466/// missing/short/garbled trailer degrades to all-`None` — pruning simply doesn't
1467/// fire, never a wrong result. The directory gives the trailer's start (the last
1468/// tile's end).
1469fn read_tile_synopsis_ranged<R: RangeReader>(
1470    reader: &R,
1471    section: ByteRange,
1472    dir: &[TileDirEntry],
1473) -> Vec<Option<TileSynopsis>> {
1474    let n = dir.len();
1475    let none = vec![None; n];
1476    let trailer_start = dir.iter().map(|e| e.end).max().unwrap_or(0);
1477    let total = section.len;
1478    if n == 0 || trailer_start >= total {
1479        return none; // no trailer bytes present
1480    }
1481    let trailer_len = total - trailer_start;
1482    let Ok(bytes) = reader.read_at(section.offset + trailer_start, trailer_len) else {
1483        return none;
1484    };
1485    match parse_tile_synopsis(&bytes, 0, n) {
1486        Some(v) => v.into_iter().map(Some).collect(),
1487        None => none,
1488    }
1489}
1490
1491/// Per-tile absolute file ranges of each permutation section, for provenance.
1492/// A malformed directory yields an empty section (provenance degrades, queries
1493/// are unaffected).
1494fn tile_file_ranges(
1495    index_bytes: &[u8],
1496    container_offset: u64,
1497    section_ranges: &[ByteRange; NUM_PERMS],
1498) -> [Vec<(u32, u32, ByteRange)>; NUM_PERMS] {
1499    let mut out: [Vec<(u32, u32, ByteRange)>; NUM_PERMS] = Default::default();
1500    for (section, range) in out.iter_mut().zip(section_ranges) {
1501        // A permutation the file does not carry has a zeroed range. Its
1502        // `offset - container_offset` underflows, and before this guard it
1503        // panicked the whole `open_ranged` path on the first lean file.
1504        if range.len == 0 || range.offset < container_offset {
1505            continue;
1506        }
1507        let start = (range.offset - container_offset) as usize;
1508        let Some(payload) = index_bytes.get(start..start + range.len as usize) else {
1509            continue;
1510        };
1511        if let Ok(dir) = parse_tile_directory(payload, payload.len() as u64) {
1512            *section = dir
1513                .into_iter()
1514                .map(|e| {
1515                    (
1516                        e.min_a,
1517                        e.max_a,
1518                        ByteRange {
1519                            offset: range.offset + e.start,
1520                            len: (e.end - e.start),
1521                        },
1522                    )
1523                })
1524                .collect();
1525        }
1526    }
1527    out
1528}
1529
1530/// Decode a tiled section payload into `(min_a, max_a, uncompressed tile)`
1531/// triples.
1532fn decode_tiled_section(payload: &[u8], codec: u8) -> Result<Vec<(u32, u32, Vec<u8>)>, FileError> {
1533    parse_tile_directory(payload, payload.len() as u64)?
1534        .into_iter()
1535        .map(|e| {
1536            Ok((
1537                e.min_a,
1538                e.max_a,
1539                decompress(codec, &payload[e.start as usize..e.end as usize])?,
1540            ))
1541        })
1542        .collect()
1543}
1544
1545/// Decode the index container: one raw tiled section payload per permutation
1546/// the file carries (`perms`, from the header — [`PermSet::ALL`] for every file
1547/// written before the mask existed), each tile compressed individually.
1548///
1549/// The section count is checked against `perms.len()`, so a file whose header
1550/// and container disagree is rejected rather than silently short-read. A reader
1551/// that does not know about lean files at all reaches the same conclusion by a
1552/// different route: it passes six, the container says three, and this errors —
1553/// which is why a lean file cannot be misread as an empty one.
1554fn decode_index_container(
1555    bytes: &[u8],
1556    codec: u8,
1557    perms: PermSet,
1558) -> Result<GraphIndex, FileError> {
1559    let isecs = decode_container(bytes, CODEC_NONE)?;
1560    if isecs.len() != perms.len() {
1561        return Err(FileError::Container(
1562            "index container section count does not match the header permutation mask",
1563        ));
1564    }
1565    decode_index_sections(isecs, codec, perms)
1566}
1567
1568fn decode_index_sections(
1569    mut isecs: Vec<Vec<u8>>,
1570    codec: u8,
1571    perms: PermSet,
1572) -> Result<GraphIndex, FileError> {
1573    let mut sections: [Vec<(u32, u32, Vec<u8>)>; NUM_PERMS] = Default::default();
1574    for (perm, sec) in perms.iter().zip(isecs.iter_mut()) {
1575        sections[perm.section_index()] = decode_tiled_section(sec, codec)?;
1576    }
1577    Ok(GraphIndex::from_tiles(sections, perms))
1578}
1579
1580/// Decode the file's **root** index container (the default graph): the
1581/// permutation sections, plus — on a quad-index file — the trailing seal, which
1582/// is checked and set aside. Returns the seal's digest of the QUAD_INDEX
1583/// section when there is one.
1584fn decode_root_index_container(
1585    bytes: &[u8],
1586    header: &Header,
1587) -> Result<(GraphIndex, Option<[u8; 16]>), FileError> {
1588    if !header.has_quad_index() {
1589        return Ok((
1590            decode_index_container(bytes, header.block_codec, header.perms)?,
1591            None,
1592        ));
1593    }
1594    let mut isecs = decode_container(bytes, CODEC_NONE)?;
1595    if isecs.len() != header.index_container_sections() {
1596        return Err(FileError::Container(
1597            "index container section count does not match the header permutation mask",
1598        ));
1599    }
1600    let seal = isecs.pop().unwrap_or_default();
1601    let digest = crate::quad::parse_seal(&seal)?;
1602    Ok((
1603        decode_index_sections(isecs, header.block_codec, header.perms)?,
1604        Some(digest),
1605    ))
1606}
1607
1608/// The named-graph layout a header announces must be the one its directory
1609/// holds: the quad flag with a QUAD_INDEX section and no NAMED_GRAPHS section,
1610/// or no flag and no QUAD_INDEX section. Anything else is a corrupt (or a
1611/// hand-edited) header, refused before either layout is read.
1612fn check_named_layout(header: &Header) -> Result<(), FileError> {
1613    let quad = header.quad_index_section().is_some();
1614    if header.has_quad_index() {
1615        if !quad {
1616            return Err(FileError::Container(
1617                "quad-index flag set but the file has no quad-index section",
1618            ));
1619        }
1620        if header.named_graphs_len > 0 {
1621            return Err(FileError::Container(
1622                "a file cannot carry both a quad index and a named-graphs section",
1623            ));
1624        }
1625        crate::quad::check_perms(header.perms)?;
1626    } else if quad {
1627        return Err(FileError::Container(
1628            "quad-index section present but the header does not flag it",
1629        ));
1630    }
1631    Ok(())
1632}
1633
1634fn container_section_payload_ranges(
1635    bytes: &[u8],
1636    container_offset: u64,
1637    expected_sections: usize,
1638) -> Result<Vec<ByteRange>, FileError> {
1639    let (section_count, mut pos) =
1640        read_uvarint(bytes).ok_or(FileError::Container("truncated count"))?;
1641    let section_count = usize::try_from(section_count)
1642        .map_err(|_| FileError::Container("section count too large"))?;
1643    if section_count != expected_sections {
1644        return Err(FileError::Container("unexpected section count"));
1645    }
1646
1647    let mut ranges = Vec::with_capacity(section_count);
1648    for _ in 0..section_count {
1649        let remaining = bytes
1650            .get(pos..)
1651            .ok_or(FileError::Container("truncated length"))?;
1652        let (payload_len, used) =
1653            read_uvarint(remaining).ok_or(FileError::Container("truncated length"))?;
1654        pos = pos
1655            .checked_add(used)
1656            .ok_or(FileError::Container("section range overflows"))?;
1657        let payload_len_usize = usize::try_from(payload_len)
1658            .map_err(|_| FileError::Container("section length too large"))?;
1659        let payload_end = pos
1660            .checked_add(payload_len_usize)
1661            .ok_or(FileError::Container("section range overflows"))?;
1662        if payload_end > bytes.len() {
1663            return Err(FileError::Container("section overruns buffer"));
1664        }
1665        ranges.push(ByteRange {
1666            offset: checked_end(container_offset, pos as u64)?,
1667            len: payload_len,
1668        });
1669        pos = payload_end;
1670    }
1671
1672    Ok(ranges)
1673}
1674
1675/// The permutation payload ranges of an index container holding `sections`
1676/// sections (the permutations, then — on a quad-index root — the seal, which
1677/// is not a permutation and is left out).
1678fn decode_index_section_ranges(
1679    bytes: &[u8],
1680    container_offset: u64,
1681    perms: PermSet,
1682    sections: usize,
1683) -> Result<[ByteRange; NUM_PERMS], FileError> {
1684    let ranges = container_section_payload_ranges(bytes, container_offset, sections)?;
1685    let mut out = [ByteRange { offset: 0, len: 0 }; NUM_PERMS];
1686    for (perm, range) in perms.iter().zip(ranges) {
1687        out[perm.section_index()] = range;
1688    }
1689    Ok(out)
1690}
1691
1692fn read_uvarint_at<R: RangeReader + ?Sized>(
1693    reader: &R,
1694    absolute_offset: u64,
1695    container_end: u64,
1696) -> Result<(u64, u64), FileError> {
1697    if absolute_offset >= container_end {
1698        return Err(FileError::Container("truncated container varint"));
1699    }
1700    let remaining = container_end - absolute_offset;
1701    let probe_len = remaining.min(10);
1702    let bytes = reader.read_at(absolute_offset, probe_len)?;
1703    read_uvarint(&bytes)
1704        .map(|(value, used)| (value, used as u64))
1705        .ok_or(FileError::Container("truncated container varint"))
1706}
1707
1708/// Locate one section's payload byte range inside a remote container, walking
1709/// only the (tiny) varint framing — no payload bytes are fetched.
1710fn locate_container_section_ranged<R: RangeReader + ?Sized>(
1711    reader: &R,
1712    container_offset: u64,
1713    container_len: u64,
1714    section_index: usize,
1715    expected_sections: u64,
1716) -> Result<ByteRange, FileError> {
1717    let container_end = checked_end(container_offset, container_len)?;
1718    let (section_count, used) = read_uvarint_at(reader, container_offset, container_end)?;
1719    if section_count != expected_sections {
1720        return Err(FileError::Container("unexpected container section count"));
1721    }
1722    if section_index >= section_count as usize {
1723        return Err(FileError::Container(
1724            "container section index out of bounds",
1725        ));
1726    }
1727
1728    let mut pos = checked_end(container_offset, used)?;
1729    for i in 0..section_count as usize {
1730        let (payload_len, len_used) = read_uvarint_at(reader, pos, container_end)?;
1731        pos = checked_end(pos, len_used)?;
1732        let payload_end = checked_end(pos, payload_len)?;
1733        if payload_end > container_end {
1734            return Err(FileError::Container("section overruns buffer"));
1735        }
1736        if i == section_index {
1737            return Ok(ByteRange {
1738                offset: pos,
1739                len: payload_len,
1740            });
1741        }
1742        pos = payload_end;
1743    }
1744    Err(FileError::Container("container section not found"))
1745}
1746
1747/// Open one index container (six tiled permutation sections) **lazily** over a
1748/// range reader: fetch each section's tile directory (and synopsis trailer,
1749/// when the file has one) but no tile payloads — tiles fault in on first scan.
1750/// Returns the index plus its section and tile file ranges (provenance).
1751///
1752/// This is the machinery `open_ranged_lazy` always used for the default
1753/// graph's container at `root_dir_offset`; a named graph's container is the
1754/// same format at a different offset, so the lazy named-graphs path opens a
1755/// LARGE graph through this too instead of decoding it resident.
1756#[allow(clippy::type_complexity)]
1757fn open_index_container_lazy(
1758    reader: &std::sync::Arc<dyn RangeReader + Send + Sync>,
1759    container: ByteRange,
1760    block_codec: u8,
1761    has_synopsis: bool,
1762    read_concurrency: usize,
1763    perms: PermSet,
1764    sections: usize,
1765) -> Result<
1766    (
1767        GraphIndex,
1768        [ByteRange; NUM_PERMS],
1769        [Vec<(u32, u32, ByteRange)>; NUM_PERMS],
1770    ),
1771    FileError,
1772> {
1773    let mut index_section_ranges = [ByteRange { offset: 0, len: 0 }; NUM_PERMS];
1774    let mut tile_ranges: [Vec<(u32, u32, ByteRange)>; NUM_PERMS] = Default::default();
1775    #[allow(clippy::type_complexity)]
1776    let mut directories: [Vec<(u32, u32, Option<TileSynopsis>)>; NUM_PERMS] = Default::default();
1777    for (pos, perm) in perms.iter().enumerate() {
1778        let si = perm.section_index();
1779        let section = locate_container_section_ranged(
1780            reader,
1781            container.offset,
1782            container.len,
1783            pos,
1784            sections as u64,
1785        )?;
1786        index_section_ranges[si] = section;
1787        let dir = read_tile_directory_ranged(reader, section)?;
1788        // Tile synopses (one extra small tail read per section) let a routed
1789        // scan prune a tile by a bound secondary component before faulting it.
1790        let syn = if has_synopsis {
1791            read_tile_synopsis_ranged(reader, section, &dir)
1792        } else {
1793            vec![None; dir.len()]
1794        };
1795        directories[si] = dir
1796            .iter()
1797            .zip(syn)
1798            .map(|(e, s)| (e.min_a, e.max_a, s))
1799            .collect();
1800        tile_ranges[si] = dir
1801            .into_iter()
1802            .map(|e| {
1803                (
1804                    e.min_a,
1805                    e.max_a,
1806                    ByteRange {
1807                        offset: section.offset + e.start,
1808                        len: (e.end - e.start),
1809                    },
1810                )
1811            })
1812            .collect();
1813    }
1814
1815    // The loader fetches and decompresses one tile per call; the bulk
1816    // loader serves multi-tile scans by coalescing adjacent tile ranges
1817    // into single range reads (tiles are back-to-back in their section,
1818    // so a full-section scan is typically one request).
1819    let codec = block_codec;
1820    let loader_ranges = tile_ranges.clone();
1821    let loader_reader = reader.clone();
1822    let loader: crate::index::TileLoader = Box::new(move |si, ti| {
1823        let (_, _, range) = loader_ranges.get(si)?.get(ti)?;
1824        let bytes = loader_reader.read_at(range.offset, range.len).ok()?;
1825        decompress(codec, &bytes).ok()
1826    });
1827    let bulk_ranges = tile_ranges.clone();
1828    let bulk_reader = reader.clone();
1829    let bulk: crate::index::TileBulkLoader = Box::new(move |si, tis| {
1830        let section = bulk_ranges.get(si)?;
1831        let want: Option<Vec<ByteRange>> = tis
1832            .iter()
1833            .map(|&ti| section.get(ti).map(|&(_, _, r)| r))
1834            .collect();
1835        let blobs = read_coalesced(bulk_reader.as_ref(), &want?, TILE_COALESCE_GAP)?;
1836        blobs.iter().map(|b| decompress(codec, b).ok()).collect()
1837    });
1838    let mut index =
1839        GraphIndex::from_remote_directories(directories, perms, loader).with_bulk_loader(bulk);
1840    // Per-tile encoded lengths (from the directory) feed the join planner's
1841    // fatness gates — free here, unavailable later without a fetch.
1842    index.set_tile_lens(std::array::from_fn(|si| {
1843        tile_ranges[si]
1844            .iter()
1845            .map(|&(_, _, r)| r.len.min(u32::MAX as u64) as u32)
1846            .collect()
1847    }));
1848    // The reader's fan-out widens the planner's remote probe budget: a
1849    // desktop/CLI reader overlapping 16 range reads probes far more cheaply
1850    // than a phone's serial sync-XHR path.
1851    index.set_read_concurrency(read_concurrency);
1852    Ok((index, index_section_ranges, tile_ranges))
1853}
1854
1855/// Serialize a complete `.rete` file image from a dictionary, index, and an
1856/// (optionally empty) encoded pyramid-meta section. `pyramid_levels` records the
1857/// number of dendrogram rounds the pyramid spans (0 if no pyramid).
1858pub fn write_file(
1859    dict: &Dictionary,
1860    index: &GraphIndex,
1861    has_quads: bool,
1862    pyramid_meta: &[u8],
1863    pyramid_levels: u16,
1864) -> Vec<u8> {
1865    write_dataset(dict, index, &[], has_quads, pyramid_meta, pyramid_levels)
1866}
1867
1868/// Encode an index container (v0.2): one raw tiled section payload per
1869/// permutation the index carries, in [`crate::index::ALL_PERMS`] order, tiles
1870/// compressed individually with `codec`. A three-permutation index writes three
1871/// sections — not six with three empty, which would be indistinguishable from
1872/// an empty graph to a reader that does not check the header mask.
1873pub(crate) fn encode_index_container(index: &GraphIndex, codec: u8) -> Vec<u8> {
1874    encode_index_container_sealed(index, codec, None)
1875}
1876
1877/// [`encode_index_container`] with an optional trailing **seal** section —
1878/// the root container of a quad-index file (see `quad.rs`). The seal is what
1879/// makes every reader that predates the quad index refuse the file: they all
1880/// check the container's section count before reading a payload.
1881fn encode_index_container_sealed(index: &GraphIndex, codec: u8, seal: Option<&[u8]>) -> Vec<u8> {
1882    let payloads: Vec<Vec<u8>> = index
1883        .perms()
1884        .iter()
1885        .map(|perm| encode_tiled_section(index, perm.section_index(), codec))
1886        .collect();
1887    let mut refs: Vec<&[u8]> = payloads.iter().map(|p| p.as_slice()).collect();
1888    if let Some(seal) = seal {
1889        refs.push(seal);
1890    }
1891    encode_container(&refs, CODEC_NONE)
1892}
1893
1894/// Encode the named-graphs section: each graph as `(iri, permutation container)`.
1895fn encode_named_graphs(named: &[(String, GraphIndex)], codec: u8) -> Vec<u8> {
1896    let mut out = Vec::new();
1897    write_uvarint(&mut out, named.len() as u64);
1898    for (iri, index) in named {
1899        write_uvarint(&mut out, iri.len() as u64);
1900        out.extend_from_slice(iri.as_bytes());
1901        let container = encode_index_container(index, codec);
1902        write_uvarint(&mut out, container.len() as u64);
1903        out.extend_from_slice(&container);
1904    }
1905    out
1906}
1907
1908fn decode_named_graphs(
1909    bytes: &[u8],
1910    codec: u8,
1911    perms: PermSet,
1912) -> Result<Vec<(String, GraphIndex)>, FileError> {
1913    let (n, mut pos) = read_uvarint(bytes).ok_or(FileError::Container("truncated graph count"))?;
1914    // Bounds-checked slice within this (already bounded) section. Lengths read
1915    // below are untrusted, so every range is validated before indexing.
1916    let bound = |start: usize, len: u64| -> Result<usize, FileError> {
1917        start
1918            .checked_add(len as usize)
1919            .filter(|&e| e <= bytes.len())
1920            .ok_or(FileError::Container("named-graph field overruns buffer"))
1921    };
1922    let mut out = Vec::with_capacity((n as usize).min(bytes.len()));
1923    for _ in 0..n {
1924        let (ilen, u1) = read_uvarint(bytes.get(pos..).unwrap_or(&[]))
1925            .ok_or(FileError::Container("truncated iri len"))?;
1926        pos += u1;
1927        let iend = bound(pos, ilen)?;
1928        let iri = String::from_utf8_lossy(&bytes[pos..iend]).into_owned();
1929        pos = iend;
1930        let (clen, u2) = read_uvarint(bytes.get(pos..).unwrap_or(&[]))
1931            .ok_or(FileError::Container("truncated container len"))?;
1932        pos += u2;
1933        let cend = bound(pos, clen)?;
1934        let index = decode_index_container(&bytes[pos..cend], codec, perms)?;
1935        out.push((iri, index));
1936        pos = cend;
1937    }
1938    Ok(out)
1939}
1940
1941/// Serialize a full RDF *dataset*: the default-graph index plus zero or more
1942/// named graphs `(iri, index)`, all sharing one dictionary.
1943pub fn write_dataset(
1944    dict: &Dictionary,
1945    default_index: &GraphIndex,
1946    named: &[(String, GraphIndex)],
1947    has_quads: bool,
1948    pyramid_meta: &[u8],
1949    pyramid_levels: u16,
1950) -> Vec<u8> {
1951    write_dataset_with_metadata(
1952        dict,
1953        default_index,
1954        named,
1955        has_quads,
1956        pyramid_meta,
1957        pyramid_levels,
1958        &[],
1959        &[],
1960    )
1961}
1962
1963/// Serialize a dictionary to its on-file container bytes (4 front-coded, chunked
1964/// sections). Exposed so a low-RAM build can serialize **and drop** the live
1965/// `Dictionary` before building the permutation index — the index build works on
1966/// id-triples and never needs the dictionary.
1967pub(crate) fn encode_dict_container(dict: &Dictionary, codec: u8) -> Vec<u8> {
1968    let raw_sections = dict.sections();
1969    let dict_payloads: Vec<Vec<u8>> = raw_sections
1970        .iter()
1971        .map(|raw| encode_chunked_dict_section(raw, codec))
1972        .collect();
1973    encode_container(
1974        &[
1975            dict_payloads[0].as_slice(),
1976            dict_payloads[1].as_slice(),
1977            dict_payloads[2].as_slice(),
1978            dict_payloads[3].as_slice(),
1979        ],
1980        CODEC_NONE,
1981    )
1982}
1983
1984/// Serialize a dataset with an opaque **metadata** payload occupying the file's
1985/// metadata section (the application layer defines its meaning — the CLI stores a
1986/// JSON Dataset Card there). The section sits immediately after the header and
1987/// before the dictionary, so `metadata_offset` stays at `HEADER_LEN` and every
1988/// downstream section shifts by `metadata.len()`. The payload is folded into the
1989/// `content_hash`, so `verify` covers it and it is tamper-evident.
1990///
1991/// Passing an empty `metadata` is byte-identical to [`write_dataset`]: the section
1992/// is omitted (`metadata_len = 0`, `dictionary_offset = HEADER_LEN`) and the hash
1993/// is computed over exactly the same parts (a zero-length hash update is a no-op).
1994#[allow(clippy::too_many_arguments)]
1995pub fn write_dataset_with_metadata(
1996    dict: &Dictionary,
1997    default_index: &GraphIndex,
1998    named: &[(String, GraphIndex)],
1999    has_quads: bool,
2000    pyramid_meta: &[u8],
2001    pyramid_levels: u16,
2002    metadata: &[u8],
2003    text_index: &[u8],
2004) -> Vec<u8> {
2005    let codec = writer_codec();
2006    let dict_container = encode_dict_container(dict, codec);
2007    // This entry point takes a finished section, so it reads the section's own
2008    // scope block to pick the directory kind (6, or 11 for a partial index) and
2009    // the header's named-graphs bit.
2010    let scope = (!text_index.is_empty())
2011        .then(|| crate::text_index::TextIndex::from_section(text_index, codec).ok())
2012        .flatten()
2013        .map(|ti| ti.scope().clone());
2014    let text_index_partial = scope
2015        .as_ref()
2016        .is_some_and(crate::text_index::TextIndexScope::restricts_predicates);
2017    let text_index_named = scope
2018        .as_ref()
2019        .is_some_and(crate::text_index::TextIndexScope::covers_named_graphs);
2020    write_dataset_from_parts(
2021        &dict_container,
2022        dict.term_count() as u64,
2023        default_index,
2024        NamedPart::PerGraph(named),
2025        has_quads,
2026        dict.has_quoted_triples(),
2027        pyramid_meta,
2028        pyramid_levels,
2029        metadata,
2030        text_index,
2031        text_index_partial,
2032        text_index_named,
2033        codec,
2034    )
2035}
2036
2037/// How the named graphs reach the writer: one index per graph (the
2038/// NAMED_GRAPHS section, kind 5), or an encoded quad index (kind 10).
2039pub(crate) enum NamedPart<'a> {
2040    PerGraph(&'a [(String, GraphIndex)]),
2041    Quad(&'a crate::quad::EncodedQuadIndex),
2042}
2043
2044/// Assemble the final file image from an **already-serialized** dictionary
2045/// container (so the caller can drop the live `Dictionary` before calling this)
2046/// plus the permutation index and optional sections. The byte output is identical
2047/// to serializing the dictionary inline.
2048#[allow(clippy::too_many_arguments)]
2049pub(crate) fn write_dataset_from_parts(
2050    dict_container: &[u8],
2051    term_count: u64,
2052    default_index: &GraphIndex,
2053    named: NamedPart<'_>,
2054    has_quads: bool,
2055    has_quoted_triples: bool,
2056    pyramid_meta: &[u8],
2057    pyramid_levels: u16,
2058    metadata: &[u8],
2059    text_index: &[u8],
2060    text_index_partial: bool,
2061    text_index_named: bool,
2062    codec: u8,
2063) -> Vec<u8> {
2064    // The named graphs: either the per-graph section (kind 5) or the quad index
2065    // (kind 10), whose digest the root container's seal carries. An empty quad
2066    // index is written as no named graphs at all.
2067    let (named_section, quad, named_quads) = match named {
2068        NamedPart::PerGraph(named) => (
2069            if named.is_empty() {
2070                Vec::new()
2071            } else {
2072                encode_named_graphs(named, codec)
2073            },
2074            None,
2075            named
2076                .iter()
2077                .map(|(_, idx)| idx.triple_count() as u64)
2078                .sum::<u64>(),
2079        ),
2080        NamedPart::Quad(q) if q.graph_count == 0 => (Vec::new(), None, 0),
2081        NamedPart::Quad(q) => (Vec::new(), Some(q), q.quad_count),
2082    };
2083    let seal = quad.map(|q| crate::quad::encode_seal(crate::quad::digest(&q.bytes)));
2084    let index_container = encode_index_container_sealed(default_index, codec, seal.as_deref());
2085    // A predicate-restricted index (kind 11) is outside the content hash, like
2086    // kinds 8 and 9, and starts with its own digest instead: a reader older
2087    // than 0.4.0 does not see kind 11, so a hashed one would make its `verify`
2088    // call every such file corrupt. A kind-6 index is hashed, as it always was.
2089    let text_index_partial = text_index_partial && !text_index.is_empty();
2090    let digested;
2091    let text_index: &[u8] = if text_index_partial {
2092        digested = crate::text_index::with_partial_digest(text_index);
2093        &digested
2094    } else {
2095        text_index
2096    };
2097
2098    // The metadata section (if any) sits between the header and the dictionary,
2099    // so the dictionary — and everything after it — shifts forward by its length.
2100    let meta_section_len = metadata.len() as u64;
2101    let dict_offset = HEADER_LEN as u64 + meta_section_len;
2102    let dict_len = dict_container.len() as u64;
2103    let index_offset = dict_offset + dict_len;
2104    let index_len = index_container.len() as u64;
2105    let pyr_offset = index_offset + index_len;
2106    let pyr_len = pyramid_meta.len() as u64;
2107    // Optional full-text index between the pyramid and the named graphs.
2108    let text_offset = pyr_offset + pyr_len;
2109    let text_len = text_index.len() as u64;
2110    let named_offset = text_offset + text_len;
2111    let named_len = named_section.len() as u64;
2112    // The quad index sits where the named-graphs section would (the two are
2113    // exclusive).
2114    let quad_len = quad.map_or(0, |q| q.bytes.len() as u64);
2115
2116    // Hash parts in physical order, with the metadata payload prepended when
2117    // present. Omitting it entirely (rather than hashing an empty slice) keeps the
2118    // no-metadata output's hash byte-identical to the pre-metadata writer.
2119    // `verify()` rebuilds this exact list from the header — any section added
2120    // here must be added there too (and covered by a tamper test).
2121    let mut parts: Vec<&[u8]> = Vec::with_capacity(5);
2122    if meta_section_len > 0 {
2123        parts.push(metadata);
2124    }
2125    parts.push(dict_container);
2126    parts.push(&index_container);
2127    parts.push(pyramid_meta);
2128    if text_len > 0 && !text_index_partial {
2129        parts.push(text_index);
2130    }
2131    if named_len > 0 {
2132        parts.push(&named_section);
2133    }
2134
2135    // Length of the trailing schema-pyramid block (0 if none), so a reader can
2136    // fetch just that block for an index/dictionary/summary-free Tier-0 read.
2137    let schema_meta_len = crate::meta::schema_block_len(pyramid_meta);
2138
2139    // The QUAD_INDEX section is not a hash part of its own: the seal inside the
2140    // index container carries its digest, so the content hash covers it — and a
2141    // reader that predates it computes the same hash over the same parts.
2142    let header = Header {
2143        version: crate::header::CURRENT_FORMAT_VERSION,
2144        flags: FLAG_TILE_SYNOPSIS
2145            | if has_quads { FLAG_HAS_QUADS } else { 0 }
2146            | if quad.is_some() {
2147                crate::header::FLAG_QUAD_INDEX
2148            } else {
2149                0
2150            }
2151            | if has_quoted_triples {
2152                FLAG_HAS_QUOTED_TRIPLES
2153            } else {
2154                0
2155            }
2156            | if text_len > 0 && text_index_named {
2157                crate::header::FLAG_TEXT_INDEX_NAMED_GRAPHS
2158            } else {
2159                0
2160            },
2161        metadata_offset: HEADER_LEN as u64,
2162        metadata_len: meta_section_len,
2163        dictionary_offset: dict_offset,
2164        dictionary_len: dict_len,
2165        root_dir_offset: index_offset,
2166        root_dir_len: index_len,
2167        pyramid_meta_offset: if pyr_len > 0 { pyr_offset } else { 0 },
2168        pyramid_meta_len: pyr_len,
2169        dict_codec: codec,
2170        block_codec: codec,
2171        pyramid_levels,
2172        // Every container in the file — default graph and named graphs alike —
2173        // is written from an index built with the same set, so the mask is a
2174        // file-level fact and the default index is its source of truth.
2175        perms: default_index.perms(),
2176        quad_count: default_index.triple_count() as u64 + named_quads,
2177        term_count,
2178        content_hash: content_hash(&parts),
2179        named_graphs_offset: if named_len > 0 { named_offset } else { 0 },
2180        named_graphs_len: named_len,
2181        schema_meta_len,
2182        text_index_offset: if text_len > 0 { text_offset } else { 0 },
2183        text_index_len: text_len,
2184        text_index_partial: text_len > 0 && text_index_partial,
2185        build_info_offset: 0,
2186        build_info_len: 0,
2187        extra_sections: if quad_len > 0 {
2188            vec![crate::header::Section {
2189                kind: crate::header::SectionKind::QuadIndex,
2190                flags: 0,
2191                offset: named_offset,
2192                length: quad_len,
2193            }]
2194        } else {
2195            Vec::new()
2196        },
2197    };
2198
2199    let mut out = Vec::with_capacity(
2200        HEADER_LEN
2201            + metadata.len()
2202            + dict_container.len()
2203            + index_container.len()
2204            + pyramid_meta.len()
2205            + text_index.len()
2206            + named_section.len()
2207            + quad_len as usize
2208            + MAGIC.len(),
2209    );
2210    out.extend_from_slice(&header.to_bytes());
2211    if meta_section_len > 0 {
2212        out.extend_from_slice(metadata);
2213    }
2214    out.extend_from_slice(dict_container);
2215    out.extend_from_slice(&index_container);
2216    out.extend_from_slice(pyramid_meta);
2217    if text_len > 0 {
2218        out.extend_from_slice(text_index);
2219    }
2220    if named_len > 0 {
2221        out.extend_from_slice(&named_section);
2222    }
2223    if let Some(q) = quad {
2224        out.extend_from_slice(&q.bytes);
2225    }
2226    out.extend_from_slice(&MAGIC); // footer marker
2227    out
2228}
2229
2230/// `rdf:type` — the predicate that assigns a class to a resource.
2231pub const RDF_TYPE: &str = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
2232
2233/// An **ontology-aware** coarse graph: instead of structural communities, group
2234/// entities by their `rdf:type` class and aggregate relations between classes.
2235/// Returns `(subject_class, predicate, object_class, count)` over the default
2236/// graph. Entities with no type are `(untyped)`; literals are `(literal)`.
2237/// `rdf:type` triples themselves define the classes and are not counted as
2238/// relations. This is the dataset's effective schema with instance volumes.
2239pub fn schema_summary(rete: &Rete) -> Vec<(String, String, String, u32)> {
2240    use std::collections::{BTreeMap, HashMap};
2241    let triples = rete.dump(None);
2242
2243    let mut class_of: HashMap<&str, &str> = HashMap::new();
2244    for (s, p, o) in &triples {
2245        if p == RDF_TYPE {
2246            class_of.insert(s.as_str(), o.as_str());
2247        }
2248    }
2249    let classify = |t: &str| -> String {
2250        if let Some(c) = class_of.get(t) {
2251            (*c).to_string()
2252        } else if t.starts_with('"') {
2253            "(literal)".to_string()
2254        } else {
2255            "(untyped)".to_string()
2256        }
2257    };
2258
2259    let mut counts: BTreeMap<(String, String, String), u32> = BTreeMap::new();
2260    for (s, p, o) in &triples {
2261        if p == RDF_TYPE {
2262            continue; // type assertions define classes, not data relations
2263        }
2264        *counts
2265            .entry((classify(s), p.clone(), classify(o)))
2266            .or_default() += 1;
2267    }
2268    counts
2269        .into_iter()
2270        .map(|((a, p, b), c)| (a, p, b, c))
2271        .collect()
2272}
2273
2274/// Class populations: the number of resources of each `rdf:type` class in the
2275/// default graph, descending by count. The instance-count companion to
2276/// [`schema_summary`].
2277pub fn schema_classes(rete: &Rete) -> Vec<(String, u32)> {
2278    use std::collections::BTreeMap;
2279    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
2280    for (_s, p, o) in rete.dump(None) {
2281        if p == RDF_TYPE {
2282            *counts.entry(o).or_default() += 1;
2283        }
2284    }
2285    let mut out: Vec<(String, u32)> = counts.into_iter().collect();
2286    out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2287    out
2288}
2289
2290/// Fetch **only** the metadata section (the opaque Dataset Card blob) via a
2291/// [`RangeReader`]: read the 1 KiB header, then the metadata byte range —
2292/// nothing else. This is the index-free CARD tier of the exploration model: a
2293/// remote/S3 client learns the dataset's self-description in **two small range
2294/// requests**, never touching the dictionary, index, or pyramid. Returns `None`
2295/// when the file carries no metadata.
2296///
2297/// Companion to [`Rete::open_ranged`] (which deliberately *skips* the card to
2298/// keep the query path minimal); this is the explicit "I want the card" path.
2299pub fn read_metadata_ranged<R: RangeReader>(reader: &R) -> Result<Option<Vec<u8>>, FileError> {
2300    let head = reader.read_at(0, HEADER_LEN as u64)?;
2301    let header = Header::from_bytes(&head)?;
2302    if header.metadata_len == 0 {
2303        return Ok(None);
2304    }
2305    let bytes = reader.read_at(header.metadata_offset, header.metadata_len)?;
2306    Ok(Some(bytes))
2307}
2308
2309/// Fetch the metadata (Dataset Card) **and** build-info sections via a
2310/// [`RangeReader`] in the fewest requests: the 1 KiB header, then — because the
2311/// writer lays the build-info section immediately after the metadata — **one**
2312/// coalesced range covering both. A file with only one of the two costs the
2313/// same two requests; a file with neither costs just the header read. This
2314/// keeps "card + build conditions" within the CARD tier's 1 header + 1 range
2315/// budget instead of adding a third request.
2316///
2317/// Returns `(metadata, build_info)`, each `None` when absent.
2318#[allow(clippy::type_complexity)]
2319pub fn read_card_and_build_info_ranged<R: RangeReader>(
2320    reader: &R,
2321) -> Result<(Option<Vec<u8>>, Option<Vec<u8>>), FileError> {
2322    read_card_and_build_info_with_header(reader).map(|(_, m, b)| (m, b))
2323}
2324
2325/// [`read_card_and_build_info_ranged`], also returning the parsed [`Header`] it
2326/// had to read anyway.
2327///
2328/// The header is not a bonus: it carries the content hash and the section
2329/// directory — including whether the file has a TEXT_INDEX section, which the
2330/// card does not state (see
2331/// [`read_text_index_token_table_len_ranged`]). A caller that wants both and
2332/// used the tuple-only form would issue a **second** 1 KiB request for bytes it
2333/// already had, turning the CARD tier's documented "one header + one coalesced
2334/// range" into three requests.
2335#[allow(clippy::type_complexity)]
2336pub fn read_card_and_build_info_with_header<R: RangeReader>(
2337    reader: &R,
2338) -> Result<(Header, Option<Vec<u8>>, Option<Vec<u8>>), FileError> {
2339    let head = reader.read_at(0, HEADER_LEN as u64)?;
2340    let header = Header::from_bytes(&head)?;
2341    let meta = (header.metadata_offset, header.metadata_len);
2342    let build = (header.build_info_offset, header.build_info_len);
2343    if meta.1 > 0 && build.1 > 0 && build.0 == meta.0 + meta.1 {
2344        // Adjacent (the layout this crate writes): one read spans both.
2345        let both = reader.read_at(meta.0, meta.1 + build.1)?;
2346        let (m, b) = both.split_at(meta.1 as usize);
2347        return Ok((header, Some(m.to_vec()), Some(b.to_vec())));
2348    }
2349    let fetch = |off: u64, len: u64| -> Result<Option<Vec<u8>>, FileError> {
2350        if len == 0 {
2351            return Ok(None);
2352        }
2353        Ok(Some(reader.read_at(off, len)?))
2354    };
2355    Ok((header, fetch(meta.0, meta.1)?, fetch(build.0, build.1)?))
2356}
2357
2358/// The build-info section of a whole file image, or `None` if absent. The
2359/// payload is an opaque application-layer blob (the CLI stores build-conditions
2360/// JSON there); it is **not** covered by the content hash — see
2361/// [`attach_build_info`].
2362pub fn read_build_info(bytes: &[u8]) -> Result<Option<Vec<u8>>, FileError> {
2363    let header = Header::from_bytes(bytes)?;
2364    if header.build_info_len == 0 {
2365        return Ok(None);
2366    }
2367    let start = header.build_info_offset as usize;
2368    let end = start
2369        .checked_add(header.build_info_len as usize)
2370        .filter(|&e| e <= bytes.len())
2371        .ok_or(FileError::Container("build-info section overruns buffer"))?;
2372    Ok(Some(bytes[start..end].to_vec()))
2373}
2374
2375/// Everything needed to splice a build-info section into a finished file
2376/// **without holding the file in memory**: the rewritten header, where the
2377/// section goes, and where the bytes that do not move resume.
2378///
2379/// See [`plan_build_info`] for what the fields mean together: the build-info
2380/// section always follows the metadata section, so the layout is
2381/// `[header][metadata][build info][everything else, verbatim]` — or, for a
2382/// card placed after the data, `[header][data…][metadata][build info][footer]`.
2383#[derive(Debug, Clone, PartialEq, Eq)]
2384pub struct BuildInfoPlan {
2385    /// The header bytes to write at offset 0.
2386    pub header: [u8; HEADER_LEN],
2387    /// Offset the new section is written at: the end of the metadata section
2388    /// (the length of the unchanged prefix up to and including the card).
2389    pub insert: u64,
2390    /// Offset in the **old** file where the bytes that do not move resume. The
2391    /// span `insert..tail_start` is the old build-info section, if any, and is
2392    /// the only part of the old file the new one drops.
2393    pub tail_start: u64,
2394    /// Length of the file this plan produces.
2395    pub new_len: u64,
2396}
2397
2398/// Plan the splice of a build-info section of `info_len` bytes into a file
2399/// whose first [`HEADER_LEN`] bytes are `head` and whose total length is
2400/// `file_len`.
2401///
2402/// The section goes immediately after the metadata section — adjacent, so
2403/// [`read_card_and_build_info_ranged`] fetches card + build info in one
2404/// coalesced range — and every later section's offset shifts by the length
2405/// delta. That shift is the whole of the arithmetic, and it lives here so the
2406/// in-memory splice ([`attach_build_info`]) and any streaming rewriter derive
2407/// the same header from the same rule instead of each carrying a copy that can
2408/// drift.
2409///
2410/// The content hash is **deliberately left untouched**: build info records the
2411/// facts that differ between two builds of identical data (timestamp, builder,
2412/// measured costs), and folding them into the hash would break the
2413/// reproducible-hash property. `verify` accordingly ignores this section, on an
2414/// old reader (which sees an unknown kind-7 entry) and a new one alike — so a
2415/// file that gains, loses or changes a build record **keeps its content
2416/// identity**.
2417///
2418/// `info_len` of 0 plans the removal of an existing section.
2419pub fn plan_build_info(
2420    head: &[u8],
2421    file_len: u64,
2422    info_len: u64,
2423) -> Result<BuildInfoPlan, FileError> {
2424    let mut header = Header::from_bytes(head)?;
2425    // The insert point: immediately after the metadata section, wherever it
2426    // lies — at HEADER_LEN as the in-RAM writers place it, or after the data
2427    // where the memory-bounded build and `recard --in-place` put it
2428    // (`crate::recard::plan_card_append`). No card: right after the header.
2429    let insert = if header.metadata_len > 0 {
2430        header
2431            .metadata_offset
2432            .checked_add(header.metadata_len)
2433            .filter(|&end| header.metadata_offset >= HEADER_LEN as u64 && end <= file_len)
2434            .ok_or(FileError::Container(
2435                "the metadata section lies outside the file",
2436            ))?
2437    } else {
2438        HEADER_LEN as u64
2439    };
2440    let old_len = header.build_info_len;
2441    if old_len > 0 && header.build_info_offset != insert {
2442        return Err(FileError::Container(
2443            "existing build-info section is not adjacent to the metadata",
2444        ));
2445    }
2446    let tail_start = insert.saturating_add(old_len);
2447    if tail_start > file_len || insert > file_len {
2448        return Err(FileError::Container("build-info splice out of bounds"));
2449    }
2450
2451    // Shift every section that lives at or after the old tail.
2452    let shift = |off: &mut u64, len: u64| {
2453        if len > 0 && *off >= tail_start {
2454            *off = *off - old_len + info_len;
2455        }
2456    };
2457    shift(&mut header.dictionary_offset, header.dictionary_len);
2458    shift(&mut header.root_dir_offset, header.root_dir_len);
2459    shift(&mut header.pyramid_meta_offset, header.pyramid_meta_len);
2460    shift(&mut header.named_graphs_offset, header.named_graphs_len);
2461    shift(&mut header.text_index_offset, header.text_index_len);
2462    for s in &mut header.extra_sections {
2463        if s.length > 0 && s.offset >= tail_start {
2464            s.offset = s.offset - old_len + info_len;
2465        }
2466    }
2467    if info_len == 0 {
2468        header.build_info_offset = 0;
2469        header.build_info_len = 0;
2470    } else {
2471        header.build_info_offset = insert;
2472        header.build_info_len = info_len;
2473    }
2474
2475    Ok(BuildInfoPlan {
2476        header: header.to_bytes(),
2477        insert,
2478        tail_start,
2479        new_len: file_len - old_len + info_len,
2480    })
2481}
2482
2483/// Attach (or replace) a **build-info** section in a finished file image,
2484/// returning the new image. Layout and hash semantics: [`plan_build_info`].
2485///
2486/// Passing an empty `info` removes an existing section (or returns the image
2487/// unchanged when there is none).
2488pub fn attach_build_info(image: &[u8], info: &[u8]) -> Result<Vec<u8>, FileError> {
2489    let plan = plan_build_info(image, image.len() as u64, info.len() as u64)?;
2490    let mut out = Vec::with_capacity(plan.new_len as usize);
2491    out.extend_from_slice(&plan.header);
2492    out.extend_from_slice(&image[HEADER_LEN..plan.insert as usize]);
2493    out.extend_from_slice(info);
2494    out.extend_from_slice(&image[plan.tail_start as usize..]);
2495    debug_assert_eq!(out.len() as u64, plan.new_len);
2496    Ok(out)
2497}
2498
2499/// Replace the **metadata** section (the Dataset Card blob) of a finished file
2500/// image, returning the new image. The section is the first one after the
2501/// header, so every later section's offset shifts by the length delta.
2502///
2503/// Unlike [`attach_build_info`], the metadata payload **is** inside the content
2504/// hash — it is part of what the file says about itself — so the hash is
2505/// recomputed here. The result is byte-identical to what the writer would have
2506/// produced had it been handed this payload in the first place
2507/// (`write_dataset_with_metadata`): the section sits at `HEADER_LEN`, every
2508/// later offset is derived from its length, and the hash folds the same parts
2509/// in the same order.
2510///
2511/// Why splice rather than rebuild: the only caller that needs this is a build
2512/// that has already assembled the file and then learned something about it by
2513/// **running** its own starter queries against it. Re-deriving the whole image
2514/// to correct a metadata blob would mean re-parsing the input; a splice costs
2515/// one memcpy and one rehash of an image that is already resident.
2516///
2517/// Passing an empty payload removes the section.
2518pub fn replace_metadata(image: &[u8], metadata: &[u8]) -> Result<Vec<u8>, FileError> {
2519    let mut header = Header::from_bytes(image)?;
2520    // The writers in this crate always place metadata (when present) at
2521    // HEADER_LEN; anything else is a file this function must not reshape.
2522    let insert = HEADER_LEN as u64;
2523    if header.metadata_len > 0 && header.metadata_offset != insert {
2524        return Err(FileError::Container(
2525            "metadata section does not sit immediately after the header",
2526        ));
2527    }
2528    let old_len = header.metadata_len;
2529    let tail_start = (insert + old_len) as usize;
2530    if tail_start > image.len() {
2531        return Err(FileError::Container("metadata splice out of bounds"));
2532    }
2533    let new_len = metadata.len() as u64;
2534
2535    let shift = |off: &mut u64, len: u64| {
2536        if len > 0 && *off >= insert + old_len {
2537            *off = *off - old_len + new_len;
2538        }
2539    };
2540    shift(&mut header.dictionary_offset, header.dictionary_len);
2541    shift(&mut header.root_dir_offset, header.root_dir_len);
2542    shift(&mut header.pyramid_meta_offset, header.pyramid_meta_len);
2543    shift(&mut header.named_graphs_offset, header.named_graphs_len);
2544    shift(&mut header.text_index_offset, header.text_index_len);
2545    shift(&mut header.build_info_offset, header.build_info_len);
2546    for s in &mut header.extra_sections {
2547        if s.length > 0 && s.offset >= insert + old_len {
2548            s.offset = s.offset - old_len + new_len;
2549        }
2550    }
2551    header.metadata_offset = insert;
2552    header.metadata_len = new_len;
2553
2554    let mut out = Vec::with_capacity(image.len() - old_len as usize + metadata.len());
2555    out.extend_from_slice(&header.to_bytes());
2556    out.extend_from_slice(metadata);
2557    out.extend_from_slice(&image[tail_start..]);
2558    // The payload changed, so the hash must: recompute it over the same part
2559    // list `verify` will rebuild from this very header, then re-serialize the
2560    // header carrying it.
2561    header.content_hash = hash_of_sections(&header, &out)?;
2562    out[..HEADER_LEN].copy_from_slice(&header.to_bytes());
2563    Ok(out)
2564}
2565
2566/// The content hash of a file image, folded over exactly the parts the writer
2567/// hashed (see `write_dataset_from_parts`): the metadata payload when present,
2568/// then dict, index, pyramid-meta, and — when present — the text index and the
2569/// named graphs. [`verify`] compares this against the header; [`replace_metadata`]
2570/// writes it.
2571fn hash_of_sections(header: &Header, bytes: &[u8]) -> Result<[u8; 16], FileError> {
2572    let slice = |off: u64, len: u64| -> Result<&[u8], FileError> {
2573        // `off` and `len` come STRAIGHT FROM THE FILE HEADER, so a crafted or
2574        // corrupt .rete can set them near u64::MAX and make `off + len` overflow
2575        // — which panics in debug and wraps in release, BEFORE `.get()` gets the
2576        // chance to reject the range. Found by the weekly fuzz run
2577        // (fuzz_targets/open.rs, "attempt to add with overflow"). Saturating the
2578        // sum turns both cases into the same clean Container error.
2579        let end = off.saturating_add(len);
2580        bytes
2581            .get(off as usize..end as usize)
2582            .ok_or(FileError::Container("section overruns buffer"))
2583    };
2584    let parts = content_hash_parts(header)
2585        .into_iter()
2586        .map(|(off, len)| slice(off, len))
2587        .collect::<Result<Vec<&[u8]>, FileError>>()?;
2588    Ok(content_hash(&parts))
2589}
2590
2591/// The byte ranges the content hash folds, **in the order it folds them**: the
2592/// metadata payload when present, then dictionary, index, pyramid-meta, and —
2593/// when present — a kind-6 text index and the named graphs. Build info, the
2594/// self-digested kinds (8 LABEL_INDEX, 9 SCHEMA, 11 predicate-restricted text
2595/// index) and any section kind this reader does not know are deliberately
2596/// outside it. The quad index (kind 10) is not a range here either: its digest
2597/// is the seal at the end of the index container, so it is hashed by reference.
2598///
2599/// The one list [`verify`], [`verify_ranged`] and [`replace_metadata`] hash
2600/// over, and what a streaming rewriter (`rete recard`) feeds a hasher while it
2601/// copies — so a section a future format adds to the hash is added here, once,
2602/// and every one of them follows.
2603pub fn content_hash_parts(header: &Header) -> Vec<(u64, u64)> {
2604    let mut parts: Vec<(u64, u64)> = Vec::with_capacity(6);
2605    if header.metadata_len > 0 {
2606        parts.push((header.metadata_offset, header.metadata_len));
2607    }
2608    parts.push((header.dictionary_offset, header.dictionary_len));
2609    parts.push((header.root_dir_offset, header.root_dir_len));
2610    // A zero-length pyramid folds nothing either way; it is listed only when
2611    // present so the ranges a rewriter walks are all real sections.
2612    if header.pyramid_meta_len > 0 {
2613        parts.push((header.pyramid_meta_offset, header.pyramid_meta_len));
2614    }
2615    // A kind-11 (predicate-restricted) text index is self-digested instead.
2616    if header.text_index_len > 0 && !header.text_index_partial {
2617        parts.push((header.text_index_offset, header.text_index_len));
2618    }
2619    if header.named_graphs_len > 0 {
2620        parts.push((header.named_graphs_offset, header.named_graphs_len));
2621    }
2622    parts
2623}
2624
2625/// Recompute the content hash from a file image and check it against the header
2626/// — detects corruption or truncation of the payload sections. The
2627/// LABEL_INDEX, SCHEMA and predicate-restricted TEXT_INDEX sections (kinds 8,
2628/// 9, 11) are outside that hash — an older reader's `verify` must keep passing
2629/// on a file that carries them — so each is checked against its own leading
2630/// digest instead. The QUAD_INDEX section (kind 10) is hashed by reference: its
2631/// digest is the seal inside the (hashed) index container, checked here.
2632pub fn verify(bytes: &[u8]) -> Result<bool, FileError> {
2633    let header = Header::from_bytes(bytes)?;
2634    if hash_of_sections(&header, bytes)? != header.content_hash {
2635        return Ok(false);
2636    }
2637    // The QUAD_INDEX section is covered through the seal's digest (inside the
2638    // hashed index container), so it is checked against that.
2639    if header.has_quad_index() || header.quad_index_section().is_some() {
2640        check_named_layout(&header)?;
2641        let slice = |off: u64, len: u64| -> Result<&[u8], FileError> {
2642            bytes
2643                .get(off as usize..off.saturating_add(len) as usize)
2644                .ok_or(FileError::Container("section overruns buffer"))
2645        };
2646        let root = slice(header.root_dir_offset, header.root_dir_len)?;
2647        let ranges = container_section_payload_ranges(
2648            root,
2649            header.root_dir_offset,
2650            header.index_container_sections(),
2651        )?;
2652        let seal = ranges
2653            .last()
2654            .ok_or(FileError::Container("missing quad-index seal"))?;
2655        let want = crate::quad::parse_seal(slice(seal.offset, seal.len)?)?;
2656        let q = header
2657            .quad_index_section()
2658            .ok_or(FileError::Container("missing quad-index section"))?;
2659        if crate::quad::digest(slice(q.offset, q.length)?) != want {
2660            return Ok(false);
2661        }
2662    }
2663    for s in self_digested_sections(&header) {
2664        let end = s.offset.saturating_add(s.length);
2665        let section = bytes
2666            .get(s.offset as usize..end as usize)
2667            .ok_or(FileError::Container("section overruns buffer"))?;
2668        if !crate::label_index::digest_ok(section) {
2669            return Ok(false);
2670        }
2671    }
2672    Ok(true)
2673}
2674
2675/// The sections that carry their own leading 16-byte digest rather than being
2676/// folded into the header content hash: LABEL_INDEX (8), SCHEMA (9) and a
2677/// predicate-restricted TEXT_INDEX (11).
2678fn self_digested_sections(header: &Header) -> Vec<Section> {
2679    [
2680        header.label_index_section(),
2681        header.schema_section(),
2682        header
2683            .section(crate::header::SectionKind::TextIndexPartial)
2684            .filter(|s| s.length > 0),
2685    ]
2686    .into_iter()
2687    .flatten()
2688    .collect()
2689}
2690
2691/// Append LABEL_INDEX / SCHEMA sections (kinds 8, 9) to a finished file image,
2692/// just before its 4-byte footer, and return the new image.
2693///
2694/// They sit outside the content hash, so nothing before them moves and the
2695/// hash is unchanged: only the header's directory gains entries. An empty
2696/// payload is skipped. Each kind may be appended once; a second append of a
2697/// kind the file already carries is an error, as is any other kind.
2698pub fn append_sections(
2699    image: &[u8],
2700    sections: &[(SectionKind, &[u8])],
2701) -> Result<Vec<u8>, FileError> {
2702    let lens: Vec<(SectionKind, u64)> =
2703        sections.iter().map(|(k, b)| (*k, b.len() as u64)).collect();
2704    let (header, body_end) = plan_append(image, image.len() as u64, &lens)?;
2705    let extra: usize = sections.iter().map(|(_, b)| b.len()).sum();
2706    let mut out = Vec::with_capacity(image.len() + extra);
2707    out.extend_from_slice(&header.to_bytes());
2708    out.extend_from_slice(&image[HEADER_LEN..body_end as usize]);
2709    for (_, b) in sections {
2710        out.extend_from_slice(b);
2711    }
2712    out.extend_from_slice(&MAGIC);
2713    Ok(out)
2714}
2715
2716/// The header an append produces, and where the old footer starts.
2717fn plan_append(
2718    head: &[u8],
2719    file_len: u64,
2720    sections: &[(SectionKind, u64)],
2721) -> Result<(Header, u64), FileError> {
2722    let mut header = Header::from_bytes(head)?;
2723    if header.expected_file_len() != Some(file_len) {
2724        return Err(FileError::Container(
2725            "cannot append sections: the file length does not match its header",
2726        ));
2727    }
2728    let body_end = file_len - MAGIC.len() as u64;
2729    let mut at = body_end;
2730    for &(kind, len) in sections {
2731        if !matches!(kind, SectionKind::LabelIndex | SectionKind::Schema) {
2732            return Err(FileError::Container(
2733                "only LABEL_INDEX and SCHEMA sections can be appended",
2734            ));
2735        }
2736        if len == 0 {
2737            continue;
2738        }
2739        if header.section(kind).is_some_and(|s| s.length > 0) {
2740            return Err(FileError::Container(
2741                "the file already carries that section",
2742            ));
2743        }
2744        header = header.with_section(kind, at, len);
2745        at += len;
2746    }
2747    Ok((header, body_end))
2748}
2749
2750/// One piece of a section appended by [`append_sections_to_file`].
2751#[cfg(not(target_arch = "wasm32"))]
2752pub(crate) enum PayloadPart<'a> {
2753    /// Bytes in memory.
2754    Bytes(&'a [u8]),
2755    /// A whole file, streamed.
2756    File(&'a std::path::Path),
2757}
2758
2759/// [`append_sections`] on a file on disk, in place: the sections overwrite the
2760/// footer and are followed by a new one, then the header is rewritten. Each
2761/// section is the concatenation of its parts, so a large one streams from a
2762/// spill file. The memory-bounded build uses this after writing its output.
2763#[cfg(not(target_arch = "wasm32"))]
2764pub(crate) fn append_sections_to_file(
2765    path: &std::path::Path,
2766    sections: &[(SectionKind, Vec<PayloadPart<'_>>)],
2767) -> Result<(), FileError> {
2768    use std::io::{Read, Seek, SeekFrom, Write};
2769    let part_len = |p: &PayloadPart<'_>| -> std::io::Result<u64> {
2770        match p {
2771            PayloadPart::Bytes(b) => Ok(b.len() as u64),
2772            PayloadPart::File(f) => Ok(std::fs::metadata(f)?.len()),
2773        }
2774    };
2775    let mut lens = Vec::with_capacity(sections.len());
2776    for (kind, parts) in sections {
2777        let mut n = 0u64;
2778        for p in parts {
2779            n += part_len(p)?;
2780        }
2781        lens.push((*kind, n));
2782    }
2783    let mut f = std::fs::OpenOptions::new()
2784        .read(true)
2785        .write(true)
2786        .open(path)?;
2787    let len = f.metadata()?.len();
2788    let mut head = vec![0u8; HEADER_LEN];
2789    f.read_exact(&mut head)?;
2790    let (header, body_end) = plan_append(&head, len, &lens)?;
2791    f.seek(SeekFrom::Start(body_end))?;
2792    let mut w = std::io::BufWriter::with_capacity(1 << 20, &mut f);
2793    for (_, parts) in sections {
2794        for p in parts {
2795            match p {
2796                PayloadPart::Bytes(b) => w.write_all(b)?,
2797                PayloadPart::File(src) => {
2798                    std::io::copy(&mut std::fs::File::open(src)?, &mut w)?;
2799                }
2800            }
2801        }
2802    }
2803    w.write_all(&MAGIC)?;
2804    w.flush()?;
2805    drop(w);
2806    f.seek(SeekFrom::Start(0))?;
2807    f.write_all(&header.to_bytes())?;
2808    f.flush()?;
2809    Ok(())
2810}
2811
2812/// Open a file's dictionary over a [`RangeReader`] with only its chunk
2813/// directories resident — for the memory-bounded build, which resolves the
2814/// SCHEMA section's few class and predicate ids through the file it just
2815/// wrote.
2816#[cfg(not(target_arch = "wasm32"))]
2817pub(crate) fn open_dictionary_lazy<R: RangeReader + Send + Sync + 'static>(
2818    reader: R,
2819) -> Result<Dictionary, FileError> {
2820    let head = reader.read_at(0, HEADER_LEN as u64)?;
2821    let header = Header::from_bytes(&head)?;
2822    ranged_chunked_dictionary(&std::sync::Arc::new(reader), &header, [true; 4])
2823}
2824
2825/// [`verify`] over a [`RangeReader`](crate::reader::RangeReader), streaming:
2826/// the hashed sections are read in bounded slices and folded into one BLAKE3
2827/// state, so peak memory is one slice, not the file. The same sections, in
2828/// the same order, as `hash_of_sections`; on a 49 GB file the in-memory form
2829/// needs 49 GB.
2830pub fn verify_ranged(reader: &dyn crate::reader::RangeReader) -> Result<bool, FileError> {
2831    /// Read size per step: large enough that a local or remote read is
2832    /// throughput-bound, small enough to be negligible next to any budget.
2833    const SLICE: u64 = 8 << 20;
2834    let len = reader.len();
2835    if len < HEADER_LEN as u64 {
2836        return Err(FileError::Container("file shorter than its header"));
2837    }
2838    let header = Header::from_bytes(&reader.read_at(0, HEADER_LEN as u64)?)?;
2839    let mut h = blake3::Hasher::new();
2840    for (off, n) in content_hash_parts(&header) {
2841        // Offsets come straight from the header: reject an overrun (or an
2842        // overflowing sum) before reading, as `hash_of_sections` does.
2843        if off.checked_add(n).is_none_or(|end| end > len) {
2844            return Err(FileError::Container("section overruns buffer"));
2845        }
2846        let mut at = off;
2847        while at < off + n {
2848            let take = (off + n - at).min(SLICE);
2849            h.update(&reader.read_at(at, take)?);
2850            at += take;
2851        }
2852    }
2853    let mut out = [0u8; 16];
2854    out.copy_from_slice(&h.finalize().as_bytes()[..16]);
2855    if out != header.content_hash {
2856        return Ok(false);
2857    }
2858    // The QUAD_INDEX section, against the digest in the index container's seal
2859    // (see `verify`), streamed the same way.
2860    if header.has_quad_index() || header.quad_index_section().is_some() {
2861        check_named_layout(&header)?;
2862        let sections = header.index_container_sections();
2863        let seal = locate_container_section_ranged(
2864            reader,
2865            header.root_dir_offset,
2866            header.root_dir_len,
2867            sections - 1,
2868            sections as u64,
2869        )?;
2870        let want = crate::quad::parse_seal(&reader.read_at(seal.offset, seal.len)?)?;
2871        let q = header
2872            .quad_index_section()
2873            .ok_or(FileError::Container("missing quad-index section"))?;
2874        if q.offset.checked_add(q.length).is_none_or(|end| end > len) {
2875            return Err(FileError::Container("section overruns buffer"));
2876        }
2877        let mut h = blake3::Hasher::new();
2878        let mut at = q.offset;
2879        while at < q.offset + q.length {
2880            let take = (q.offset + q.length - at).min(SLICE);
2881            h.update(&reader.read_at(at, take)?);
2882            at += take;
2883        }
2884        let mut got = [0u8; 16];
2885        got.copy_from_slice(&h.finalize().as_bytes()[..16]);
2886        if got != want {
2887            return Ok(false);
2888        }
2889    }
2890    // The self-digested sections (kinds 8, 9, 11), streamed the same way.
2891    for s in self_digested_sections(&header) {
2892        if s.length < 16 || s.offset.checked_add(s.length).is_none_or(|end| end > len) {
2893            return Err(FileError::Container("section overruns buffer"));
2894        }
2895        let want = reader.read_at(s.offset, 16)?;
2896        let mut h = blake3::Hasher::new();
2897        let mut at = s.offset + 16;
2898        while at < s.offset + s.length {
2899            let take = (s.offset + s.length - at).min(SLICE);
2900            h.update(&reader.read_at(at, take)?);
2901            at += take;
2902        }
2903        if h.finalize().as_bytes()[..16] != want[..] {
2904            return Ok(false);
2905        }
2906    }
2907    Ok(true)
2908}
2909
2910/// Faults the pyramid meta in on first access. `None` = the fetch failed.
2911type PyramidLoader = Box<dyn Fn() -> Option<PyramidMeta> + Send + Sync>;
2912
2913/// The pyramid-meta section, held either resident (eager opens) or deferred
2914/// (the lazy remote open). SPARQL never touches the pyramid, but on a Wikidata
2915/// file it can be tens of MB (114k communities, millions of superedges), so a
2916/// remote SPARQL query must not pay to fetch it — it faults in only when a
2917/// community/pyramid query actually calls [`Rete::pyramid`].
2918enum PyramidSlot {
2919    Resident(Option<PyramidMeta>),
2920    Lazy {
2921        loader: PyramidLoader,
2922        cell: std::sync::OnceLock<Option<PyramidMeta>>,
2923    },
2924}
2925
2926/// A section held resident (eager opens) or faulted in on first use (lazy
2927/// opens) — the LABEL_INDEX and SCHEMA sections. `None` inside means the file
2928/// has no such section, or it could not be read or parsed: both are optional
2929/// and derived, so a bad one degrades to "absent" (and `verify` reports it).
2930pub(crate) enum SectionSlot<T> {
2931    Resident(Option<T>),
2932    Lazy {
2933        loader: Box<dyn Fn() -> Option<T> + Send + Sync>,
2934        cell: std::sync::OnceLock<Option<T>>,
2935    },
2936}
2937
2938impl<T> SectionSlot<T> {
2939    /// The section, faulting it in on the lazy path.
2940    pub(crate) fn get(&self) -> Option<&T> {
2941        match self {
2942            SectionSlot::Resident(v) => v.as_ref(),
2943            SectionSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
2944        }
2945    }
2946
2947    /// The section only if it is resident or already faulted — never a read.
2948    pub(crate) fn get_if_loaded(&self) -> Option<&T> {
2949        match self {
2950            SectionSlot::Resident(v) => v.as_ref(),
2951            SectionSlot::Lazy { cell, .. } => cell.get().and_then(|o| o.as_ref()),
2952        }
2953    }
2954}
2955
2956/// The LABEL_INDEX of a file image held in memory.
2957fn resident_label_slot<'a>(
2958    header: &Header,
2959    region: &dyn Fn(u64, u64) -> Result<&'a [u8], FileError>,
2960) -> SectionSlot<crate::label_index::LabelIndex> {
2961    SectionSlot::Resident(header.label_index_section().and_then(|s| {
2962        let bytes = region(s.offset, s.length).ok()?;
2963        crate::label_index::LabelIndex::from_section(bytes, header.block_codec).ok()
2964    }))
2965}
2966
2967/// The SCHEMA section of a file image held in memory.
2968fn resident_schema_slot<'a>(
2969    header: &Header,
2970    region: &dyn Fn(u64, u64) -> Result<&'a [u8], FileError>,
2971) -> SectionSlot<crate::schema_section::SchemaMeta> {
2972    SectionSlot::Resident(header.schema_section().and_then(|s| {
2973        crate::schema_section::SchemaMeta::decode(region(s.offset, s.length).ok()?).ok()
2974    }))
2975}
2976
2977/// The deferred LABEL_INDEX of a ranged open: the first search reads the
2978/// section head (preamble + root, ≤ [`crate::label_index::HEAD_READ`] bytes),
2979/// then one directory node per level and the data blocks it needs.
2980fn ranged_label_slot<R: RangeReader + Send + Sync + 'static>(
2981    reader: &std::sync::Arc<R>,
2982    header: &Header,
2983) -> SectionSlot<crate::label_index::LabelIndex> {
2984    let Some(s) = header.label_index_section() else {
2985        return SectionSlot::Resident(None);
2986    };
2987    let r = reader.clone();
2988    let codec = header.block_codec;
2989    SectionSlot::Lazy {
2990        loader: Box::new(move || {
2991            let head = r
2992                .read_at(s.offset, crate::label_index::HEAD_READ.min(s.length))
2993                .ok()?;
2994            let rr = r.clone();
2995            let loader = Box::new(move |off: u64, len: u64| rr.read_at(s.offset + off, len).ok());
2996            crate::label_index::LabelIndex::from_head(&head, s.length, codec, loader).ok()
2997        }),
2998        cell: std::sync::OnceLock::new(),
2999    }
3000}
3001
3002/// The deferred SCHEMA section of a ranged open: one range read, on first use.
3003fn ranged_schema_slot<R: RangeReader + Send + Sync + 'static>(
3004    reader: &std::sync::Arc<R>,
3005    header: &Header,
3006) -> SectionSlot<crate::schema_section::SchemaMeta> {
3007    let Some(s) = header.schema_section() else {
3008        return SectionSlot::Resident(None);
3009    };
3010    let r = reader.clone();
3011    SectionSlot::Lazy {
3012        loader: Box::new(move || {
3013            let bytes = r.read_at(s.offset, s.length).ok()?;
3014            crate::schema_section::SchemaMeta::decode(&bytes).ok()
3015        }),
3016        cell: std::sync::OnceLock::new(),
3017    }
3018}
3019
3020/// Read and decode a file's SCHEMA section over a [`RangeReader`] — the
3021/// header and one range. `Ok(None)` when the file has none (or it is
3022/// malformed: the section is optional and derived).
3023pub fn read_schema_ranged<R: RangeReader + ?Sized>(
3024    reader: &R,
3025) -> Result<Option<crate::schema_section::SchemaMeta>, FileError> {
3026    let head = reader.read_at(0, HEADER_LEN as u64)?;
3027    let header = Header::from_bytes(&head)?;
3028    Ok(read_schema_with_header(reader, &header))
3029}
3030
3031fn read_schema_with_header<R: RangeReader + ?Sized>(
3032    reader: &R,
3033    header: &Header,
3034) -> Option<crate::schema_section::SchemaMeta> {
3035    let s = header.schema_section()?;
3036    let bytes = reader.read_at(s.offset, s.length).ok()?;
3037    crate::schema_section::SchemaMeta::decode(&bytes).ok()
3038}
3039
3040/// Faults the text index in on first search. `None` = the file has none, or the
3041/// fetch/parse failed.
3042type TextIndexLoader = Box<dyn Fn() -> Option<crate::text_index::TextIndex> + Send + Sync>;
3043
3044/// Answers the token table's byte length from the section's first ≤10 bytes —
3045/// the length varint alone, never the table it measures.
3046type TokenTableProbe = Box<dyn Fn() -> Option<u64> + Send + Sync>;
3047
3048/// The TEXT_INDEX section, held either resident (eager opens decode the whole
3049/// thing) or deferred (the lazy remote open keeps only a loader that fetches the
3050/// token table on first search, then faults posting lists one at a time). SPARQL
3051/// never touches it, so the lazy remote path keeps its small range budget.
3052///
3053/// Both variants can also answer how long the section's leading **token table**
3054/// is without faulting it — the figure a caller needs to state what a first
3055/// search will cost, since `header.text_index_len` counts the postings blob too
3056/// and overstates it by several times on a literal-heavy graph.
3057enum TextIndexSlot {
3058    Resident {
3059        index: Option<crate::text_index::TextIndex>,
3060        /// Measured from the section bytes this open already held; `None` when
3061        /// the file carries no text index.
3062        token_table_len: Option<u64>,
3063    },
3064    Lazy {
3065        loader: TextIndexLoader,
3066        cell: std::sync::OnceLock<Option<crate::text_index::TextIndex>>,
3067        /// One ≤10-byte range read, memoized in `token_table_cell`.
3068        token_table: TokenTableProbe,
3069        token_table_cell: std::sync::OnceLock<Option<u64>>,
3070    },
3071}
3072
3073impl TextIndexSlot {
3074    /// The parsed index, faulting it in (token table first) on the lazy path.
3075    fn index(&self) -> Option<&crate::text_index::TextIndex> {
3076        match self {
3077            TextIndexSlot::Resident { index, .. } => index.as_ref(),
3078            TextIndexSlot::Lazy { loader, cell, .. } => cell.get_or_init(loader).as_ref(),
3079        }
3080    }
3081
3082    /// Byte length of the section's leading token table — see
3083    /// [`Rete::text_index_token_table_len`].
3084    fn token_table_len(&self) -> Option<u64> {
3085        match self {
3086            TextIndexSlot::Resident {
3087                token_table_len, ..
3088            } => *token_table_len,
3089            TextIndexSlot::Lazy {
3090                token_table,
3091                token_table_cell,
3092                ..
3093            } => *token_table_cell.get_or_init(token_table),
3094        }
3095    }
3096}
3097
3098/// Decode a whole TEXT_INDEX section held in memory, measuring its token table
3099/// on the way past. `section` is `None` when the file carries no text index.
3100fn resident_text_index_slot(section: Option<&[u8]>, codec: u8) -> Result<TextIndexSlot, FileError> {
3101    let Some(section) = section else {
3102        return Ok(TextIndexSlot::Resident {
3103            index: None,
3104            token_table_len: None,
3105        });
3106    };
3107    Ok(TextIndexSlot::Resident {
3108        index: Some(
3109            crate::text_index::TextIndex::from_section(section, codec)
3110                .map_err(|_| FileError::Container("malformed text index"))?,
3111        ),
3112        token_table_len: crate::text_index::TextIndex::postings_base(section).map(|b| b as u64),
3113    })
3114}
3115
3116/// Byte length of the TEXT_INDEX section's leading **token table**, from a
3117/// header a caller has already parsed — the prefix range a first
3118/// [`Rete::text_search`] fetches, and the honest figure to quote as its cost
3119/// (`header.text_index_len` counts the postings blob too and overstates it
3120/// several-fold). `None` when the file carries no text index, or its first
3121/// bytes could not be read.
3122///
3123/// Costs **one range read of ≤10 bytes** — the section's leading length varint
3124/// and nothing else, never the table it measures. This is the free-standing
3125/// companion to [`Rete::text_index_token_table_len`], for readers that hold a
3126/// [`Header`] and a [`RangeReader`] but never open the file (the CARD tier:
3127/// `rete card`, `rete card-url`).
3128pub fn read_text_index_token_table_len_ranged<R: RangeReader + ?Sized>(
3129    reader: &R,
3130    header: &Header,
3131) -> Option<u64> {
3132    let (ti_off, ti_len) = header.text_index_payload();
3133    if ti_len == 0 {
3134        return None;
3135    }
3136    read_token_table_len(reader, ti_off, ti_len)
3137}
3138
3139/// Byte length of the TEXT_INDEX section's leading token table — its length
3140/// varint plus the compressed table, i.e. the prefix range a first search
3141/// fetches. Reads the section's first ≤10 bytes and nothing else.
3142fn read_token_table_len<R: RangeReader + ?Sized>(reader: &R, off: u64, len: u64) -> Option<u64> {
3143    let head = reader.read_at(off, 10u64.min(len)).ok()?;
3144    let (ttlen, n) = crate::varint::read_uvarint(&head)?;
3145    Some((n as u64 + ttlen).min(len))
3146}
3147
3148/// A named graph's container decodes RESIDENT (one range read, one decode) up
3149/// to this size on the lazy path; a larger container opens as a remote-lazy
3150/// tile index instead, so one selective `GRAPH <g>` query over a multi-GB
3151/// graph faults only the tile directories plus the tiles it touches.
3152const NAMED_GRAPH_RESIDENT_MAX: u64 = 1 << 20; // 1 MiB
3153
3154/// Largest quad-index graph (in quads) a many-graph walk evaluates through an
3155/// owned, dropped view ([`Rete::named_graph_transient`]) rather than a memoised
3156/// one. A view is ~1–2 KB of descriptors whatever the graph's size, so keeping
3157/// one per graph is what grows with the graph count; past this size a graph is
3158/// worth memoising, and its rows are no longer worth materializing per graph.
3159pub const TRANSIENT_GRAPH_QUADS: u64 = 1024;
3160
3161/// Entries per lazily-allocated named-graph slab. The outer table is sized by
3162/// the section's leading count varint, which is UNTRUSTED — slabbing keeps a
3163/// hostile count from ballooning the allocation (the outer table costs a few
3164/// bytes per claimed slab; entry storage only materializes when touched).
3165const NAMED_SLAB: usize = 1024;
3166
3167/// First read of the named-graph walk, and the floor its ramp resets to. One
3168/// chunk covers a few dozen small-graph records, so a `LIMIT`ed `GRAPH ?g`
3169/// pays exactly one small read — the targeted-query win this size protects.
3170const NAMED_WALK_CHUNK: u64 = 64 * 1024;
3171
3172/// Ceiling of the walk's geometric read ramp — the read size an exhaustive
3173/// walk converges to (and starts at, when the demand is known exhaustive).
3174///
3175/// Sized from measurement, not taste. Against the nkod reference file
3176/// (67.9 MB, 31,974 named graphs, 65.4 MB section) the fixed 64 KiB walk cost
3177/// 262 requests / ~16.5 s in Chromium where the old eager whole-section fetch
3178/// cost ~8.5 s — i.e. ~31 ms of per-request overhead on ~8 s of transfer
3179/// (~7.7 MB/s). At 8 MiB per read a chunk transfers in ~1 s, putting the
3180/// request overhead near 3% of total time; halving the cap to 4 MiB doubles
3181/// that overhead, doubling the cap to 16 MiB buys back under 2% while doubling
3182/// both the walk buffer a wasm32 session must hold resident and the worst-case
3183/// over-read past the point a query stops. 8 MiB is the knee.
3184const NAMED_WALK_CHUNK_MAX: u64 = 8 * 1024 * 1024;
3185
3186/// One named graph, walked lazily out of the NAMED_GRAPHS section.
3187#[derive(Default)]
3188struct NamedEntry {
3189    /// `(iri, absolute byte range of the graph's index container)` — filled
3190    /// when the sequential directory walk reaches this slot.
3191    meta: std::sync::OnceLock<(String, ByteRange)>,
3192    /// The graph's permutation index, opened on first access. Boxed: most
3193    /// entries of a many-graph dataset are never touched by a given query.
3194    index: std::sync::OnceLock<Box<GraphIndex>>,
3195}
3196
3197/// Sequential position of the lazy named-graphs walk. The section has no
3198/// random-access directory — each entry's offset is the previous entry's end —
3199/// so entries are parsed strictly in order and memoised.
3200#[derive(Default)]
3201struct NamedWalk {
3202    /// Next entry index to parse.
3203    next: usize,
3204    /// Absolute file offset of that entry's header; `0` = the leading count
3205    /// varint has not been consumed yet (offset 0 is the file header, never
3206    /// inside a section).
3207    pos: u64,
3208    /// Read buffer carried ACROSS walk calls: `buf` holds the section bytes at
3209    /// `[buf_off, buf_off + buf.len())`. Entry-by-entry iteration (the
3210    /// `GRAPH ?g` path advances one entry per call) parses ~30 small-graph
3211    /// headers per 64 KiB chunk instead of re-reading a chunk per entry.
3212    /// [`LazyNamedGraphs::open_graph`] also serves whole small containers out
3213    /// of it, so a bulk chunk's ride-along payloads are never re-fetched.
3214    buf: Vec<u8>,
3215    buf_off: u64,
3216    /// Current read size of the geometric ramp (`0` = not started; treated as
3217    /// [`NAMED_WALK_CHUNK`]). Doubles on every buffer refill up to
3218    /// [`NAMED_WALK_CHUNK_MAX`], so a walk that keeps going converges to bulk
3219    /// reads after fetching at most as many bytes as it already used (the
3220    /// doubling bound: wasted read-ahead ≤ useful bytes). Reset to the floor
3221    /// whenever an oversized container is parsed — see `big_seen`.
3222    chunk: u64,
3223    /// A container larger than [`NAMED_GRAPH_RESIDENT_MAX`] has been parsed.
3224    /// Such graphs open tile-lazily (their payload bytes are NOT wanted by the
3225    /// walk), so ride-along prefetch stops paying off: the ramp resets and the
3226    /// exhaustive-demand hint stops seeding full-size reads. Without this, a
3227    /// header walk over a file of multi-MB containers would fetch the payloads
3228    /// it exists to hop over.
3229    big_seen: bool,
3230}
3231
3232/// The NAMED_GRAPHS section opened lazily over a [`RangeReader`]: nothing is
3233/// fetched at open. The section is a count varint followed by
3234/// `iri_len | iri | container_len | container` records, so the directory is
3235/// walked forward on demand — header bytes only, skipping container payloads —
3236/// and each touched graph's index is decoded (or opened tile-lazily) on first
3237/// access. A `LIMIT`ed `GRAPH ?g` query therefore reads a prefix of the
3238/// section; only a query that genuinely touches every graph (e.g. a full
3239/// `COUNT` over `GRAPH ?g`) walks it all — which is exactly what the eager
3240/// open used to fetch unconditionally.
3241///
3242/// **Failure contract** (same as lazy tiles/chunks): a failed read or a
3243/// malformed record sets a sticky flag and the accessor returns `None`;
3244/// nothing failed is ever memoised, so a later evaluation retries.
3245struct LazyNamedGraphs {
3246    reader: std::sync::Arc<dyn RangeReader + Send + Sync>,
3247    /// Absolute byte range of the NAMED_GRAPHS section.
3248    section: ByteRange,
3249    codec: u8,
3250    has_synopsis: bool,
3251    read_concurrency: usize,
3252    /// The file's permutation set — every graph container in a file carries
3253    /// the same one, so it comes from the header rather than per graph.
3254    perms: PermSet,
3255    /// `(count, slab table)` — set once the leading count varint is read.
3256    /// Slabs allocate on first touch; boxed slices never move, so `&` handed
3257    /// out to entries stay valid for `&self`'s lifetime with no unsafe.
3258    #[allow(clippy::type_complexity)]
3259    dir: std::sync::OnceLock<(usize, Box<[std::sync::OnceLock<Box<[NamedEntry]>>]>)>,
3260    walk: std::sync::Mutex<NamedWalk>,
3261    failed: std::sync::atomic::AtomicBool,
3262    /// Tile-cache byte cap applied to each LARGE (lazily-opened, loader-bearing)
3263    /// named graph as it is opened (bounded-export phase 2). `u64::MAX` =
3264    /// unlimited. Small resident graphs (no loader, ≤ 1 MiB) are never capped —
3265    /// an evicted tile could not be reloaded — but they are bounded by size and
3266    /// released per slot, so they need no cap.
3267    tile_cap: std::sync::atomic::AtomicU64,
3268}
3269
3270impl LazyNamedGraphs {
3271    fn new(
3272        reader: std::sync::Arc<dyn RangeReader + Send + Sync>,
3273        section: ByteRange,
3274        codec: u8,
3275        has_synopsis: bool,
3276        read_concurrency: usize,
3277        perms: PermSet,
3278    ) -> Self {
3279        LazyNamedGraphs {
3280            reader,
3281            section,
3282            codec,
3283            has_synopsis,
3284            read_concurrency,
3285            perms,
3286            dir: std::sync::OnceLock::new(),
3287            walk: std::sync::Mutex::new(NamedWalk::default()),
3288            failed: std::sync::atomic::AtomicBool::new(false),
3289            tile_cap: std::sync::atomic::AtomicU64::new(u64::MAX),
3290        }
3291    }
3292
3293    /// Set the tile-cache cap for large named graphs: records it for graphs
3294    /// opened later, and re-caps every large graph already opened. Small
3295    /// resident (loaderless) graphs are left unlimited.
3296    fn set_tile_cap(&self, cap: u64) {
3297        self.tile_cap
3298            .store(cap, std::sync::atomic::Ordering::Relaxed);
3299        self.for_each_opened(|g| {
3300            if g.is_lazy() {
3301                g.set_cache_cap(cap);
3302            }
3303        });
3304    }
3305
3306    /// Record a fetch/parse failure (checked by `index_incomplete`).
3307    fn fail(&self) {
3308        self.failed
3309            .store(true, std::sync::atomic::Ordering::Relaxed);
3310    }
3311
3312    /// The directory scaffold, reading the leading count varint on first call.
3313    #[allow(clippy::type_complexity)]
3314    fn directory(&self) -> Option<&(usize, Box<[std::sync::OnceLock<Box<[NamedEntry]>>]>)> {
3315        if let Some(d) = self.dir.get() {
3316            return Some(d);
3317        }
3318        let end = self.section.offset.checked_add(self.section.len)?;
3319        let (n, used) = match read_uvarint_at(&self.reader, self.section.offset, end) {
3320            Ok(v) => v,
3321            Err(_) => {
3322                self.fail();
3323                return None;
3324            }
3325        };
3326        // Each record needs at least 2 bytes of framing (two length varints),
3327        // so a count beyond len/2 is malformed — refuse before sizing by it.
3328        if n > self.section.len / 2 {
3329            self.fail();
3330            return None;
3331        }
3332        let n = n as usize;
3333        let slabs = n.div_ceil(NAMED_SLAB);
3334        let table: Box<[std::sync::OnceLock<Box<[NamedEntry]>>]> =
3335            (0..slabs).map(|_| std::sync::OnceLock::new()).collect();
3336        let _ = self.dir.set((n, table));
3337        {
3338            // Idempotent under an init race: both racers computed the same
3339            // `used` from the same bytes.
3340            let mut w = self.walk.lock().unwrap();
3341            if w.pos == 0 {
3342                w.pos = self.section.offset + used;
3343            }
3344        }
3345        self.dir.get()
3346    }
3347
3348    fn count(&self) -> usize {
3349        self.directory().map(|(n, _)| *n).unwrap_or(0)
3350    }
3351
3352    /// The `i`-th entry's cell (allocating its slab on first touch).
3353    fn entry(&self, i: usize) -> Option<&NamedEntry> {
3354        let (n, table) = self.directory()?;
3355        if i >= *n {
3356            return None;
3357        }
3358        let slab = table[i / NAMED_SLAB].get_or_init(|| {
3359            let len = NAMED_SLAB.min(n - (i / NAMED_SLAB) * NAMED_SLAB);
3360            (0..len).map(|_| NamedEntry::default()).collect()
3361        });
3362        slab.get(i % NAMED_SLAB)
3363    }
3364
3365    /// The next walk read's length, advancing the geometric ramp: at least
3366    /// `need` (a long IRI's header), at most the section remainder, and
3367    /// otherwise the current ramp value — which doubles per refill up to
3368    /// [`NAMED_WALK_CHUNK_MAX`]. The doubling keeps a stopping walk's wasted
3369    /// read-ahead bounded by the bytes it already used, while a walk that
3370    /// keeps going converges to bulk reads: the runtime safety net for
3371    /// exhaustive-looking queries the demand analysis could not prove.
3372    fn next_read_len(w: &mut NamedWalk, pos: u64, end: u64, need: u64) -> u64 {
3373        let cur = w.chunk.max(NAMED_WALK_CHUNK);
3374        w.chunk = (cur * 2).min(NAMED_WALK_CHUNK_MAX);
3375        cur.max(need).min(end - pos)
3376    }
3377
3378    /// Walk entry headers forward until `entries[upto].meta` is set. Reads the
3379    /// section in chunks and hops over container payloads, so the walk fetches
3380    /// O(headers) — not O(section) — where containers are large; small
3381    /// containers ride along in the same chunk and cost nothing extra.
3382    ///
3383    /// `exhaustive` is the demand hint from the query engine: the caller has
3384    /// PROVEN it will visit and open every named graph (an unrestricted
3385    /// `GRAPH ?g` under a consumer that drains the pipeline), so the walk
3386    /// starts at full-size reads instead of ramping up to them — the whole
3387    /// section arrives in `section/8 MiB` requests instead of hundreds. The
3388    /// hint is per-call, never memoised: a later targeted query on the same
3389    /// handle walks small again. It also defers to the layout: once an
3390    /// oversized (tile-lazy) container is seen, ride-along bytes stop being
3391    /// useful and the hint no longer seeds bulk reads.
3392    fn ensure_meta(&self, upto: usize, exhaustive: bool) -> Option<()> {
3393        let target = self.entry(upto)?;
3394        if target.meta.get().is_some() {
3395            return Some(());
3396        }
3397        let end = self.section.offset.checked_add(self.section.len)?;
3398        let mut w = self.walk.lock().unwrap();
3399        if exhaustive && !w.big_seen {
3400            w.chunk = NAMED_WALK_CHUNK_MAX;
3401        }
3402        while w.next <= upto {
3403            let pos = w.pos;
3404            if pos >= end {
3405                // The count varint promised more entries than the bytes hold.
3406                self.fail();
3407                return None;
3408            }
3409            // Make sure the whole header (two varints + the IRI, ≤ 20+iri_len
3410            // bytes) is buffered; refetch a larger window when the IRI is long.
3411            let have =
3412                |b: &[u8], off: u64, need: u64| pos >= off && pos + need <= off + b.len() as u64;
3413            if !have(&w.buf, w.buf_off, 20.min(end - pos)) {
3414                let len = Self::next_read_len(&mut w, pos, end, 20.min(end - pos));
3415                w.buf = match self.reader.read_at(pos, len) {
3416                    Ok(b) => b,
3417                    Err(_) => {
3418                        self.fail();
3419                        return None;
3420                    }
3421                };
3422                w.buf_off = pos;
3423            }
3424            let rel = (pos - w.buf_off) as usize;
3425            let Some((ilen, u1)) = read_uvarint(&w.buf[rel..]) else {
3426                self.fail();
3427                return None;
3428            };
3429            let header_need = u1 as u64 + ilen + 10; // iri_len + iri + container_len varint
3430            if pos + u1 as u64 + ilen > end {
3431                self.fail(); // IRI overruns the section: malformed
3432                return None;
3433            }
3434            if !have(&w.buf, w.buf_off, header_need.min(end - pos)) {
3435                let len = Self::next_read_len(&mut w, pos, end, header_need);
3436                w.buf = match self.reader.read_at(pos, len) {
3437                    Ok(b) => b,
3438                    Err(_) => {
3439                        self.fail();
3440                        return None;
3441                    }
3442                };
3443                w.buf_off = pos;
3444            }
3445            let rel = (pos - w.buf_off) as usize;
3446            let istart = rel + u1;
3447            let iend = istart + ilen as usize;
3448            let iri = String::from_utf8_lossy(&w.buf[istart..iend]).into_owned();
3449            let Some((clen, u2)) = read_uvarint(&w.buf[iend..]) else {
3450                self.fail();
3451                return None;
3452            };
3453            let cstart = pos + u1 as u64 + ilen + u2 as u64;
3454            let cend = match cstart.checked_add(clen) {
3455                Some(e) if e <= end => e,
3456                _ => {
3457                    self.fail(); // container overruns the section: malformed
3458                    return None;
3459                }
3460            };
3461            if clen > NAMED_GRAPH_RESIDENT_MAX {
3462                // This graph opens tile-lazily: the walk exists to HOP its
3463                // payload, so big read-ahead here is pure over-fetch. Drop the
3464                // ramp back to the floor and stop the exhaustive hint from
3465                // re-seeding it (headers stay cheap on payload-heavy files).
3466                w.big_seen = true;
3467                w.chunk = NAMED_WALK_CHUNK;
3468            }
3469            let range = ByteRange {
3470                offset: cstart,
3471                len: clen,
3472            };
3473            if let Some(e) = self.entry(w.next) {
3474                let _ = e.meta.set((iri, range));
3475            }
3476            w.pos = cend;
3477            w.next += 1;
3478        }
3479        Some(())
3480    }
3481
3482    /// The `i`-th graph's IRI — a header walk, never an index decode.
3483    /// `exhaustive`: see [`ensure_meta`](Self::ensure_meta).
3484    fn name_at(&self, i: usize, exhaustive: bool) -> Option<&str> {
3485        self.ensure_meta(i, exhaustive)?;
3486        self.entry(i)?.meta.get().map(|(iri, _)| iri.as_str())
3487    }
3488
3489    /// The `i`-th graph as `(iri, index)`, opening the index on first access.
3490    /// `exhaustive`: see [`ensure_meta`](Self::ensure_meta).
3491    fn graph_at(&self, i: usize, exhaustive: bool) -> Option<(&str, &GraphIndex)> {
3492        self.ensure_meta(i, exhaustive)?;
3493        let e = self.entry(i)?;
3494        let (iri, range) = e.meta.get()?;
3495        if e.index.get().is_none() {
3496            let opened = self.open_graph(*range)?;
3497            let _ = e.index.set(Box::new(opened));
3498        }
3499        Some((iri.as_str(), e.index.get()?.as_ref()))
3500    }
3501
3502    /// A small container's bytes: out of the walk buffer when the last chunk
3503    /// already carried them (bulk chunks always do — that is their point),
3504    /// else one targeted range read. Never called for tile-lazy containers.
3505    fn container_bytes(&self, range: ByteRange) -> Option<Vec<u8>> {
3506        {
3507            let w = self.walk.lock().unwrap();
3508            let buf_end = w.buf_off + w.buf.len() as u64;
3509            if range.offset >= w.buf_off && range.offset + range.len <= buf_end {
3510                let a = (range.offset - w.buf_off) as usize;
3511                return Some(w.buf[a..a + range.len as usize].to_vec());
3512            }
3513        }
3514        match self.reader.read_at(range.offset, range.len) {
3515            Ok(b) => Some(b),
3516            Err(_) => {
3517                self.fail();
3518                None
3519            }
3520        }
3521    }
3522
3523    /// Open one graph's index container: small ones resident (one read),
3524    /// large ones as a remote-lazy tile index — the same machinery as the
3525    /// default graph, so a selective query over a huge named graph fetches
3526    /// only the tile directories plus the tiles it touches.
3527    fn open_graph(&self, range: ByteRange) -> Option<GraphIndex> {
3528        if range.len <= NAMED_GRAPH_RESIDENT_MAX {
3529            let bytes = self.container_bytes(range)?;
3530            match decode_index_container(&bytes, self.codec, self.perms) {
3531                Ok(g) => Some(g),
3532                Err(_) => {
3533                    self.fail();
3534                    None
3535                }
3536            }
3537        } else {
3538            match open_index_container_lazy(
3539                &self.reader,
3540                range,
3541                self.codec,
3542                self.has_synopsis,
3543                self.read_concurrency,
3544                self.perms,
3545                self.perms.len(),
3546            ) {
3547                Ok((g, _, _)) => {
3548                    // A large graph faults its tiles through a loader, so bound
3549                    // its tile residency to the budget's share (an evicted tile
3550                    // simply re-faults). No-op when unlimited.
3551                    let cap = self.tile_cap.load(std::sync::atomic::Ordering::Relaxed);
3552                    if cap != u64::MAX {
3553                        g.set_cache_cap(cap);
3554                    }
3555                    Some(g)
3556                }
3557                Err(_) => {
3558                    self.fail();
3559                    None
3560                }
3561            }
3562        }
3563    }
3564
3565    /// Drop the decoded index of the graph named `iri`, if it has been opened,
3566    /// so its tiles and per-graph tile cache are freed. Takes `&mut self`:
3567    /// `OnceLock::take` needs unique access, which the export loop has between
3568    /// slots. Leaves the entry's `meta` (iri + range) so the graph re-opens
3569    /// lazily if touched again. A no-op if the graph is absent or never opened.
3570    fn release(&mut self, iri: &str) {
3571        let Some((_, table)) = self.dir.get_mut() else {
3572            return;
3573        };
3574        for slab in table.iter_mut() {
3575            let Some(entries) = slab.get_mut() else {
3576                continue;
3577            };
3578            for e in entries.iter_mut() {
3579                let matches = e
3580                    .meta
3581                    .get()
3582                    .map(|(name, _)| name.as_str() == iri)
3583                    .unwrap_or(false);
3584                if matches {
3585                    e.index.take();
3586                    return;
3587                }
3588            }
3589        }
3590    }
3591
3592    /// Visit every already-opened graph index (never triggers a fetch).
3593    fn for_each_opened(&self, mut f: impl FnMut(&GraphIndex)) {
3594        if let Some((_, table)) = self.dir.get() {
3595            for slab in table.iter().filter_map(|s| s.get()) {
3596                for e in slab.iter() {
3597                    if let Some(g) = e.index.get() {
3598                        f(g);
3599                    }
3600                }
3601            }
3602        }
3603    }
3604}
3605
3606/// The NAMED_GRAPHS section, held resident (in-memory and eager ranged opens —
3607/// unchanged) or walked lazily (the lazy ranged open, where fetching and
3608/// decoding every graph's index up front defeated remote laziness: 67 MB
3609/// fetched and ~32k indexes built before the first query on a many-graph file).
3610enum NamedGraphsSlot {
3611    Resident(Vec<(String, GraphIndex)>),
3612    Lazy(LazyNamedGraphs),
3613    /// The quad index (kind 10), on every open path: graphs are views over
3614    /// shared tiles, found through the dictionary and the sorted directory.
3615    Quad(crate::quad::QuadNamed),
3616}
3617
3618impl NamedGraphsSlot {
3619    /// Heap bytes the named-graph directory holds outside any cache: every
3620    /// walked entry's IRI and range, the walk buffer, and the directories of
3621    /// the graph indexes currently open (see [`GraphIndex::directory_bytes`]).
3622    fn directory_bytes(&self) -> u64 {
3623        match self {
3624            NamedGraphsSlot::Resident(v) => v
3625                .iter()
3626                .map(|(iri, g)| iri.capacity() as u64 + g.directory_bytes())
3627                .sum(),
3628            NamedGraphsSlot::Lazy(l) => {
3629                let mut bytes = l.walk.lock().map(|w| w.buf.capacity() as u64).unwrap_or(0);
3630                if let Some((_, table)) = l.dir.get() {
3631                    bytes += table.len() as u64
3632                        * std::mem::size_of::<std::sync::OnceLock<Box<[NamedEntry]>>>() as u64;
3633                    for slab in table.iter().filter_map(|s| s.get()) {
3634                        bytes += slab.len() as u64 * std::mem::size_of::<NamedEntry>() as u64;
3635                        for e in slab.iter() {
3636                            if let Some((iri, _)) = e.meta.get() {
3637                                bytes += iri.capacity() as u64;
3638                            }
3639                            if let Some(g) = e.index.get() {
3640                                bytes += g.directory_bytes();
3641                            }
3642                        }
3643                    }
3644                }
3645                bytes
3646            }
3647            NamedGraphsSlot::Quad(q) => q.directory_bytes(),
3648        }
3649    }
3650
3651    fn count(&self) -> usize {
3652        match self {
3653            NamedGraphsSlot::Resident(v) => v.len(),
3654            NamedGraphsSlot::Lazy(l) => l.count(),
3655            NamedGraphsSlot::Quad(q) => q.count(),
3656        }
3657    }
3658
3659    /// `exhaustive` is the walk-demand hint (resident opens ignore it): see
3660    /// [`LazyNamedGraphs::ensure_meta`]. `dict` names the graphs of a quad
3661    /// index (their names are dictionary terms there).
3662    fn name_at<'a>(&'a self, dict: &Dictionary, i: usize, exhaustive: bool) -> Option<&'a str> {
3663        match self {
3664            NamedGraphsSlot::Resident(v) => v.get(i).map(|(iri, _)| iri.as_str()),
3665            NamedGraphsSlot::Lazy(l) => l.name_at(i, exhaustive),
3666            NamedGraphsSlot::Quad(q) => q.name_at(dict, i, exhaustive),
3667        }
3668    }
3669
3670    fn graph_at<'a>(
3671        &'a self,
3672        dict: &Dictionary,
3673        i: usize,
3674        exhaustive: bool,
3675    ) -> Option<(&'a str, &'a GraphIndex)> {
3676        match self {
3677            NamedGraphsSlot::Resident(v) => v.get(i).map(|(iri, g)| (iri.as_str(), g)),
3678            NamedGraphsSlot::Lazy(l) => l.graph_at(i, exhaustive),
3679            NamedGraphsSlot::Quad(q) => q.graph_at(dict, i, exhaustive),
3680        }
3681    }
3682
3683    fn find<'a>(&'a self, dict: &Dictionary, iri: &str) -> Option<&'a GraphIndex> {
3684        match self {
3685            NamedGraphsSlot::Quad(q) => q.find(dict, iri),
3686            NamedGraphsSlot::Resident(v) => v.iter().find(|(name, _)| name == iri).map(|(_, g)| g),
3687            NamedGraphsSlot::Lazy(l) => {
3688                // Walk headers only until the IRI matches; decode just that
3689                // graph's index. An absent IRI costs a full header walk — the
3690                // layout has no by-name directory. A point lookup is targeted
3691                // demand: never hint the walk exhaustive.
3692                for i in 0..l.count() {
3693                    if l.name_at(i, false)? == iri {
3694                        return l.graph_at(i, false).map(|(_, g)| g);
3695                    }
3696                }
3697                None
3698            }
3699        }
3700    }
3701
3702    fn load_incomplete(&self) -> bool {
3703        match self {
3704            NamedGraphsSlot::Resident(v) => v.iter().any(|(_, g)| g.load_incomplete()),
3705            NamedGraphsSlot::Lazy(l) => {
3706                if l.failed.load(std::sync::atomic::Ordering::Relaxed) {
3707                    return true;
3708                }
3709                let mut bad = false;
3710                l.for_each_opened(|g| bad |= g.load_incomplete());
3711                bad
3712            }
3713            NamedGraphsSlot::Quad(q) => q.load_incomplete(),
3714        }
3715    }
3716
3717    fn reset_load_failures(&self) {
3718        match self {
3719            NamedGraphsSlot::Resident(v) => {
3720                for (_, g) in v {
3721                    g.reset_load_failure();
3722                }
3723            }
3724            NamedGraphsSlot::Lazy(l) => {
3725                l.failed.store(false, std::sync::atomic::Ordering::Relaxed);
3726                l.for_each_opened(|g| g.reset_load_failure());
3727            }
3728            NamedGraphsSlot::Quad(q) => q.reset_load_failures(),
3729        }
3730    }
3731
3732    /// The named-graph slot of a file opened with all its bytes in hand
3733    /// (`Rete::open`, `open_ranged`): the per-graph section decoded resident,
3734    /// or the quad index read through an owned copy of its section.
3735    fn resident(
3736        header: &Header,
3737        quad_section: impl FnOnce(u64, u64) -> Result<Vec<u8>, FileError>,
3738        per_graph: impl FnOnce() -> Result<Vec<(String, GraphIndex)>, FileError>,
3739    ) -> Result<Self, FileError> {
3740        check_named_layout(header)?;
3741        if let Some(q) = header.quad_index_section() {
3742            let bytes = quad_section(q.offset, q.length)?;
3743            return Ok(NamedGraphsSlot::Quad(crate::quad::QuadNamed::resident(
3744                bytes,
3745                header.block_codec,
3746                header.perms,
3747            )));
3748        }
3749        Ok(NamedGraphsSlot::Resident(if header.named_graphs_len > 0 {
3750            per_graph()?
3751        } else {
3752            Vec::new()
3753        }))
3754    }
3755}
3756
3757/// A read-only, in-memory view over a `.rete` file image.
3758pub struct Rete {
3759    header: Header,
3760    dict: Dictionary,
3761    index: GraphIndex,
3762    index_section_ranges: [ByteRange; NUM_PERMS],
3763    /// Per-permutation tile directories as absolute file ranges
3764    /// (`(min_a, max_a, compressed-tile range)`), for provenance. Empty for
3765    /// pre-tiling (v0.1) files.
3766    tile_ranges: [Vec<(u32, u32, ByteRange)>; NUM_PERMS],
3767    pyramid: PyramidSlot,
3768    text_index: TextIndexSlot,
3769    /// The LABEL_INDEX section (kind 8), if any.
3770    labels: SectionSlot<crate::label_index::LabelIndex>,
3771    /// The SCHEMA section (kind 9), if any.
3772    schema: SectionSlot<crate::schema_section::SchemaMeta>,
3773    named_graphs: NamedGraphsSlot,
3774    /// Raw bytes of the metadata section (empty if the file has none). The
3775    /// application layer decodes this (the CLI stores a JSON Dataset Card here).
3776    /// Only [`Rete::open`] populates it; [`Rete::open_ranged`] leaves it empty to
3777    /// preserve its minimal-fetch budget.
3778    metadata: Vec<u8>,
3779    /// Executes remote `SERVICE` blocks (SPARQL 1.1 federated query) — attached
3780    /// by the host via [`Rete::set_service_client`]; `None` means a non-SILENT
3781    /// `SERVICE` fails the query. Like the range readers, the engine never does
3782    /// I/O itself.
3783    service_client: Option<Box<dyn crate::service::ServiceClient>>,
3784    /// First failed non-SILENT `SERVICE` call of the current query. The row
3785    /// pipeline is infallible (the same contract as lazy tile fetches), so the
3786    /// failure is recorded here and taken by the top-level eval entry points.
3787    service_error: std::sync::Mutex<Option<String>>,
3788    /// Bytes a query's `COUNT(DISTINCT …)` set may hold before it spills to
3789    /// disk — see [`Rete::spill_threshold`].
3790    spill_threshold: std::sync::atomic::AtomicU64,
3791}
3792
3793impl Rete {
3794    /// Parse a full file image (v0 loads everything; a range-reading client
3795    /// will fetch only the sections it needs — same container format).
3796    pub fn open(bytes: &[u8]) -> Result<Self, FileError> {
3797        let header = Header::from_bytes(bytes)?;
3798
3799        // Header offsets/lengths are untrusted (a `.rete` may be fetched truncated
3800        // or corrupt from an arbitrary URL). Slice through a checked helper so a
3801        // bad region yields an error instead of panicking on an OOB index.
3802        let region = |off: u64, len: u64| -> Result<&[u8], FileError> {
3803            let start = off as usize;
3804            let end = start
3805                .checked_add(len as usize)
3806                .filter(|&e| e <= bytes.len())
3807                .ok_or(FileError::Container("section range out of bounds"))?;
3808            Ok(&bytes[start..end])
3809        };
3810
3811        let dict = decode_dictionary_container(
3812            region(header.dictionary_offset, header.dictionary_len)?,
3813            header.dict_codec,
3814        )?;
3815
3816        check_named_layout(&header)?;
3817        let index_bytes = region(header.root_dir_offset, header.root_dir_len)?;
3818        let (index, _) = decode_root_index_container(index_bytes, &header)?;
3819        let index_section_ranges = decode_index_section_ranges(
3820            index_bytes,
3821            header.root_dir_offset,
3822            header.perms,
3823            header.index_container_sections(),
3824        )?;
3825
3826        let pyramid = PyramidSlot::Resident(if header.pyramid_meta_len > 0 {
3827            Some(
3828                PyramidMeta::decode(region(header.pyramid_meta_offset, header.pyramid_meta_len)?)
3829                    .map_err(|_| FileError::Container("malformed pyramid meta"))?,
3830            )
3831        } else {
3832            None
3833        });
3834
3835        // The TEXT_INDEX section (opt-in `--text-index`); decode the whole thing
3836        // resident on a full-image open.
3837        let (ti_off, ti_len) = header.text_index_payload();
3838        let text_index = resident_text_index_slot(
3839            if ti_len > 0 {
3840                Some(region(ti_off, ti_len)?)
3841            } else {
3842                None
3843            },
3844            header.block_codec,
3845        )?;
3846        let labels = resident_label_slot(&header, &region);
3847        let schema = resident_schema_slot(&header, &region);
3848
3849        let named_graphs = NamedGraphsSlot::resident(
3850            &header,
3851            |off, len| Ok(region(off, len)?.to_vec()),
3852            || {
3853                decode_named_graphs(
3854                    region(header.named_graphs_offset, header.named_graphs_len)?,
3855                    header.block_codec,
3856                    header.perms,
3857                )
3858            },
3859        )?;
3860
3861        let metadata = if header.metadata_len > 0 {
3862            region(header.metadata_offset, header.metadata_len)?.to_vec()
3863        } else {
3864            Vec::new()
3865        };
3866
3867        let tile_ranges =
3868            tile_file_ranges(index_bytes, header.root_dir_offset, &index_section_ranges);
3869        Ok(Self {
3870            header,
3871            dict,
3872            index,
3873            index_section_ranges,
3874            tile_ranges,
3875            pyramid,
3876            text_index,
3877            labels,
3878            schema,
3879            named_graphs,
3880            metadata,
3881            service_client: None,
3882            service_error: std::sync::Mutex::new(None),
3883            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
3884        })
3885    }
3886
3887    /// Attach the client that executes `SERVICE <endpoint> { … }` blocks
3888    /// (SPARQL 1.1 federated query) against remote SPARQL endpoints. Without
3889    /// one, a non-SILENT `SERVICE` fails the query with a clear error and a
3890    /// `SERVICE SILENT` degrades to one empty solution, per the spec.
3891    pub fn set_service_client(&mut self, client: Box<dyn crate::service::ServiceClient>) {
3892        self.service_client = Some(client);
3893    }
3894
3895    pub(crate) fn service_client(&self) -> Option<&dyn crate::service::ServiceClient> {
3896        self.service_client.as_deref()
3897    }
3898
3899    /// Record a failed non-SILENT `SERVICE` call (first error wins).
3900    pub(crate) fn record_service_error(&self, msg: &str) {
3901        let mut e = self.service_error.lock().unwrap();
3902        if e.is_none() {
3903            *e = Some(msg.to_string());
3904        }
3905    }
3906
3907    /// Take (and clear) the pending `SERVICE` failure — called by every
3908    /// top-level eval entry so it can never leak into a later query.
3909    pub(crate) fn take_service_error(&self) -> Option<String> {
3910        self.service_error.lock().unwrap().take()
3911    }
3912
3913    pub fn header(&self) -> &Header {
3914        &self.header
3915    }
3916
3917    /// The file's byte layout, for visualization: header, metadata,
3918    /// dictionary, each index permutation's tile directory and individual
3919    /// tiles, pyramid summary, and named graphs — sorted by offset. Bytes not
3920    /// covered by any segment are container framing (section directories and
3921    /// length fields).
3922    pub fn file_layout(&self) -> Vec<LayoutSegment> {
3923        let h = &self.header;
3924        let seg = |kind: &'static str, label: String, offset: u64, len: u64| LayoutSegment {
3925            kind,
3926            label,
3927            offset,
3928            len,
3929        };
3930        // The size in the label is DERIVED from the constant that defines the
3931        // span, never typed out: this line read "fixed 128 bytes" — the
3932        // pre-v0.3 header size — while emitting a 1024-byte span, and it is
3933        // what the file explorer shows a reader inspecting a real file.
3934        // `layout_header_label_matches_its_span` pins the two together.
3935        let mut out = vec![seg(
3936            "header",
3937            format!("header (fixed {} bytes)", crate::header::HEADER_LEN),
3938            0,
3939            crate::header::HEADER_LEN as u64,
3940        )];
3941        if h.metadata_len > 0 {
3942            out.push(seg(
3943                "metadata",
3944                "metadata (dataset card)".into(),
3945                h.metadata_offset,
3946                h.metadata_len,
3947            ));
3948        }
3949        out.push(seg(
3950            "dictionary",
3951            "dictionary (4 front-coded term sections)".into(),
3952            h.dictionary_offset,
3953            h.dictionary_len,
3954        ));
3955        for (si, perm) in crate::index::ALL_PERMS.into_iter().enumerate() {
3956            let sec = self.index_section_ranges[si];
3957            if sec.len == 0 {
3958                continue;
3959            }
3960            let first_tile = self.tile_ranges[si]
3961                .first()
3962                .map(|&(_, _, r)| r.offset)
3963                .unwrap_or(sec.offset + sec.len);
3964            if first_tile > sec.offset {
3965                out.push(seg(
3966                    "directory",
3967                    format!("{} tile directory", perm.name()),
3968                    sec.offset,
3969                    first_tile - sec.offset,
3970                ));
3971            }
3972            for (ti, &(min_a, max_a, r)) in self.tile_ranges[si].iter().enumerate() {
3973                out.push(seg(
3974                    "tile",
3975                    format!("{} tile {ti} (leading ids {min_a}..{max_a})", perm.name()),
3976                    r.offset,
3977                    r.len,
3978                ));
3979            }
3980        }
3981        if h.pyramid_meta_len > 0 {
3982            out.push(seg(
3983                "pyramid",
3984                "pyramid summary (communities + superedges)".into(),
3985                h.pyramid_meta_offset,
3986                h.pyramid_meta_len,
3987            ));
3988        }
3989        if h.named_graphs_len > 0 {
3990            out.push(seg(
3991                "named-graphs",
3992                format!("named graphs ({})", self.named_graphs.count()),
3993                h.named_graphs_offset,
3994                h.named_graphs_len,
3995            ));
3996        }
3997        if let Some(s) = h.label_index_section() {
3998            let label = match self.labels.get_if_loaded() {
3999                Some(li) => format!("label index ({} entries)", li.entry_count()),
4000                None => "label index".to_string(),
4001            };
4002            out.push(seg("label-index", label, s.offset, s.length));
4003        }
4004        if let Some(s) = h.schema_section() {
4005            out.push(seg(
4006                "schema",
4007                "schema (classes, query_stats, shapes)".into(),
4008                s.offset,
4009                s.length,
4010            ));
4011        }
4012        if let Some(q) = h.quad_index_section() {
4013            out.push(seg(
4014                "quad-index",
4015                format!("quad index ({} named graphs)", self.named_graphs.count()),
4016                q.offset,
4017                q.length,
4018            ));
4019        }
4020        out.sort_by_key(|s| s.offset);
4021        out
4022    }
4023
4024    /// Raw bytes of the file's metadata section, or `None` if it has none. The
4025    /// CLI stores a JSON Dataset Card here; `rete-core` treats it as opaque.
4026    /// Populated by [`Rete::open`] only — an [`Rete::open_ranged`] view returns
4027    /// `None` here (the card is not fetched on the minimal query path).
4028    pub fn metadata(&self) -> Option<&[u8]> {
4029        if self.metadata.is_empty() {
4030            None
4031        } else {
4032            Some(&self.metadata)
4033        }
4034    }
4035
4036    pub fn dictionary(&self) -> &Dictionary {
4037        &self.dict
4038    }
4039
4040    /// The pyramid metadata (summary graph + tiles), if the file has a pyramid.
4041    pub fn pyramid(&self) -> Option<&PyramidMeta> {
4042        match &self.pyramid {
4043            PyramidSlot::Resident(p) => p.as_ref(),
4044            // Faults the (possibly large) pyramid section on first access only.
4045            PyramidSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
4046        }
4047    }
4048
4049    /// The pyramid metadata **only if already resident or previously faulted** —
4050    /// never triggers a lazy range read. The query planner uses this for
4051    /// cardinality estimation so it is free for an in-memory file and never adds
4052    /// a fetch on the lazy remote path (which defers the pyramid by design).
4053    pub fn pyramid_if_loaded(&self) -> Option<&PyramidMeta> {
4054        match &self.pyramid {
4055            PyramidSlot::Resident(p) => p.as_ref(),
4056            PyramidSlot::Lazy { cell, .. } => cell.get().and_then(|o| o.as_ref()),
4057        }
4058    }
4059
4060    /// The SCHEMA section (kind 9): class hierarchy, level rollups and links,
4061    /// coherence axioms, `query_stats`, characteristic sets. Faults it in (one
4062    /// small range read) on the lazy path. `None` when the file has none — a
4063    /// file built before it existed keeps those fields in its pyramid-meta
4064    /// ([`schema_or_pyramid`](Self::schema_or_pyramid) reads either).
4065    pub fn schema(&self) -> Option<&crate::schema_section::SchemaMeta> {
4066        self.schema.get()
4067    }
4068
4069    /// The schema fields from the SCHEMA section when the file has one, else
4070    /// from the pyramid-meta (faulting either on the lazy path). `None` when
4071    /// the file carries neither.
4072    pub fn schema_or_pyramid(
4073        &self,
4074    ) -> Option<std::borrow::Cow<'_, crate::schema_section::SchemaMeta>> {
4075        if let Some(s) = self.schema() {
4076            return Some(std::borrow::Cow::Borrowed(s));
4077        }
4078        self.pyramid()
4079            .map(|p| std::borrow::Cow::Owned(crate::schema_section::SchemaMeta::from_pyramid(p)))
4080    }
4081
4082    /// Per-predicate planner statistics (`query_stats`): from the SCHEMA
4083    /// section (faulting it on the lazy path — it is small), else from the
4084    /// pyramid-meta when that is resident. Empty when the file has neither.
4085    /// See [`crate::meta::PredStat`].
4086    pub fn predicate_stats(&self) -> &[crate::meta::PredStat] {
4087        if let Some(s) = self.schema().filter(|s| !s.predicate_stats.is_empty()) {
4088            return &s.predicate_stats;
4089        }
4090        self.pyramid_if_loaded()
4091            .map(|p| p.predicate_stats.as_slice())
4092            .unwrap_or(&[])
4093    }
4094
4095    /// The statistics the planner may use **without a read it was not going to
4096    /// make**: the SCHEMA section if it is resident or already faulted — or,
4097    /// with `fault`, after faulting it (one small range, once per session) —
4098    /// else the resident pyramid-meta's block.
4099    pub(crate) fn planner_stats(&self, fault: bool) -> &[crate::meta::PredStat] {
4100        let schema = if fault {
4101            self.schema.get()
4102        } else {
4103            self.schema.get_if_loaded()
4104        };
4105        if let Some(s) = schema.filter(|s| !s.predicate_stats.is_empty()) {
4106            return &s.predicate_stats;
4107        }
4108        self.pyramid_if_loaded()
4109            .map(|p| p.predicate_stats.as_slice())
4110            .unwrap_or(&[])
4111    }
4112
4113    /// The entity shapes (characteristic sets): from the SCHEMA section, else
4114    /// from a resident pyramid-meta. See [`crate::meta::CharSet`].
4115    pub fn char_sets(&self) -> &[crate::meta::CharSet] {
4116        if let Some(s) = self.schema().filter(|s| !s.char_sets.is_empty()) {
4117            return &s.char_sets;
4118        }
4119        self.pyramid_if_loaded()
4120            .map(|p| p.char_sets.as_slice())
4121            .unwrap_or(&[])
4122    }
4123
4124    /// The **legacy** label block of a pyramid-meta (≤ 8,192 entries) — empty
4125    /// when the file has none or the pyramid isn't resident. Files built with
4126    /// `--label-index` carry a LABEL_INDEX section instead; see
4127    /// [`label_index_section`](Self::label_index_section). See
4128    /// [`crate::meta::LabelEntry`].
4129    pub fn label_index(&self) -> &[crate::meta::LabelEntry] {
4130        self.pyramid_if_loaded()
4131            .map(|p| p.label_index.as_slice())
4132            .unwrap_or(&[])
4133    }
4134
4135    /// Whether the file carries a LABEL_INDEX section (kind 8). Header only.
4136    pub fn has_label_index(&self) -> bool {
4137        self.header.label_index_section().is_some()
4138    }
4139
4140    /// The LABEL_INDEX section, faulting its head in on the lazy path.
4141    pub fn label_index_section(&self) -> Option<&crate::label_index::LabelIndex> {
4142        self.labels.get()
4143    }
4144
4145    /// Prefix-search the labels: the subjects whose label starts with `prefix`
4146    /// (case-insensitive), as `(label, subject term)`, capped at `limit`.
4147    /// Reads the LABEL_INDEX section when the file has one (every labelled
4148    /// subject, no dictionary read); otherwise the legacy pyramid-meta block,
4149    /// which **faults the pyramid** on the lazy path. Empty when the file
4150    /// carries neither.
4151    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<(String, String)> {
4152        if let Some(li) = self.labels.get() {
4153            return li.prefix_search(prefix, limit);
4154        }
4155        let Some(pyr) = self.pyramid() else {
4156            return Vec::new();
4157        };
4158        pyr.prefix_search(prefix, limit)
4159            .into_iter()
4160            .filter_map(|e| {
4161                self.dict
4162                    .subject_term(e.subject)
4163                    .map(|iri| (e.label.clone(), iri))
4164            })
4165            .collect()
4166    }
4167
4168    /// The full-text index (TEXT_INDEX section), faulting it in on first access
4169    /// on the lazy remote path. `None` when the file carries no text index.
4170    pub(crate) fn text_index(&self) -> Option<&crate::text_index::TextIndex> {
4171        self.text_index.index()
4172    }
4173
4174    /// Whether this file carries a full-text (TEXT_INDEX) section, i.e. it was
4175    /// built with `--text-index`. Cheap — reads the header, never faults.
4176    pub fn has_text_index(&self) -> bool {
4177        self.header.text_index_len > 0
4178    }
4179
4180    /// What the full-text index covers (graphs, predicates) — `None` when the
4181    /// file has none. Faults the token table on the lazy remote path, as a
4182    /// search does; the scope block lives in it.
4183    pub fn text_index_scope(&self) -> Option<crate::text_index::TextIndexScope> {
4184        self.text_index().map(|ti| ti.scope().clone())
4185    }
4186
4187    /// Byte length of the TEXT_INDEX section's leading **token table** — the
4188    /// length varint plus the compressed table, which is exactly what a first
4189    /// [`text_search`](Self::text_search) faults. `None` when the file carries
4190    /// no text index (or its head could not be read).
4191    ///
4192    /// This is the number to quote as the cost of a first search, NOT
4193    /// `header().text_index_len`: that counts the postings blob too, which is
4194    /// the bulk of the section and is only ever fetched one posting list at a
4195    /// time. On the published `epfl-infoscience.rete` the section is 195 MB and
4196    /// its token table 29 MB — a 6.5× difference.
4197    ///
4198    /// Costs nothing on a resident open (measured while the bytes were in hand);
4199    /// a lazy/ranged open pays one ≤10-byte range read, memoized, and still
4200    /// never faults the table itself.
4201    pub fn text_index_token_table_len(&self) -> Option<u64> {
4202        self.text_index.token_table_len()
4203    }
4204
4205    /// Full-text search over the literals: subject IRIs that carry **every** word
4206    /// in `words` (whole-word, case-insensitive — AND semantics), optionally also
4207    /// requiring a word that **starts with** `prefix` (token-prefix). Results are
4208    /// ordered by subject id and capped at `limit` (0 = uncapped). Empty when the
4209    /// file has no text index or nothing matches.
4210    ///
4211    /// Like [`prefix_search`](Self::prefix_search), this **faults** the index on
4212    /// the lazy remote path — a search is an explicit read, and only the queried
4213    /// posting lists are fetched, not the whole index.
4214    pub fn text_search(&self, words: &[&str], prefix: Option<&str>, limit: usize) -> Vec<String> {
4215        text_search_in(self.text_index(), &self.dict, words, prefix, limit)
4216    }
4217
4218    /// The default-graph permutation index.
4219    pub fn default_index(&self) -> &GraphIndex {
4220        &self.index
4221    }
4222
4223    /// Resolve every triple of a graph (`None` = default graph) back to terms.
4224    pub fn dump(&self, graph: Option<&str>) -> Vec<TermTriple> {
4225        let index = match graph {
4226            None => &self.index,
4227            Some(g) => match self.graph_index(g) {
4228                Some(i) => i,
4229                None => return Vec::new(),
4230            },
4231        };
4232        index
4233            .match_pattern((None, None, None))
4234            .into_iter()
4235            .filter_map(|(s, p, o)| {
4236                Some((
4237                    self.dict.subject_term(s)?,
4238                    self.dict.predicate_term(p)?,
4239                    self.dict.object_term(o)?,
4240                ))
4241            })
4242            .collect()
4243    }
4244
4245    /// Stream every triple of a graph (`None` = default) to `f`, resolving terms
4246    /// a batch at a time — no full `Vec` materialization, so it is safe on graphs far
4247    /// larger than RAM. `rete export` uses this to serialize 100M+ triple files
4248    /// that `dump()` (which collects every term into a `Vec<String>`) would OOM on.
4249    ///
4250    /// On a lazy/ranged open the dictionary is **not** prefetched whole: each
4251    /// batch faults only the chunks its own terms live in. Dumping ONE named
4252    /// graph therefore costs that graph's terms, not the file's. Dumping
4253    /// *everything* still reads every dictionary byte and leaves every chunk
4254    /// resident — that is inherent, and it, not the prefetch, is what sets the
4255    /// peak of a full export. See the comment in the body for both measurements.
4256    ///
4257    /// To dump a *slice* — one predicate, one subject, one object — call
4258    /// [`dump_filtered_each`](Self::dump_filtered_each), which is this method
4259    /// with a triple pattern and prunes tiles instead of filtering rows.
4260    pub fn dump_each<F: FnMut(&str, &str, &str)>(&self, graph: Option<&str>, f: F) {
4261        self.dump_filtered_each(graph, None, None, None, f)
4262    }
4263
4264    /// [`dump_each`](Self::dump_each) restricted to a triple **pattern** — the
4265    /// filtered dump. Any of `s` / `p` / `o` may be bound (canonical N-Triples
4266    /// term tokens); all three unbound is exactly `dump_each`.
4267    ///
4268    /// # Why this is cheap, and how much
4269    ///
4270    /// It is the same access path a routed *query* takes, not a full scan with a
4271    /// predicate test bolted on: [`GraphIndex::scan_iter`] picks the permutation
4272    /// with the longest bound prefix, binary-searches that section's tile
4273    /// directory down to the span a bound leading id can live in, and then drops
4274    /// every remaining tile whose recorded synopsis proves it cannot hold a
4275    /// bound secondary component — **all from directories, without fetching a
4276    /// tile**. So a filtered dump pays for the slice, which is the whole point
4277    /// of issue #117.
4278    ///
4279    /// Measured on `cordis.rete` (801 MB, a 417 MB dictionary, 26.4M quads,
4280    /// six named graphs), lazily opened; "before" is the only way to get a
4281    /// predicate slice out of a dump previously — dump the graph and throw away
4282    /// what does not match, in the consumer:
4283    ///
4284    /// ```text
4285    ///   graph=results  p=s66#doi     -> 337,811 rows
4286    ///     before  375.8 MB · 1797 req · 2105.8 MB RSS · 12.8 s · first row 409 ms
4287    ///     after    16.0 MB ·  105 req ·  182.6 MB RSS ·  0.41 s · first row  14 ms
4288    ///   graph=results  p=s66#isbn    ->  40,761 rows
4289    ///     before  375.8 MB · 1797 req · 2105.7 MB RSS · 12.8 s
4290    ///     after    15.5 MB ·  104 req ·  155.2 MB RSS ·  0.23 s
4291    /// ```
4292    ///
4293    /// # Where it does NOT help
4294    ///
4295    /// The index is pruned; the **dictionary is not**. Term resolution still
4296    /// faults whichever chunks the surviving rows' terms live in, and on a file
4297    /// whose payload *is* the literals those chunks are most of the file. Same
4298    /// file, a predicate whose objects are the long abstracts:
4299    ///
4300    /// ```text
4301    ///   graph=projects p=s66#abstract ->  80,206 rows
4302    ///     before  260.7 MB · 1142 req · 1496.9 MB RSS · 5.97 s
4303    ///     after   213.4 MB · 1606 req · 1144.4 MB RSS · 2.91 s   (1.2x, not 23x)
4304    /// ```
4305    ///
4306    /// An *unfiltered* dump is unchanged by construction — it is the floor, and
4307    /// its peak is the resident dictionary, not this. Two ceilings sit above
4308    /// both: a faulted dictionary chunk is a `OnceCell` nothing ever evicts, and
4309    /// on a literal-heavy file the chunk directory alone can be a third of the
4310    /// file before any dump work at all (#198).
4311    ///
4312    /// # Permutations
4313    ///
4314    /// Every one of the eight bound/unbound shapes routes inside
4315    /// [`PermSet::CORE`] — `{SPO, POS, OSP}`, the minimum a legal file carries
4316    /// (see `perm_routing_never_leaves_core`). A file built with
4317    /// `--permutations 3` therefore prunes *identically* to a six-permutation
4318    /// one: same tiles, same rows, no fallback path to get wrong.
4319    ///
4320    /// A bound term the dictionary does not know, or an absent graph IRI, yields
4321    /// nothing without touching the index.
4322    ///
4323    /// # Order
4324    ///
4325    /// Rows arrive in the **routed permutation's** order, which is the price of
4326    /// routing at all. Unfiltered (and subject-bound) that is SPO — canonical
4327    /// `(s, p, o)`, unchanged from [`dump_each`](Self::dump_each). A bound
4328    /// predicate routes to POS and streams `(p, o, s)`; a bound object routes to
4329    /// OSP. The *set* is identical to the unfiltered dump filtered; the order is
4330    /// not, so a consumer that needs canonical order must sort. N-Quads, the
4331    /// format `rete export` writes, is order-independent.
4332    ///
4333    /// [`PermSet::CORE`]: crate::index::PermSet::CORE
4334    pub fn dump_filtered_each<F: FnMut(&str, &str, &str)>(
4335        &self,
4336        graph: Option<&str>,
4337        s: Option<&str>,
4338        p: Option<&str>,
4339        o: Option<&str>,
4340        mut f: F,
4341    ) {
4342        let index = match graph {
4343            None => Some(&self.index),
4344            Some(g) => self.graph_index(g),
4345        };
4346        let Some(index) = index else { return };
4347        // A bound term the dictionary does not know can match nothing, and the
4348        // index is never touched — same contract as `query_iter`/`query_batch`.
4349        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
4350            return;
4351        };
4352        let dict = &self.dict;
4353        // Resolve in fixed-size batches with ONE coalesced dictionary fault per
4354        // batch — the same `prefetch_terms` call `dump_batch` makes, for the
4355        // same reason.
4356        //
4357        // This used to be `Dictionary::prefetch_all` up front, on the argument
4358        // that a full dump reaches every term anyway so the bytes are owed
4359        // either way. That argument only holds for a dump of EVERYTHING, and
4360        // this takes a graph AND a pattern. Measured on `cordis.rete` (801 MB,
4361        // a 417 MB dictionary, six named graphs), lazily opened:
4362        //
4363        //   dump_each(Some(fundingschemes))  417 MB read, 2510 MB peak RSS
4364        //                              →     173 MB read,  619 MB peak RSS
4365        //   dump_each(None) — the EMPTY default graph, which is the first thing
4366        //   `rete export` calls on a quads file:
4367        //                                    415 MB read, 2508 MB peak RSS
4368        //                              →       0 MB read,   95 MB peak RSS
4369        //
4370        // The 2.5 GB comes from asking for every chunk at once: the bulk loader
4371        // coalesces adjacent chunks into one span, so the whole dictionary
4372        // section is materialized three times over before the first triple —
4373        // the fetched span, the per-chunk copies sliced out of it, and the
4374        // decompressed bodies.
4375        //
4376        // Be clear about what this does NOT buy. A dump of the whole default
4377        // graph still faults every chunk, and chunks stay resident once
4378        // faulted, so `rete export` of a single-graph file is unchanged:
4379        // `figshare.rete` (222 MB, a 126 MB dictionary) peaks at 874 MB lazily
4380        // either way. The resident dictionary is the floor there, not this.
4381        //
4382        // Within each window, term resolution is CHUNK-ORDERED, not row-ordered
4383        // (`Dictionary::resolve_window`, bounded-export phase 1): the window's
4384        // ids are grouped by the `(section, chunk)` they live in and each chunk
4385        // is decoded once and its runs walked once, filling every slot that
4386        // chunk owns, then the rows are emitted in the original scan order. Two
4387        // things follow. First, a window's objects — which scatter across the
4388        // object-only section for one subject — touch each chunk a single time
4389        // instead of re-faulting it per row. Second, the per-id re-walk from a
4390        // run's start that the old row-order path did for *every* id collapses
4391        // to one monotonic forward pass per run. The output is byte-for-byte
4392        // identical (same bytes per id, same emission order, same
4393        // all-three-resolved emit rule); the cap is still unlimited, so this is
4394        // structural preparation for the real bound (phase 2), plus a small
4395        // free speedup. The coalesced bulk fault (`prefetch_terms`) is
4396        // unchanged, so the read pattern and peak residency are as before.
4397        const WINDOW: usize = 4096;
4398        // The whole-window coalesced prefetch (`prefetch_terms`) materializes
4399        // every distinct chunk the window touches at once — up to ~2·WINDOW
4400        // chunks for scattered objects. Under a GENEROUS budget that transient
4401        // fits and the coalesced reads are the fast path (small/normal files,
4402        // `--in-memory`, unlimited). Under a TIGHT budget it would blow the cap
4403        // and thrash (insert-then-evict every chunk, then re-fault it in
4404        // `resolve_window`), so skip it: `resolve_window` is chunk-ordered and
4405        // holds one chunk `Arc` at a time (plan §3.5), keeping the window's
4406        // working set to a single chunk regardless of how the objects scatter.
4407        // Output is byte-identical either way — prefetch only warms the cache.
4408        let cap = dict.cache_cap();
4409        let prefetch_window =
4410            cap == u64::MAX || cap >= (WINDOW as u64) * 2 * (DICT_CHUNK_BUDGET as u64);
4411        // Under a finite budget the prefetch also goes in batches of
4412        // PREFETCH_BATCH chunks: one bulk read holds everything it fetched
4413        // (span, slices, decoded bodies) until it is all cached, which for a
4414        // scattered window was thousands of chunks — up to ~1 GB on
4415        // openaire-relation — outside the cache cap. Unlimited keeps one read
4416        // per section (the latency-optimal shape for a remote file).
4417        const PREFETCH_BATCH: usize = 64;
4418        let prefetch_batch = if cap == u64::MAX {
4419            usize::MAX
4420        } else {
4421            PREFETCH_BATCH
4422        };
4423        let mut ids: Vec<(u32, u32, u32)> = Vec::with_capacity(WINDOW);
4424        let mut nodes: Vec<u32> = Vec::with_capacity(WINDOW * 2);
4425        let mut preds: Vec<u32> = Vec::with_capacity(WINDOW);
4426        let mut resolver = crate::dictionary::WindowResolver::default();
4427        let mut flush = |ids: &mut Vec<(u32, u32, u32)>,
4428                         resolver: &mut crate::dictionary::WindowResolver,
4429                         f: &mut F| {
4430            if ids.is_empty() {
4431                return;
4432            }
4433            // Coalesced bulk fault of exactly the chunks this window needs —
4434            // only under a generous budget (see `prefetch_window` above); a
4435            // tight budget faults chunk-by-chunk inside `resolve_window`.
4436            if prefetch_window {
4437                nodes.clear();
4438                preds.clear();
4439                for &(s, p, o) in ids.iter() {
4440                    nodes.push(dict.subject_node(s));
4441                    nodes.push(dict.object_node(o));
4442                    preds.push(p);
4443                }
4444                dict.prefetch_terms_batched(&nodes, &preds, prefetch_batch);
4445            }
4446            dict.resolve_window(ids, resolver, |s, p, o| f(s, p, o));
4447            ids.clear();
4448        };
4449        for t in index.scan_iter(pattern) {
4450            ids.push(t);
4451            if ids.len() == WINDOW {
4452                flush(&mut ids, &mut resolver, &mut f);
4453            }
4454        }
4455        flush(&mut ids, &mut resolver, &mut f);
4456    }
4457
4458    /// What a [`dump_filtered_each`](Self::dump_filtered_each) over this graph
4459    /// and pattern will fetch, **before** it starts — the dump twin of
4460    /// `rete cost`'s query preview.
4461    ///
4462    /// Costed from the tile directories, so it fetches no tile. It does read the
4463    /// dictionary far enough to resolve the bound terms (that is a real,
4464    /// unavoidable cost of the dump too) and to learn whether the graph exists.
4465    pub fn dump_plan(
4466        &self,
4467        graph: Option<&str>,
4468        s: Option<&str>,
4469        p: Option<&str>,
4470        o: Option<&str>,
4471    ) -> DumpPlan {
4472        let index = match graph {
4473            None => Some(&self.index),
4474            Some(g) => self.graph_index(g),
4475        };
4476        let scan = index
4477            .zip(self.resolve_query_pattern(s, p, o))
4478            .map(|(ix, pattern)| ix.scan_plan(pattern));
4479        DumpPlan {
4480            scan,
4481            dictionary_bytes: self.header.dictionary_len,
4482        }
4483    }
4484
4485    /// The **pull** twin of [`Rete::dump_each`](Self::dump_each): every triple of a
4486    /// graph (`None` = default) as a lazy iterator of resolved terms.
4487    ///
4488    /// Same constant-memory scan — one triple decoded and resolved per `next()`,
4489    /// never a `Vec` of the whole graph — but the caller drives it, so it can
4490    /// stop early or be **suspended and resumed** across a foreign-function
4491    /// boundary. That is what the wasm/JS client's batched quad cursor needs:
4492    /// a callback cannot be paused mid-scan to hand control back to JavaScript,
4493    /// an iterator can.
4494    ///
4495    /// The dictionary is NOT prefetched: terms fault in as they are resolved, so
4496    /// stopping after a handful of triples costs a handful of dictionary reads
4497    /// rather than the whole dictionary — which on a lazy remote open of a large
4498    /// file would be gigabytes before the first triple, defeating the point of a
4499    /// pull API. [`dump_each`](Self::dump_each) does not prefetch it whole
4500    /// either; it batches the same per-term faults. Peak memory is
4501    /// O(faulted dictionary chunks + faulted index tiles), *not* O(triples).
4502    pub fn dump_iter(&self, graph: Option<&str>) -> impl Iterator<Item = TermTriple> + '_ {
4503        self.query_iter(graph, None, None, None)
4504    }
4505
4506    /// [`dump_iter`](Self::dump_iter) generalized to a triple **pattern**: the
4507    /// lazy pull form of [`query_in_graph`](Self::query_in_graph). A bound term
4508    /// unknown to the dictionary, or an unknown graph IRI, yields nothing.
4509    ///
4510    /// Same constant-memory guarantee — one triple decoded and resolved per
4511    /// `next()`, never a `Vec` of every match — and the same absence of a
4512    /// dictionary prefetch, so stopping after a handful of matches costs a
4513    /// handful of dictionary reads. `query_in_graph` is the eager twin; prefer
4514    /// this one wherever the consumer can stop early (`ASK`, `LIMIT`, an RDF4J
4515    /// `getStatements` that is about to be sliced).
4516    pub fn query_iter(
4517        &self,
4518        graph: Option<&str>,
4519        s: Option<&str>,
4520        p: Option<&str>,
4521        o: Option<&str>,
4522    ) -> impl Iterator<Item = TermTriple> + '_ {
4523        let index = match graph {
4524            None => Some(&self.index),
4525            Some(g) => self.graph_index(g),
4526        };
4527        index
4528            .zip(self.resolve_query_pattern(s, p, o))
4529            .into_iter()
4530            .flat_map(|(ix, pattern)| ix.scan_iter(pattern))
4531            .filter_map(move |(s, p, o)| {
4532                Some((
4533                    self.dict.subject_term(s)?,
4534                    self.dict.predicate_term(p)?,
4535                    self.dict.object_term(o)?,
4536                ))
4537            })
4538    }
4539
4540    /// One bounded, **resumable** slice of a pattern's matches inside one graph
4541    /// (`None` = default): `(triples, next_cursor, done)`. Start at `cursor = 0`
4542    /// and feed the returned cursor back until `done`.
4543    ///
4544    /// This is [`query_in_graph`](Self::query_in_graph) made streamable across a
4545    /// boundary that cannot hold a Rust borrow — the wasm/JVM one. The engine
4546    /// keeps no iterator alive between calls; the entire resume state is the
4547    /// opaque `u64` (see [`GraphIndex::scan_batch`]), so a client's cursor
4548    /// survives being suspended, and a cursor that is abandoned leaks nothing
4549    /// but a `u64`.
4550    ///
4551    /// The dictionary is faulted **per batch**, not whole: taking ten matches
4552    /// off the front of a 9.8-billion-quad file reads ten matches' worth of
4553    /// terms. That is the difference between `LIMIT 1` answering in bounded time
4554    /// and it reading a 30 GB dictionary first.
4555    ///
4556    /// `max_quads` is a floor (batches end on an a-group boundary), and every
4557    /// call either returns at least one row or reports `done`.
4558    pub fn query_batch(
4559        &self,
4560        graph: Option<&str>,
4561        s: Option<&str>,
4562        p: Option<&str>,
4563        o: Option<&str>,
4564        cursor: u64,
4565        max_quads: usize,
4566    ) -> (Vec<TermTriple>, u64, bool) {
4567        let index = match graph {
4568            None => &self.index,
4569            // An unknown graph IRI is an empty scan, not an error — same as
4570            // `query_in_graph` and `dump_batch`.
4571            Some(g) => match self.graph_index(g) {
4572                Some(i) => i,
4573                None => return (Vec::new(), cursor, true),
4574            },
4575        };
4576        // A bound term the dictionary does not know can match nothing, and the
4577        // index is never touched.
4578        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
4579            return (Vec::new(), cursor, true);
4580        };
4581        let (ids, next, done) = index.scan_batch(pattern, cursor, max_quads);
4582        // One coalesced dictionary fault for THIS batch — the same call
4583        // `query_in_graph` and `dump_batch` make, and the reason a partial walk
4584        // costs in proportion to what it actually read.
4585        if !ids.is_empty() {
4586            let mut nodes = Vec::with_capacity(ids.len() * 2);
4587            let mut preds = Vec::with_capacity(ids.len());
4588            for &(s, p, o) in &ids {
4589                nodes.push(self.dict.subject_node(s));
4590                nodes.push(self.dict.object_node(o));
4591                preds.push(p);
4592            }
4593            self.dict.prefetch_terms(&nodes, &preds);
4594        }
4595        let triples = ids
4596            .into_iter()
4597            .filter_map(|(s, p, o)| {
4598                Some((
4599                    self.dict.subject_term(s)?,
4600                    self.dict.predicate_term(p)?,
4601                    self.dict.object_term(o)?,
4602                ))
4603            })
4604            .collect();
4605        (triples, next, done)
4606    }
4607
4608    /// Resolve one bounded slice of a graph's triples. Returns
4609    /// `(triples, next_cursor, done)`; start with `cursor = 0` and feed the
4610    /// returned cursor back until `done`.
4611    ///
4612    /// This is the PULL half of the dump, and it exists because [`Rete::dump_each`] is
4613    /// push-based: it drives the scan itself and hands each triple to a
4614    /// callback. A client that wants to pull (a Python generator, a JS async
4615    /// iterator) has to hold the scan's stack between calls — which means a
4616    /// thread (unavailable on Pyodide and in wasm) or a self-referential struct
4617    /// holding an iterator that borrows the `Rete` stored next to it, whose
4618    /// soundness rests on drop order plus an unstated promise that this crate's
4619    /// lazily-faulted caches stay write-once. Neither is worth it for a dump.
4620    ///
4621    /// Instead the scan is made RESUMABLE: SPO tiles are ordered by subject id,
4622    /// so "every triple of subject `sid`, for ascending `sid`" visits exactly the
4623    /// tiles a full scan visits, in the same order, and the whole resume state
4624    /// collapses to one `u32`. No thread, no `unsafe`, no borrow held across
4625    /// calls — and unlike `(offset, limit)` it is O(n) overall rather than
4626    /// O(n²/limit), because nothing is ever re-scanned.
4627    ///
4628    /// `max_quads` is the FLOOR of a batch, not a hard cut: a batch always ends
4629    /// on a subject boundary, so no subject is split across two calls. Work per
4630    /// call is bounded by a probe budget too — a sparse named graph whose few
4631    /// subjects are spread over a huge id space returns early with `done = false`
4632    /// rather than grinding through absent ids, and the caller just asks again.
4633    pub fn dump_batch(
4634        &self,
4635        graph: Option<&str>,
4636        cursor: u32,
4637        max_quads: usize,
4638    ) -> (Vec<TermTriple>, u32, bool) {
4639        /// The first subject id at or after `sid` that any SPO tile can hold, or
4640        /// `None` when `sid` is past the last tile. Tiles ascend by leading
4641        /// range, so this is a binary search; it returns `sid` itself when a tile
4642        /// already covers it (an ordinary "this subject has no triples" step).
4643        fn next_tile_start(tiles: &[crate::index::Tile], sid: u32) -> Option<u32> {
4644            let i = tiles.partition_point(|t| t.leading_range().1 < sid);
4645            tiles.get(i).map(|t| t.leading_range().0.max(sid))
4646        }
4647
4648        let index = match graph {
4649            None => self.default_index(),
4650            // An unknown graph IRI is an empty dump, not an error — same as
4651            // `dump_each`.
4652            Some(g) => match self.graph_index(g) {
4653                Some(i) => i,
4654                None => return (Vec::new(), cursor, true),
4655            },
4656        };
4657        // Tile leading ranges live in the section directory, so both the span and
4658        // the gap jumps below are free on a lazy/remote open — no tile is fetched
4659        // to decide where to look.
4660        let tiles = index.tile_sections()[IndexPermutation::Spo.section_index()];
4661        let (Some(first), Some(last)) = (
4662            tiles.first().map(|t| t.leading_range().0),
4663            tiles.last().map(|t| t.leading_range().1),
4664        ) else {
4665            return (Vec::new(), cursor, true); // empty graph
4666        };
4667
4668        let mut sid = cursor.max(first);
4669        let mut probes = max_quads.saturating_mul(4).max(1 << 16);
4670        let mut ids: Vec<(u32, u32, u32)> = Vec::new();
4671        let mut exhausted = false;
4672        while sid <= last && probes > 0 {
4673            probes -= 1;
4674            let hits = index.match_pattern((Some(sid), None, None));
4675            if hits.is_empty() {
4676                // No triples at this id: jump to the next subject the index
4677                // holds (the tile's group directory knows it), instead of
4678                // stepping through absent ids one at a time. That is what keeps
4679                // a quad-index view — whose tile shared with other graphs has
4680                // the conservative range `0..=u32::MAX` — from probing four
4681                // billion ids. Only when that lookup cannot answer (a failed
4682                // fetch, already recorded) does it fall back to the directory:
4683                // if the id falls in a hole BETWEEN tiles, jump to the next
4684                // tile's first id.
4685                match index.next_leading(IndexPermutation::Spo.section_index(), sid) {
4686                    Some(next) if next > sid => {
4687                        sid = next;
4688                        continue;
4689                    }
4690                    None if !index.load_incomplete() => {
4691                        exhausted = true;
4692                        break;
4693                    }
4694                    _ => {}
4695                }
4696                match next_tile_start(tiles, sid) {
4697                    None => {
4698                        exhausted = true;
4699                        break;
4700                    }
4701                    Some(next) if next > sid => {
4702                        sid = next;
4703                        continue;
4704                    }
4705                    Some(_) => {}
4706                }
4707            } else {
4708                ids.extend(hits);
4709            }
4710            if sid == u32::MAX {
4711                exhausted = true;
4712                break;
4713            }
4714            sid += 1;
4715            if ids.len() >= max_quads {
4716                break;
4717            }
4718        }
4719        let done = exhausted || sid > last;
4720
4721        // One coalesced dictionary fault for this batch, then resolve.
4722        // Deliberately NOT `Dictionary::prefetch_all` (what `dump_each` uses):
4723        // on a lazy open that pulls the ENTIRE dictionary — gigabytes on a big
4724        // file — before the first quad, so taking five quads off the front would
4725        // cost as much as a full dump. Per-batch prefetch keeps a partial walk
4726        // proportional to what it actually read.
4727        let dict = self.dictionary();
4728        if !ids.is_empty() {
4729            let mut nodes = Vec::with_capacity(ids.len() * 2);
4730            let mut preds = Vec::with_capacity(ids.len());
4731            for &(s, p, o) in &ids {
4732                nodes.push(dict.subject_node(s));
4733                nodes.push(dict.object_node(o));
4734                preds.push(p);
4735            }
4736            dict.prefetch_terms(&nodes, &preds);
4737        }
4738        let triples = ids
4739            .into_iter()
4740            .filter_map(|(s, p, o)| {
4741                Some((
4742                    dict.subject_term(s)?,
4743                    dict.predicate_term(p)?,
4744                    dict.object_term(o)?,
4745                ))
4746            })
4747            .collect();
4748        (triples, sid, done)
4749    }
4750
4751    /// How many named graphs this dataset has. On a lazy ranged open this
4752    /// reads only the section's leading count varint — never the graphs.
4753    pub fn named_graph_count(&self) -> usize {
4754        self.named_graphs.count()
4755    }
4756
4757    /// The `i`-th named graph's quad count, when the file records it: a
4758    /// quad-index file keeps it in its graph directory, so this reads one
4759    /// directory block and never the graph's index. `None` for a per-graph
4760    /// (NAMED_GRAPHS section) file, or past the last graph.
4761    pub fn named_graph_quad_count(&self, i: usize) -> Option<u64> {
4762        match &self.named_graphs {
4763            NamedGraphsSlot::Quad(q) => q.quad_count_at(i),
4764            _ => None,
4765        }
4766    }
4767
4768    /// The `i`-th named graph as an **owned** index that is not memoised,
4769    /// when the file can make one cheaply: a quad-index file's graph of at
4770    /// most [`TRANSIENT_GRAPH_QUADS`] quads. A walk over every graph (`GRAPH
4771    /// ?g`, the union default graph, `query_quads`) evaluates each such graph
4772    /// and drops it, instead of keeping one index per graph for the life of
4773    /// the open — 1.5 M of them on the OpenCitations sample. `None` for a
4774    /// larger graph, a per-graph file, or a failed read: use
4775    /// [`named_graph_at`](Self::named_graph_at) then.
4776    pub fn named_graph_transient(&self, i: usize) -> Option<GraphIndex> {
4777        match &self.named_graphs {
4778            NamedGraphsSlot::Quad(q) => q.transient_view(i, TRANSIENT_GRAPH_QUADS),
4779            _ => None,
4780        }
4781    }
4782
4783    /// Deduplicated quads across every named graph, when the file records it
4784    /// (a quad-index file, from its preamble: one small read). `None` for a
4785    /// per-graph file — there it is the header's `quad_count` minus the
4786    /// default graph's triples.
4787    pub fn named_quad_count(&self) -> Option<u64> {
4788        match &self.named_graphs {
4789            NamedGraphsSlot::Quad(q) => Some(q.quad_count()),
4790            _ => None,
4791        }
4792    }
4793
4794    /// The `i`-th named graph's IRI (stored order). On a lazy ranged open this
4795    /// walks entry HEADERS up to `i` — it never decodes a graph's index.
4796    pub fn named_graph_name_at(&self, i: usize) -> Option<&str> {
4797        self.named_graphs.name_at(&self.dict, i, false)
4798    }
4799
4800    /// The `i`-th named graph as `(iri, index)`. On a lazy ranged open the
4801    /// index is fetched and decoded on first access and memoised; check
4802    /// [`index_incomplete`](Self::index_incomplete) after evaluating.
4803    pub fn named_graph_at(&self, i: usize) -> Option<(&str, &GraphIndex)> {
4804        self.named_graphs.graph_at(&self.dict, i, false)
4805    }
4806
4807    /// [`named_graph_name_at`](Self::named_graph_name_at) under a declared
4808    /// demand: `exhaustive = true` tells a lazy walk the caller will visit
4809    /// every graph, so it may read the section in bulk chunks. Purely a fetch
4810    /// strategy — results are identical either way. Only the SPARQL evaluator
4811    /// sets it, from shapes where full consumption is provable.
4812    pub(crate) fn named_graph_name_at_demand(&self, i: usize, exhaustive: bool) -> Option<&str> {
4813        self.named_graphs.name_at(&self.dict, i, exhaustive)
4814    }
4815
4816    /// [`named_graph_at`](Self::named_graph_at) under a declared demand: see
4817    /// [`named_graph_name_at_demand`](Self::named_graph_name_at_demand).
4818    pub(crate) fn named_graph_at_demand(
4819        &self,
4820        i: usize,
4821        exhaustive: bool,
4822    ) -> Option<(&str, &GraphIndex)> {
4823        self.named_graphs.graph_at(&self.dict, i, exhaustive)
4824    }
4825
4826    /// IRIs of the named graphs in this dataset (the default graph is unnamed).
4827    /// On a lazy ranged open this walks the whole directory (headers only) —
4828    /// prefer [`named_graph_count`](Self::named_graph_count) when the number
4829    /// is all that's needed.
4830    pub fn graph_names(&self) -> Vec<&str> {
4831        (0..self.named_graphs.count())
4832            .filter_map(|i| self.named_graphs.name_at(&self.dict, i, false))
4833            .collect()
4834    }
4835
4836    /// The permutation index of a named graph, or `None` if absent.
4837    pub fn graph_index(&self, iri: &str) -> Option<&GraphIndex> {
4838        self.named_graphs.find(&self.dict, iri)
4839    }
4840
4841    /// Match a triple pattern in dictionary-ID space (subject/predicate/object
4842    /// IDs), returning integer triples — the fast path used by the BGP engine.
4843    pub fn match_ids(
4844        &self,
4845        pattern: (Option<u32>, Option<u32>, Option<u32>),
4846    ) -> Vec<(u32, u32, u32)> {
4847        self.index.match_pattern(pattern)
4848    }
4849
4850    /// All `(subject_node, object_node)` pairs for a predicate, as unified node
4851    /// IDs — no term resolution. The fast path for graph traversal.
4852    pub fn predicate_pairs(&self, predicate: &str) -> Vec<(u32, u32)> {
4853        let pid = match self.dict.predicate_id(predicate) {
4854            Some(p) => p,
4855            None => return Vec::new(),
4856        };
4857        self.index
4858            .match_pattern((None, Some(pid), None))
4859            .into_iter()
4860            .map(|(s, _p, o)| (self.dict.subject_node(s), self.dict.object_node(o)))
4861            .collect()
4862    }
4863
4864    /// Open via a [`RangeReader`], fetching only the header and the named
4865    /// section ranges — never a linear scan of the whole resource. A full query
4866    /// open touches at most 4 ranges (header, dictionary, index, pyramid-meta).
4867    pub fn open_ranged<R: RangeReader>(reader: &R) -> Result<Self, FileError> {
4868        let head = reader.read_at(0, HEADER_LEN as u64)?;
4869        let header = Header::from_bytes(&head)?;
4870
4871        let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
4872        let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
4873
4874        check_named_layout(&header)?;
4875        let index_bytes = reader.read_at(header.root_dir_offset, header.root_dir_len)?;
4876        let (index, _) = decode_root_index_container(&index_bytes, &header)?;
4877        let index_section_ranges = decode_index_section_ranges(
4878            &index_bytes,
4879            header.root_dir_offset,
4880            header.perms,
4881            header.index_container_sections(),
4882        )?;
4883
4884        let pyramid = PyramidSlot::Resident(if header.pyramid_meta_len > 0 {
4885            let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
4886            Some(
4887                PyramidMeta::decode(&mb)
4888                    .map_err(|_| FileError::Container("malformed pyramid meta"))?,
4889            )
4890        } else {
4891            None
4892        });
4893
4894        // Fetch the whole TEXT_INDEX section resident (this opener does one range
4895        // read per section; the lazy opener below is the one that defers it).
4896        let (ti_off, ti_len) = header.text_index_payload();
4897        let text_index_bytes = if ti_len > 0 {
4898            Some(reader.read_at(ti_off, ti_len)?)
4899        } else {
4900            None
4901        };
4902        let text_index = resident_text_index_slot(text_index_bytes.as_deref(), header.block_codec)?;
4903        // LABEL_INDEX and SCHEMA: one range read each, held resident like the
4904        // other sections of this opener.
4905        let labels = SectionSlot::Resident(header.label_index_section().and_then(|s| {
4906            let b = reader.read_at(s.offset, s.length).ok()?;
4907            crate::label_index::LabelIndex::from_section(&b, header.block_codec).ok()
4908        }));
4909        let schema = SectionSlot::Resident(read_schema_with_header(reader, &header));
4910
4911        let named_graphs = NamedGraphsSlot::resident(
4912            &header,
4913            |off, len| Ok(reader.read_at(off, len)?),
4914            || {
4915                let nb = reader.read_at(header.named_graphs_offset, header.named_graphs_len)?;
4916                decode_named_graphs(&nb, header.block_codec, header.perms)
4917            },
4918        )?;
4919
4920        // The metadata section (Dataset Card) is deliberately NOT fetched here:
4921        // a ranged query open keeps to its small range budget. Use `Rete::open`
4922        // (or a dedicated card fetch) when the card is actually needed.
4923        let tile_ranges =
4924            tile_file_ranges(&index_bytes, header.root_dir_offset, &index_section_ranges);
4925        Ok(Self {
4926            header,
4927            dict,
4928            index,
4929            index_section_ranges,
4930            tile_ranges,
4931            pyramid,
4932            text_index,
4933            labels,
4934            schema,
4935            named_graphs,
4936            metadata: Vec::new(),
4937            service_client: None,
4938            service_error: std::sync::Mutex::new(None),
4939            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
4940        })
4941    }
4942
4943    /// Open via an **owned** [`RangeReader`] with lazy tile faulting (tiled
4944    /// v0.2 files): fetches the header, dictionary, pyramid meta, named graphs,
4945    /// and each permutation's tile **directory** — but no default-graph tile
4946    /// payloads. Tiles fault in (one range request each) the first time a scan
4947    /// touches them, so a selective SPARQL query fetches O(touched tiles)
4948    /// bytes instead of the whole index.
4949    ///
4950    /// **Failure contract:** scans are infallible by design, so a failed tile
4951    /// fetch yields an empty tile and sets a sticky flag — after evaluating,
4952    /// callers MUST check [`index_incomplete`](Self::index_incomplete) and
4953    /// surface an error instead of the (possibly partial) results.
4954    pub fn open_ranged_lazy<R: RangeReader + Send + Sync + 'static>(
4955        reader: R,
4956    ) -> Result<Self, FileError> {
4957        let head = reader.read_at(0, HEADER_LEN as u64)?;
4958        let header = Header::from_bytes(&head)?;
4959        check_named_layout(&header)?;
4960        let reader = std::sync::Arc::new(reader);
4961        // Captured before the loader closures take the Arc: the reader's
4962        // concurrent-range fan-out, stamped onto the index for the planner.
4963        let read_concurrency = reader.concurrency();
4964
4965        // Lazily-chunked dictionary: locate the four sections, fetch each
4966        // section's header + restart table + chunk directory (small), and
4967        // fault the chunk bodies in on first term lookup.
4968        let open_dict = || ranged_chunked_dictionary(&reader, &header, [true; 4]);
4969
4970        // Locate the six index section payloads (container framing only) and
4971        // fetch just their tile directories — shared with the per-named-graph
4972        // lazy opener, which opens a large graph's container the same way.
4973        let reader_dyn: std::sync::Arc<dyn RangeReader + Send + Sync> = reader.clone();
4974        let open_index = || {
4975            open_index_container_lazy(
4976                &reader_dyn,
4977                ByteRange {
4978                    offset: header.root_dir_offset,
4979                    len: header.root_dir_len,
4980                },
4981                header.block_codec,
4982                header.has_tile_synopsis(),
4983                read_concurrency,
4984                header.perms,
4985                header.index_container_sections(),
4986            )
4987        };
4988
4989        // Each of the two is a chain of small dependent reads (a container's
4990        // sections are found by walking their length prefixes), ~8 round trips
4991        // apiece on a real file, and the header already says where both
4992        // containers start. So a reader that can keep several reads in flight
4993        // walks them at the same time: the open costs the longer chain, not
4994        // the sum. Same reads, same result; only their overlap changes.
4995        let (dict, index) = open_side_by_side(read_concurrency, open_dict, open_index);
4996        let dict = dict?;
4997        let (index, index_section_ranges, tile_ranges) = index?;
4998
4999        // The pyramid meta is large on real graphs (tens of MB) and SPARQL never
5000        // reads it, so defer its fetch: it faults in only if `pyramid()` is
5001        // called (community / pyramid_tree / inspect queries).
5002        let pyramid = ranged_pyramid_slot(&reader, &header);
5003
5004        // The TEXT_INDEX section is also deferred: a text search faults the token
5005        // table on first call (the leading varint then its compressed bytes), then
5006        // fetches individual posting lists by `(offset, len)` — never the whole
5007        // postings blob. A SPARQL query, which never searches, pays nothing.
5008        let text_index = ranged_text_index_slot(&reader, &header);
5009
5010        // LABEL_INDEX: the first prefix search reads its head and walks down.
5011        // SCHEMA: read once, on first use — a stats question, or the planner
5012        // ordering a join that needs a measured selectivity.
5013        let labels = ranged_label_slot(&reader, &header);
5014        let schema = ranged_schema_slot(&reader, &header);
5015
5016        // Named graphs are the last eagerly-fetched section standing on this
5017        // path — and on a many-graph file they dwarf everything else (67 MB
5018        // fetched and ~32k graph indexes decoded before the first query, on a
5019        // file whose queries then touch a handful of graphs). Defer them like
5020        // the pyramid and text index: nothing is read at open; a query walks
5021        // the directory and decodes only the graphs it touches.
5022        let named_graphs = if let Some(q) = header.quad_index_section() {
5023            // The quad index: nothing read at open either; the preamble on the
5024            // first named-graph touch, then only the routing parts and tiles
5025            // a query uses.
5026            NamedGraphsSlot::Quad(crate::quad::QuadNamed::new(
5027                reader_dyn,
5028                ByteRange {
5029                    offset: q.offset,
5030                    len: q.length,
5031                },
5032                header.block_codec,
5033                header.perms,
5034                read_concurrency,
5035            ))
5036        } else if header.named_graphs_len > 0 {
5037            NamedGraphsSlot::Lazy(LazyNamedGraphs::new(
5038                reader_dyn,
5039                ByteRange {
5040                    offset: header.named_graphs_offset,
5041                    len: header.named_graphs_len,
5042                },
5043                header.block_codec,
5044                header.has_tile_synopsis(),
5045                read_concurrency,
5046                header.perms,
5047            ))
5048        } else {
5049            NamedGraphsSlot::Resident(Vec::new())
5050        };
5051
5052        let rete = Self {
5053            header,
5054            dict,
5055            index,
5056            index_section_ranges,
5057            tile_ranges,
5058            pyramid,
5059            text_index,
5060            labels,
5061            schema,
5062            named_graphs,
5063            metadata: Vec::new(),
5064            service_client: None,
5065            service_error: std::sync::Mutex::new(None),
5066            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
5067        };
5068        // A lazy open is the path for files too big to read whole, and the
5069        // one a long-lived session (relay, Python/R/Java clients, `rete serve`)
5070        // keeps resident across queries. Unbounded, its dictionary and tile
5071        // caches grow with everything any query ever touched: a GROUP BY over
5072        // a 9.5-billion-triple file ran a 16 GB container out of memory. Cap
5073        // them by default; a caller that wants another figure (or unlimited)
5074        // calls `set_memory_budget` again. Not on wasm32, whose 4 GiB address
5075        // space makes the default meaningless and whose callers bound their own
5076        // range budgets.
5077        #[cfg(not(target_arch = "wasm32"))]
5078        rete.set_memory_budget(Some(DEFAULT_QUERY_BUDGET_MB << 20));
5079        Ok(rete)
5080    }
5081
5082    /// Did any lazy fetch (index tile or dictionary chunk) fail since this
5083    /// `Rete` was opened? When true, query results may be silently incomplete —
5084    /// callers using [`Rete::open_ranged_lazy`] must check this after
5085    /// evaluating and turn it into an error.
5086    pub fn index_incomplete(&self) -> bool {
5087        self.index.load_incomplete()
5088            || self.dict.load_incomplete()
5089            || self.named_graphs.load_incomplete()
5090    }
5091
5092    /// Forget recorded lazy-fetch failures — the start-of-evaluation reset for
5093    /// a RESIDENT session (a browser worker holding one `Rete` across many
5094    /// queries): it makes [`index_incomplete`](Self::index_incomplete) a
5095    /// per-query verdict instead of a per-open one, so a single transient
5096    /// network failure no longer fails every subsequent query on the session.
5097    /// Sound because failed tiles/chunks are never cached — the next
5098    /// evaluation simply retries the fetch.
5099    pub fn reset_load_failures(&self) {
5100        self.index.reset_load_failure();
5101        self.dict.reset_load_failure();
5102        self.named_graphs.reset_load_failures();
5103    }
5104
5105    /// Bound peak resident memory to roughly `budget` bytes by capping the
5106    /// evictable dictionary chunk cache and the default-graph index tile cache
5107    /// (bounded-export phase 2). `None` or `u64::MAX` means unlimited — nothing
5108    /// is evicted and residency is identical to a plain open, which is what
5109    /// `--in-memory` and every query/wasm path use.
5110    ///
5111    /// Sound at any cap: the dictionary and the default-graph index both fault
5112    /// their chunks/tiles through a loader, so an evicted body is simply
5113    /// re-faulted on its next touch; the export dump reads each needed body
5114    /// while holding it (the cache `Arc` handout invariant), so eviction never
5115    /// drops bytes in use. Named-graph indexes are **not** capped here — a small
5116    /// resident graph has no loader and could never reload an evicted tile — so
5117    /// they stay unlimited and are released whole per slot instead (see
5118    /// [`release_named_graph`](Self::release_named_graph)).
5119    ///
5120    /// The open's **fixed** heap — chunk and tile directories, the named-graph
5121    /// directory walked so far ([`fixed_overhead`](Self::fixed_overhead)) — is
5122    /// paid whatever the caches hold, so it comes out of the dictionary share:
5123    /// the caps plus the fixed heap stay within `budget`. It grows with the
5124    /// file's chunk/tile/graph count (~18 MB of directories on the 14.75 GiB
5125    /// openaire-relation, 88 MB with its tile directories; 224 MB on a pre-#198
5126    /// epfl-infoscience whose chunk keys are whole boundary terms). Calling
5127    /// this again after a walk of the named-graph directory (as `rete export`
5128    /// does once it has listed the graph slots) re-subtracts the larger figure.
5129    /// The dictionary share never drops below its 64 MiB floor, which is the
5130    /// only way the total can be exceeded: a file whose directories alone
5131    /// approach the budget.
5132    ///
5133    /// Returns the split actually applied (dictionary share net of the fixed
5134    /// heap), for `RETE_OPEN_DEBUG` logging.
5135    pub fn set_memory_budget(&self, budget: Option<u64>) -> MemoryBudget {
5136        let total = budget.unwrap_or(u64::MAX);
5137        self.set_spill_threshold(budget.map(spill_threshold_for));
5138        let mut split = split_memory_budget(total);
5139        let fixed = if total == u64::MAX {
5140            0
5141        } else {
5142            self.fixed_overhead().total()
5143        };
5144        if split.dict_cache != u64::MAX {
5145            split.dict_cache = split
5146                .dict_cache
5147                .saturating_sub(fixed)
5148                .max(MIN_DICT_CACHE_CAP);
5149        }
5150        self.dict.set_cache_cap(split.dict_cache);
5151        self.index.set_cache_cap(split.tile_cache);
5152        // Large named graphs (opened lazily, loader-bearing) get the same tile
5153        // cap; small resident ones stay unlimited (bounded by size + per-slot
5154        // release). Combined with `release_named_graph`, a many-graph dump holds
5155        // one graph's tiles at a time, each capped.
5156        match &self.named_graphs {
5157            NamedGraphsSlot::Lazy(lazy) => lazy.set_tile_cap(split.tile_cache),
5158            // Every quad-index view, and the tiles they share, re-fault
5159            // through loaders — on every open path — so they take the cap too.
5160            NamedGraphsSlot::Quad(q) => q.set_tile_cap(split.tile_cache),
5161            NamedGraphsSlot::Resident(_) => {}
5162        }
5163        if std::env::var("RETE_OPEN_DEBUG").is_ok() {
5164            eprintln!(
5165                "[memory-budget] total={} dict_cache={} tile_cache={} block_cache={} fixed={:.1}MiB",
5166                if total == u64::MAX {
5167                    "unlimited".to_string()
5168                } else {
5169                    format!("{}MiB", total >> 20)
5170                },
5171                fmt_cap(split.dict_cache),
5172                fmt_cap(split.tile_cache),
5173                fmt_cap(split.block_cache),
5174                fixed as f64 / (1u64 << 20) as f64,
5175            );
5176        }
5177        split
5178    }
5179
5180    /// How many bytes a query's `COUNT(DISTINCT …)` keeps in memory before it
5181    /// writes sorted runs to the temporary directory (`TMPDIR`) and counts by
5182    /// merging them; the count is exact either way. `u64::MAX` (the default of
5183    /// an eager open) never spills. [`set_memory_budget`](Self::set_memory_budget)
5184    /// sets it to an eighth of the budget, so a lazy open spills past 512 MiB
5185    /// by default.
5186    pub fn spill_threshold(&self) -> u64 {
5187        self.spill_threshold
5188            .load(std::sync::atomic::Ordering::Relaxed)
5189    }
5190
5191    /// Set [`spill_threshold`](Self::spill_threshold) without touching the
5192    /// caches — for an eager (resident) open, whose caches cannot be capped.
5193    /// `None` = never spill.
5194    pub fn set_spill_threshold(&self, bytes: Option<u64>) {
5195        self.spill_threshold.store(
5196            bytes.unwrap_or(u64::MAX),
5197            std::sync::atomic::Ordering::Relaxed,
5198        );
5199    }
5200
5201    /// The dictionary chunk cache's observability counters (decoded chunks and
5202    /// bytes, hits/misses, evictions, resident bytes) — for the bounded-export
5203    /// work-accounting tests and `RETE_OPEN_DEBUG`.
5204    pub fn dict_cache_stats(&self) -> crate::chunk_cache::CacheStats {
5205        self.dict.cache_stats()
5206    }
5207
5208    /// Heap bytes this open holds outside the bounded caches — the dictionary
5209    /// chunk directories, the index tile directories and the named-graph
5210    /// directory — for `RETE_OPEN_DEBUG` / `RETE_MEM_TRACE` accounting.
5211    pub fn fixed_overhead(&self) -> FixedOverhead {
5212        FixedOverhead {
5213            dict_directory: self.dict.directory_bytes(),
5214            index_directory: self.index.directory_bytes(),
5215            named_graphs: self.named_graphs.directory_bytes(),
5216        }
5217    }
5218
5219    /// The default-graph index tile cache's observability counters.
5220    pub fn index_cache_stats(&self) -> crate::chunk_cache::CacheStats {
5221        self.index.cache_stats()
5222    }
5223
5224    /// The current dictionary chunk-cache byte cap (`u64::MAX` = unlimited).
5225    pub fn dict_cache_cap(&self) -> u64 {
5226        self.dict.cache_cap()
5227    }
5228
5229    /// Release a named graph's decoded index after its slot has been exported,
5230    /// dropping its `GraphIndex` (and, with it, that graph's decoded tiles and
5231    /// its own tile cache) so a many-graph dump does not accumulate one resident
5232    /// index per graph — the next linear floor on many-graph files once the
5233    /// dictionary is bounded (plan §6). A no-op for the default graph, an
5234    /// unknown IRI, a resident (in-memory/eager) open, or a graph never opened.
5235    ///
5236    /// Takes `&mut self`: the export loop finishes one slot's dump (which only
5237    /// borrows `&self`) before releasing, so no outstanding borrow of the index
5238    /// exists. Correct because a released index is simply re-opened (headers +
5239    /// tile directory) on a later access — the same lazy contract as a chunk
5240    /// that was never faulted.
5241    pub fn release_named_graph(&mut self, iri: &str) {
5242        match &mut self.named_graphs {
5243            NamedGraphsSlot::Lazy(lazy) => lazy.release(iri),
5244            NamedGraphsSlot::Quad(q) => q.release(&self.dict, iri),
5245            NamedGraphsSlot::Resident(_) => {}
5246        }
5247    }
5248
5249    fn resolve_query_pattern(
5250        &self,
5251        s: Option<&str>,
5252        p: Option<&str>,
5253        o: Option<&str>,
5254    ) -> Option<Pattern> {
5255        let sid = match s {
5256            Some(t) => match self.dict.subject_id(t) {
5257                Some(id) => Some(id),
5258                None => return None,
5259            },
5260            None => None,
5261        };
5262        let pid = match p {
5263            Some(t) => match self.dict.predicate_id(t) {
5264                Some(id) => Some(id),
5265                None => return None,
5266            },
5267            None => None,
5268        };
5269        let oid = match o {
5270            Some(t) => match self.dict.object_id(t) {
5271                Some(id) => Some(id),
5272                None => return None,
5273            },
5274            None => None,
5275        };
5276        Some((sid, pid, oid))
5277    }
5278
5279    /// Evaluate a triple pattern and include the file/index provenance for every
5280    /// matched result. A bound term that is unknown to the dictionary yields no
5281    /// matches.
5282    pub fn query_with_provenance(
5283        &self,
5284        s: Option<&str>,
5285        p: Option<&str>,
5286        o: Option<&str>,
5287    ) -> Vec<TripleProvenance> {
5288        let pattern = match self.resolve_query_pattern(s, p, o) {
5289            Some(pattern) => pattern,
5290            None => return Vec::new(),
5291        };
5292
5293        let index_permutation = GraphIndex::best_permutation_in(self.header.perms, pattern);
5294        let dictionary_range = ByteRange {
5295            offset: self.header.dictionary_offset,
5296            len: self.header.dictionary_len,
5297        };
5298        let index_range = ByteRange {
5299            offset: self.header.root_dir_offset,
5300            len: self.header.root_dir_len,
5301        };
5302        let index_section_range = self.index_section_ranges[index_permutation.section_index()];
5303        let pyramid_range = (self.header.pyramid_meta_len > 0).then_some(ByteRange {
5304            offset: self.header.pyramid_meta_offset,
5305            len: self.header.pyramid_meta_len,
5306        });
5307
5308        let tiles = &self.tile_ranges[index_permutation.section_index()];
5309        self.index
5310            .match_pattern(pattern)
5311            .into_iter()
5312            .filter_map(|(s, p, o)| {
5313                let terms = (
5314                    self.dict.subject_term(s)?,
5315                    self.dict.predicate_term(p)?,
5316                    self.dict.object_term(o)?,
5317                );
5318                // The physical tile holding this match: the one whose
5319                // leading-id range covers the match's permuted leading id.
5320                let a = index_permutation.forward((s, p, o)).0;
5321                let ti = tiles.partition_point(|&(_, max_a, _)| max_a < a);
5322                let (tile, tile_range) = match tiles.get(ti) {
5323                    Some(&(min_a, _, range)) if min_a <= a => (
5324                        Some(format!("{}/{ti}", index_permutation.name())),
5325                        Some(range),
5326                    ),
5327                    _ => (None, None),
5328                };
5329                Some(TripleProvenance {
5330                    terms,
5331                    ids: (s, p, o),
5332                    graph: None,
5333                    matched_pattern: pattern,
5334                    index_permutation,
5335                    dictionary_range,
5336                    index_range,
5337                    index_section_range,
5338                    pyramid_range,
5339                    tile,
5340                    tile_range,
5341                })
5342            })
5343            .collect()
5344    }
5345
5346    /// Evaluate a triple pattern given as optional term strings, returning
5347    /// matching triples resolved back to terms. A bound term that is unknown to
5348    /// the dictionary yields no matches.
5349    pub fn query(&self, s: Option<&str>, p: Option<&str>, o: Option<&str>) -> Vec<TermTriple> {
5350        self.query_with_provenance(s, p, o)
5351            .into_iter()
5352            .map(|m| m.terms)
5353            .collect()
5354    }
5355
5356    /// Match a triple pattern **within a single graph** — `None` is the default
5357    /// graph, `Some(iri)` a named graph — resolving matches to canonical terms.
5358    /// This is [`Rete::query`] (default-graph only) generalized to any graph: the
5359    /// graph-scoped primitive a quad-aware consumer (e.g. an RDF4J `Sail`'s
5360    /// `getStatements`) needs. An unknown graph IRI, or a bound term absent from
5361    /// the shared dictionary, yields an empty result. All graphs share one
5362    /// dictionary, so the pattern resolves once against that ID space.
5363    pub fn query_in_graph(
5364        &self,
5365        graph: Option<&str>,
5366        s: Option<&str>,
5367        p: Option<&str>,
5368        o: Option<&str>,
5369    ) -> Vec<TermTriple> {
5370        let pattern = match self.resolve_query_pattern(s, p, o) {
5371            Some(pattern) => pattern,
5372            None => return Vec::new(),
5373        };
5374        let index = match graph {
5375            None => &self.index,
5376            Some(g) => match self.graph_index(g) {
5377                Some(i) => i,
5378                None => return Vec::new(),
5379            },
5380        };
5381        self.resolve_matches(index.match_pattern(pattern))
5382    }
5383
5384    /// Resolve matched id-triples to terms, with one coalesced dictionary
5385    /// fault for exactly their terms.
5386    fn resolve_matches(&self, ids: Vec<Triple>) -> Vec<TermTriple> {
5387        // One coalesced dictionary fault for THIS pattern's output, not the
5388        // whole dictionary. `prefetch_all` used to sit here, which made a
5389        // bounded `(s, p, o, g)` lookup — the primitive an RDF4J `Sail`'s
5390        // `getStatements` calls, once per named graph via `query_quads` — pay
5391        // for every term in the file: on `cordis.rete` (801 MB, a 417 MB
5392        // dictionary) a 15-row answer read 416 MB and peaked at 2.5 GB RSS.
5393        // Same call `dump_batch` makes, for the same reason.
5394        if !ids.is_empty() {
5395            let mut nodes = Vec::with_capacity(ids.len() * 2);
5396            let mut preds = Vec::with_capacity(ids.len());
5397            for &(s, p, o) in &ids {
5398                nodes.push(self.dict.subject_node(s));
5399                nodes.push(self.dict.object_node(o));
5400                preds.push(p);
5401            }
5402            self.dict.prefetch_terms(&nodes, &preds);
5403        }
5404        ids.into_iter()
5405            .filter_map(|(s, p, o)| {
5406                Some((
5407                    self.dict.subject_term(s)?,
5408                    self.dict.predicate_term(p)?,
5409                    self.dict.object_term(o)?,
5410                ))
5411            })
5412            .collect()
5413    }
5414
5415    /// Match a triple pattern across the default graph **and every named graph**,
5416    /// tagging each match with its graph (`None` = default). The quad-level
5417    /// companion to [`Rete::query`]; default-graph matches come first, then each
5418    /// named graph in stored order.
5419    pub fn query_quads(
5420        &self,
5421        s: Option<&str>,
5422        p: Option<&str>,
5423        o: Option<&str>,
5424    ) -> Vec<(TermTriple, Option<String>)> {
5425        let mut out: Vec<(TermTriple, Option<String>)> = self
5426            .query_in_graph(None, s, p, o)
5427            .into_iter()
5428            .map(|t| (t, None))
5429            .collect();
5430        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
5431            return out;
5432        };
5433        for i in 0..self.named_graphs.count() {
5434            // A quad dump visits every graph by construction — exhaustive
5435            // demand, so a lazy walk may read the section in bulk chunks.
5436            let Some(iri) = self.named_graphs.name_at(&self.dict, i, true) else {
5437                continue;
5438            };
5439            // A small quad-index graph through an owned view, dropped here:
5440            // the walk holds one view at a time, not one per graph.
5441            let owned = self.named_graph_transient(i);
5442            let index = match &owned {
5443                Some(view) => view,
5444                None => match self.named_graphs.graph_at(&self.dict, i, true) {
5445                    Some((_, g)) => g,
5446                    None => continue,
5447                },
5448            };
5449            for triple in self.resolve_matches(index.match_pattern(pattern)) {
5450                out.push((triple, Some(iri.to_string())));
5451            }
5452        }
5453        out
5454    }
5455
5456    /// Evaluate one triple pattern through a [`RangeReader`] by fetching only
5457    /// the header, the dictionary, and — for a tiled (v0.2) file — the
5458    /// selected permutation section's tile **directory** plus the tile(s) the
5459    /// bound leading id routes to; an unbound leading id fetches the section's
5460    /// tile body in one request. v0.1 files fetch the whole selected section.
5461    /// Unknown bound terms return an empty result before touching the index.
5462    pub fn query_ranged<R: RangeReader>(
5463        reader: &R,
5464        s: Option<&str>,
5465        p: Option<&str>,
5466        o: Option<&str>,
5467    ) -> Result<Vec<TermTriple>, FileError> {
5468        let routed = match route_pattern(reader, s, p, o)? {
5469            Some(routed) => routed,
5470            None => return Ok(Vec::new()),
5471        };
5472        let matches = fetch_routed_matches(reader, &routed)?;
5473        Ok(matches
5474            .into_iter()
5475            .filter_map(|(s, p, o)| {
5476                Some((
5477                    routed.dict.subject_term(s)?,
5478                    routed.dict.predicate_term(p)?,
5479                    routed.dict.object_term(o)?,
5480                ))
5481            })
5482            .collect())
5483    }
5484
5485    /// Route one triple pattern to its permutation section without fetching
5486    /// any payload bytes. Returns `false` when a bound term is unknown and the
5487    /// index was skipped.
5488    pub fn route_pattern_ranged<R: RangeReader>(
5489        reader: &R,
5490        s: Option<&str>,
5491        p: Option<&str>,
5492        o: Option<&str>,
5493    ) -> Result<bool, FileError> {
5494        Ok(route_pattern(reader, s, p, o)?.is_some())
5495    }
5496}
5497
5498/// A pattern routed to its permutation section: everything needed to fetch
5499/// matches, with no payload bytes read yet.
5500struct RoutedPattern {
5501    dict: Dictionary,
5502    pattern: Pattern,
5503    permutation: IndexPermutation,
5504    header: Header,
5505    /// Absolute byte range of the selected section's payload.
5506    section: ByteRange,
5507}
5508
5509/// Resolve a pattern against the remote dictionary and locate its permutation
5510/// section (header + dictionary + container framing only).
5511fn route_pattern<R: RangeReader>(
5512    reader: &R,
5513    s: Option<&str>,
5514    p: Option<&str>,
5515    o: Option<&str>,
5516) -> Result<Option<RoutedPattern>, FileError> {
5517    let head = reader.read_at(0, HEADER_LEN as u64)?;
5518    let header = Header::from_bytes(&head)?;
5519
5520    let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
5521    let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
5522
5523    let Some(pattern) = resolve_query_pattern(&dict, s, p, o) else {
5524        return Ok(None);
5525    };
5526    let permutation = GraphIndex::best_permutation_in(header.perms, pattern);
5527    let section = locate_container_section_ranged(
5528        reader,
5529        header.root_dir_offset,
5530        header.root_dir_len,
5531        header
5532            .perms
5533            .position(permutation)
5534            .ok_or(FileError::Container("routed to an absent permutation"))?,
5535        header.index_container_sections() as u64,
5536    )?;
5537    Ok(Some(RoutedPattern {
5538        dict,
5539        pattern,
5540        permutation,
5541        header,
5542        section,
5543    }))
5544}
5545
5546/// Fetch and scan a routed pattern's matches: read the tile directory, then only
5547/// the matching tile byte ranges (the run of covering tiles for a bound leading
5548/// id — one for ordinary groups, several for a split mega-group — the
5549/// O(matching bytes) promise).
5550fn fetch_routed_matches<R: RangeReader>(
5551    reader: &R,
5552    routed: &RoutedPattern,
5553) -> Result<Vec<Triple>, FileError> {
5554    let dir = read_tile_directory_ranged(reader, routed.section)?;
5555    let [pa, _, _] = routed.permutation.order_pattern(routed.pattern);
5556    let codec = routed.header.block_codec;
5557    let mut out = Vec::new();
5558    match pa {
5559        // Bound leading id: the run of covering tiles (several for a split
5560        // mega-group; one otherwise).
5561        Some(a) => {
5562            for e in dir.iter().filter(|e| e.min_a <= a && a <= e.max_a) {
5563                let bytes = reader.read_at(routed.section.offset + e.start, e.end - e.start)?;
5564                let tile = decompress(codec, &bytes)?;
5565                out.extend(GraphIndex::match_serialized_block(
5566                    &tile,
5567                    routed.permutation,
5568                    routed.pattern,
5569                ));
5570            }
5571        }
5572        // Unbound leading id: every tile matters — fetch the contiguous tile
5573        // body in one request and slice it.
5574        None => {
5575            if let (Some(first), Some(last)) = (dir.first(), dir.last()) {
5576                let base = first.start;
5577                let body = reader.read_at(routed.section.offset + base, last.end - base)?;
5578                for e in &dir {
5579                    let tile = decompress(
5580                        codec,
5581                        &body[(e.start - base) as usize..(e.end - base) as usize],
5582                    )?;
5583                    out.extend(GraphIndex::match_serialized_block(
5584                        &tile,
5585                        routed.permutation,
5586                        routed.pattern,
5587                    ));
5588                }
5589            }
5590        }
5591    }
5592    out.sort_unstable();
5593    Ok(out)
5594}
5595
5596fn resolve_query_pattern(
5597    dict: &Dictionary,
5598    s: Option<&str>,
5599    p: Option<&str>,
5600    o: Option<&str>,
5601) -> Option<Pattern> {
5602    let sid = match s {
5603        Some(t) => Some(dict.subject_id(t)?),
5604        None => None,
5605    };
5606    let pid = match p {
5607        Some(t) => Some(dict.predicate_id(t)?),
5608        None => None,
5609    };
5610    let oid = match o {
5611        Some(t) => Some(dict.object_id(t)?),
5612        None => None,
5613    };
5614    Some((sid, pid, oid))
5615}
5616
5617/// The lazily-chunked dictionary of a ranged open: per section, fetch the
5618/// header + chunk directory and fault the chunk bodies in on first term lookup.
5619///
5620/// `want` selects which of the four sections (0 shared, 1 subject-only,
5621/// 2 object-only, 3 predicates) are really read; a skipped one becomes an empty
5622/// section whose terms resolve to `None`. On a file written before #198 a
5623/// **directory is not small**: it carried each chunk's first term verbatim, so
5624/// the object-only section of a dataset that stores abstracts can run to
5625/// hundreds of megabytes, and fetching it is most of what a remote open costs.
5626/// Current builds key it by the shortest separator instead (a few bytes per
5627/// chunk), but that is write-side, so every already-published file still pays.
5628/// A reader that only ever resolves subjects (see [`SearchView`]) skips
5629/// sections 2 and 3 and pays none of it. [`Rete::open_ranged_lazy`] wants all
5630/// four.
5631fn ranged_chunked_dictionary<R: RangeReader + Send + Sync + 'static>(
5632    reader: &std::sync::Arc<R>,
5633    header: &Header,
5634    want: [bool; 4],
5635) -> Result<Dictionary, FileError> {
5636    // One (unlimited) cache shared across the four sections, keyed by
5637    // (section_index, chunk_index).
5638    let cache = crate::chunk_cache::ChunkCache::unlimited_arc();
5639    let mut dict_sections: Vec<crate::dict::ChunkedSection> = Vec::with_capacity(4);
5640    let mut section_ranges: [std::sync::Arc<Vec<ByteRange>>; 4] = Default::default();
5641    for si in 0..4 {
5642        if !want[si] {
5643            dict_sections.push(crate::dict::ChunkedSection::from_parts(
5644                crate::dict::SectionMeta {
5645                    term_count: 0,
5646                    restart_interval: 1,
5647                    restart_offsets: Vec::new(),
5648                },
5649                Vec::new(),
5650                None,
5651                cache.clone(),
5652                si as u8,
5653            ));
5654            continue;
5655        }
5656        let section = locate_container_section_ranged(
5657            reader.as_ref(),
5658            header.dictionary_offset,
5659            header.dictionary_len,
5660            si,
5661            4,
5662        )?;
5663        let (meta, entries) = read_dict_dir_ranged(reader.as_ref(), section)?;
5664        // One copy of the chunk ranges, shared by this section's loaders and
5665        // the dictionary's cross-section batch loader.
5666        let ranges: std::sync::Arc<Vec<ByteRange>> = std::sync::Arc::new(
5667            entries
5668                .iter()
5669                .map(|e| ByteRange {
5670                    offset: section.offset + e.start,
5671                    len: (e.end - e.start),
5672                })
5673                .collect(),
5674        );
5675        section_ranges[si] = ranges.clone();
5676        let chunks: Vec<crate::dict::SectionChunk> = entries
5677            .into_iter()
5678            .map(|e| crate::dict::SectionChunk::new(e.first_run, e.key, e.body_start))
5679            .collect();
5680        let chunk_reader = reader.clone();
5681        let codec = header.dict_codec;
5682        let loader_ranges = ranges.clone();
5683        let loader: crate::dict::ChunkLoader = Box::new(move |ci| {
5684            let range = loader_ranges.get(ci)?;
5685            let bytes = chunk_reader.read_at(range.offset, range.len).ok()?;
5686            decompress(codec, &bytes).ok()
5687        });
5688        // Full-section sweeps (export/dump) batch their chunk fetches:
5689        // adjacent chunk ranges coalesce into a handful of range reads.
5690        let bulk_reader = reader.clone();
5691        let bulk: crate::dict::ChunkBulkLoader = Box::new(move |cis| {
5692            let want: Option<Vec<ByteRange>> =
5693                cis.iter().map(|&ci| ranges.get(ci).copied()).collect();
5694            let blobs = read_coalesced(bulk_reader.as_ref(), &want?, DICT_COALESCE_GAP)?;
5695            blobs.iter().map(|b| decompress(codec, b).ok()).collect()
5696        });
5697        dict_sections.push(
5698            crate::dict::ChunkedSection::from_parts(
5699                meta,
5700                chunks,
5701                Some(loader),
5702                cache.clone(),
5703                si as u8,
5704            )
5705            .with_bulk_loader(bulk),
5706        );
5707    }
5708    let dict_arr: [crate::dict::ChunkedSection; 4] = dict_sections
5709        .try_into()
5710        .map_err(|_| FileError::Container("expected 4 dictionary sections"))?;
5711    // Decoding a result touches several sections at once; this fetches all
5712    // their missing chunks in one batch (pairs arrive section by section in
5713    // ascending chunk order, so ascending in the file, as coalescing wants).
5714    let multi_reader = reader.clone();
5715    let codec = header.dict_codec;
5716    let multi: crate::dict::MultiChunkBulkLoader = Box::new(move |pairs| {
5717        let want: Option<Vec<ByteRange>> = pairs
5718            .iter()
5719            .map(|&(si, ci)| section_ranges.get(si as usize)?.get(ci).copied())
5720            .collect();
5721        let blobs = read_coalesced(multi_reader.as_ref(), &want?, DICT_COALESCE_GAP)?;
5722        blobs.iter().map(|b| decompress(codec, b).ok()).collect()
5723    });
5724    Ok(Dictionary::from_chunked_sections(dict_arr).with_multi_bulk_loader(multi))
5725}
5726
5727/// Run two independent steps of an open: at the same time when the reader can
5728/// keep several reads in flight (`concurrency > 1`) on a target with threads,
5729/// else one after the other. Either way both run to completion and their
5730/// results come back as they would inline; a panic in either propagates.
5731#[cfg(not(target_arch = "wasm32"))]
5732fn open_side_by_side<A, B: Send>(
5733    concurrency: usize,
5734    a: impl FnOnce() -> A,
5735    b: impl FnOnce() -> B + Send,
5736) -> (A, B) {
5737    if concurrency <= 1 {
5738        return (a(), b());
5739    }
5740    std::thread::scope(|scope| {
5741        let hb = scope.spawn(b);
5742        let ra = a();
5743        let rb = hb
5744            .join()
5745            .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
5746        (ra, rb)
5747    })
5748}
5749
5750/// wasm32 has no threads here: always one after the other.
5751#[cfg(target_arch = "wasm32")]
5752fn open_side_by_side<A, B>(
5753    _concurrency: usize,
5754    a: impl FnOnce() -> A,
5755    b: impl FnOnce() -> B,
5756) -> (A, B) {
5757    (a(), b())
5758}
5759
5760/// The pyramid meta is large on real graphs (tens of MB) and SPARQL never reads
5761/// it, so a ranged open defers its fetch: it faults in only if `pyramid()` is
5762/// called (community / pyramid_tree / label-prefix queries).
5763fn ranged_pyramid_slot<R: RangeReader + Send + Sync + 'static>(
5764    reader: &std::sync::Arc<R>,
5765    header: &Header,
5766) -> PyramidSlot {
5767    if header.pyramid_meta_len == 0 {
5768        return PyramidSlot::Resident(None);
5769    }
5770    let pyr_reader = reader.clone();
5771    let pyr_off = header.pyramid_meta_offset;
5772    let pyr_len = header.pyramid_meta_len;
5773    PyramidSlot::Lazy {
5774        loader: Box::new(move || {
5775            let mb = pyr_reader.read_at(pyr_off, pyr_len).ok()?;
5776            PyramidMeta::decode(&mb).ok()
5777        }),
5778        cell: std::sync::OnceLock::new(),
5779    }
5780}
5781
5782/// The deferred TEXT_INDEX of a ranged open: a text search faults the token
5783/// table on first call (the leading varint then its compressed bytes), then
5784/// fetches individual posting lists by `(offset, len)` — never the whole
5785/// postings blob. A caller that never searches pays nothing.
5786fn ranged_text_index_slot<R: RangeReader + Send + Sync + 'static>(
5787    reader: &std::sync::Arc<R>,
5788    header: &Header,
5789) -> TextIndexSlot {
5790    let (ti_off, ti_len) = header.text_index_payload();
5791    if ti_len == 0 {
5792        return TextIndexSlot::Resident {
5793            index: None,
5794            token_table_len: None,
5795        };
5796    }
5797    let ti_reader = reader.clone();
5798    let probe_reader = reader.clone();
5799    let codec = header.block_codec;
5800    TextIndexSlot::Lazy {
5801        loader: Box::new(move || {
5802            // The section opens with `varint token_table_len`; read enough to
5803            // decode it (a uvarint is ≤ 10 bytes), then fetch the varint + the
5804            // compressed token table as one prefix range.
5805            let prefix_len = read_token_table_len(&*ti_reader, ti_off, ti_len)?;
5806            let prefix = ti_reader.read_at(ti_off, prefix_len).ok()?;
5807            let postings_base = crate::text_index::TextIndex::postings_base(&prefix)? as u64;
5808            let postings_abs = ti_off + postings_base;
5809            let pr = ti_reader.clone();
5810            let posting_loader =
5811                Box::new(move |off: u64, len: u64| pr.read_at(postings_abs + off, len).ok());
5812            crate::text_index::TextIndex::from_token_table(&prefix, codec, posting_loader).ok()
5813        }),
5814        cell: std::sync::OnceLock::new(),
5815        // The same leading varint the loader starts from, asked on its own so a
5816        // caller can *state* the cost of a first search without paying it.
5817        token_table: Box::new(move || read_token_table_len(&*probe_reader, ti_off, ti_len)),
5818        token_table_cell: std::sync::OnceLock::new(),
5819    }
5820}
5821
5822/// Full-text search over `ti`, resolving the matched subject ids through `dict`.
5823/// The engine behind [`Rete::text_search`] and [`SearchView::text_search`].
5824fn text_search_in(
5825    ti: Option<&crate::text_index::TextIndex>,
5826    dict: &Dictionary,
5827    words: &[&str],
5828    prefix: Option<&str>,
5829    limit: usize,
5830) -> Vec<String> {
5831    let Some(ti) = ti else {
5832        return Vec::new();
5833    };
5834    // Each query word is tokenized exactly as at build time (so "Glucose"
5835    // matches the stored "glucose"); a word that splits into several tokens
5836    // requires all of them. AND across every required token + the prefix.
5837    let mut acc: Option<Vec<u32>> = None;
5838    if let Some(p) = prefix {
5839        acc = Some(ti.prefix(&p.to_lowercase()));
5840    }
5841    for w in words {
5842        for tok in crate::text_index::tokenize(w) {
5843            let posting = ti.lookup(&tok);
5844            acc = Some(match acc {
5845                Some(a) => intersect_sorted(&a, &posting),
5846                None => posting,
5847            });
5848            if acc.as_ref().is_some_and(|a| a.is_empty()) {
5849                return Vec::new();
5850            }
5851        }
5852    }
5853    let ids = acc.unwrap_or_default();
5854    let mut out = Vec::with_capacity(if limit > 0 {
5855        limit.min(ids.len())
5856    } else {
5857        ids.len()
5858    });
5859    // The hits' subject IRIs are scattered over many dictionary chunks: fault
5860    // the ones a page can return in one batch (on a remote file, one round
5861    // trip instead of one per chunk). Only warms the cache.
5862    let page = if limit > 0 {
5863        limit.min(ids.len())
5864    } else {
5865        ids.len()
5866    };
5867    dict.prefetch_subject_terms(&ids[..page]);
5868    for id in ids {
5869        if let Some(iri) = dict.subject_term(id) {
5870            out.push(iri);
5871            if limit > 0 && out.len() >= limit {
5872                break;
5873            }
5874        }
5875    }
5876    out
5877}
5878
5879/// A `.rete` opened for **search only**: the dictionary's chunk directories, and
5880/// the TEXT_INDEX / pyramid deferred behind their lazy slots. Nothing else.
5881///
5882/// What makes this cheap is skipping dictionary sections 2 and 3 (object-only
5883/// and predicates) — NOT skipping the index. Measured on the published
5884/// `epfl-infoscience.rete` (1.64 GB), a full [`Rete::open_ranged_lazy`] reads
5885/// 536,947,344 B, of which the six permutation tile directories are **49,940 B
5886/// in 40 reads** and the dictionary is 536,896,380 B in 36 reads: the
5887/// object-only chunk directory alone is 234,400,728 B, because that file stores
5888/// every chunk's first term verbatim. Files built since #198 key it by the
5889/// shortest separator (~600 KB on this graph); the published one still does
5890/// not, and only a rebuild changes that. This view opens sections 0 and 1
5891/// only — both search modes return subject IRIs — and costs **21,554 B in 9
5892/// reads** on the same file. It also skips the index container and defers the
5893/// TEXT_INDEX and pyramid, so it pays only for what it reads: the token table
5894/// on the first search, then one range per posting list and per dictionary
5895/// chunk holding a matched subject. This is what backs `rete search-url`.
5896///
5897/// On a file with a LABEL_INDEX section (kind 8) the dictionary is not opened
5898/// at all until a full-text search needs it: the section stores each subject's
5899/// term, so a prefix search costs the header, the section head, one directory
5900/// node per level and one data block.
5901pub struct SearchView {
5902    header: Header,
5903    dict: SectionSlot<Dictionary>,
5904    pyramid: PyramidSlot,
5905    text_index: TextIndexSlot,
5906    labels: SectionSlot<crate::label_index::LabelIndex>,
5907}
5908
5909impl SearchView {
5910    /// Open for search over any [`RangeReader`] (HTTP or local): header +
5911    /// dictionary chunk directories only (none at all on a file with a
5912    /// LABEL_INDEX, until a text search needs them), with the text index,
5913    /// label index and pyramid faulting in on first use.
5914    pub fn open_ranged<R: RangeReader + Send + Sync + 'static>(
5915        reader: R,
5916    ) -> Result<Self, FileError> {
5917        let head = reader.read_at(0, HEADER_LEN as u64)?;
5918        let header = Header::from_bytes(&head)?;
5919        let reader = std::sync::Arc::new(reader);
5920        // Sections 0 (shared) and 1 (subject-only) are the only ones a search
5921        // resolves — both modes return subject IRIs. Skipping 2 and 3 is what
5922        // makes this open cheap where a full one is not.
5923        let dict = if header.label_index_section().is_some() {
5924            let (r, h) = (reader.clone(), header.clone());
5925            SectionSlot::Lazy {
5926                loader: Box::new(move || {
5927                    ranged_chunked_dictionary(&r, &h, [true, true, false, false]).ok()
5928                }),
5929                cell: std::sync::OnceLock::new(),
5930            }
5931        } else {
5932            SectionSlot::Resident(Some(ranged_chunked_dictionary(
5933                &reader,
5934                &header,
5935                [true, true, false, false],
5936            )?))
5937        };
5938        let pyramid = ranged_pyramid_slot(&reader, &header);
5939        let text_index = ranged_text_index_slot(&reader, &header);
5940        let labels = ranged_label_slot(&reader, &header);
5941        Ok(Self {
5942            header,
5943            dict,
5944            pyramid,
5945            text_index,
5946            labels,
5947        })
5948    }
5949
5950    /// Whether the file can answer a prefix search: it carries a LABEL_INDEX
5951    /// section or a pyramid (whose meta may hold the legacy label block). A
5952    /// header read, no fault.
5953    pub fn has_labels(&self) -> bool {
5954        self.header.label_index_section().is_some() || self.header.pyramid_meta_len > 0
5955    }
5956
5957    /// Whether the file carries a LABEL_INDEX section (kind 8).
5958    pub fn has_label_index(&self) -> bool {
5959        self.header.label_index_section().is_some()
5960    }
5961
5962    /// The file header (already fetched by [`open_ranged`](Self::open_ranged)).
5963    pub fn header(&self) -> &Header {
5964        &self.header
5965    }
5966
5967    /// Whether the file carries a TEXT_INDEX section — a header read, no fault.
5968    pub fn has_text_index(&self) -> bool {
5969        self.header.text_index_len > 0
5970    }
5971
5972    /// Whether the file carries a pyramid (where the label index lives) — a
5973    /// header read, no fault.
5974    pub fn has_pyramid(&self) -> bool {
5975        self.header.pyramid_meta_len > 0
5976    }
5977
5978    /// Full-text search — see [`Rete::text_search`] for the semantics. Faults
5979    /// the token table on the first call, then only the queried postings.
5980    pub fn text_search(&self, words: &[&str], prefix: Option<&str>, limit: usize) -> Vec<String> {
5981        let Some(dict) = self.dict.get() else {
5982            return Vec::new();
5983        };
5984        text_search_in(self.text_index.index(), dict, words, prefix, limit)
5985    }
5986
5987    /// What that first search will cost — see
5988    /// [`Rete::text_index_token_table_len`]. One ≤10-byte range read, so a
5989    /// caller can quote the figure before deciding to search.
5990    pub fn text_index_token_table_len(&self) -> Option<u64> {
5991        self.text_index.token_table_len()
5992    }
5993
5994    /// Label-prefix search — see [`Rete::prefix_search`]. Reads the
5995    /// LABEL_INDEX section when the file has one; otherwise faults the pyramid
5996    /// (which carries the legacy label block), the pricier path.
5997    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<(String, String)> {
5998        if let Some(li) = self.labels.get() {
5999            return li.prefix_search(prefix, limit);
6000        }
6001        let pyr = match &self.pyramid {
6002            PyramidSlot::Resident(p) => p.as_ref(),
6003            PyramidSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
6004        };
6005        let (Some(pyr), Some(dict)) = (pyr, self.dict.get()) else {
6006            return Vec::new();
6007        };
6008        pyr.prefix_search(prefix, limit)
6009            .into_iter()
6010            .filter_map(|e| {
6011                dict.subject_term(e.subject)
6012                    .map(|iri| (e.label.clone(), iri))
6013            })
6014            .collect()
6015    }
6016}
6017
6018/// A lightweight, overview-only view of a file: the pyramid summary graph plus
6019/// just enough dictionary to label predicates. Fetched via ranges *without*
6020/// touching the (large) triple index — the "load the coarse graph first" path
6021/// from SPEC.md §7.2.
6022#[must_use]
6023pub struct SummaryView {
6024    pub round: u32,
6025    pub summary: Vec<SuperEdge>,
6026    /// The shipped `subClassOf` hierarchy (v2 schema pyramid; empty on v1 files).
6027    pub class_hierarchy: Vec<ClassNode>,
6028    /// Per-level type rollups — the leveled legend, read index-free.
6029    pub level_rollups: Vec<LevelRollup>,
6030    /// Per-level lateral class-relation graph (the non-`is-a` connections).
6031    pub level_links: Vec<LevelLinks>,
6032    /// Per-community descriptors (Phase 4 progressive refinement; may be empty).
6033    pub descriptors: Vec<CommunityDescriptor>,
6034    /// `subClassOf` cycles (v2.1; empty on older files).
6035    pub subclass_cycles: Vec<Vec<String>>,
6036    /// `owl:disjointWith` class pairs (v2.1; empty on older files).
6037    pub disjoint_pairs: Vec<(String, String)>,
6038    /// `owl:equivalentClass` class pairs (v2.1; empty on older files).
6039    pub equivalent_pairs: Vec<(String, String)>,
6040    dict: Dictionary,
6041}
6042
6043impl SummaryView {
6044    /// Read header → dictionary → pyramid-meta only (skips the index container).
6045    pub fn open_ranged<R: RangeReader>(reader: &R) -> Result<Option<Self>, FileError> {
6046        let head = reader.read_at(0, HEADER_LEN as u64)?;
6047        let header = Header::from_bytes(&head)?;
6048        if header.pyramid_meta_len == 0 {
6049            return Ok(None);
6050        }
6051
6052        let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
6053        let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
6054
6055        let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
6056        let mut meta =
6057            PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
6058        // A file whose schema moved to its own section (kind 9) keeps only the
6059        // community material here: take the schema fields from that section.
6060        if let Some(s) = read_schema_with_header(reader, &header) {
6061            meta.class_hierarchy = s.class_hierarchy;
6062            meta.level_rollups = s.level_rollups;
6063            meta.level_links = s.level_links;
6064            meta.subclass_cycles = s.subclass_cycles;
6065            meta.disjoint_pairs = s.disjoint_pairs;
6066            meta.equivalent_pairs = s.equivalent_pairs;
6067        }
6068
6069        Ok(Some(SummaryView {
6070            round: meta.round,
6071            summary: meta.summary,
6072            class_hierarchy: meta.class_hierarchy,
6073            level_rollups: meta.level_rollups,
6074            level_links: meta.level_links,
6075            descriptors: meta.descriptors,
6076            subclass_cycles: meta.subclass_cycles,
6077            disjoint_pairs: meta.disjoint_pairs,
6078            equivalent_pairs: meta.equivalent_pairs,
6079            dict,
6080        }))
6081    }
6082
6083    /// A schema-only view of a file **without a pyramid**, from its SCHEMA
6084    /// section (kind 9): the hierarchy, levels and axioms, with an empty
6085    /// community summary. Reads the header and the SCHEMA section only — the
6086    /// section carries its own class and predicate strings, so no dictionary.
6087    /// `Ok(None)` when the file has no SCHEMA section. Callers that need the
6088    /// summary (`cost`, federation source selection, progressive queries) use
6089    /// [`open_ranged`](Self::open_ranged), which keeps requiring a pyramid.
6090    pub fn open_schema_ranged<R: RangeReader>(reader: &R) -> Result<Option<Self>, FileError> {
6091        let head = reader.read_at(0, HEADER_LEN as u64)?;
6092        let header = Header::from_bytes(&head)?;
6093        let Some(s) = read_schema_with_header(reader, &header) else {
6094            return Ok(None);
6095        };
6096        let dict = crate::DictionaryBuilder::new().build();
6097        Ok(Some(SummaryView {
6098            round: 0,
6099            summary: Vec::new(),
6100            class_hierarchy: s.class_hierarchy,
6101            level_rollups: s.level_rollups,
6102            level_links: s.level_links,
6103            descriptors: Vec::new(),
6104            subclass_cycles: s.subclass_cycles,
6105            disjoint_pairs: s.disjoint_pairs,
6106            equivalent_pairs: s.equivalent_pairs,
6107            dict,
6108        }))
6109    }
6110
6111    /// Number of semantic-zoom levels in the schema pyramid (0 if none shipped).
6112    pub fn level_count(&self) -> usize {
6113        self.level_rollups.len()
6114    }
6115
6116    /// The type rollup at semantic level `k` (0 = coarsest/most abstract), or
6117    /// `None` if `k` is out of range. Index-free — answered from the pyramid-meta.
6118    pub fn level_rollup(&self, k: usize) -> Option<&LevelRollup> {
6119        self.level_rollups.get(k)
6120    }
6121
6122    /// Resolve a predicate ID in the summary to its term.
6123    pub fn predicate_term(&self, id: u32) -> Option<String> {
6124        self.dict.predicate_term(id)
6125    }
6126
6127    /// Exact number of triples using `predicate`, summed from the summary's
6128    /// superedge counts — answered without ever reading the triple index.
6129    pub fn predicate_total(&self, predicate: &str) -> u32 {
6130        match self.dict.predicate_id(predicate) {
6131            Some(pid) => self
6132                .summary
6133                .iter()
6134                .filter(|e| e.predicate == pid)
6135                .map(|e| e.count)
6136                .sum(),
6137            None => 0,
6138        }
6139    }
6140
6141    /// All predicates with their exact triple totals, descending by count.
6142    pub fn predicate_totals(&self) -> Vec<(String, u32)> {
6143        let mut by_pred: std::collections::BTreeMap<u32, u32> = std::collections::BTreeMap::new();
6144        for e in &self.summary {
6145            *by_pred.entry(e.predicate).or_default() += e.count;
6146        }
6147        let mut out: Vec<(String, u32)> = by_pred
6148            .into_iter()
6149            .filter_map(|(pid, c)| self.dict.predicate_term(pid).map(|t| (t, c)))
6150            .collect();
6151        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
6152        out
6153    }
6154
6155    /// Number of communities the summary spans (distinct supernode endpoints).
6156    pub fn community_count(&self) -> usize {
6157        let mut comms = std::collections::BTreeSet::new();
6158        for e in &self.summary {
6159            comms.insert(e.s_comm);
6160            comms.insert(e.o_comm);
6161        }
6162        comms.len()
6163    }
6164
6165    /// **Index-free T-Box coherence (Tier-0).** Detect schema-level incoherent
6166    /// points purely from the shipped schema pyramid — no triple index, no
6167    /// instance data, O(ontology) regardless of graph size:
6168    /// - `subclass-cycle`: a set of classes that are mutually `rdfs:subClassOf`.
6169    /// - `unsatisfiable-class`: a class whose ancestor closure (over all parents,
6170    ///   folded through `owl:equivalentClass`) contains both ends of an
6171    ///   `owl:disjointWith` pair, so no individual can ever be one.
6172    ///
6173    /// Soundness is bounded by what the pyramid ships: the `subClassOf` hierarchy
6174    /// is capped (`MAX_HIERARCHY` in `schema_pyramid`), so on a very large ontology
6175    /// a pruned ancestor can hide an unsatisfiable class (a false *coherent*, never
6176    /// a false *incoherent*). Instance-level clashes (a node typed into disjoint
6177    /// classes, functional-property clashes) are NOT visible here — they need the
6178    /// A-Box (Tier-1/Tier-2 `reason`).
6179    pub fn tbox_coherence(&self) -> Vec<crate::reason::Inconsistency> {
6180        schema_coherence(
6181            &self.class_hierarchy,
6182            &self.subclass_cycles,
6183            &self.disjoint_pairs,
6184            &self.equivalent_pairs,
6185        )
6186    }
6187
6188    /// True when [`tbox_coherence`](Self::tbox_coherence) finds no schema-level
6189    /// incoherent point.
6190    pub fn tbox_is_coherent(&self) -> bool {
6191        self.tbox_coherence().is_empty()
6192    }
6193}
6194
6195/// Compute T-Box coherence points from the schema-pyramid fields alone — no
6196/// dictionary, no index, no instance data. Shared by [`SummaryView::tbox_coherence`]
6197/// and the dictionary-free [`read_schema_coherence_ranged`]. Emits `subclass-cycle`
6198/// and `unsatisfiable-class` (a class whose ancestor closure — over all parents,
6199/// folded through `owl:equivalentClass` — contains both ends of a disjoint pair).
6200pub fn schema_coherence(
6201    class_hierarchy: &[ClassNode],
6202    subclass_cycles: &[Vec<String>],
6203    disjoint_pairs: &[(String, String)],
6204    equivalent_pairs: &[(String, String)],
6205) -> Vec<crate::reason::Inconsistency> {
6206    use crate::reason::Inconsistency;
6207    use std::collections::{BTreeMap, BTreeSet, VecDeque};
6208    const MAX_REACH: usize = 100_000;
6209
6210    let mut out: Vec<Inconsistency> = Vec::new();
6211
6212    for cyc in subclass_cycles {
6213        let detail = if cyc.len() == 1 {
6214            format!("{} is rdfs:subClassOf itself (a cycle)", cyc[0])
6215        } else {
6216            format!(
6217                "classes {{{}}} are mutually rdfs:subClassOf (a cycle)",
6218                cyc.join(", ")
6219            )
6220        };
6221        out.push(Inconsistency {
6222            kind: "subclass-cycle",
6223            detail,
6224        });
6225    }
6226
6227    if !disjoint_pairs.is_empty() {
6228        // Upward adjacency: subClassOf parents + bidirectional equivalence.
6229        let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
6230        for n in class_hierarchy {
6231            let e = adj.entry(n.class.as_str()).or_default();
6232            for p in &n.parents {
6233                e.push(p.as_str());
6234            }
6235        }
6236        for (a, b) in equivalent_pairs {
6237            adj.entry(a.as_str()).or_default().push(b.as_str());
6238            adj.entry(b.as_str()).or_default().push(a.as_str());
6239        }
6240
6241        // Candidate focus classes: every class named anywhere in the schema.
6242        let mut focuses: BTreeSet<&str> =
6243            class_hierarchy.iter().map(|n| n.class.as_str()).collect();
6244        for (a, b) in disjoint_pairs.iter().chain(equivalent_pairs) {
6245            focuses.insert(a.as_str());
6246            focuses.insert(b.as_str());
6247        }
6248
6249        let mut seen: BTreeSet<&str> = BTreeSet::new();
6250        for &c in &focuses {
6251            // reach(c) = {c} ∪ ancestors (capped BFS over `adj`).
6252            let mut reach: BTreeSet<&str> = BTreeSet::new();
6253            let mut q: VecDeque<&str> = VecDeque::new();
6254            reach.insert(c);
6255            q.push_back(c);
6256            while let Some(x) = q.pop_front() {
6257                if reach.len() > MAX_REACH {
6258                    break;
6259                }
6260                if let Some(ns) = adj.get(x) {
6261                    for &p in ns {
6262                        if reach.insert(p) {
6263                            q.push_back(p);
6264                        }
6265                    }
6266                }
6267            }
6268            for (x, y) in disjoint_pairs {
6269                if reach.contains(x.as_str()) && reach.contains(y.as_str()) && seen.insert(c) {
6270                    out.push(Inconsistency {
6271                        kind: "unsatisfiable-class",
6272                        detail: format!(
6273                            "{c} is a subclass of both {x} and {y}, which are \
6274                             owl:disjointWith — no individual can be a {c}"
6275                        ),
6276                    });
6277                    break;
6278                }
6279            }
6280        }
6281    }
6282
6283    out.sort_by(|a, b| (a.kind, &a.detail).cmp(&(b.kind, &b.detail)));
6284    out
6285}
6286
6287/// **Dictionary-free Tier-0 coherence read.** Fetch only the header and the
6288/// pyramid-meta range (2 small range reads) and run [`schema_coherence`] over the
6289/// schema pyramid — never touching the **dictionary** (which a literal-heavy file
6290/// makes large) or the triple index. `Ok(None)` if the file ships no pyramid.
6291///
6292/// This is what makes the Tier-0 check cheap on big graphs: the schema pyramid
6293/// carries its own class-string table, so coherence needs none of the dictionary.
6294pub fn read_schema_coherence_ranged<R: RangeReader>(
6295    reader: &R,
6296) -> Result<Option<Vec<crate::reason::Inconsistency>>, FileError> {
6297    let head = reader.read_at(0, HEADER_LEN as u64)?;
6298    let header = Header::from_bytes(&head)?;
6299    // The SCHEMA section (kind 9) first: one range, and no pyramid needed.
6300    if let Some(s) = read_schema_with_header(reader, &header) {
6301        return Ok(Some(schema_coherence(
6302            &s.class_hierarchy,
6303            &s.subclass_cycles,
6304            &s.disjoint_pairs,
6305            &s.equivalent_pairs,
6306        )));
6307    }
6308    if header.pyramid_meta_len == 0 {
6309        return Ok(None);
6310    }
6311    // Fast path: the header records the trailing schema block's length, so read ONLY
6312    // that block (at the end of pyramid-meta) — never the community summary, the
6313    // dictionary, or the index. This is what makes Tier-0 flat at any graph size.
6314    if header.schema_meta_len > 0 && (header.schema_meta_len as u64) <= header.pyramid_meta_len {
6315        let off =
6316            header.pyramid_meta_offset + header.pyramid_meta_len - header.schema_meta_len as u64;
6317        let block = reader.read_at(off, header.schema_meta_len as u64)?;
6318        let (hierarchy, cycles, disjoint, equivalent) = crate::meta::decode_schema_block(&block)
6319            .map_err(|_| FileError::Container("malformed schema block"))?;
6320        return Ok(Some(schema_coherence(
6321            &hierarchy,
6322            &cycles,
6323            &disjoint,
6324            &equivalent,
6325        )));
6326    }
6327    // Fallback (pre-v0.2.1 files with no header field): decode the whole pyramid-meta.
6328    let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
6329    let meta =
6330        PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
6331    Ok(Some(schema_coherence(
6332        &meta.class_hierarchy,
6333        &meta.subclass_cycles,
6334        &meta.disjoint_pairs,
6335        &meta.equivalent_pairs,
6336    )))
6337}
6338
6339/// The **schema summary** (per-class histogram + class relations at the finest
6340/// level) read over a [`RangeReader`] from the schema pyramid alone — the
6341/// index-free, range-readable source for a Schema view of a remote graph. Returns
6342/// `(classes, relations)` with `classes = [(class_iri, count)]` and `relations =
6343/// [(s_class, predicate, o_class, count)]`; `None` when the file has no schema
6344/// pyramid. Like [`read_schema_coherence_ranged`], it reads only the trailing
6345/// schema block, so it stays flat at any graph size.
6346#[allow(clippy::type_complexity)]
6347pub fn read_schema_summary_ranged<R: RangeReader>(
6348    reader: &R,
6349) -> Result<Option<(Vec<(String, u64)>, Vec<(String, String, String, u64)>)>, FileError> {
6350    let head = reader.read_at(0, HEADER_LEN as u64)?;
6351    let header = Header::from_bytes(&head)?;
6352    // The SCHEMA section (kind 9) first: one range, and no pyramid needed.
6353    let meta = if let Some(s) = read_schema_with_header(reader, &header) {
6354        PyramidMeta::new(0, Vec::new(), &[]).with_schema(
6355            Vec::new(),
6356            s.level_rollups,
6357            s.level_links,
6358            Vec::new(),
6359            Vec::new(),
6360            Vec::new(),
6361            Vec::new(),
6362        )
6363    } else {
6364        if header.pyramid_meta_len == 0 {
6365            return Ok(None);
6366        }
6367        if header.schema_meta_len > 0 && (header.schema_meta_len as u64) <= header.pyramid_meta_len
6368        {
6369            let off = header.pyramid_meta_offset + header.pyramid_meta_len
6370                - header.schema_meta_len as u64;
6371            let block = reader.read_at(off, header.schema_meta_len as u64)?;
6372            let summary = crate::meta::decode_schema_block_summary(&block)
6373                .map_err(|_| FileError::Container("malformed schema block"))?;
6374            return Ok(Some(summary));
6375        }
6376        // Fallback (pre-v0.2.1 files): decode the whole pyramid-meta, pull finest levels.
6377        let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
6378        PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?
6379    };
6380    if meta.level_rollups.is_empty() && meta.level_links.is_empty() {
6381        return Ok(None);
6382    }
6383    let classes = meta
6384        .level_rollups
6385        .iter()
6386        .max_by_key(|r| r.depth)
6387        .map(|r| r.classes.clone())
6388        .unwrap_or_default();
6389    let relations = meta
6390        .level_links
6391        .iter()
6392        .max_by_key(|l| l.depth)
6393        .map(|l| {
6394            l.links
6395                .iter()
6396                .map(|c| {
6397                    (
6398                        c.s_class.clone(),
6399                        c.predicate.clone(),
6400                        c.o_class.clone(),
6401                        c.count,
6402                    )
6403                })
6404                .collect()
6405        })
6406        .unwrap_or_default();
6407    Ok(Some((classes, relations)))
6408}
6409
6410/// Every dictionary section's chunk **routing keys**, exactly as stored, from a
6411/// finished file image. Test-only: it exists so a test in another module of
6412/// this crate (`extbuild`) can assert on the directory the writers produce
6413/// without any of this becoming public surface.
6414#[cfg(test)]
6415pub(crate) fn dict_chunk_keys_for_test(image: &[u8]) -> Vec<Vec<Vec<u8>>> {
6416    let header = Header::from_bytes(&image[..HEADER_LEN]).unwrap();
6417    let s = header.dictionary_offset as usize;
6418    let e = s + header.dictionary_len as usize;
6419    decode_container(&image[s..e], CODEC_NONE)
6420        .unwrap()
6421        .iter()
6422        .map(|payload| {
6423            let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
6424            entries.into_iter().map(|c| c.key).collect()
6425        })
6426        .collect()
6427}
6428
6429#[cfg(test)]
6430mod tests {
6431    use super::*;
6432    use crate::dictionary::DictionaryBuilder;
6433    use crate::index::GraphIndexBuilder;
6434
6435    /// The pure-Rust decode path (wasm, `--no-default-features`) reads a zstd
6436    /// frame back byte for byte. Guards the `ruzstd` API across upgrades.
6437    #[cfg(not(feature = "compression"))]
6438    #[test]
6439    fn ruzstd_decodes_a_zstd_frame() {
6440        let data: Vec<u8> = (0..50_000u32)
6441            .flat_map(|i| (i % 251).to_le_bytes())
6442            .collect();
6443        let frame = ruzstd::encoding::compress_to_vec(
6444            &data[..],
6445            ruzstd::encoding::CompressionLevel::Fastest,
6446        );
6447        assert!(frame.len() < data.len());
6448        assert_eq!(decompress(CODEC_ZSTD, &frame).unwrap(), data);
6449    }
6450
6451    /// A three-permutation file must survive every opener — resident, ranged,
6452    /// ranged-lazy — and answer every pattern shape with the rows the
6453    /// six-permutation twin gives, including through a named graph's own
6454    /// container. The mask is a file-level fact, so getting it to the named
6455    /// graphs' decoder is a separate thing that can be forgotten.
6456    #[test]
6457    fn three_permutation_file_round_trips_every_open_path() {
6458        use crate::index::PermSet;
6459        use crate::reader::SliceReader;
6460
6461        let build = |perms: PermSet| {
6462            let mut db = crate::DictionaryBuilder::new();
6463            let mut triples = Vec::new();
6464            for i in 0..60u32 {
6465                let (s, p, o) = (
6466                    format!("<http://ex/s{}>", i % 11),
6467                    format!("<http://ex/p{}>", i % 3),
6468                    format!("<http://ex/o{}>", i % 7),
6469                );
6470                db.observe(&s, &p, &o);
6471                triples.push((s, p, o));
6472            }
6473            let dict = db.build();
6474            let ids: Vec<(u32, u32, u32)> = triples
6475                .iter()
6476                .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
6477                .collect();
6478            let def = GraphIndexBuilder::from_triples(ids.clone())
6479                .with_tile_budget(96)
6480                .with_perms(perms)
6481                .build();
6482            let named = GraphIndexBuilder::from_triples(ids)
6483                .with_tile_budget(96)
6484                .with_perms(perms)
6485                .build();
6486            write_dataset(
6487                &dict,
6488                &def,
6489                &[("<http://ex/g>".to_string(), named)],
6490                true,
6491                &[],
6492                0,
6493            )
6494        };
6495
6496        let six = build(PermSet::ALL);
6497        let three = build(PermSet::CORE);
6498        assert!(three.len() < six.len());
6499        assert_eq!(Header::from_bytes(&three).unwrap().perms, PermSet::CORE);
6500        assert_eq!(Header::from_bytes(&six).unwrap().perms, PermSet::ALL);
6501
6502        let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>)> = vec![
6503            (None, None, None),
6504            (Some("<http://ex/s3>"), None, None),
6505            (None, Some("<http://ex/p1>"), None),
6506            (None, None, Some("<http://ex/o5>")),
6507            (Some("<http://ex/s3>"), Some("<http://ex/p1>"), None),
6508            (Some("<http://ex/s3>"), None, Some("<http://ex/o5>")),
6509            (None, Some("<http://ex/p1>"), Some("<http://ex/o5>")),
6510            (
6511                Some("<http://ex/s3>"),
6512                Some("<http://ex/p1>"),
6513                Some("<http://ex/o5>"),
6514            ),
6515        ];
6516
6517        for (image, tag) in [(&six, "six"), (&three, "three")] {
6518            let resident = Rete::open(image).unwrap();
6519            let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
6520            let ranged = Rete::open_ranged(&SliceReader::new(leaked)).unwrap();
6521            let lazy = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
6522            for (s, p, o) in &shapes {
6523                let want: Vec<_> = {
6524                    let mut v = Rete::open(&six).unwrap().query(*s, *p, *o);
6525                    v.sort();
6526                    v
6527                };
6528                for (got, path) in [
6529                    (resident.query(*s, *p, *o), "resident"),
6530                    (ranged.query(*s, *p, *o), "ranged"),
6531                    (lazy.query(*s, *p, *o), "lazy"),
6532                ] {
6533                    let mut got = got;
6534                    got.sort();
6535                    assert_eq!(got, want, "{tag}/{path} disagreed on {:?}", (s, p, o));
6536                }
6537                // The named graph's container carries the same mask.
6538                let mut g = resident.query_in_graph(Some("<http://ex/g>"), *s, *p, *o);
6539                g.sort();
6540                assert_eq!(g, want, "{tag}/named disagreed on {:?}", (s, p, o));
6541            }
6542        }
6543
6544        // And the single-pattern ROUTED read (the `query-url` path) locates its
6545        // section by container position, not by the format's fixed six-wide
6546        // slot — the one place the two indexes differ.
6547        let leaked: &'static [u8] = Box::leak(three.clone().into_boxed_slice());
6548        let reader = SliceReader::new(leaked);
6549        let routed = Rete::query_ranged(&reader, None, None, Some("<http://ex/o5>")).unwrap();
6550        let mut want = Rete::open(&six)
6551            .unwrap()
6552            .query(None, None, Some("<http://ex/o5>"));
6553        want.sort();
6554        let mut got = routed;
6555        got.sort();
6556        assert_eq!(got, want, "routed single-pattern read on a lean file");
6557    }
6558
6559    #[test]
6560    fn read_coalesced_merges_within_gap_and_splits_beyond() {
6561        use crate::reader::{CountingReader, SliceReader};
6562        let bytes = vec![0u8; 4096];
6563        // Three 16-byte ranges: A..B gap = 32, B..C gap = 1024.
6564        let ranges = [
6565            ByteRange { offset: 0, len: 16 },
6566            ByteRange {
6567                offset: 48,
6568                len: 16,
6569            },
6570            ByteRange {
6571                offset: 1088,
6572                len: 16,
6573            },
6574        ];
6575        // Tight gap (16): nothing merges → one read per range.
6576        let r = CountingReader::new(SliceReader::new(&bytes));
6577        let out = read_coalesced(&r, &ranges, 16).unwrap();
6578        assert_eq!(out.len(), 3);
6579        assert_eq!(r.requests(), 3);
6580        // Gap 64 merges A+B (gap 32) but not C (gap 1024) → two reads.
6581        let r = CountingReader::new(SliceReader::new(&bytes));
6582        read_coalesced(&r, &ranges, 64).unwrap();
6583        assert_eq!(r.requests(), 2);
6584        // Gap 4096 merges all three into one read, over-fetching the gaps.
6585        let r = CountingReader::new(SliceReader::new(&bytes));
6586        read_coalesced(&r, &ranges, 4096).unwrap();
6587        assert_eq!(r.requests(), 1);
6588    }
6589
6590    fn build_image() -> Vec<u8> {
6591        let triples = [
6592            ("Alice", "knows", "Bob"),
6593            ("Bob", "knows", "Carol"),
6594            ("Alice", "age", "30"),
6595        ];
6596        let mut db = DictionaryBuilder::new();
6597        for (s, p, o) in triples {
6598            db.observe(s, p, o);
6599        }
6600        let dict = db.build();
6601
6602        let mut ib = GraphIndexBuilder::new();
6603        for (s, p, o) in triples {
6604            ib.push(dict.encode(s, p, o).unwrap());
6605        }
6606        let index = ib.build();
6607
6608        let (meta, levels) = build_pyramid_meta(&dict, &triples_ids(&dict), DEFAULT_TILE_BUDGET);
6609        write_file(&dict, &index, false, &meta, levels)
6610    }
6611
6612    fn triples_ids(dict: &Dictionary) -> Vec<(u32, u32, u32)> {
6613        [
6614            ("Alice", "knows", "Bob"),
6615            ("Bob", "knows", "Carol"),
6616            ("Alice", "age", "30"),
6617        ]
6618        .iter()
6619        .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
6620        .collect()
6621    }
6622
6623    #[test]
6624    fn file_round_trips_header_and_counts() {
6625        let bytes = build_image();
6626        let rete = Rete::open(&bytes).unwrap();
6627        assert_eq!(rete.header().quad_count, 3);
6628        assert!(rete.header().term_count >= 5);
6629        let expected_codec = writer_codec();
6630        assert_eq!(rete.header().dict_codec, expected_codec);
6631        assert_eq!(rete.header().block_codec, expected_codec);
6632        assert_eq!(&bytes[bytes.len() - 4..], &MAGIC); // footer marker
6633    }
6634
6635    /// A file whose index was built with a tiny tile budget (forcing many
6636    /// tiles per permutation) must round-trip through write/open and answer
6637    /// every query shape identically — through both the in-memory and the
6638    /// routed ranged read paths.
6639    #[test]
6640    fn multi_tile_file_round_trips_and_routes() {
6641        let triples: Vec<(String, String, String)> = (0..200)
6642            .map(|i| {
6643                (
6644                    format!("<http://ex/s/{i}>"),
6645                    format!("<http://ex/p/{}>", i % 5),
6646                    format!("<http://ex/o/{}>", i % 23),
6647                )
6648            })
6649            .collect();
6650        let mut db = DictionaryBuilder::new();
6651        for (s, p, o) in &triples {
6652            db.observe(s, p, o);
6653        }
6654        let dict = db.build();
6655        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6656        for (s, p, o) in &triples {
6657            ib.push(dict.encode(s, p, o).unwrap());
6658        }
6659        let index = ib.build();
6660        assert!(
6661            index.tile_sections()[0].len() > 3,
6662            "tiny budget must force many tiles"
6663        );
6664        let bytes = write_file(&dict, &index, false, &[], 0);
6665
6666        let rete = Rete::open(&bytes).unwrap();
6667        assert_eq!(rete.header().version, crate::header::CURRENT_FORMAT_VERSION);
6668        assert_eq!(rete.query(None, None, None).len(), 200);
6669        assert_eq!(rete.query(Some("<http://ex/s/7>"), None, None).len(), 1);
6670        assert_eq!(
6671            rete.query(None, Some("<http://ex/p/3>"), None).len(),
6672            40,
6673            "predicate extent spans tiles"
6674        );
6675        assert_eq!(
6676            rete.query(None, None, Some("<http://ex/o/22>")).len(),
6677            8 // 22, 45, 68, ... < 200
6678        );
6679
6680        // Routed ranged read must agree (and only decompress matching tiles).
6681        use crate::reader::SliceReader;
6682        let reader = SliceReader::new(&bytes);
6683        let routed = Rete::query_ranged(&reader, Some("<http://ex/s/7>"), None, None).unwrap();
6684        assert_eq!(routed.len(), 1);
6685        let routed = Rete::query_ranged(&reader, None, Some("<http://ex/p/3>"), None).unwrap();
6686        assert_eq!(routed.len(), 40);
6687        let routed = Rete::query_ranged(&reader, None, None, Some("<http://ex/o/22>")).unwrap();
6688        assert_eq!(routed.len(), 8);
6689    }
6690
6691    /// The tile-synopsis trailer round-trips through encode/parse, and each parsed
6692    /// synopsis is **exactly** the tile block's own b/c zone — so the directory
6693    /// can never prune a tile the tile itself would have matched.
6694    /// Section-internal byte offsets are u64: a directory whose tiles sit past
6695    /// 4 GiB must parse with exact offsets on EVERY platform. On wasm32 (32-bit
6696    /// usize) the old parse truncated a >4 GiB section length and rejected the
6697    /// tail ("dict chunk overruns section" on the first >4 GiB dictionary —
6698    /// crossref's 5.2 GB g.obj — the playground regression this guards).
6699    #[test]
6700    fn tile_directory_offsets_survive_past_4gib() {
6701        let mut dir = Vec::new();
6702        write_uvarint(&mut dir, 2); // two tiles
6703        write_uvarint(&mut dir, 5); // tile 1: Δmin_a
6704        write_uvarint(&mut dir, 0); //         span
6705        write_uvarint(&mut dir, 3 << 30); //   len = 3 GiB
6706        write_uvarint(&mut dir, 1); // tile 2: Δmin_a
6707        write_uvarint(&mut dir, 0);
6708        write_uvarint(&mut dir, 2 << 30); //   len = 2 GiB
6709        let total = dir.len() as u64 + (3u64 << 30) + (2u64 << 30) + 64;
6710        let entries = parse_tile_directory(&dir, total).unwrap();
6711        assert_eq!(entries.len(), 2);
6712        assert_eq!(entries[1].start, dir.len() as u64 + (3u64 << 30));
6713        assert!(
6714            entries[1].end > u32::MAX as u64,
6715            "tail tile sits past 4 GiB"
6716        );
6717        // a total smaller than the tiles must still reject the directory
6718        assert!(parse_tile_directory(&dir, 1 << 20).is_err());
6719    }
6720
6721    #[test]
6722    fn tile_synopsis_trailer_round_trips() {
6723        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6724        for i in 0..200u32 {
6725            ib.push((i, i % 7, i % 13));
6726        }
6727        let index = ib.build();
6728        let tile_count = index.tile_sections()[0].len();
6729        assert!(tile_count > 3, "tiny budget forces many tiles");
6730
6731        let payload = encode_tiled_section(&index, 0, CODEC_NONE);
6732        let dir = parse_tile_directory(&payload, payload.len() as u64).unwrap();
6733        assert_eq!(dir.len(), tile_count);
6734        // The trailer sits past the last tile; the old directory parse stops there.
6735        let trailer_start = dir.iter().map(|e| e.end).max().unwrap();
6736        assert!(
6737            trailer_start < payload.len() as u64,
6738            "a trailer follows the tiles"
6739        );
6740        for e in &dir {
6741            assert!(
6742                e.end <= payload.len() as u64,
6743                "tiles still located within the payload"
6744            );
6745        }
6746        let syn = parse_tile_synopsis(&payload, trailer_start as usize, dir.len()).unwrap();
6747        for (e, (min_b, max_b, min_c, max_c)) in dir.iter().zip(syn) {
6748            let block = decompress(CODEC_NONE, &payload[e.start as usize..e.end as usize]).unwrap();
6749            let z = *crate::triples::TripleBlock::parse(&block).unwrap().zone();
6750            assert_eq!(
6751                (min_b, max_b, min_c, max_c),
6752                (z.min_b, z.max_b, z.min_c, z.max_c),
6753                "synopsis equals the tile's own zone"
6754            );
6755        }
6756    }
6757
6758    /// End-to-end safety: a synopsis-carrying file, opened **lazily** (range
6759    /// reads), must return exactly the brute-force answer for every pattern shape
6760    /// — the synopsis prune may never drop a real match.
6761    #[test]
6762    fn tile_synopsis_lazy_matches_reference_every_shape() {
6763        use crate::reader::{CountingReader, SliceReader};
6764        let triples: Vec<(String, String, String)> = (0..200u32)
6765            .map(|i| {
6766                (
6767                    format!("<http://ex/s/{i:04}>"),
6768                    format!("<http://ex/p/{}>", i % 7),
6769                    format!("<http://ex/o/{:04}>", i % 13),
6770                )
6771            })
6772            .collect();
6773        let mut db = DictionaryBuilder::new();
6774        for (s, p, o) in &triples {
6775            db.observe(s, p, o);
6776        }
6777        let dict = db.build();
6778        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6779        for (s, p, o) in &triples {
6780            ib.push(dict.encode(s, p, o).unwrap());
6781        }
6782        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
6783
6784        let eager = Rete::open(&bytes).unwrap();
6785        assert!(
6786            eager.header().has_tile_synopsis(),
6787            "new files set the synopsis flag"
6788        );
6789
6790        // `open_ranged_lazy` needs a `'static` reader; leak the image (the test
6791        // process exits straight after).
6792        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
6793        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
6794        let lazy = Rete::open_ranged_lazy(reader).unwrap();
6795
6796        let brute = |s: Option<&str>, p: Option<&str>, o: Option<&str>| {
6797            let mut v: Vec<(String, String, String)> = triples
6798                .iter()
6799                .filter(|(a, b, c)| {
6800                    s.is_none_or(|x| x == a) && p.is_none_or(|x| x == b) && o.is_none_or(|x| x == c)
6801                })
6802                .cloned()
6803                .collect();
6804            v.sort();
6805            v
6806        };
6807        // Existing + absent terms in every position (and unbound) — 4×4×4 shapes.
6808        let sv = [
6809            None,
6810            Some("<http://ex/s/0007>"),
6811            Some("<http://ex/s/0130>"),
6812            Some("<http://ex/s/9999>"),
6813        ];
6814        let pv = [
6815            None,
6816            Some("<http://ex/p/3>"),
6817            Some("<http://ex/p/6>"),
6818            Some("<http://ex/p/999>"),
6819        ];
6820        let ov = [
6821            None,
6822            Some("<http://ex/o/0000>"),
6823            Some("<http://ex/o/0012>"),
6824            Some("<http://ex/o/9999>"),
6825        ];
6826        for &s in &sv {
6827            for &p in &pv {
6828                for &o in &ov {
6829                    let mut e = eager.query(s, p, o);
6830                    e.sort();
6831                    let mut l = lazy.query(s, p, o);
6832                    l.sort();
6833                    let r = brute(s, p, o);
6834                    assert_eq!(e, r, "eager {s:?} {p:?} {o:?}");
6835                    assert_eq!(l, r, "lazy {s:?} {p:?} {o:?} — synopsis over-pruned");
6836                }
6837            }
6838        }
6839        assert!(!lazy.index_incomplete(), "no lazy fetch failed");
6840    }
6841
6842    /// Byte-0 / polyglot experiment: a real `.rete` embedded behind a large HTML
6843    /// shell (so byte 0 is `<`, not `RETE`) still opens and queries LAZILY through
6844    /// an `OffsetReader`, touching only the graph's bytes and never the shell.
6845    #[test]
6846    fn polyglot_offset_reads_lazily() {
6847        use crate::reader::{
6848            detect_polyglot_base, CountingReader, OffsetReader, SliceReader, POLYGLOT_DIGITS,
6849            POLYGLOT_MARKER,
6850        };
6851
6852        // A real .rete image, hidden behind a 50 KB HTML shell that carries the
6853        // polyglot base-offset marker in its first bytes (a browser-ignored
6854        // comment). The .rete begins right after the shell.
6855        let image = build_image();
6856        let mut shell = Vec::new();
6857        shell.extend_from_slice(b"<!DOCTYPE html><html><head><!--");
6858        shell.extend_from_slice(POLYGLOT_MARKER);
6859        let digits_at = shell.len();
6860        shell.extend_from_slice(&[b'0'; POLYGLOT_DIGITS]); // patched below
6861        shell.extend_from_slice(b"--></head><body>a web page</body></html>\n");
6862        shell.resize(50_000, b' '); // >> the .rete, to prove the shell is untouched
6863        let base = shell.len() as u64;
6864        let digits = format!("{base:0width$}", width = POLYGLOT_DIGITS);
6865        shell[digits_at..digits_at + POLYGLOT_DIGITS].copy_from_slice(digits.as_bytes());
6866
6867        let mut poly = shell;
6868        poly.extend_from_slice(&image);
6869
6870        // byte 0 is a web page, not a .rete...
6871        assert_ne!(
6872            &poly[0..4],
6873            b"RETE",
6874            "polyglot must not start with the magic"
6875        );
6876        // ...but the marker in the first header window points at the embedded .rete.
6877        let detected = detect_polyglot_base(&poly[..crate::header::HEADER_LEN]).unwrap();
6878        assert_eq!(detected, base);
6879
6880        // Open it LAZILY through the offset shim and query it.
6881        let leaked: &'static [u8] = Box::leak(poly.into_boxed_slice());
6882        let counting = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
6883        let lazy = Rete::open_ranged_lazy(OffsetReader::new(counting.clone(), detected)).unwrap();
6884
6885        // The polyglot must answer exactly like the plain .rete it embeds.
6886        let plain = Rete::open(&image).unwrap();
6887        let mut want = plain.query(None, None, None);
6888        want.sort();
6889        let mut got = lazy.query(None, None, None);
6890        got.sort();
6891        assert_eq!(
6892            got, want,
6893            "polyglot lazy query differs from the plain .rete"
6894        );
6895        assert!(!got.is_empty(), "expected some triples");
6896        assert!(!lazy.index_incomplete(), "a lazy fetch failed");
6897
6898        // It was LAZY: it read fewer bytes than the HTML shell alone, i.e. it
6899        // never touched the prefix — only the embedded .rete's own bytes.
6900        let read = counting.bytes_read();
6901        eprintln!(
6902            "polyglot lazy read: HTML shell {base} B + .rete image {} B; \
6903             the query touched only {read} B (never the shell).",
6904            image.len()
6905        );
6906        assert!(
6907            read < base,
6908            "read {read} bytes; the HTML shell alone is {base} — the reader \
6909             touched the prefix instead of only the embedded .rete"
6910        );
6911    }
6912
6913    /// Build a small file whose objects are string literals, **with** a text
6914    /// index, and return `(image, triples)`. Shared by the text-index tests.
6915    #[cfg(test)]
6916    fn build_text_indexed(triples: &[(String, String, String)]) -> Vec<u8> {
6917        let mut db = DictionaryBuilder::new();
6918        for (s, p, o) in triples {
6919            db.observe(s, p, o);
6920        }
6921        let dict = db.build();
6922        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6923        let mut id_triples: Vec<(u32, u32, u32)> = Vec::with_capacity(triples.len());
6924        for (s, p, o) in triples {
6925            let t = dict.encode(s, p, o).unwrap();
6926            ib.push(t);
6927            id_triples.push(t);
6928        }
6929        let index = ib.build();
6930        let text_index = compute_text_index(&dict, &id_triples);
6931        assert!(
6932            !text_index.is_empty(),
6933            "literals should produce a text index"
6934        );
6935        write_dataset_with_metadata(&dict, &index, &[], false, &[], 0, &[], &text_index)
6936    }
6937
6938    /// A `--text-index` build round-trips: `text_search` returns exactly the
6939    /// subjects whose literals contain the queried word(s), with AND across words
6940    /// and token-prefix — matching a brute-force scan of the literals.
6941    #[test]
6942    fn text_index_eager_matches_brute_force() {
6943        let triples: Vec<(String, String, String)> = vec![
6944            (
6945                "<http://ex/s0>",
6946                "<http://ex/label>",
6947                "\"alpha glucose phosphate\"",
6948            ),
6949            ("<http://ex/s1>", "<http://ex/label>", "\"beta Glucose\""),
6950            ("<http://ex/s2>", "<http://ex/label>", "\"gamma fructose\""),
6951            (
6952                "<http://ex/s3>",
6953                "<http://ex/note>",
6954                "\"einstein relativity\"",
6955            ),
6956            (
6957                "<http://ex/s4>",
6958                "<http://ex/ref>",
6959                "<http://ex/not-a-literal>",
6960            ),
6961        ]
6962        .into_iter()
6963        .map(|(s, p, o)| (s.to_string(), p.to_string(), o.to_string()))
6964        .collect();
6965        let bytes = build_text_indexed(&triples);
6966        let rete = Rete::open(&bytes).unwrap();
6967        assert!(rete.has_text_index());
6968
6969        // Brute-force reference: subjects whose literal objects contain all words.
6970        let brute = |words: &[&str]| -> Vec<String> {
6971            let mut v: Vec<String> = triples
6972                .iter()
6973                .filter(|(_, _, o)| {
6974                    crate::terms::is_literal(o)
6975                        && words.iter().all(|w| {
6976                            let wl = w.to_lowercase();
6977                            crate::terms::literal_lexical(o)
6978                                .unwrap()
6979                                .split(|c: char| !c.is_alphanumeric())
6980                                .any(|t| t.to_lowercase() == wl)
6981                        })
6982                })
6983                .map(|(s, _, _)| s.clone())
6984                .collect();
6985            v.sort();
6986            v.dedup();
6987            v
6988        };
6989
6990        let mut got = rete.text_search(&["glucose"], None, 0);
6991        got.sort();
6992        assert_eq!(got, brute(&["glucose"]), "case-insensitive single word");
6993
6994        // AND across two words: only s0 has both.
6995        let mut got = rete.text_search(&["glucose", "phosphate"], None, 0);
6996        got.sort();
6997        assert_eq!(got, brute(&["glucose", "phosphate"]));
6998
6999        // A word nobody has → empty.
7000        assert!(rete.text_search(&["zzznope"], None, 0).is_empty());
7001
7002        // Token-prefix: "ein…" matches "einstein".
7003        let got = rete.text_search(&[], Some("ein"), 0);
7004        assert_eq!(got, vec!["<http://ex/s3>".to_string()]);
7005
7006        // No text index → empty, has_text_index() false.
7007        let mut db = DictionaryBuilder::new();
7008        for (s, p, o) in &triples {
7009            db.observe(s, p, o);
7010        }
7011        let dict = db.build();
7012        let mut ib = GraphIndexBuilder::new();
7013        for (s, p, o) in &triples {
7014            ib.push(dict.encode(s, p, o).unwrap());
7015        }
7016        let plain = write_dataset(&dict, &ib.build(), &[], false, &[], 0);
7017        let plain_rete = Rete::open(&plain).unwrap();
7018        assert!(!plain_rete.has_text_index());
7019        assert!(plain_rete.text_search(&["glucose"], None, 0).is_empty());
7020    }
7021
7022    /// The lazy/remote path returns the same subjects as the eager path **and**
7023    /// faults only the token table + the queried posting list — never the whole
7024    /// postings blob. A `CountingReader` proves the byte budget stays small.
7025    #[test]
7026    fn text_index_lazy_faults_only_queried_postings() {
7027        use crate::reader::{CountingReader, SliceReader};
7028        // Many subjects so the postings blob is large relative to one posting:
7029        // every subject carries "common", but only a few carry "rare".
7030        let mut triples: Vec<(String, String, String)> = (0..300u32)
7031            .map(|i| {
7032                (
7033                    format!("<http://ex/s/{i:04}>"),
7034                    "<http://ex/label>".to_string(),
7035                    format!("\"common word number {i}\""),
7036                )
7037            })
7038            .collect();
7039        for i in [3u32, 77, 250] {
7040            triples.push((
7041                format!("<http://ex/s/{i:04}>"),
7042                "<http://ex/tag>".to_string(),
7043                "\"raretoken\"".to_string(),
7044            ));
7045        }
7046        let bytes = build_text_indexed(&triples);
7047        let eager = Rete::open(&bytes).unwrap();
7048        let mut want = eager.text_search(&["raretoken"], None, 0);
7049        want.sort();
7050        assert_eq!(want.len(), 3);
7051
7052        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7053        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7054        let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
7055        // Bytes pulled by the open itself (dict dirs, index dirs, named graphs) —
7056        // the text index is deferred and not touched yet.
7057        let before = reader.bytes_read();
7058        let mut got = lazy.text_search(&["raretoken"], None, 0);
7059        got.sort();
7060        assert_eq!(got, want, "lazy search matches eager");
7061        let pulled = reader.bytes_read() - before;
7062        // The search faulted the token table + the one "raretoken" posting; it must
7063        // be far less than the whole text-index section (300 "common" postings).
7064        let ti_len = eager.header().text_index_len;
7065        assert!(
7066            pulled < ti_len,
7067            "search pulled {pulled} B but the section is {ti_len} B — faulted too much"
7068        );
7069        assert!(!lazy.index_incomplete());
7070    }
7071
7072    /// `SearchView` answers exactly what a full open answers, having read
7073    /// strictly less: it skips the permutation tile directories and — the part
7074    /// that actually costs on a literal-heavy graph — the object-only
7075    /// dictionary's chunk directory.
7076    #[test]
7077    fn search_view_matches_full_open_for_far_fewer_bytes() {
7078        use crate::reader::{CountingReader, SliceReader};
7079        // Long object literals so the object-only dictionary (and its chunk
7080        // directory) dominates the file, as on a graph that stores abstracts.
7081        let filler = "lorem ipsum dolor sit amet consectetur adipiscing elit ".repeat(20);
7082        let mut triples: Vec<(String, String, String)> = (0..300u32)
7083            .map(|i| {
7084                (
7085                    format!("<http://ex/s/{i:04}>"),
7086                    "<http://ex/abstract>".to_string(),
7087                    format!("\"{filler} number {i}\""),
7088                )
7089            })
7090            .collect();
7091        for i in [3u32, 77, 250] {
7092            triples.push((
7093                format!("<http://ex/s/{i:04}>"),
7094                "<http://ex/tag>".to_string(),
7095                "\"raretoken\"".to_string(),
7096            ));
7097        }
7098        let bytes = build_text_indexed(&triples);
7099        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7100
7101        let full_reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7102        let full = Rete::open_ranged_lazy(full_reader.clone()).unwrap();
7103        let mut want = full.text_search(&["raretoken"], None, 0);
7104        want.sort();
7105        assert_eq!(want.len(), 3);
7106
7107        let search_reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7108        let view = SearchView::open_ranged(search_reader.clone()).unwrap();
7109        assert!(view.has_text_index());
7110        let mut got = view.text_search(&["raretoken"], None, 0);
7111        got.sort();
7112        assert_eq!(got, want, "search view matches the full open");
7113        assert!(
7114            search_reader.bytes_read() < full_reader.bytes_read(),
7115            "search read {} B, full open read {} B — the narrow open bought nothing",
7116            search_reader.bytes_read(),
7117            full_reader.bytes_read()
7118        );
7119
7120        // A file without a text index says so from the header alone, and the
7121        // label-prefix mode still works through the same view.
7122        let mut prefix_hits = view.prefix_search("", 5);
7123        prefix_hits.sort();
7124        assert!(!prefix_hits.is_empty() || !view.has_pyramid());
7125    }
7126
7127    /// Nothing records a dictionary chunk directory's byte length, so a ranged
7128    /// open probes for it. That probe must cost about ONE directory, not several
7129    /// — it used to re-read the whole prefix on every round, so the sum was
7130    /// ~2x the last (already over-sized) read: 537 MB of range reads to fetch
7131    /// epfl-infoscience's 234 MB object-only directory. Here the same probe runs
7132    /// against a file whose object literals are long enough that its directory
7133    /// is well past the 8 KiB header prefix, and must (a) stay inside the
7134    /// append-only bound of 2x the directory and (b) beat what the old
7135    /// re-reading loop would have spent.
7136    #[test]
7137    fn dict_chunk_directory_probe_costs_about_one_directory() {
7138        use crate::reader::{CountingReader, SliceReader};
7139        // Long literals that share a 3 KiB prefix with their neighbour, each
7140        // ending in a 1 KiB tail of its own: the WKT-polygon / JSON-blob shape
7141        // (#198's weak case). The directory stores the SHORTEST SEPARATOR per
7142        // chunk, and a separator has to reproduce the whole shared prefix — so
7143        // these keys are still ~3 KiB and the directory still runs well past
7144        // the 8 KiB header prefix, which is what gives the probe something to
7145        // measure.
7146        //
7147        // This fixture used to put the varying bytes FIRST (`"{i:04} xxxx…"`).
7148        // That shape is the one separators annihilate: its keys collapse to ~4
7149        // bytes, the whole directory to 192 B, it fits the header prefix, and
7150        // the probe never runs — the assertion below then fails naming itself.
7151        // Keep the shared prefix.
7152        let head = "x".repeat(3000);
7153        let triples: Vec<(String, String, String)> = (0..2000u32)
7154            .map(|i| {
7155                let tail = char::from(b'a' + (i % 26) as u8).to_string().repeat(1000);
7156                (
7157                    format!("<http://ex/s/{i:04}>"),
7158                    "<http://ex/abstract>".to_string(),
7159                    format!("\"{head}{i:04}{tail}\""),
7160                )
7161            })
7162            .collect();
7163        let mut db = DictionaryBuilder::new();
7164        for (s, p, o) in &triples {
7165            db.observe(s, p, o);
7166        }
7167        let dict = db.build();
7168        let mut ib = GraphIndexBuilder::new();
7169        for (s, p, o) in &triples {
7170            ib.push(dict.encode(s, p, o).unwrap());
7171        }
7172        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7173
7174        let reader = CountingReader::new(SliceReader::new(&bytes));
7175        let header = Header::from_bytes(&reader.read_at(0, HEADER_LEN as u64).unwrap()).unwrap();
7176        // Section 2 is object-only: the one carrying the long literals.
7177        let section = locate_container_section_ranged(
7178            &reader,
7179            header.dictionary_offset,
7180            header.dictionary_len,
7181            2,
7182            4,
7183        )
7184        .unwrap();
7185
7186        let before = reader.bytes_read();
7187        let (_meta, entries) = read_dict_dir_ranged(&reader, section).unwrap();
7188        let spent = reader.bytes_read() - before;
7189
7190        // The first chunk body starts where the directory ends.
7191        let dir_end = entries[0].start;
7192        let head = reader.read_at(section.offset, 64).unwrap();
7193        let (header_len, n0) = read_uvarint(&head).unwrap();
7194        let dir_start = n0 as u64 + header_len;
7195        let dir_len = dir_end - dir_start;
7196        let dir_total = section.len - dir_start;
7197        assert!(
7198            dir_start + dir_len > 8192,
7199            "directory fits the header prefix ({dir_len} B) — the probe never runs"
7200        );
7201
7202        // What the previous loop spent: every round re-read from the start.
7203        let mut old = 0u64;
7204        let mut p = 4096u64.min(dir_total).max(1);
7205        loop {
7206            let got = p.min(dir_total);
7207            old += got;
7208            if got >= dir_len || p >= dir_total {
7209                break;
7210            }
7211            p = p.saturating_mul(2).min(dir_total);
7212        }
7213        assert!(
7214            spent <= dir_len * 2,
7215            "probe read {spent} B for a {dir_len} B directory — past the append-only bound"
7216        );
7217        assert!(
7218            spent < old,
7219            "probe read {spent} B; the re-reading loop it replaced spent {old} B"
7220        );
7221    }
7222
7223    // ---------------------------------------------------------------------
7224    // Separator routing keys (#198 option (d))
7225    // ---------------------------------------------------------------------
7226
7227    /// A verbatim-keyed twin of a chunked dictionary section: same header, same
7228    /// chunking, byte-identical compressed bodies — only every directory key
7229    /// replaced by the chunk's actual first term. That is exactly what
7230    /// `encode_chunked_dict_section` wrote before separator keys, so it is the
7231    /// control for every comparison below (and for the proptest).
7232    fn verbatim_keyed_twin(payload: &[u8], codec: u8) -> Vec<u8> {
7233        let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
7234        let (header_len, n0) = read_uvarint(payload).unwrap();
7235        let mut out = Vec::new();
7236        write_uvarint(&mut out, header_len);
7237        out.extend_from_slice(&payload[n0..n0 + header_len as usize]);
7238        write_uvarint(&mut out, entries.len() as u64);
7239        let mut prev_run = 0usize;
7240        for e in &entries {
7241            let body = decompress(codec, &payload[e.start as usize..e.end as usize]).unwrap();
7242            let first = crate::dict::run_first_term(&body, 0).unwrap();
7243            write_uvarint(&mut out, (e.first_run - prev_run) as u64);
7244            write_uvarint(&mut out, first.len() as u64);
7245            out.extend_from_slice(&first);
7246            write_uvarint(&mut out, e.end - e.start);
7247            prev_run = e.first_run;
7248        }
7249        for e in &entries {
7250            out.extend_from_slice(&payload[e.start as usize..e.end as usize]);
7251        }
7252        out
7253    }
7254
7255    /// Each chunk's (first term, last term), decoded from the bodies.
7256    fn chunk_bounds_of(payload: &[u8], codec: u8) -> Vec<(Vec<u8>, Vec<u8>)> {
7257        let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
7258        entries
7259            .iter()
7260            .map(|e| {
7261                let body = decompress(codec, &payload[e.start as usize..e.end as usize]).unwrap();
7262                (
7263                    crate::dict::run_first_term(&body, 0).unwrap(),
7264                    crate::dict::run_last_term(&body, 0, body.len()).unwrap(),
7265                )
7266            })
7267            .collect()
7268    }
7269
7270    /// A term set built to hit every shape the corpus has: plain IRIs, literals
7271    /// sharing a long prefix with their neighbour (separators are LONG there —
7272    /// the WKT/`ohm-full` weak case), literals that diverge in their first bytes
7273    /// (separators are tiny — the `epfl-infoscience` case), literals larger than
7274    /// `DICT_CHUNK_BUDGET` on their own, and multibyte UTF-8.
7275    fn adversarial_terms() -> Vec<String> {
7276        let mut terms: Vec<String> = Vec::new();
7277        for i in 0..6_000 {
7278            terms.push(format!("<http://example.org/work/{i:06}>"));
7279        }
7280        let shared = "X".repeat(900);
7281        for i in 0..4_000 {
7282            terms.push(format!("\"{shared}-{i:05}-tail\""));
7283        }
7284        let deep = "Y".repeat(3_000); // shared prefix far past any short key
7285        for i in 0..120 {
7286            terms.push(format!("\"{deep}{i:03}\""));
7287        }
7288        for i in 0..6 {
7289            // one literal larger than the 64 KiB budget: a chunk of one run
7290            terms.push(format!("\"BIG-{i:02}-{}\"", "Z".repeat(90_000)));
7291        }
7292        for i in 0..800 {
7293            terms.push(format!(
7294                "\"h\u{e9}llo-{}-\u{1F600}{i:04}\"",
7295                "\u{e9}".repeat(40)
7296            ));
7297        }
7298        for i in 0..2_000 {
7299            terms.push(format!("\"{i:05}-{}\"", "T".repeat(1_800)));
7300        }
7301        terms.sort();
7302        terms.dedup();
7303        terms
7304    }
7305
7306    fn chunked_section_of(terms: &[String]) -> Vec<u8> {
7307        let mut b = crate::dict::DictSectionBuilder::new().with_restart_interval(16);
7308        for t in terms {
7309            b.push(t.clone());
7310        }
7311        b.build()
7312    }
7313
7314    /// The invariant, asserted against the real chunk contents: the stored key
7315    /// of chunk `i` is strictly above chunk `i-1`'s LAST term and at most chunk
7316    /// `i`'s own first term, chunk 0's key is empty, and the keys ascend — the
7317    /// three facts `ChunkedSection::id`'s `partition_point` relies on.
7318    ///
7319    /// Plus the anti-regression the rename exists for: on a section like this
7320    /// the key must NOT be the first term. A writer that quietly went back to
7321    /// storing terms would pass every other test in this file — dumps and
7322    /// `term(id)` route by `first_run` and never read the key.
7323    #[test]
7324    fn chunk_directory_keys_are_separators_not_terms() {
7325        let terms = adversarial_terms();
7326        let raw = chunked_section_of(&terms);
7327        let payload = encode_chunked_dict_section(&raw, CODEC_NONE);
7328        let (_meta, entries) = parse_chunked_dict_dir(&payload, payload.len() as u64).unwrap();
7329        let bounds = chunk_bounds_of(&payload, CODEC_NONE);
7330        assert!(
7331            entries.len() > 20,
7332            "need a many-chunk section, got {}",
7333            entries.len()
7334        );
7335
7336        assert!(entries[0].key.is_empty(), "chunk 0 needs no separator");
7337        let mut key_bytes = 0usize;
7338        let mut term_bytes = 0usize;
7339        let mut differ = 0usize;
7340        for (i, e) in entries.iter().enumerate() {
7341            key_bytes += e.key.len();
7342            term_bytes += bounds[i].0.len();
7343            if e.key != bounds[i].0 {
7344                differ += 1;
7345            }
7346            assert!(
7347                e.key <= bounds[i].0,
7348                "chunk {i}: key must be <= its own first term"
7349            );
7350            if i > 0 {
7351                assert!(
7352                    e.key.as_slice() > bounds[i - 1].1.as_slice(),
7353                    "chunk {i}: key must be > the previous chunk's last term"
7354                );
7355                assert!(
7356                    e.key > entries[i - 1].key,
7357                    "chunk {i}: keys must ascend for partition_point"
7358                );
7359                // shortest: one byte past the divergence from the predecessor
7360                assert_eq!(
7361                    e.key,
7362                    crate::dict::shortest_separator(&bounds[i - 1].1, &bounds[i].0),
7363                    "chunk {i}: key is not the SHORTEST separator"
7364                );
7365            }
7366        }
7367        assert!(
7368            differ > 0,
7369            "every key equalled its first term — the writer stores terms again"
7370        );
7371        assert!(
7372            key_bytes * 4 < term_bytes,
7373            "separators bought almost nothing: {key_bytes} B vs {term_bytes} B of first terms"
7374        );
7375        eprintln!(
7376            "chunks={} first-term keys={term_bytes} B  separator keys={key_bytes} B ({:.1}x)",
7377            entries.len(),
7378            term_bytes as f64 / key_bytes.max(1) as f64
7379        );
7380    }
7381
7382    /// A separator-keyed section answers **identically** to the verbatim-keyed
7383    /// one built over the same bodies, under the same unmodified reader: every
7384    /// id, every term, and every boundary probe — the first and last term of
7385    /// each chunk, one byte either side of each, strings that fall strictly
7386    /// *between* two chunks, truncations, the empty string, and probes below
7387    /// and above the whole section.
7388    ///
7389    /// Probing `id(term)` is the entire point. A broken key (a truncation, a
7390    /// hash) leaves `term(id)`, `dump` and `export` byte-perfect — they route by
7391    /// `first_run` — and only lookups lie.
7392    #[test]
7393    fn separator_keys_route_identically_to_verbatim_first_terms() {
7394        let terms = adversarial_terms();
7395        let raw = chunked_section_of(&terms);
7396        let sep_payload = encode_chunked_dict_section(&raw, CODEC_NONE);
7397        let ver_payload = verbatim_keyed_twin(&sep_payload, CODEC_NONE);
7398
7399        // Same chunking, byte-identical bodies — only the keys differ.
7400        let (_ms, es) = parse_chunked_dict_dir(&sep_payload, sep_payload.len() as u64).unwrap();
7401        let (_mv, ev) = parse_chunked_dict_dir(&ver_payload, ver_payload.len() as u64).unwrap();
7402        assert_eq!(es.len(), ev.len());
7403        for (a, b) in es.iter().zip(ev.iter()) {
7404            assert_eq!(a.first_run, b.first_run);
7405            assert_eq!(
7406                &sep_payload[a.start as usize..a.end as usize],
7407                &ver_payload[b.start as usize..b.end as usize],
7408                "chunk bodies must be identical"
7409            );
7410        }
7411        assert!(
7412            sep_payload.len() < ver_payload.len(),
7413            "separator payload is not smaller"
7414        );
7415
7416        let sec_s = decode_chunked_dict_section(
7417            &sep_payload,
7418            CODEC_NONE,
7419            crate::chunk_cache::ChunkCache::unlimited_arc(),
7420            0,
7421        )
7422        .unwrap();
7423        let sec_v = decode_chunked_dict_section(
7424            &ver_payload,
7425            CODEC_NONE,
7426            crate::chunk_cache::ChunkCache::unlimited_arc(),
7427            0,
7428        )
7429        .unwrap();
7430
7431        // 1. every present term → the same, correct id
7432        for (i, t) in terms.iter().enumerate() {
7433            let want = Some(i as u32 + 1);
7434            assert_eq!(sec_v.id(t), want, "verbatim id() lost a term");
7435            assert_eq!(sec_s.id(t), want, "separator id() lost a term");
7436        }
7437        // 2. every id → the same term
7438        for i in 0..terms.len() as u32 {
7439            assert_eq!(sec_s.term(i + 1), sec_v.term(i + 1));
7440            assert_eq!(
7441                sec_s.term(i + 1).as_deref(),
7442                Some(terms[i as usize].as_str())
7443            );
7444        }
7445        // 3. boundary probes, from the real chunk bounds
7446        let bounds = chunk_bounds_of(&sep_payload, CODEC_NONE);
7447        let mut probes: Vec<String> = Vec::new();
7448        let push = |bytes: &[u8], probes: &mut Vec<String>| {
7449            if let Ok(s) = std::str::from_utf8(bytes) {
7450                probes.push(s.to_string());
7451            }
7452        };
7453        for (first, last) in &bounds {
7454            for base in [first, last] {
7455                push(base, &mut probes); // present
7456                let mut after = base.clone();
7457                after.push(0x01); // immediately after — between chunks when base = last
7458                push(&after, &mut probes);
7459                let mut far = base.clone();
7460                far.push(b'~');
7461                push(&far, &mut probes);
7462                if !base.is_empty() {
7463                    push(&base[..base.len() - 1], &mut probes); // truncated
7464                    let mut down = base.clone();
7465                    *down.last_mut().unwrap() = down.last().unwrap().wrapping_sub(1);
7466                    push(&down, &mut probes);
7467                    let mut up = base.clone();
7468                    *up.last_mut().unwrap() = up.last().unwrap().wrapping_add(1);
7469                    push(&up, &mut probes);
7470                }
7471            }
7472        }
7473        // 4. the stored separators themselves, and the ends of the world
7474        for e in &es {
7475            push(&e.key, &mut probes);
7476            let mut plus = e.key.clone();
7477            plus.push(0);
7478            push(&plus, &mut probes);
7479        }
7480        probes.push(String::new()); // the empty key, i.e. chunk 0's
7481        probes.push("\u{1}".to_string()); // below every term
7482        probes.push("\u{10FFFF}\u{10FFFF}".to_string()); // above every term
7483        probes.push(terms.first().unwrap().clone());
7484        probes.push(terms.last().unwrap().clone());
7485
7486        let mut hits = 0usize;
7487        for p in &probes {
7488            let (a, b) = (sec_v.id(p), sec_s.id(p));
7489            assert_eq!(
7490                a,
7491                b,
7492                "probe diverged: {:?}",
7493                p.get(..p.len().min(48)).unwrap_or(p.as_str())
7494            );
7495            hits += usize::from(a.is_some());
7496        }
7497        assert!(
7498            probes.len() > 500 && hits > 50,
7499            "probe set is too thin: {} probes, {hits} of them present",
7500            probes.len()
7501        );
7502        eprintln!(
7503            "{} boundary probes, {hits} present, all identical",
7504            probes.len()
7505        );
7506    }
7507
7508    /// The same claim as a **property**: for arbitrary term sets and arbitrary
7509    /// probes, a separator-keyed section and the verbatim-keyed twin built from
7510    /// the same input are indistinguishable through the reader. The fixed test
7511    /// above picks the shapes I thought of; this one shrinks a counterexample
7512    /// out of the ones I did not.
7513    mod separator_props {
7514        use super::*;
7515        use proptest::prelude::*;
7516
7517        /// Sorted, deduplicated term sets mixing the three shapes that decide a
7518        /// separator's length: short IRIs, literals whose payload comes BEFORE
7519        /// the discriminating bytes (long shared prefixes ⇒ long separators),
7520        /// and literals that diverge immediately (⇒ one-byte separators). Sizes
7521        /// straddle `DICT_CHUNK_BUDGET`, so single- and many-chunk sections both
7522        /// occur.
7523        fn term_set() -> impl Strategy<Value = Vec<String>> {
7524            prop::collection::vec(("[a-c]{1,6}", 0u8..3, 300usize..1200), 60..250).prop_map(
7525                |specs| {
7526                    let mut terms: Vec<String> = specs
7527                        .into_iter()
7528                        .enumerate()
7529                        .map(|(i, (core, kind, pad))| {
7530                            let block = "z".repeat(pad);
7531                            match kind {
7532                                0 => format!("<http://ex/{core}/{i:05}>"),
7533                                1 => format!("\"{block}{core}{i:05}\""),
7534                                _ => format!("\"{core}{i:05}{block}\""),
7535                            }
7536                        })
7537                        .collect();
7538                    terms.sort();
7539                    terms.dedup();
7540                    terms
7541                },
7542            )
7543        }
7544
7545        proptest! {
7546            #[test]
7547            fn prop_separator_keyed_section_answers_like_verbatim(
7548                terms in term_set(),
7549                extra in prop::collection::vec(any::<String>(), 0..8),
7550            ) {
7551                let raw = chunked_section_of(&terms);
7552                let sep = encode_chunked_dict_section(&raw, CODEC_NONE);
7553                let ver = verbatim_keyed_twin(&sep, CODEC_NONE);
7554                prop_assert!(sep.len() <= ver.len());
7555
7556                // The separator invariant, against the real chunk contents.
7557                let (_m, es) = parse_chunked_dict_dir(&sep, sep.len() as u64).unwrap();
7558                let bounds = chunk_bounds_of(&sep, CODEC_NONE);
7559                prop_assert!(es[0].key.is_empty());
7560                for i in 1..es.len() {
7561                    prop_assert!(es[i].key.as_slice() > bounds[i - 1].1.as_slice());
7562                    prop_assert!(es[i].key <= bounds[i].0);
7563                    prop_assert!(es[i].key > es[i - 1].key);
7564                }
7565
7566                let sec_s = decode_chunked_dict_section(
7567                    &sep,
7568                    CODEC_NONE,
7569                    crate::chunk_cache::ChunkCache::unlimited_arc(),
7570                    0,
7571                )
7572                .unwrap();
7573                let sec_v = decode_chunked_dict_section(
7574                    &ver,
7575                    CODEC_NONE,
7576                    crate::chunk_cache::ChunkCache::unlimited_arc(),
7577                    0,
7578                )
7579                .unwrap();
7580
7581                for (i, t) in terms.iter().enumerate() {
7582                    prop_assert_eq!(sec_s.id(t), Some(i as u32 + 1));
7583                    prop_assert_eq!(sec_v.id(t), Some(i as u32 + 1));
7584                    prop_assert_eq!(sec_s.term(i as u32 + 1), sec_v.term(i as u32 + 1));
7585                }
7586
7587                // Probes: arbitrary strings, plus mutations of every boundary.
7588                let mut probes: Vec<String> = extra;
7589                probes.push(String::new());
7590                for (first, last) in &bounds {
7591                    for base in [first, last] {
7592                        if let Ok(s) = std::str::from_utf8(base) {
7593                            probes.push(s.to_string());
7594                            probes.push(format!("{s}\u{1}"));
7595                            probes.push(s[..s.len() - s.chars().next_back()
7596                                .map(char::len_utf8).unwrap_or(0)].to_string());
7597                        }
7598                    }
7599                }
7600                for e in &es {
7601                    if let Ok(s) = std::str::from_utf8(&e.key) {
7602                        probes.push(s.to_string());
7603                    }
7604                }
7605                for p in &probes {
7606                    prop_assert_eq!(sec_s.id(p), sec_v.id(p), "probe diverged: {:?}", p);
7607                }
7608            }
7609        }
7610    }
7611
7612    /// The TEXT_INDEX section is inside the content hash: a freshly built
7613    /// text-indexed file must pass `verify()`, and flipping a byte inside the
7614    /// section must break it. (Regression: `verify()` once rebuilt the hash
7615    /// without the text index, so every `--text-index` file failed as corrupt.)
7616    #[test]
7617    fn text_index_is_tamper_evident_and_verifies() {
7618        let triples: Vec<(String, String, String)> = vec![(
7619            "<http://ex/s0>".to_string(),
7620            "<http://ex/label>".to_string(),
7621            "\"alpha glucose phosphate\"".to_string(),
7622        )];
7623        let bytes = build_text_indexed(&triples);
7624        let header = Rete::open(&bytes).unwrap().header().clone();
7625        assert!(header.text_index_len > 0);
7626        assert!(verify(&bytes).unwrap(), "a text-indexed build must verify");
7627
7628        let mut tampered = bytes.clone();
7629        tampered[header.text_index_offset as usize] ^= 0xff;
7630        assert!(
7631            !verify(&tampered).unwrap(),
7632            "tampering with the text index must break verify()"
7633        );
7634    }
7635
7636    /// End-to-end win: on a remote (range-read) file, a lookup whose routed tile
7637    /// is ruled out by a bound secondary fetches **fewer bytes** with the synopsis
7638    /// than without it — and the answer is identical (empty) either way.
7639    #[test]
7640    fn synopsis_cuts_remote_fetch_bytes() {
7641        use crate::header::FLAG_TILE_SYNOPSIS;
7642        use crate::reader::{CountingReader, SliceReader};
7643
7644        // Zero-padded terms ⇒ dictionary ids are monotonic in i; subject s_i pairs
7645        // only with object o_i, so an OSP tile (routed by object) holds a
7646        // contiguous subject range — a subject from a far tile is provably absent.
7647        let triples: Vec<(String, String, String)> = (0..400u32)
7648            .map(|i| {
7649                (
7650                    format!("<http://ex/s/{i:04}>"),
7651                    "<http://ex/p>".to_string(),
7652                    format!("<http://ex/o/{i:04}>"),
7653                )
7654            })
7655            .collect();
7656        let mut db = DictionaryBuilder::new();
7657        for (s, p, o) in &triples {
7658            db.observe(s, p, o);
7659        }
7660        let dict = db.build();
7661        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
7662        for (s, p, o) in &triples {
7663            ib.push(dict.encode(s, p, o).unwrap());
7664        }
7665        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7666
7667        // (s_0395, ?, o_0005): routes OSP by the (early) object, secondary = the
7668        // (late) subject — outside that tile's subject range, so the synopsis
7669        // prunes the one routed tile.
7670        let q = (Some("<http://ex/s/0395>"), None, Some("<http://ex/o/0005>"));
7671        // Measure the bytes the QUERY pulls (after open) — isolating the per-query
7672        // saving from the one-time synopsis trailer reads done at open, which a
7673        // persistent remote session amortizes over many queries.
7674        let query_bytes = |image: &[u8]| -> (u64, usize) {
7675            let leaked: &'static [u8] = Box::leak(image.to_vec().into_boxed_slice());
7676            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7677            let rete = Rete::open_ranged_lazy(reader.clone()).unwrap();
7678            let before = reader.bytes_read(); // after open (incl. trailer reads)
7679            let n = rete.query(q.0, q.1, q.2).len();
7680            assert!(!rete.index_incomplete());
7681            (reader.bytes_read() - before, n)
7682        };
7683
7684        let (on_bytes, on_n) = query_bytes(&bytes);
7685        // Same file with the synopsis flag cleared = an older reader's behavior.
7686        let mut off = bytes.clone();
7687        off[5] &= !FLAG_TILE_SYNOPSIS;
7688        let (off_bytes, off_n) = query_bytes(&off);
7689
7690        assert_eq!(on_n, 0, "the pair never co-occurs");
7691        assert_eq!(off_n, 0, "same answer without the synopsis");
7692        // Both pay the same dictionary-resolution bytes; the difference is the one
7693        // routed index tile that the synopsis skips (and the no-synopsis path
7694        // fetches only to have its zone map reject it).
7695        assert!(
7696            on_bytes < off_bytes,
7697            "synopsis skips the routed tile fetch: {on_bytes} < {off_bytes}"
7698        );
7699    }
7700
7701    /// A double-bound-object intersection (`?p P o1 ; P o2 ; label ?l`) — the
7702    /// shape whose REMOTE join strategy changed (scan + hash-join instead of
7703    /// probing each prefix row) — must return the SAME rows opened eagerly (in
7704    /// memory) and lazily (remote-style, `is_remote()` true). Strategy is a
7705    /// performance choice; the result multiset is invariant.
7706    #[test]
7707    fn double_bound_object_join_eager_matches_lazy() {
7708        use crate::reader::SliceReader;
7709        let occ = "<http://ex/occ>";
7710        let phys = "<http://ex/physicist>";
7711        let phil = "<http://ex/philosopher>";
7712        let label = "<http://www.w3.org/2000/01/rdf-schema#label>";
7713        // p00..p19 are physicists; p00..p09 are also philosophers (the answer).
7714        let mut triples: Vec<(String, String, String)> = Vec::new();
7715        for i in 0..20u32 {
7716            triples.push((format!("<http://ex/p/{i:02}>"), occ.into(), phys.into()));
7717            if i < 10 {
7718                triples.push((format!("<http://ex/p/{i:02}>"), occ.into(), phil.into()));
7719            }
7720            triples.push((
7721                format!("<http://ex/p/{i:02}>"),
7722                label.into(),
7723                format!("\"Name {i:02}\""),
7724            ));
7725        }
7726        let mut db = DictionaryBuilder::new();
7727        for (s, p, o) in &triples {
7728            db.observe(s, p, o);
7729        }
7730        let dict = db.build();
7731        let mut ib = GraphIndexBuilder::new().with_tile_budget(16);
7732        for (s, p, o) in &triples {
7733            ib.push(dict.encode(s, p, o).unwrap());
7734        }
7735        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7736
7737        let q = "SELECT ?l WHERE { \
7738            ?p <http://ex/occ> <http://ex/physicist> ; \
7739               <http://ex/occ> <http://ex/philosopher> ; \
7740               <http://www.w3.org/2000/01/rdf-schema#label> ?l }";
7741        let run = |rete: &Rete| -> Vec<String> {
7742            let (_, sols) = crate::eval_sparql(rete, q).unwrap();
7743            let mut v: Vec<String> = sols.iter().filter_map(|b| b.get("l").cloned()).collect();
7744            v.sort();
7745            v
7746        };
7747
7748        let eager_rows = run(&Rete::open(&bytes).unwrap());
7749        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7750        let lazy = Rete::open_ranged_lazy(std::sync::Arc::new(SliceReader::new(leaked))).unwrap();
7751        let lazy_rows = run(&lazy);
7752        assert!(!lazy.index_incomplete());
7753
7754        assert_eq!(eager_rows.len(), 10, "the 10 physicist∩philosopher labels");
7755        assert_eq!(eager_rows, lazy_rows, "eager and lazy must agree exactly");
7756    }
7757
7758    /// A dictionary big enough to split into multiple chunks per section must
7759    /// round-trip every id↔term mapping through the chunked (v0.2) encoding —
7760    /// including terms at chunk boundaries and absent near-misses.
7761    #[test]
7762    fn multi_chunk_dictionary_round_trips() {
7763        let mut db = DictionaryBuilder::new();
7764        let term = |i: u32| format!("<http://example.org/some/long/prefix/entity/{i:06}>");
7765        for i in 0..6000u32 {
7766            db.observe(&term(i), "<http://ex/p>", &term(i + 1));
7767        }
7768        let dict = db.build();
7769        let mut ib = GraphIndexBuilder::new();
7770        for i in 0..6000u32 {
7771            ib.push(
7772                dict.encode(&term(i), "<http://ex/p>", &term(i + 1))
7773                    .unwrap(),
7774            );
7775        }
7776        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7777        let rete = Rete::open(&bytes).unwrap();
7778        let d = rete.dictionary();
7779        assert_eq!(d.term_count(), dict.term_count());
7780        for i in (0..6000).step_by(97).chain([0, 1, 5999, 6000]) {
7781            let t = term(i);
7782            let sid = dict.subject_id(&t);
7783            assert_eq!(d.subject_id(&t), sid, "subject_id({t})");
7784            if let Some(id) = sid {
7785                assert_eq!(d.subject_term(id).as_deref(), Some(t.as_str()));
7786            }
7787            let oid = dict.object_id(&t);
7788            assert_eq!(d.object_id(&t), oid, "object_id({t})");
7789        }
7790        assert_eq!(d.subject_id("<http://example.org/absent>"), None);
7791        assert_eq!(d.predicate_id("<http://ex/p>"), Some(1));
7792        assert_eq!(d.predicate_term(1).as_deref(), Some("<http://ex/p>"));
7793    }
7794
7795    #[test]
7796    #[cfg(feature = "compression")]
7797    fn compression_shrinks_repetitive_data() {
7798        // Many triples sharing IRI prefixes — exactly what front-coding + zstd
7799        // should crush. The compressed file must be far smaller than the raw
7800        // term bytes, and still query correctly.
7801        let mut db = DictionaryBuilder::new();
7802        let triples: Vec<(String, String, String)> = (0..500)
7803            .map(|i| {
7804                (
7805                    format!("<http://example.org/entity/{i}>"),
7806                    "<http://example.org/p/relatedTo>".to_string(),
7807                    format!("<http://example.org/entity/{}>", (i + 1) % 500),
7808                )
7809            })
7810            .collect();
7811        for (s, p, o) in &triples {
7812            db.observe(s, p, o);
7813        }
7814        let dict = db.build();
7815        let mut ib = GraphIndexBuilder::new();
7816        for (s, p, o) in &triples {
7817            ib.push(dict.encode(s, p, o).unwrap());
7818        }
7819        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7820
7821        let raw: usize = triples
7822            .iter()
7823            .map(|(s, p, o)| s.len() + p.len() + o.len())
7824            .sum();
7825        assert!(
7826            bytes.len() < raw / 2,
7827            "expected strong compression: file {} vs raw terms {raw}",
7828            bytes.len()
7829        );
7830
7831        // Still queryable after compression.
7832        let rete = Rete::open(&bytes).unwrap();
7833        let r = rete.query(Some("<http://example.org/entity/0>"), None, None);
7834        assert_eq!(r.len(), 1);
7835        assert_eq!(r[0].2, "<http://example.org/entity/1>");
7836    }
7837
7838    fn big_file_with_pyramid() -> Vec<u8> {
7839        // A ring of 300 entities -> index dwarfs dict+meta.
7840        let triples: Vec<(String, String, String)> = (0..300)
7841            .map(|i| {
7842                (
7843                    format!("<http://ex/e{i}>"),
7844                    "<http://ex/next>".to_string(),
7845                    format!("<http://ex/e{}>", (i + 1) % 300),
7846                )
7847            })
7848            .collect();
7849        let mut db = DictionaryBuilder::new();
7850        for (s, p, o) in &triples {
7851            db.observe(s, p, o);
7852        }
7853        let dict = db.build();
7854        let ids: Vec<_> = triples
7855            .iter()
7856            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
7857            .collect();
7858        let mut ib = GraphIndexBuilder::new();
7859        for &t in &ids {
7860            ib.push(t);
7861        }
7862        let (meta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
7863        write_file(&dict, &ib.build(), false, &meta, levels)
7864    }
7865
7866    #[test]
7867    fn ranged_open_is_minimal_and_correct() {
7868        use crate::reader::{CountingReader, SliceReader};
7869        let bytes = big_file_with_pyramid();
7870
7871        let full = CountingReader::new(SliceReader::new(&bytes));
7872        let rete = Rete::open_ranged(&full).unwrap();
7873        // Full open touches at most 4 ranges (header, dict, index, meta).
7874        assert!(full.requests() <= 4, "requests = {}", full.requests());
7875        assert_eq!(
7876            rete.query(Some("<http://ex/e0>"), None, None)[0].2,
7877            "<http://ex/e1>"
7878        );
7879
7880        // Summary-only open skips the index → strictly fewer bytes than the file.
7881        let summ_reader = CountingReader::new(SliceReader::new(&bytes));
7882        let view = SummaryView::open_ranged(&summ_reader).unwrap().unwrap();
7883        assert!(!view.summary.is_empty());
7884        assert!(
7885            summ_reader.bytes_read() < bytes.len() as u64,
7886            "summary read {} of {} bytes",
7887            summ_reader.bytes_read(),
7888            bytes.len()
7889        );
7890        // And fewer than a full open, since it never fetched the index.
7891        assert!(summ_reader.bytes_read() < full.bytes_read());
7892    }
7893
7894    #[test]
7895    fn content_hash_is_set_and_verifies() {
7896        let bytes = build_image();
7897        let rete = Rete::open(&bytes).unwrap();
7898        assert_ne!(
7899            rete.header().content_hash,
7900            [0u8; 16],
7901            "hash must be populated"
7902        );
7903        assert!(verify(&bytes).unwrap(), "freshly built file verifies");
7904        assert!(
7905            verify_ranged(&crate::reader::SliceReader::new(&bytes)).unwrap(),
7906            "the streaming verify agrees"
7907        );
7908
7909        // Same data builds an identical hash (deterministic).
7910        assert_eq!(
7911            Rete::open(&build_image()).unwrap().header().content_hash,
7912            rete.header().content_hash
7913        );
7914
7915        // Corrupting a payload byte breaks verification.
7916        let mut tampered = bytes.clone();
7917        let last = tampered.len() - 5; // inside payload, before footer magic
7918        tampered[last] ^= 0xff;
7919        assert!(!verify(&tampered).unwrap());
7920        assert!(!verify_ranged(&crate::reader::SliceReader::new(&tampered)).unwrap());
7921        // A truncated file is an error, never a pass.
7922        let short = &bytes[..bytes.len() / 2];
7923        assert!(verify_ranged(&crate::reader::SliceReader::new(short)).is_err());
7924        assert!(verify_ranged(&crate::reader::SliceReader::new(&bytes[..10])).is_err());
7925    }
7926
7927    /// Build the standard 3-triple image with an opaque metadata payload.
7928    fn build_with_metadata(meta: &[u8]) -> Vec<u8> {
7929        let triples = [
7930            ("Alice", "knows", "Bob"),
7931            ("Bob", "knows", "Carol"),
7932            ("Alice", "age", "30"),
7933        ];
7934        let mut db = DictionaryBuilder::new();
7935        for (s, p, o) in triples {
7936            db.observe(s, p, o);
7937        }
7938        let dict = db.build();
7939        let ids: Vec<_> = triples
7940            .iter()
7941            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
7942            .collect();
7943        let mut ib = GraphIndexBuilder::new();
7944        for &t in &ids {
7945            ib.push(t);
7946        }
7947        let (pmeta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
7948        write_dataset_with_metadata(&dict, &ib.build(), &[], false, &pmeta, levels, meta, &[])
7949    }
7950
7951    #[test]
7952    fn metadata_round_trips_and_shifts_offsets() {
7953        let card = br#"{"title":"My Dataset"}"#;
7954        let bytes = build_with_metadata(card);
7955        let rete = Rete::open(&bytes).unwrap();
7956
7957        // The opaque payload reads back verbatim.
7958        assert_eq!(rete.metadata(), Some(card.as_slice()));
7959        let h = rete.header();
7960        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
7961        assert_eq!(h.metadata_len, card.len() as u64);
7962        // The dictionary (and everything after it) shifted forward by the card.
7963        assert_eq!(h.dictionary_offset, HEADER_LEN as u64 + card.len() as u64);
7964
7965        // The index still decodes correctly at its shifted offset.
7966        assert_eq!(
7967            rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
7968            1
7969        );
7970        // The card is inside the content hash, so the file still verifies.
7971        assert!(verify(&bytes).unwrap());
7972    }
7973
7974    #[test]
7975    fn empty_metadata_is_byte_identical_to_plain_writer() {
7976        // The `&[]` path must produce exactly the bytes of the metadata-free
7977        // writer for identical inputs — old files and outputs are unchanged.
7978        assert_eq!(
7979            build_with_metadata(&[]),
7980            build_image(),
7981            "empty-metadata output must equal the plain writer byte-for-byte"
7982        );
7983    }
7984
7985    /// The contract `replace_metadata` has to meet to be usable at the end of a
7986    /// build: swapping the card must land on **exactly** the bytes the writer
7987    /// would have produced with that card in the first place — shorter, longer,
7988    /// and gone — so a spliced file and a rebuilt one are the same file.
7989    #[test]
7990    fn replace_metadata_matches_a_direct_write() {
7991        let first = br#"{"title":"My Dataset","queries":[1,2,3]}"#;
7992        let bytes = build_with_metadata(first);
7993        for target in [
7994            br#"{"title":"My Dataset","queries":[1,2]}"#.as_slice(), // shorter
7995            br#"{"title":"My Dataset","queries":[1,2,3,4,5,6,7]}"#.as_slice(), // longer
7996            b"".as_slice(),                                          // removed
7997            first.as_slice(),                                        // unchanged
7998        ] {
7999            let spliced = replace_metadata(&bytes, target).unwrap();
8000            assert_eq!(
8001                spliced,
8002                build_with_metadata(target),
8003                "splicing {} bytes of metadata must equal a direct write",
8004                target.len()
8005            );
8006            assert!(
8007                verify(&spliced).unwrap(),
8008                "the new hash covers the new card"
8009            );
8010            let rete = Rete::open(&spliced).unwrap();
8011            assert_eq!(
8012                rete.metadata(),
8013                (!target.is_empty()).then_some(target),
8014                "the new payload reads back verbatim"
8015            );
8016            // The data is untouched by the surgery.
8017            assert_eq!(
8018                rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
8019                1
8020            );
8021        }
8022    }
8023
8024    /// A build-info section survives the splice, still adjacent to the card, and
8025    /// still outside the content hash.
8026    #[test]
8027    fn replace_metadata_keeps_build_info_adjacent() {
8028        let with_info =
8029            attach_build_info(&build_with_metadata(br#"{"a":1}"#), br#"{"builder":"x"}"#).unwrap();
8030        let spliced = replace_metadata(&with_info, br#"{"a":1,"b":2}"#).unwrap();
8031        let h = Header::from_bytes(&spliced).unwrap();
8032        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
8033        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + h.metadata_len);
8034        assert_eq!(
8035            read_build_info(&spliced).unwrap().as_deref(),
8036            Some(br#"{"builder":"x"}"#.as_slice())
8037        );
8038        assert!(verify(&spliced).unwrap());
8039        // Same card, same content hash, whatever the build-info says.
8040        assert_eq!(
8041            h.content_hash,
8042            Header::from_bytes(&build_with_metadata(br#"{"a":1,"b":2}"#))
8043                .unwrap()
8044                .content_hash
8045        );
8046    }
8047
8048    #[test]
8049    fn metadata_is_tamper_evident() {
8050        let card = br#"{"title":"x"}"#;
8051        let mut bytes = build_with_metadata(card);
8052        assert!(verify(&bytes).unwrap());
8053        // The card occupies [HEADER_LEN .. HEADER_LEN+card_len); flip a byte in it.
8054        bytes[HEADER_LEN + 2] ^= 0xff;
8055        assert!(
8056            !verify(&bytes).unwrap(),
8057            "tampering with the card must break verify()"
8058        );
8059    }
8060
8061    #[test]
8062    fn ranged_opens_do_not_fetch_metadata() {
8063        use crate::reader::{CountingReader, SliceReader};
8064        let card = vec![0xABu8; 512]; // distinctive and sizable
8065        let bytes = build_with_metadata(&card);
8066        let total = bytes.len() as u64;
8067
8068        // A full ranged open never loads the card and never reads its byte range.
8069        let r = CountingReader::new(SliceReader::new(&bytes));
8070        let rete = Rete::open_ranged(&r).unwrap();
8071        assert!(
8072            rete.metadata().is_none(),
8073            "open_ranged must not load the card"
8074        );
8075        assert!(r.requests() <= 4, "requests = {}", r.requests());
8076        assert!(
8077            r.bytes_read() <= total - card.len() as u64,
8078            "read {} of {} bytes; the {}-byte card must be skipped",
8079            r.bytes_read(),
8080            total,
8081            card.len()
8082        );
8083
8084        // Summary-only open likewise ignores the card and still summarizes.
8085        let rs = CountingReader::new(SliceReader::new(&bytes));
8086        let view = SummaryView::open_ranged(&rs).unwrap().unwrap();
8087        assert!(!view.summary.is_empty());
8088        assert!(rs.bytes_read() <= total - card.len() as u64);
8089    }
8090
8091    #[test]
8092    fn metadata_ranged_fetches_only_header_and_card() {
8093        use crate::reader::{CountingReader, SliceReader};
8094        // The CARD tier: fetch the self-description over a RangeReader touching
8095        // only the header + metadata range — never the dictionary/index/pyramid.
8096        let card = vec![0xCDu8; 384];
8097        let bytes = build_with_metadata(&card);
8098
8099        let r = CountingReader::new(SliceReader::new(&bytes));
8100        let got = read_metadata_ranged(&r).unwrap().unwrap();
8101        assert_eq!(got, card, "the card reads back verbatim");
8102        assert_eq!(r.requests(), 2, "exactly header + metadata ranges");
8103        assert_eq!(
8104            r.bytes_read(),
8105            HEADER_LEN as u64 + card.len() as u64,
8106            "no dictionary/index/pyramid bytes are touched"
8107        );
8108
8109        // A cardless file resolves to None after a single header read.
8110        let plain = build_image();
8111        let rp = CountingReader::new(SliceReader::new(&plain));
8112        assert!(read_metadata_ranged(&rp).unwrap().is_none());
8113        assert_eq!(rp.requests(), 1, "header only for a cardless file");
8114        assert_eq!(rp.bytes_read(), HEADER_LEN as u64);
8115    }
8116
8117    #[test]
8118    fn build_info_attaches_outside_the_hash() {
8119        let card = br#"{"title":"My Dataset"}"#;
8120        let base = build_with_metadata(card);
8121        let base_hash = Header::from_bytes(&base).unwrap().content_hash;
8122
8123        let info = br#"{"built_at":"2026-08-04T00:00:00Z","builder":"rete-cli 0.3.2"}"#;
8124        let with = attach_build_info(&base, info).unwrap();
8125
8126        // The section reads back verbatim, adjacent to the metadata.
8127        assert_eq!(read_build_info(&with).unwrap().unwrap(), info);
8128        let h = Header::from_bytes(&with).unwrap();
8129        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + card.len() as u64);
8130        assert_eq!(h.build_info_len, info.len() as u64);
8131        assert_eq!(h.expected_file_len(), Some(with.len() as u64));
8132
8133        // OUTSIDE the hash: the content hash is unchanged and the file still
8134        // verifies — two builds of identical data stay hash-equal even though
8135        // their build-info differs.
8136        assert_eq!(h.content_hash, base_hash);
8137        assert!(verify(&with).unwrap());
8138        let other = attach_build_info(&base, br#"{"built_at":"1999-01-01T00:00:00Z"}"#).unwrap();
8139        assert_eq!(Header::from_bytes(&other).unwrap().content_hash, base_hash);
8140
8141        // The graph still opens and answers at its shifted offsets.
8142        let rete = Rete::open(&with).unwrap();
8143        assert_eq!(
8144            rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
8145            1
8146        );
8147        assert_eq!(rete.metadata(), Some(card.as_slice()));
8148
8149        // Replacing an existing section works and removing it restores the
8150        // original image byte-for-byte (the strip-equality determinism proof).
8151        let replaced = attach_build_info(&with, br#"{"builder":"other"}"#).unwrap();
8152        assert_eq!(
8153            read_build_info(&replaced).unwrap().unwrap(),
8154            br#"{"builder":"other"}"#
8155        );
8156        assert!(verify(&replaced).unwrap());
8157        let stripped = attach_build_info(&replaced, &[]).unwrap();
8158        assert_eq!(stripped, base, "stripping build-info restores the image");
8159    }
8160
8161    #[test]
8162    fn build_info_attaches_on_a_metadata_free_file() {
8163        // No card: the section slots in right after the header.
8164        let base = build_image();
8165        let info = b"{\"builder\":\"x\"}";
8166        let with = attach_build_info(&base, info).unwrap();
8167        let h = Header::from_bytes(&with).unwrap();
8168        assert_eq!(h.build_info_offset, HEADER_LEN as u64);
8169        assert_eq!(h.metadata_len, 0);
8170        assert_eq!(read_build_info(&with).unwrap().unwrap(), info);
8171        assert!(verify(&with).unwrap());
8172        assert_eq!(
8173            h.content_hash,
8174            Header::from_bytes(&base).unwrap().content_hash
8175        );
8176        Rete::open(&with).unwrap();
8177    }
8178
8179    /// The contract a **streaming** rewriter needs: a plan is enough to rebuild
8180    /// the file from three spans — the header it hands back, the untouched
8181    /// `[HEADER_LEN, insert)` prefix, the new section, and the untouched
8182    /// `[tail_start, len)` tail — and the result is byte-identical to the
8183    /// in-memory splice. That is what lets a 17 GB file gain a build record
8184    /// with a 4 MiB buffer instead of 34 GB of RAM.
8185    #[test]
8186    fn a_plan_rebuilds_exactly_what_the_in_memory_splice_produces() {
8187        let card = br#"{"title":"My Dataset"}"#;
8188        let base = build_with_metadata(card);
8189        let base_hash = Header::from_bytes(&base).unwrap().content_hash;
8190        for start in [
8191            base.clone(),
8192            attach_build_info(&base, b"{\"a\":1}").unwrap(),
8193        ] {
8194            for info in [
8195                br#"{"builder":"rete-cli 0.3.2","query_costs":{}}"#.as_slice(), // longer
8196                b"{}".as_slice(),                                               // shorter
8197                b"".as_slice(),                                                 // removed
8198            ] {
8199                let plan = plan_build_info(&start, start.len() as u64, info.len() as u64).unwrap();
8200                let mut streamed = Vec::new();
8201                streamed.extend_from_slice(&plan.header);
8202                streamed.extend_from_slice(&start[HEADER_LEN..plan.insert as usize]);
8203                streamed.extend_from_slice(info);
8204                streamed.extend_from_slice(&start[plan.tail_start as usize..]);
8205                assert_eq!(streamed.len() as u64, plan.new_len);
8206                assert_eq!(
8207                    streamed,
8208                    attach_build_info(&start, info).unwrap(),
8209                    "a {}-byte section streamed must equal the same section spliced",
8210                    info.len()
8211                );
8212                // The point of the exercise: the file's identity is untouched.
8213                assert!(verify(&streamed).unwrap());
8214                let h = Header::from_bytes(&streamed).unwrap();
8215                assert_eq!(h.content_hash, base_hash);
8216                assert_eq!(h.expected_file_len(), Some(plan.new_len));
8217                let rete = Rete::open(&streamed).unwrap();
8218                assert_eq!(rete.metadata(), Some(card.as_slice()));
8219                assert_eq!(
8220                    rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
8221                    1
8222                );
8223            }
8224        }
8225    }
8226
8227    /// A plan is refused rather than trusted when the header's own numbers put
8228    /// the section outside the file — the header is attacker-controlled input.
8229    #[test]
8230    fn a_plan_refuses_a_header_that_overruns_the_file() {
8231        let base = build_with_metadata(br#"{"title":"x"}"#);
8232        assert!(plan_build_info(&base, 8, 16).is_err());
8233        assert!(plan_build_info(&base[..HEADER_LEN], base.len() as u64, 16).is_ok());
8234    }
8235
8236    #[test]
8237    fn card_and_build_info_ranged_is_one_header_plus_one_range() {
8238        use crate::reader::{CountingReader, SliceReader};
8239        let card = vec![0xCDu8; 384];
8240        let info = vec![0xEFu8; 200];
8241        let bytes = attach_build_info(&build_with_metadata(&card), &info).unwrap();
8242
8243        // Both sections in ONE coalesced range after the header — the CARD
8244        // tier's 1 header + 1 range budget holds with build info included.
8245        let r = CountingReader::new(SliceReader::new(&bytes));
8246        let (m, b) = read_card_and_build_info_ranged(&r).unwrap();
8247        assert_eq!(m.unwrap(), card);
8248        assert_eq!(b.unwrap(), info);
8249        assert_eq!(r.requests(), 2, "header + one coalesced range");
8250        assert_eq!(
8251            r.bytes_read(),
8252            HEADER_LEN as u64 + card.len() as u64 + info.len() as u64
8253        );
8254
8255        // Card only: same two requests, no build info.
8256        let plain = build_with_metadata(&card);
8257        let rp = CountingReader::new(SliceReader::new(&plain));
8258        let (m, b) = read_card_and_build_info_ranged(&rp).unwrap();
8259        assert_eq!(m.unwrap(), card);
8260        assert!(b.is_none());
8261        assert_eq!(rp.requests(), 2);
8262        // Stated as the comparison every client depends on: carrying a build
8263        // record costs a reader NOTHING in requests. Any client that shows the
8264        // build record — the CLI, the browser card modal — can therefore do it
8265        // in the card's own budget, and does not need a second call.
8266        assert_eq!(
8267            r.requests(),
8268            rp.requests(),
8269            "build info must not cost an extra request",
8270        );
8271
8272        // Neither: the header read alone answers.
8273        let none = build_image();
8274        let rn = CountingReader::new(SliceReader::new(&none));
8275        let (m, b) = read_card_and_build_info_ranged(&rn).unwrap();
8276        assert!(m.is_none() && b.is_none());
8277        assert_eq!(rn.requests(), 1);
8278    }
8279
8280    #[test]
8281    fn schema_summary_groups_by_type() {
8282        let rt = RDF_TYPE;
8283        let bytes = build_from(&[
8284            ("Alice", rt, "Person"),
8285            ("Bob", rt, "Person"),
8286            ("NYC", rt, "City"),
8287            ("Alice", "knows", "Bob"),
8288            ("Alice", "livesIn", "NYC"),
8289            ("Alice", "name", "\"Alice\""),
8290        ]);
8291        let rete = Rete::open(&bytes).unwrap();
8292        let summary = schema_summary(&rete);
8293        // Expect class-level relations, rdf:type excluded.
8294        assert!(summary.contains(&("Person".into(), "knows".into(), "Person".into(), 1)));
8295        assert!(summary.contains(&("Person".into(), "livesIn".into(), "City".into(), 1)));
8296        assert!(summary.contains(&("Person".into(), "name".into(), "(literal)".into(), 1)));
8297        // No rdf:type relations in the summary.
8298        assert!(!summary.iter().any(|(_, p, _, _)| p == RDF_TYPE));
8299
8300        // Class populations: 2 People, 1 City, sorted by count desc.
8301        let classes = schema_classes(&rete);
8302        assert_eq!(
8303            classes,
8304            vec![("Person".into(), 2u32), ("City".into(), 1u32)]
8305        );
8306    }
8307
8308    fn build_from(triples: &[(&str, &str, &str)]) -> Vec<u8> {
8309        let mut db = DictionaryBuilder::new();
8310        for (s, p, o) in triples {
8311            db.observe(s, p, o);
8312        }
8313        let dict = db.build();
8314        let mut ib = GraphIndexBuilder::new();
8315        for (s, p, o) in triples {
8316            ib.push(dict.encode(s, p, o).unwrap());
8317        }
8318        write_file(&dict, &ib.build(), false, &[], 0)
8319    }
8320
8321    /// Build a file WITH a pyramid (so the schema pyramid + coherence axioms ship).
8322    fn build_with_pyramid(triples: &[(&str, &str, &str)]) -> Vec<u8> {
8323        let mut db = DictionaryBuilder::new();
8324        for (s, p, o) in triples {
8325            db.observe(s, p, o);
8326        }
8327        let dict = db.build();
8328        let encoded: Vec<_> = triples
8329            .iter()
8330            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
8331            .collect();
8332        let mut ib = GraphIndexBuilder::new();
8333        for t in &encoded {
8334            ib.push(*t);
8335        }
8336        let (meta, levels) = build_pyramid_meta(&dict, &encoded, DEFAULT_TILE_BUDGET);
8337        write_dataset(&dict, &ib.build(), &[], false, &meta, levels)
8338    }
8339
8340    #[test]
8341    fn tbox_coherence_flags_unsatisfiable_class_index_free() {
8342        use crate::reader::{CountingReader, SliceReader};
8343        let rt = RDF_TYPE;
8344        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
8345        let disj = "<http://www.w3.org/2002/07/owl#disjointWith>";
8346        // C ⊑ D, C ⊑ E, D disjointWith E ⇒ C unsatisfiable — schema-only, with one
8347        // instance present just so the schema pyramid gets built.
8348        let bytes = build_with_pyramid(&[
8349            ("<http://ex/C>", sub, "<http://ex/D>"),
8350            ("<http://ex/C>", sub, "<http://ex/E>"),
8351            ("<http://ex/D>", disj, "<http://ex/E>"),
8352            ("<http://ex/x>", rt, "<http://ex/C>"),
8353        ]);
8354
8355        let r = CountingReader::new(SliceReader::new(&bytes));
8356        let view = SummaryView::open_ranged(&r).unwrap().unwrap();
8357        let points = view.tbox_coherence();
8358        assert!(
8359            points
8360                .iter()
8361                .any(|i| i.kind == "unsatisfiable-class" && i.detail.contains("http://ex/C>")),
8362            "expected C unsatisfiable from the schema alone, got {points:?}"
8363        );
8364
8365        // Proven index-free: bytes read never reach the (root_dir) index section,
8366        // mirroring `schema_pyramid_round_trips_through_file_index_free`.
8367        let header = Header::from_bytes(&bytes[..HEADER_LEN]).unwrap();
8368        assert!(
8369            r.bytes_read() <= bytes.len() as u64 - header.root_dir_len,
8370            "tbox_coherence must not read the triple index"
8371        );
8372    }
8373
8374    #[test]
8375    fn schema_coherence_reads_only_the_schema_block() {
8376        use crate::reader::{CountingReader, SliceReader};
8377        let rt = RDF_TYPE;
8378        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
8379        let disj = "<http://www.w3.org/2002/07/owl#disjointWith>";
8380        // 500 instances with unique literals → a sizable dictionary + community
8381        // summary, so a whole-pyramid-meta read would be large; the schema block
8382        // (bounded by the tiny ontology) stays small.
8383        let mut triples: Vec<(String, String, String)> = vec![
8384            ("<http://ex/C>".into(), sub.into(), "<http://ex/D>".into()),
8385            ("<http://ex/C>".into(), sub.into(), "<http://ex/E>".into()),
8386            ("<http://ex/D>".into(), disj.into(), "<http://ex/E>".into()),
8387        ];
8388        for i in 0..500 {
8389            let s = format!("<http://ex/x{i}>");
8390            triples.push((s.clone(), rt.into(), "<http://ex/C>".into()));
8391            triples.push((
8392                s,
8393                "<http://ex/label>".into(),
8394                format!("\"unique label {i}\""),
8395            ));
8396        }
8397        let trefs: Vec<(&str, &str, &str)> = triples
8398            .iter()
8399            .map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
8400            .collect();
8401        let bytes = build_with_pyramid(&trefs);
8402
8403        let header = Header::from_bytes(&bytes[..HEADER_LEN]).unwrap();
8404        assert!(
8405            header.schema_meta_len > 0,
8406            "the writer recorded a schema-block length"
8407        );
8408        assert!(
8409            (header.schema_meta_len as u64) < header.pyramid_meta_len,
8410            "schema block ({}) should be far smaller than the whole pyramid-meta ({})",
8411            header.schema_meta_len,
8412            header.pyramid_meta_len
8413        );
8414
8415        let r = CountingReader::new(SliceReader::new(&bytes));
8416        let points = read_schema_coherence_ranged(&r).unwrap().unwrap();
8417        assert!(points.iter().any(|i| i.kind == "unsatisfiable-class"));
8418        // It read only the header + the schema block — not the summary or dictionary.
8419        assert!(
8420            r.bytes_read() <= HEADER_LEN as u64 + header.schema_meta_len as u64,
8421            "read {} bytes; expected <= header + schema block ({})",
8422            r.bytes_read(),
8423            HEADER_LEN as u64 + header.schema_meta_len as u64
8424        );
8425    }
8426
8427    #[test]
8428    fn tbox_coherence_clean_schema_is_coherent() {
8429        use crate::reader::SliceReader;
8430        let rt = RDF_TYPE;
8431        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
8432        let bytes = build_with_pyramid(&[
8433            ("<http://ex/Dog>", sub, "<http://ex/Animal>"),
8434            ("<http://ex/x>", rt, "<http://ex/Dog>"),
8435        ]);
8436        let view = SummaryView::open_ranged(&SliceReader::new(&bytes))
8437            .unwrap()
8438            .unwrap();
8439        assert!(view.tbox_is_coherent(), "a plain hierarchy is coherent");
8440    }
8441
8442    /// A quad-index file must be **refused** by every reader that predates it —
8443    /// an old reader ignores flag bit 3 and the unknown kind 10, so it would
8444    /// otherwise open the file as one without named graphs and answer `GRAPH`
8445    /// queries with nothing. The seal makes each of their open paths fail on the
8446    /// index container's section count, before any payload is read; their
8447    /// `verify` still passes, because the part list it hashes is unchanged.
8448    /// (Checked against real 0.3.x binaries too: QUAD-INDEX-DESIGN.md §9.)
8449    #[test]
8450    fn readers_before_the_quad_index_refuse_its_files() {
8451        for perms in [PermSet::ALL, PermSet::CORE] {
8452            let quads: Vec<crate::ingest::RawQuad> = (0..50)
8453                .map(|i| {
8454                    (
8455                        format!("<http://ex/s{i}>"),
8456                        "<http://ex/p>".to_string(),
8457                        format!("\"{i}\""),
8458                        Some(format!("<http://ex/g{}>", i % 7)),
8459                    )
8460                })
8461                .collect();
8462            let (bytes, _) = crate::ingest::assemble_dataset_with_layout(
8463                quads,
8464                false,
8465                false,
8466                None,
8467                crate::PyramidAlgo::Louvain,
8468                perms,
8469                crate::ingest::GraphLayout::Quad,
8470                |_, _| Vec::<u8>::new(),
8471            );
8472            let h = Header::from_bytes(&bytes).unwrap();
8473            assert!(h.has_quad_index());
8474            let root =
8475                &bytes[h.root_dir_offset as usize..(h.root_dir_offset + h.root_dir_len) as usize];
8476            // 0.3.4–0.3.7, resident (`Rete::open`, `open_ranged`): count != mask.
8477            assert!(decode_index_container(root, h.block_codec, h.perms).is_err());
8478            assert!(
8479                container_section_payload_ranges(root, h.root_dir_offset, h.perms.len()).is_err()
8480            );
8481            // 0.3.0–0.3.3 expected exactly six sections, whatever the mask.
8482            assert_ne!(decode_container(root, CODEC_NONE).unwrap().len(), NUM_PERMS);
8483            assert_ne!(
8484                decode_container(root, CODEC_NONE).unwrap().len(),
8485                h.perms.len()
8486            );
8487            // Every lazy / remote open (`open_ranged_lazy`, `query_ranged`).
8488            let r = crate::reader::SliceReader::new(&bytes);
8489            for expect in [h.perms.len(), NUM_PERMS] {
8490                assert!(locate_container_section_ranged(
8491                    &r,
8492                    h.root_dir_offset,
8493                    h.root_dir_len,
8494                    0,
8495                    expect as u64
8496                )
8497                .is_err());
8498            }
8499            // Their `verify` hashes the same parts the writer hashed.
8500            assert_eq!(hash_of_sections(&h, &bytes).unwrap(), h.content_hash);
8501            // And a current reader reads the file.
8502            let rete = Rete::open(&bytes).unwrap();
8503            assert_eq!(rete.named_graph_count(), 7);
8504            assert_eq!(rete.dump(Some("<http://ex/g3>")).len(), 7);
8505        }
8506    }
8507
8508    #[test]
8509    fn named_graphs_round_trip() {
8510        // One shared dictionary; default graph + a named graph "g1".
8511        let all = [
8512            ("Alice", "knows", "Bob"), // default
8513            ("Bob", "age", "30"),      // named g1
8514        ];
8515        let mut db = DictionaryBuilder::new();
8516        for (s, p, o) in all {
8517            db.observe(s, p, o);
8518        }
8519        let dict = db.build();
8520
8521        let mut def = GraphIndexBuilder::new();
8522        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
8523        let mut g1 = GraphIndexBuilder::new();
8524        g1.push(dict.encode("Bob", "age", "30").unwrap());
8525
8526        let named = vec![("http://ex/g1".to_string(), g1.build())];
8527        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8528
8529        assert!(verify(&bytes).unwrap());
8530        let rete = Rete::open(&bytes).unwrap();
8531        assert_eq!(rete.graph_names(), vec!["http://ex/g1"]);
8532        // The named graph contains Bob age 30, not the default-graph triple.
8533        let gi = rete.graph_index("http://ex/g1").unwrap();
8534        assert_eq!(gi.triple_count(), 1);
8535        assert!(rete.graph_index("http://ex/missing").is_none());
8536
8537        // quad_count counts ALL quads — default graph + named graphs (1 + 1),
8538        // not just the default index (which would report 1).
8539        assert_eq!(rete.header().quad_count, 2);
8540
8541        // Default-graph query path is unchanged.
8542        assert_eq!(rete.query(Some("Alice"), None, None).len(), 1);
8543
8544        // dump() round-trips each graph back to terms.
8545        assert_eq!(
8546            rete.dump(None),
8547            vec![("Alice".into(), "knows".into(), "Bob".into())]
8548        );
8549        assert_eq!(
8550            rete.dump(Some("http://ex/g1")),
8551            vec![("Bob".into(), "age".into(), "30".into())]
8552        );
8553    }
8554
8555    /// A named graph whose container exceeds [`NAMED_GRAPH_RESIDENT_MAX`]
8556    /// opens through [`open_index_container_lazy`] — the default graph's own
8557    /// machinery, at the container's offset. Drive that helper directly over a
8558    /// named graph's container range (the threshold itself is too large to
8559    /// cross cheaply in a unit test) and require identical matches.
8560    #[test]
8561    fn named_graph_container_opens_tile_lazily_like_the_root() {
8562        let mut db = DictionaryBuilder::new();
8563        let node = |n: u32| format!("<http://ex/n{n}>");
8564        let p = "<http://ex/p>".to_string();
8565        db.observe(&node(0), &p, &node(1));
8566        for i in 0..500u32 {
8567            db.observe(&node(1000 + i), &p, &node(2000 + i));
8568        }
8569        let dict = db.build();
8570        let mut def = GraphIndexBuilder::new();
8571        def.push(dict.encode(&node(0), &p, &node(1)).unwrap());
8572        let mut g = GraphIndexBuilder::new().with_tile_budget(256); // many tiles
8573        for i in 0..500u32 {
8574            g.push(dict.encode(&node(1000 + i), &p, &node(2000 + i)).unwrap());
8575        }
8576        let named = vec![("<http://ex/g>".to_string(), g.build())];
8577        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8578        let rete = Rete::open(&bytes).unwrap();
8579        let header = rete.header();
8580
8581        // The single entry's container range: count varint, iri_len varint,
8582        // iri, container_len varint, then the container.
8583        let soff = header.named_graphs_offset as usize;
8584        let send = soff + header.named_graphs_len as usize;
8585        let sec = &bytes[soff..send];
8586        let (n, mut pos) = read_uvarint(sec).unwrap();
8587        assert_eq!(n, 1);
8588        let (ilen, u1) = read_uvarint(&sec[pos..]).unwrap();
8589        pos += u1 + ilen as usize;
8590        let (clen, u2) = read_uvarint(&sec[pos..]).unwrap();
8591        pos += u2;
8592        let container = ByteRange {
8593            offset: (soff + pos) as u64,
8594            len: clen,
8595        };
8596
8597        use crate::reader::SliceReader;
8598        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8599        let reader: std::sync::Arc<dyn RangeReader + Send + Sync> =
8600            std::sync::Arc::new(SliceReader::new(leaked));
8601        let (lazy_idx, _, _) = open_index_container_lazy(
8602            &reader,
8603            container,
8604            header.block_codec,
8605            header.has_tile_synopsis(),
8606            1,
8607            header.perms,
8608            header.perms.len(),
8609        )
8610        .unwrap();
8611        let mut want = rete
8612            .graph_index("<http://ex/g>")
8613            .unwrap()
8614            .match_pattern((None, None, None));
8615        let mut got = lazy_idx.match_pattern((None, None, None));
8616        want.sort_unstable();
8617        got.sort_unstable();
8618        assert_eq!(got.len(), 500);
8619        assert_eq!(got, want);
8620        assert!(!lazy_idx.load_incomplete());
8621    }
8622
8623    /// `dump_iter` must agree with `dump` exactly — and must be *lazy*: taking
8624    /// the first triple of a lazily range-read file may not drag the whole
8625    /// index across the reader, or the JS client's streaming quad cursor (which
8626    /// is this iterator, suspended between wasm calls) would be a materializing
8627    /// dump wearing a cursor's clothes.
8628    #[test]
8629    fn dump_iter_matches_dump_and_stops_early() {
8630        use crate::reader::{CountingReader, SliceReader};
8631
8632        // Enough triples over tiny tiles to force many tiles per permutation.
8633        let triples: Vec<(String, String, String)> = (0..2000u32)
8634            .map(|i| {
8635                (
8636                    format!("<http://ex/s/{i:04}>"),
8637                    "<http://ex/p>".to_string(),
8638                    format!("<http://ex/o/{i:04}>"),
8639                )
8640            })
8641            .collect();
8642        let mut db = DictionaryBuilder::new();
8643        for (s, p, o) in &triples {
8644            db.observe(s, p, o);
8645        }
8646        let dict = db.build();
8647        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
8648        for (s, p, o) in &triples {
8649            ib.push(dict.encode(s, p, o).unwrap());
8650        }
8651        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8652
8653        // Same triples, same order, as the eager dump.
8654        let rete = Rete::open(&bytes).unwrap();
8655        assert_eq!(rete.dump_iter(None).collect::<Vec<_>>(), rete.dump(None));
8656        // A missing named graph yields nothing rather than the default graph.
8657        assert_eq!(rete.dump_iter(Some("http://ex/nope")).count(), 0);
8658
8659        // Lazily: taking one triple must cost far fewer bytes than draining.
8660        let read_bytes = |take: usize| -> u64 {
8661            let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8662            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8663            let rete = Rete::open_ranged_lazy(reader.clone()).unwrap();
8664            let before = reader.bytes_read();
8665            let n = rete.dump_iter(None).take(take).count();
8666            assert_eq!(n, take.min(triples.len()));
8667            assert!(!rete.index_incomplete());
8668            reader.bytes_read() - before
8669        };
8670        let first_only = read_bytes(1);
8671        let everything = read_bytes(triples.len());
8672        // Always true: stopping after one triple cannot cost a full scan.
8673        assert!(
8674            first_only < everything,
8675            "dump_iter is not lazy: one triple read {first_only} B, a full scan {everything} B"
8676        );
8677        // The STRONG claim — under half — only holds once the payload dwarfs the
8678        // fixed open cost. This fixture is ~32 KB, and with `compression` off the
8679        // dictionary is essentially the whole file, so opening it at all already
8680        // pays most of `everything` (21 KB of 33 KB) and the ratio says nothing
8681        // about laziness. Asserting it unconditionally is how this test failed on
8682        // `--no-default-features` while passing everywhere else.
8683        #[cfg(feature = "compression")]
8684        assert!(
8685            first_only * 2 < everything,
8686            "dump_iter is not lazy: one triple read {first_only} B of the {everything} B a full scan reads"
8687        );
8688
8689        // A FRESH open: these assertions fault tiles and the dictionary in,
8690        // and the laziness measurement above counts bytes on a COLD reader —
8691        // sharing one `rete` makes first_only and everything both ~0, so the
8692        // comparison silently stops testing anything.
8693        let fresh = Rete::open(&bytes).unwrap();
8694        // `dump_batch` is the resumable form the language clients pull through.
8695        // Driven to exhaustion it must reproduce the eager dump EXACTLY — same
8696        // triples, same order — for any batch size, including one so small that
8697        // every call ends on a subject boundary before reaching it.
8698        let drain = |max_quads: usize| {
8699            let (mut out, mut cursor, mut calls) = (Vec::new(), 0u32, 0);
8700            loop {
8701                let (batch, next, done) = fresh.dump_batch(None, cursor, max_quads);
8702                out.extend(batch);
8703                cursor = next;
8704                calls += 1;
8705                assert!(calls < 10_000, "cursor failed to advance");
8706                if done {
8707                    break out;
8708                }
8709            }
8710        };
8711        let eager = fresh.dump(None);
8712        for max in [1usize, 7, 128, 100_000] {
8713            assert_eq!(drain(max), eager, "batch size {max} changed the dump");
8714        }
8715        // An unknown graph is an empty, already-finished dump — not an error and
8716        // not the default graph leaking through.
8717        let (t, _, done) = fresh.dump_batch(Some("http://ex/nope"), 0, 16);
8718        assert!(t.is_empty() && done);
8719    }
8720
8721    /// `query_batch` is `dump_batch` generalized to a PATTERN — the primitive the
8722    /// Java client's RDF4J cursor pulls through, one bounded batch per wasm call.
8723    /// Three things have to hold or the cursor is a materializing scan wearing a
8724    /// cursor's clothes: driven to exhaustion it must reproduce the eager
8725    /// `query_in_graph` for every pattern shape and every batch size; taking one
8726    /// row must cost far fewer bytes than draining; and every call must either
8727    /// yield a row or say `done`, so the caller can never spin.
8728    #[test]
8729    fn query_batch_matches_query_in_graph_and_stops_early() {
8730        use crate::reader::{CountingReader, SliceReader};
8731
8732        // Enough triples over tiny tiles that a scan spans many tiles, with two
8733        // predicates and a repeated subject so bound patterns have real fanout.
8734        let triples: Vec<(String, String, String)> = (0..2000u32)
8735            .map(|i| {
8736                (
8737                    format!("<http://ex/s/{:04}>", i / 2),
8738                    format!("<http://ex/p/{}>", i % 2),
8739                    format!("<http://ex/o/{i:04}>"),
8740                )
8741            })
8742            .collect();
8743        let mut db = DictionaryBuilder::new();
8744        for (s, p, o) in &triples {
8745            db.observe(s, p, o);
8746        }
8747        let dict = db.build();
8748        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
8749        for (s, p, o) in &triples {
8750            ib.push(dict.encode(s, p, o).unwrap());
8751        }
8752        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8753        let rete = Rete::open(&bytes).unwrap();
8754
8755        let drain = |s: Option<&str>, p: Option<&str>, o: Option<&str>, max: usize| {
8756            let (mut out, mut cursor, mut calls) = (Vec::new(), 0u64, 0);
8757            loop {
8758                let (batch, next, done) = rete.query_batch(None, s, p, o, cursor, max);
8759                // The contract the client relies on: rows, or done. Never
8760                // "nothing yet, call again" — that is an unbounded spin.
8761                assert!(!batch.is_empty() || done, "empty non-final batch");
8762                out.extend(batch);
8763                cursor = next;
8764                calls += 1;
8765                assert!(calls < 20_000, "cursor failed to advance");
8766                if done {
8767                    break out;
8768                }
8769            }
8770        };
8771        let shapes: [(Option<&str>, Option<&str>, Option<&str>); 6] = [
8772            (None, None, None),
8773            (Some("<http://ex/s/0007>"), None, None),
8774            (None, Some("<http://ex/p/1>"), None),
8775            (None, None, Some("<http://ex/o/0042>")),
8776            (Some("<http://ex/s/0007>"), Some("<http://ex/p/0>"), None),
8777            (
8778                Some("<http://ex/s/0007>"),
8779                Some("<http://ex/p/0>"),
8780                Some("<http://ex/o/0014>"),
8781            ),
8782        ];
8783        for (s, p, o) in shapes {
8784            let eager = rete.query_in_graph(None, s, p, o);
8785            assert!(
8786                !eager.is_empty(),
8787                "fixture shape {s:?} {p:?} {o:?} matches nothing"
8788            );
8789            // The lazy pull form agrees exactly, order included.
8790            assert_eq!(
8791                rete.query_iter(None, s, p, o).collect::<Vec<_>>(),
8792                eager,
8793                "query_iter disagrees for {s:?} {p:?} {o:?}"
8794            );
8795            for max in [1usize, 3, 64, 100_000] {
8796                let mut got = drain(s, p, o, max);
8797                let mut want = eager.clone();
8798                // A batch is re-sorted canonically per batch, not globally, so
8799                // a bound pattern's batches can arrive in permutation order.
8800                got.sort();
8801                want.sort();
8802                assert_eq!(got, want, "batch size {max} changed {s:?} {p:?} {o:?}");
8803            }
8804            // The fully unbound scan routes to SPO, so batching is order-exact.
8805            if s.is_none() && p.is_none() && o.is_none() {
8806                assert_eq!(drain(s, p, o, 7), eager, "unbound batching reordered rows");
8807            }
8808        }
8809
8810        // A bound term the dictionary has never seen, and an unknown graph, are
8811        // both finished-and-empty rather than errors or a leak of everything.
8812        let (t, _, done) = rete.query_batch(None, Some("<http://ex/nope>"), None, None, 0, 16);
8813        assert!(t.is_empty() && done);
8814        let (t, _, done) = rete.query_batch(Some("http://ex/nope"), None, None, None, 0, 16);
8815        assert!(t.is_empty() && done);
8816        assert_eq!(
8817            rete.query_iter(None, Some("<http://ex/nope>"), None, None)
8818                .count(),
8819            0
8820        );
8821
8822        // Lazily: one row must not drag the whole scan across the reader. This
8823        // is the property that makes `LIMIT 1` bounded on a 48 GiB file.
8824        let read_bytes = |take: usize| -> u64 {
8825            let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8826            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8827            let rete = Rete::open_ranged_lazy(reader.clone()).unwrap();
8828            let before = reader.bytes_read();
8829            let (mut n, mut cursor) = (0usize, 0u64);
8830            loop {
8831                let (batch, next, done) = rete.query_batch(None, None, None, None, cursor, 1);
8832                n += batch.len();
8833                cursor = next;
8834                if done || n >= take {
8835                    break;
8836                }
8837            }
8838            assert!(n >= take.min(triples.len()));
8839            assert!(!rete.index_incomplete());
8840            reader.bytes_read() - before
8841        };
8842        let first_only = read_bytes(1);
8843        let everything = read_bytes(triples.len());
8844        assert!(
8845            first_only < everything,
8846            "query_batch is not lazy: one row read {first_only} B, a full scan {everything} B"
8847        );
8848        #[cfg(feature = "compression")]
8849        assert!(
8850            first_only * 2 < everything,
8851            "query_batch is not lazy: one row read {first_only} B of the {everything} B a full scan reads"
8852        );
8853    }
8854
8855    /// A batched cursor over a QUADS file must stay inside the graph it was
8856    /// given: the default graph and each named graph resume independently.
8857    #[test]
8858    fn query_batch_is_graph_scoped() {
8859        let mut db = DictionaryBuilder::new();
8860        for (s, p, o) in [
8861            ("Alice", "knows", "Bob"),
8862            ("Alice", "knows", "Carol"),
8863            ("Alice", "knows", "Dave"),
8864            ("Erin", "knows", "Frank"),
8865            ("Alice", "knows", "Zoe"),
8866        ] {
8867            db.observe(s, p, o);
8868        }
8869        let dict = db.build();
8870        let mut def = GraphIndexBuilder::new();
8871        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
8872        def.push(dict.encode("Alice", "knows", "Carol").unwrap());
8873        let mut g1 = GraphIndexBuilder::new();
8874        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
8875        g1.push(dict.encode("Erin", "knows", "Frank").unwrap());
8876        let mut g2 = GraphIndexBuilder::new();
8877        g2.push(dict.encode("Alice", "knows", "Zoe").unwrap());
8878        let named = vec![
8879            ("http://ex/g1".to_string(), g1.build()),
8880            ("http://ex/g2".to_string(), g2.build()),
8881        ];
8882        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8883        let rete = Rete::open(&bytes).unwrap();
8884        for graph in [None, Some("http://ex/g1"), Some("http://ex/g2")] {
8885            let eager = rete.query_in_graph(graph, None, None, None);
8886            let (mut out, mut cursor) = (Vec::new(), 0u64);
8887            loop {
8888                let (batch, next, done) = rete.query_batch(graph, None, None, None, cursor, 1);
8889                out.extend(batch);
8890                cursor = next;
8891                if done {
8892                    break;
8893                }
8894            }
8895            assert_eq!(out, eager, "graph {graph:?} batched differently");
8896        }
8897    }
8898
8899    #[test]
8900    fn query_in_graph_is_graph_scoped() {
8901        // Default graph: Alice knows Bob, Alice knows Carol.
8902        // Named g1: Alice knows Dave (same predicate, different graph).
8903        let mut db = DictionaryBuilder::new();
8904        for (s, p, o) in [
8905            ("Alice", "knows", "Bob"),
8906            ("Alice", "knows", "Carol"),
8907            ("Alice", "knows", "Dave"),
8908        ] {
8909            db.observe(s, p, o);
8910        }
8911        let dict = db.build();
8912
8913        let mut def = GraphIndexBuilder::new();
8914        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
8915        def.push(dict.encode("Alice", "knows", "Carol").unwrap());
8916        let mut g1 = GraphIndexBuilder::new();
8917        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
8918
8919        let named = vec![("http://ex/g1".to_string(), g1.build())];
8920        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8921        let rete = Rete::open(&bytes).unwrap();
8922
8923        // Default graph only: the two default-graph objects, not Dave.
8924        let mut def_objs: Vec<String> = rete
8925            .query_in_graph(None, Some("Alice"), Some("knows"), None)
8926            .into_iter()
8927            .map(|(_, _, o)| o)
8928            .collect();
8929        def_objs.sort();
8930        assert_eq!(def_objs, vec!["Bob".to_string(), "Carol".to_string()]);
8931
8932        // Named graph only: just Dave.
8933        assert_eq!(
8934            rete.query_in_graph(Some("http://ex/g1"), Some("Alice"), None, None),
8935            vec![("Alice".into(), "knows".into(), "Dave".into())]
8936        );
8937
8938        // A wildcard-everything scan is scoped to its graph.
8939        assert_eq!(rete.query_in_graph(None, None, None, None).len(), 2);
8940        assert_eq!(
8941            rete.query_in_graph(Some("http://ex/g1"), None, None, None)
8942                .len(),
8943            1
8944        );
8945
8946        // An unknown graph IRI is empty, not an error.
8947        assert!(rete
8948            .query_in_graph(Some("http://ex/missing"), None, None, None)
8949            .is_empty());
8950    }
8951
8952    /// A **bounded** `query_in_graph` — and a dump of one named graph — must not
8953    /// fault the whole dictionary on a lazily range-read file.
8954    ///
8955    /// Both used to call `Dictionary::prefetch_all`, so the graph-scoped pattern
8956    /// primitive an RDF4J `Sail` calls once per named graph paid for every term
8957    /// in the file to answer a handful of rows. Measured on `cordis.rete`
8958    /// (801 MB, a 417 MB dictionary) before the fix: a 15-row answer read
8959    /// 416 MB and peaked at 2.5 GB RSS. The fixture below is the same shape in
8960    /// miniature — long literals so the dictionary dominates the file — and the
8961    /// assertion is the invariant that regressed: bytes read must stay well
8962    /// under the dictionary section. Lazy answers are compared against eager
8963    /// throughout: cheaper is only interesting if it is also correct.
8964    #[test]
8965    fn bounded_query_in_graph_does_not_fault_the_whole_dictionary() {
8966        use crate::reader::{CountingReader, SliceReader};
8967
8968        // Long, distinct, poorly-compressible object literals: the dictionary is
8969        // the payload here, exactly as on a graph that stores abstracts or
8970        // embedded media. Repeating one filler string would not do — front
8971        // coding plus zstd shrink 2000 near-identical literals to ~6 KB, which
8972        // is smaller than the index and makes the measurement meaningless.
8973        let payload = |i: u32| -> String {
8974            let mut s = String::with_capacity(800);
8975            let mut x = i as u64 ^ 0x9e37_79b9_7f4a_7c15;
8976            while s.len() < 800 {
8977                x = x
8978                    .wrapping_mul(6364136223846793005)
8979                    .wrapping_add(1442695040888963407);
8980                s.push_str(&format!("{:016x}", x ^ (x >> 29)));
8981            }
8982            s
8983        };
8984        // Half the quads go to the named graph, and the two halves are kept
8985        // lexically apart (`a…` / `b…`) so each lands in its own dictionary
8986        // chunks — the real layout on a quads file whose graphs use different
8987        // IRI namespaces, e.g. `cordis.rete`. Interleaving them instead would
8988        // put a term of both graphs in every chunk, and then no per-graph
8989        // access pattern could avoid faulting the whole dictionary.
8990        let quads: Vec<(String, String, String, bool)> = (0..2000u32)
8991            .map(|i| {
8992                let named = i % 2 == 0;
8993                let ns = if named { 'a' } else { 'b' };
8994                (
8995                    format!("<http://ex/{ns}/s/{i:04}>"),
8996                    "<http://ex/abstract>".to_string(),
8997                    format!("\"{ns}{} number {i}\"", payload(i)),
8998                    named,
8999                )
9000            })
9001            .collect();
9002        let mut db = DictionaryBuilder::new();
9003        for (s, p, o, _) in &quads {
9004            db.observe(s, p, o);
9005        }
9006        let dict = db.build();
9007        let mut def = GraphIndexBuilder::new().with_tile_budget(256);
9008        let mut g1 = GraphIndexBuilder::new().with_tile_budget(256);
9009        for (s, p, o, named) in &quads {
9010            let t = dict.encode(s, p, o).unwrap();
9011            if *named {
9012                g1.push(t);
9013            } else {
9014                def.push(t);
9015            }
9016        }
9017        let named = vec![("http://ex/g1".to_string(), g1.build())];
9018        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9019
9020        let eager = Rete::open(&bytes).unwrap();
9021        let dict_len = eager.header().dictionary_len;
9022
9023        // `probe` runs one closure against a COLD lazy open and reports the
9024        // bytes it faulted beyond the open itself.
9025        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
9026        let probe = |f: &dyn Fn(&Rete) -> Vec<TermTriple>| -> (Vec<TermTriple>, u64) {
9027            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9028            let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
9029            let before = reader.bytes_read();
9030            let out = f(&lazy);
9031            assert!(!lazy.index_incomplete(), "a chunk fetch failed");
9032            (out, reader.bytes_read() - before)
9033        };
9034
9035        // 1. One bound subject in the named graph.
9036        let want = eager.query_in_graph(
9037            Some("http://ex/g1"),
9038            Some("<http://ex/a/s/0100>"),
9039            None,
9040            None,
9041        );
9042        assert_eq!(want.len(), 1, "fixture: expected exactly one match");
9043        let (got, pulled) = probe(&|r| {
9044            r.query_in_graph(
9045                Some("http://ex/g1"),
9046                Some("<http://ex/a/s/0100>"),
9047                None,
9048                None,
9049            )
9050        });
9051        assert_eq!(got, want, "lazy query_in_graph disagrees with eager");
9052        assert!(
9053            pulled * 4 < dict_len,
9054            "a 1-row query_in_graph faulted {pulled} B of a {dict_len} B dictionary — \
9055             it is prefetching the whole thing again"
9056        );
9057
9058        // 2. A graph with NOTHING to resolve must not fault the dictionary.
9059        //    Both shapes below used to pull every chunk before returning an
9060        //    empty answer, and `rete export` hits the second one first on a
9061        //    quads file whose default graph is empty — on `cordis.rete` that
9062        //    was 415 MB read and 2.5 GB of peak RSS to emit zero triples. What
9063        //    is left is the named-graph directory walk it takes to learn the
9064        //    graph is absent at all; that is index, not dictionary.
9065        let (empty, q_pulled) =
9066            probe(&|r| r.query_in_graph(Some("http://ex/nope"), None, None, None));
9067        assert!(empty.is_empty());
9068        let (_, d_pulled) = probe(&|r| {
9069            let mut n = 0usize;
9070            r.dump_each(Some("http://ex/nope"), |_, _, _| n += 1);
9071            assert_eq!(n, 0, "an absent graph yielded triples");
9072            Vec::new()
9073        });
9074        assert_eq!(
9075            q_pulled, d_pulled,
9076            "query_in_graph and dump_each disagree on what an absent graph costs"
9077        );
9078        assert!(
9079            q_pulled * 4 < dict_len,
9080            "an absent graph faulted {q_pulled} B of a {dict_len} B dictionary"
9081        );
9082
9083        // 3. Dumping ONE named graph costs that graph, not the whole file: the
9084        //    default graph's half of the dictionary must stay unfaulted.
9085        let want: Vec<TermTriple> = eager.dump(Some("http://ex/g1"));
9086        assert_eq!(want.len(), 1000);
9087        let (got, pulled) = probe(&|r| {
9088            let mut out = Vec::new();
9089            r.dump_each(Some("http://ex/g1"), |s, p, o| {
9090                out.push((s.to_string(), p.to_string(), o.to_string()))
9091            });
9092            out
9093        });
9094        assert_eq!(got, want, "lazy dump_each disagrees with eager dump");
9095        assert!(
9096            pulled * 3 < dict_len * 2,
9097            "dumping half the graph faulted {pulled} B of a {dict_len} B dictionary — \
9098             the other graph's chunks are being pulled in too"
9099        );
9100    }
9101
9102    /// Build a multi-chunk single-graph ranged file image for the eviction
9103    /// tests: 4000 distinct ~70-byte object literals overflow the 64 KiB
9104    /// dictionary chunk budget into several chunks, and one multi-KiB literal
9105    /// forces a large run/chunk boundary — the shape a tiny budget must survive.
9106    #[cfg(test)]
9107    fn build_multichunk_image() -> Vec<u8> {
9108        use crate::index::PermSet;
9109        let mut db = DictionaryBuilder::new();
9110        let mut triples: Vec<(String, String, String)> = (0..4000u32)
9111            .map(|i| {
9112                (
9113                    format!("<http://ex/s/{i:07}>"),
9114                    format!("<http://ex/p/{}>", i % 8),
9115                    format!("\"object literal number {i:07} with padding text to widen the term\""),
9116                )
9117            })
9118            .collect();
9119        triples.push((
9120            "<http://ex/s/big>".to_string(),
9121            "<http://ex/p/big>".to_string(),
9122            format!("\"{}\"", "Z".repeat(8000)),
9123        ));
9124        for (s, p, o) in &triples {
9125            db.observe(s, p, o);
9126        }
9127        let dict = db.build();
9128        let mut idx = GraphIndexBuilder::new()
9129            .with_tile_budget(64)
9130            .with_perms(PermSet::ALL);
9131        for (s, p, o) in &triples {
9132            idx.push(dict.encode(s, p, o).unwrap());
9133        }
9134        write_dataset(&dict, &idx.build(), &[], true, &[], 0)
9135    }
9136
9137    /// **Bounded-export phase 2: eviction is byte-transparent, and progress is
9138    /// guaranteed at any cap.** A cap far below the decompressed dictionary
9139    /// forces the chunk cache to evict during a full dump, yet the dump is
9140    /// byte-for-byte the unlimited-cap dump — the `Arc` handout keeps every
9141    /// chunk alive across its one decode, so eviction never drops bytes in use.
9142    /// A 1-byte cap (smaller than any single chunk, so every insert evicts
9143    /// itself immediately) still completes with identical output: it is the
9144    /// handout invariant, not residency, that makes a window make progress.
9145    #[test]
9146    fn tiny_dict_cache_evicts_yet_dump_is_byte_identical() {
9147        use crate::reader::SliceReader;
9148        let leaked: &'static [u8] = Box::leak(build_multichunk_image().into_boxed_slice());
9149        let dump = |cap: u64| -> (
9150            Vec<(String, String, String)>,
9151            crate::chunk_cache::CacheStats,
9152        ) {
9153            let rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
9154            rete.dict.set_cache_cap(cap);
9155            let mut out = Vec::new();
9156            rete.dump_filtered_each(None, None, None, None, |s, p, o| {
9157                out.push((s.to_string(), p.to_string(), o.to_string()))
9158            });
9159            assert!(
9160                !rete.index_incomplete(),
9161                "no lazy fault should fail at cap {cap}"
9162            );
9163            let stats = rete.dict_cache_stats();
9164            (out, stats)
9165        };
9166
9167        let (unlimited, us) = dump(u64::MAX);
9168        assert_eq!(unlimited.len(), 4001, "every triple dumped");
9169        assert_eq!(us.evictions, 0, "unlimited cap evicts nothing");
9170
9171        // A cap far below the decompressed dictionary: eviction happens, output
9172        // unchanged.
9173        let (tiny, ts) = dump(128 * 1024);
9174        assert_eq!(tiny, unlimited, "128 KiB-cap dump differs from unlimited");
9175        assert!(ts.evictions > 0, "a tiny cap must evict (stats {ts:?})");
9176
9177        // Progress guarantee: a 1-byte cap still completes, byte-identical.
9178        let (starved, ss) = dump(1);
9179        assert_eq!(starved, unlimited, "1-byte-cap dump differs from unlimited");
9180        assert!(
9181            ss.evictions > 0,
9182            "1-byte cap evicts every insert (stats {ss:?})"
9183        );
9184    }
9185
9186    /// **The budget pays for the open's fixed directories.** A ranged open holds
9187    /// one descriptor per dictionary chunk and per index tile for its whole
9188    /// life, outside every cache — 88 MB on the 14.75 GiB openaire-relation,
9189    /// 224 MB on epfl-infoscience. `set_memory_budget` used to give the caches
9190    /// the whole budget on top of that; it now measures the directories
9191    /// ([`Rete::fixed_overhead`]) and takes them out of the dictionary share, so
9192    /// caps + directories stay within the total. This checks the arithmetic on
9193    /// a many-chunk file, the 64 MiB floor, the unlimited pass-through, and that
9194    /// a dump under the capped budget keeps the dictionary cache within its
9195    /// (net) share.
9196    #[test]
9197    fn memory_budget_charges_the_fixed_directories() {
9198        use crate::reader::SliceReader;
9199        let leaked: &'static [u8] = Box::leak(build_multichunk_image().into_boxed_slice());
9200        let rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
9201
9202        let fixed = rete.fixed_overhead();
9203        // Every chunk and every tile has a descriptor; a multi-chunk dictionary
9204        // and a 64-triple tile budget make both directories non-trivial.
9205        assert!(
9206            fixed.dict_directory > 0 && fixed.index_directory > 0,
9207            "{fixed:?}"
9208        );
9209        assert_eq!(fixed.named_graphs, 0, "no named graphs in this file");
9210        assert_eq!(
9211            fixed.total(),
9212            fixed.dict_directory + fixed.index_directory + fixed.named_graphs
9213        );
9214
9215        // A budget well above the floor: the dictionary share is the plain
9216        // split less the directories, exactly.
9217        let total: u64 = 512 << 20;
9218        let applied = rete.set_memory_budget(Some(total));
9219        let plain = split_memory_budget(total);
9220        assert_eq!(applied.dict_cache, plain.dict_cache - fixed.total());
9221        assert_eq!(rete.dict_cache_cap(), applied.dict_cache);
9222        assert_eq!(applied.tile_cache, plain.tile_cache);
9223        assert_eq!(applied.block_cache, plain.block_cache);
9224        assert!(
9225            applied.dict_cache + applied.tile_cache + applied.block_cache + fixed.total() <= total,
9226            "caps + fixed directories exceed the budget: {applied:?} + {fixed:?} > {total}"
9227        );
9228
9229        // A budget whose share would fall under the floor keeps the floor.
9230        let applied = rete.set_memory_budget(Some(1 << 20));
9231        assert_eq!(applied.dict_cache, MIN_DICT_CACHE_CAP);
9232
9233        // Unlimited stays unlimited: nothing is subtracted from u64::MAX.
9234        let applied = rete.set_memory_budget(None);
9235        assert_eq!(applied.dict_cache, u64::MAX);
9236        assert_eq!(rete.dict_cache_cap(), u64::MAX);
9237
9238        // Under a cap smaller than the decompressed dictionary, a full dump
9239        // keeps the cache within it.
9240        let rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
9241        rete.dict.set_cache_cap(128 * 1024);
9242        let mut rows = 0usize;
9243        rete.dump_filtered_each(None, None, None, None, |_, _, _| rows += 1);
9244        assert_eq!(rows, 4001);
9245        let s = rete.dict_cache_stats();
9246        assert!(s.evictions > 0, "the cap must bind: {s:?}");
9247        // One oversized chunk may sit alone above the cap (the handout
9248        // invariant); otherwise residency is within it.
9249        assert!(
9250            s.entries <= 1 || s.resident_bytes <= 128 * 1024,
9251            "dictionary cache over its cap after the dump: {s:?}"
9252        );
9253    }
9254
9255    /// **Bounded-export phase 2: per-graph index release.** After a named
9256    /// graph's slot is exported, [`Rete::release_named_graph`] drops its decoded
9257    /// index (and its tiles) so a many-graph dump holds one graph's index at a
9258    /// time, not all of them. The graph re-opens lazily if touched again.
9259    #[test]
9260    fn releasing_a_named_graph_drops_and_reopens_its_index() {
9261        use crate::index::PermSet;
9262        use crate::reader::SliceReader;
9263        let mut db = DictionaryBuilder::new();
9264        let all: Vec<(String, String, String)> = (0..40u32)
9265            .map(|i| {
9266                (
9267                    format!("<http://ex/s{}>", i % 7),
9268                    format!("<http://ex/p{}>", i % 3),
9269                    format!("<http://ex/o{}>", i % 5),
9270                )
9271            })
9272            .collect();
9273        for (s, p, o) in &all {
9274            db.observe(s, p, o);
9275        }
9276        let dict = db.build();
9277        let mk = |take: fn(u32) -> bool| {
9278            let mut g = GraphIndexBuilder::new()
9279                .with_tile_budget(16)
9280                .with_perms(PermSet::ALL);
9281            for (i, (s, p, o)) in all.iter().enumerate() {
9282                if take(i as u32) {
9283                    g.push(dict.encode(s, p, o).unwrap());
9284                }
9285            }
9286            g.build()
9287        };
9288        let def = mk(|_| true);
9289        let g1 = mk(|i| i % 2 == 0);
9290        let g2 = mk(|i| i % 3 == 0);
9291        let image = write_dataset(
9292            &dict,
9293            &def,
9294            &[
9295                ("<http://ex/g1>".to_string(), g1),
9296                ("<http://ex/g2>".to_string(), g2),
9297            ],
9298            true,
9299            &[],
9300            0,
9301        );
9302        let leaked: &'static [u8] = Box::leak(image.into_boxed_slice());
9303        let mut rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
9304
9305        let opened = |r: &Rete| -> usize {
9306            match &r.named_graphs {
9307                NamedGraphsSlot::Lazy(l) => {
9308                    let mut n = 0;
9309                    l.for_each_opened(|_| n += 1);
9310                    n
9311                }
9312                _ => 0,
9313            }
9314        };
9315
9316        assert_eq!(opened(&rete), 0, "nothing opened before first touch");
9317        // Touching g1 opens its index.
9318        assert!(rete.graph_index("<http://ex/g1>").is_some());
9319        assert_eq!(opened(&rete), 1, "g1 opened");
9320        // Release it: the index is dropped.
9321        rete.release_named_graph("<http://ex/g1>");
9322        assert_eq!(opened(&rete), 0, "g1 released");
9323        // Releasing an unknown / already-released graph is a no-op.
9324        rete.release_named_graph("<http://ex/absent>");
9325        rete.release_named_graph("<http://ex/g1>");
9326        assert_eq!(opened(&rete), 0);
9327        // The graph re-opens lazily on the next access.
9328        assert!(rete.graph_index("<http://ex/g1>").is_some());
9329        assert_eq!(opened(&rete), 1, "g1 re-opened after release");
9330    }
9331
9332    /// **The filtered-dump correctness invariant** (#117 point 1): for every
9333    /// graph and every one of the eight bound/unbound shapes,
9334    /// `dump_filtered_each` must return *exactly* the quads `dump_each` returns,
9335    /// filtered — no more, and above all no fewer.
9336    ///
9337    /// "No fewer" is the whole risk. The speedup comes from *not fetching* tiles
9338    /// a synopsis says cannot match, and a pruning bug does not crash, it
9339    /// silently returns a shorter answer. So the oracle here is the unfiltered
9340    /// dump with a plain Rust `retain` over it — the thing the filter is meant
9341    /// to be a cheaper spelling of — and it is checked on every open path, since
9342    /// the eager path prunes with a different code path than the lazy one.
9343    ///
9344    /// It runs on a `PermSet::CORE` (`--permutations 3`) file as well as a
9345    /// six-permutation one. Every shape routes inside CORE — `perm_routing_never_leaves_core`
9346    /// proves the routing table does — so the two must agree row for row, and if
9347    /// a future shape ever routed outside CORE this test fails on the three-perm
9348    /// build rather than silently returning nothing from an absent section.
9349    #[test]
9350    fn filtered_dump_is_exactly_the_unfiltered_dump_filtered() {
9351        use crate::index::PermSet;
9352        use crate::reader::SliceReader;
9353
9354        let terms = |i: u32| {
9355            (
9356                format!("<http://ex/s{}>", i % 11),
9357                format!("<http://ex/p{}>", i % 3),
9358                // Mix IRIs and literals in the object column: a literal lives in
9359                // a different dictionary section than a node, and the object
9360                // filter has to resolve through the right one.
9361                if i.is_multiple_of(4) {
9362                    format!("\"lit {}\"@en", i % 5)
9363                } else {
9364                    format!("<http://ex/o{}>", i % 7)
9365                },
9366            )
9367        };
9368        let build = |perms: PermSet| {
9369            let mut db = DictionaryBuilder::new();
9370            let all: Vec<_> = (0..90u32).map(terms).collect();
9371            for (s, p, o) in &all {
9372                db.observe(s, p, o);
9373            }
9374            let dict = db.build();
9375            let mut def = GraphIndexBuilder::new()
9376                .with_tile_budget(16)
9377                .with_perms(perms);
9378            let mut g1 = GraphIndexBuilder::new()
9379                .with_tile_budget(16)
9380                .with_perms(perms);
9381            for (i, (s, p, o)) in all.iter().enumerate() {
9382                let t = dict.encode(s, p, o).unwrap();
9383                // Deliberately overlapping, not disjoint: every second triple is
9384                // in both graphs, so a filter that leaked across graphs would
9385                // still look plausible on counts alone.
9386                if i % 2 == 0 {
9387                    g1.push(t);
9388                }
9389                def.push(t);
9390            }
9391            write_dataset(
9392                &dict,
9393                &def.build(),
9394                &[("<http://ex/g1>".to_string(), g1.build())],
9395                true,
9396                &[],
9397                0,
9398            )
9399        };
9400
9401        let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>)> = vec![
9402            (None, None, None),
9403            (Some("<http://ex/s3>"), None, None),
9404            (None, Some("<http://ex/p1>"), None),
9405            (None, None, Some("<http://ex/o5>")),
9406            (None, None, Some("\"lit 0\"@en")),
9407            (Some("<http://ex/s3>"), Some("<http://ex/p0>"), None),
9408            (Some("<http://ex/s4>"), None, Some("<http://ex/o4>")),
9409            (None, Some("<http://ex/p1>"), Some("<http://ex/o5>")),
9410            (
9411                Some("<http://ex/s3>"),
9412                Some("<http://ex/p0>"),
9413                Some("<http://ex/o3>"),
9414            ),
9415            // Terms the dictionary has never seen, in each position: these must
9416            // return nothing, not everything (an unresolvable bound term must
9417            // not silently degrade to "unbound").
9418            (Some("<http://ex/nope>"), None, None),
9419            (None, Some("<http://ex/nope>"), None),
9420            (None, None, Some("<http://ex/nope>")),
9421        ];
9422
9423        let mut any_pruned = false;
9424        for perms in [PermSet::ALL, PermSet::CORE] {
9425            let image = build(perms);
9426            let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
9427            let opens: Vec<(&str, Rete)> = vec![
9428                ("resident", Rete::open(&image).unwrap()),
9429                (
9430                    "ranged",
9431                    Rete::open_ranged(&SliceReader::new(leaked)).unwrap(),
9432                ),
9433                (
9434                    "lazy",
9435                    Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap(),
9436                ),
9437            ];
9438            for graph in [None, Some("<http://ex/g1>"), Some("<http://ex/absent>")] {
9439                // The oracle: the unfiltered dump, filtered in Rust.
9440                let full: Vec<TermTriple> = {
9441                    let mut v = Vec::new();
9442                    Rete::open(&image).unwrap().dump_each(graph, |s, p, o| {
9443                        v.push((s.to_string(), p.to_string(), o.to_string()))
9444                    });
9445                    v.sort();
9446                    v
9447                };
9448                for &(s, p, o) in &shapes {
9449                    let mut want = full.clone();
9450                    want.retain(|(ts, tp, to)| {
9451                        s.is_none_or(|x| x == ts)
9452                            && p.is_none_or(|x| x == tp)
9453                            && o.is_none_or(|x| x == to)
9454                    });
9455                    for (path, rete) in &opens {
9456                        let mut got = Vec::new();
9457                        rete.dump_filtered_each(graph, s, p, o, |s, p, o| {
9458                            got.push((s.to_string(), p.to_string(), o.to_string()))
9459                        });
9460                        got.sort();
9461                        assert_eq!(
9462                            got,
9463                            want,
9464                            "{path}/{perms:?} graph={graph:?} pattern={:?} — filtered dump \
9465                             is not the unfiltered dump filtered",
9466                            (s, p, o)
9467                        );
9468                        assert!(!rete.index_incomplete(), "{path}: a fetch failed");
9469                    }
9470                    // …and the plan must agree with what the dump actually did:
9471                    // an empty answer means either no scan at all, or a scan
9472                    // whose admitted tiles simply held nothing.
9473                    let plan = opens[2].1.dump_plan(graph, s, p, o);
9474                    if graph == Some("<http://ex/absent>")
9475                        || [s, p, o].contains(&Some("<http://ex/nope>"))
9476                    {
9477                        assert!(
9478                            plan.scan.is_none(),
9479                            "an unmatchable dump still planned a scan: {:?}",
9480                            (graph, s, p, o)
9481                        );
9482                    } else {
9483                        let scan = plan.scan.expect("a matchable dump plans a scan");
9484                        assert!(
9485                            PermSet::CORE.contains(scan.permutation),
9486                            "a dump routed to {:?}, outside PermSet::CORE — a \
9487                             --permutations 3 file has no such section",
9488                            scan.permutation
9489                        );
9490                        assert!(scan.tiles_admitted <= scan.tiles_routed);
9491                        assert!(scan.tiles_routed <= scan.tiles_total);
9492                        assert!(scan.tile_bytes <= scan.section_bytes);
9493                        if scan.tiles_admitted < scan.tiles_total {
9494                            any_pruned = true;
9495                        }
9496                    }
9497                }
9498            }
9499        }
9500        assert!(
9501            any_pruned,
9502            "no shape pruned a single tile — the fixture is too small to be \
9503             measuring anything"
9504        );
9505    }
9506
9507    /// The point of the filter: a predicate-scoped dump of a lazily range-read
9508    /// file must **fetch less** than the same graph's full dump, not merely
9509    /// return less.
9510    ///
9511    /// The fixture is the shape the win shows on — many tiles, and a predicate
9512    /// whose triples live in a minority of them — so the assertion can be about
9513    /// bytes rather than about the code path being taken on faith. Measured on
9514    /// the real thing (`cordis.rete`, 801 MB): one predicate of one named graph
9515    /// read 16.0 MB where the graph's full dump read 375.8 MB.
9516    #[test]
9517    fn a_predicate_scoped_dump_fetches_less_than_the_graph() {
9518        use crate::reader::{CountingReader, SliceReader};
9519
9520        // 200 subjects × 20 predicates, tiled small so there are many tiles, and
9521        // one distinct object per cell so that a predicate slice needs a
9522        // twentieth of the object dictionary rather than all of it — otherwise
9523        // the dictionary, which this change does not prune, swamps the index
9524        // saving the test is about.
9525        let mut db = DictionaryBuilder::new();
9526        let mut all = Vec::new();
9527        for s in 0..200u32 {
9528            for p in 0..20u32 {
9529                let t = (
9530                    format!("<http://ex/s/{s:04}>"),
9531                    format!("<http://ex/p/{p:02}>"),
9532                    format!("\"object {s:04}/{p:02}\""),
9533                );
9534                db.observe(&t.0, &t.1, &t.2);
9535                all.push(t);
9536            }
9537        }
9538        let dict = db.build();
9539        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
9540        for (s, p, o) in &all {
9541            ib.push(dict.encode(s, p, o).unwrap());
9542        }
9543        let image = write_dataset(&dict, &ib.build(), &[], false, &[], 0);
9544        let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
9545
9546        // Bytes faulted past the open, for one dump.
9547        let probe = |s: Option<&str>, p: Option<&str>, o: Option<&str>| -> (usize, u64) {
9548            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9549            let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
9550            let before = reader.bytes_read();
9551            let mut n = 0usize;
9552            lazy.dump_filtered_each(None, s, p, o, |_, _, _| n += 1);
9553            assert!(!lazy.index_incomplete());
9554            (n, reader.bytes_read() - before)
9555        };
9556
9557        let (full_rows, full_bytes) = probe(None, None, None);
9558        let (pred_rows, pred_bytes) = probe(None, Some("<http://ex/p/07>"), None);
9559        let (subj_rows, subj_bytes) = probe(Some("<http://ex/s/0021>"), None, None);
9560        assert_eq!(full_rows, 4000);
9561        assert_eq!(pred_rows, 200, "one predicate covers every subject once");
9562        assert_eq!(subj_rows, 20);
9563        // True in any build: a slice fetches strictly less than the graph.
9564        assert!(
9565            pred_bytes < full_bytes && subj_bytes < full_bytes,
9566            "a slice fetched {pred_bytes} / {subj_bytes} B where the whole graph fetched \
9567             {full_bytes} B — the scan is not being pruned at all"
9568        );
9569        // The SIZE of the win is a property of the FILE, not of this change: it is
9570        // the index share of what a dump reads, and the dictionary — which no
9571        // filter prunes — is the rest. With the default `compression` feature the
9572        // dictionary chunk bodies are zstd-compressed and the slice costs about a
9573        // tenth of the graph (measured here: 1,565 B for the predicate and 864 B
9574        // for the subject, against 15,714 B). Built `--no-default-features` those
9575        // same chunks are stored RAW, so resolving 200 rows' terms dominates and
9576        // the identical, identically-pruned scan costs 22,225 B of 40,165 — 55%.
9577        // Asserting the strong ratio unconditionally would be asserting that zstd
9578        // is enabled; the index-side prune is checked below and holds either way.
9579        #[cfg(feature = "compression")]
9580        assert!(
9581            pred_bytes * 4 < full_bytes && subj_bytes * 4 < full_bytes,
9582            "a 1-in-20 predicate slice fetched {pred_bytes} B and one subject \
9583             {subj_bytes} B where the whole graph fetched {full_bytes} B"
9584        );
9585
9586        // And the plan predicted the index side of it without fetching a tile.
9587        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9588        let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
9589        let before = reader.bytes_read();
9590        let plan = lazy
9591            .dump_plan(None, None, Some("<http://ex/p/07>"), None)
9592            .scan
9593            .expect("a known predicate plans a scan");
9594        assert!(
9595            reader.bytes_read() - before < 64 * 1024,
9596            "planning fetched {} B — it is supposed to read directories, not tiles",
9597            reader.bytes_read() - before
9598        );
9599        assert!(plan.tiles_admitted < plan.tiles_total);
9600        // The index side on its own, with the dictionary's share taken out: one
9601        // predicate of twenty routes to a handful of POS tiles. (Measured here:
9602        // the whole dump faults 15,714 B, the predicate slice 1,565 B and one
9603        // subject 864 B — a 10x and an 18x, most of what is left being the
9604        // dictionary this change does not prune.)
9605        assert!(
9606            plan.tile_bytes * 5 < plan.section_bytes,
9607            "one predicate of twenty planned {} B of a {} B section",
9608            plan.tile_bytes,
9609            plan.section_bytes
9610        );
9611    }
9612
9613    #[test]
9614    fn query_quads_tags_every_graph() {
9615        let mut db = DictionaryBuilder::new();
9616        for (s, p, o) in [("Alice", "knows", "Bob"), ("Alice", "knows", "Dave")] {
9617            db.observe(s, p, o);
9618        }
9619        let dict = db.build();
9620        let mut def = GraphIndexBuilder::new();
9621        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
9622        let mut g1 = GraphIndexBuilder::new();
9623        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
9624        let named = vec![("http://ex/g1".to_string(), g1.build())];
9625        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9626        let rete = Rete::open(&bytes).unwrap();
9627
9628        // `Alice knows ?` spans both graphs; each match carries its graph tag.
9629        let quads = rete.query_quads(Some("Alice"), Some("knows"), None);
9630        assert_eq!(quads.len(), 2);
9631        assert_eq!(
9632            quads[0],
9633            (("Alice".into(), "knows".into(), "Bob".into()), None)
9634        );
9635        assert_eq!(
9636            quads[1],
9637            (
9638                ("Alice".into(), "knows".into(), "Dave".into()),
9639                Some("http://ex/g1".to_string())
9640            )
9641        );
9642
9643        // A bound term absent from the dictionary yields nothing, in any graph.
9644        assert!(rete.query_quads(Some("Nobody"), None, None).is_empty());
9645    }
9646
9647    #[test]
9648    fn pyramid_meta_round_trips_in_file() {
9649        let rete = Rete::open(&build_image()).unwrap();
9650        let pyr = rete.pyramid().expect("file has a pyramid");
9651        // Summary covers all 3 triples by count; tiles are not stored in v0.
9652        let total: u32 = pyr.summary.iter().map(|e| e.count).sum();
9653        assert_eq!(total, 3);
9654        assert!(!pyr.summary.is_empty());
9655        assert!(pyr.tiles.is_empty());
9656    }
9657
9658    #[test]
9659    fn schema_pyramid_round_trips_through_file_index_free() {
9660        use crate::reader::{CountingReader, SliceReader};
9661        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
9662        let q = |s: &str, p: &str, o: &str| {
9663            (s.to_string(), p.to_string(), o.to_string(), None::<String>)
9664        };
9665        // Astronomer ⊑ Scientist ⊑ Person ⊑ Agent, instances at the leaves.
9666        let quads = vec![
9667            q("<a>", RDF_TYPE, "<Astronomer>"),
9668            q("<b>", RDF_TYPE, "<Astronomer>"),
9669            q("<c>", RDF_TYPE, "<Person>"),
9670            q("<Astronomer>", sub, "<Scientist>"),
9671            q("<Scientist>", sub, "<Person>"),
9672            q("<Person>", sub, "<Agent>"),
9673            q("<a>", "<knows>", "<b>"),
9674            q("<b>", "<knows>", "<c>"),
9675        ];
9676        let (bytes, _) =
9677            crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
9678
9679        // The v2 schema pyramid round-trips through the built file.
9680        let rete = Rete::open(&bytes).unwrap();
9681        let pyr = rete.pyramid().expect("pyramid present");
9682        assert!(!pyr.level_rollups.is_empty(), "schema pyramid shipped");
9683        assert!(pyr
9684            .class_hierarchy
9685            .iter()
9686            .any(|n| n.class == "<Agent>" && n.depth == 0));
9687
9688        // It reads index-free: a SummaryView open never touches the index section.
9689        let r = CountingReader::new(SliceReader::new(&bytes));
9690        let view = SummaryView::open_ranged(&r).unwrap().unwrap();
9691        assert!(view.level_count() >= 2, "multi-level pyramid");
9692        let coarse = view.level_rollup(0).unwrap();
9693        assert!(
9694            coarse.classes.iter().any(|(c, _)| c == "<Agent>"),
9695            "coarsest level rolls up to the root Agent"
9696        );
9697        let h = Header::from_bytes(&bytes).unwrap();
9698        assert!(
9699            r.bytes_read() <= bytes.len() as u64 - h.root_dir_len,
9700            "summary read {} bytes; the {}-byte index section must be skipped",
9701            r.bytes_read(),
9702            h.root_dir_len
9703        );
9704    }
9705
9706    #[test]
9707    fn predicate_totals_from_summary_only() {
9708        use crate::reader::SliceReader;
9709        // build_image: 2 `knows` triples + 1 `age` triple.
9710        let bytes = build_image();
9711        let reader = SliceReader::new(&bytes);
9712        let view = SummaryView::open_ranged(&reader).unwrap().unwrap();
9713        assert_eq!(view.predicate_total("knows"), 2);
9714        assert_eq!(view.predicate_total("age"), 1);
9715        assert_eq!(view.predicate_total("missing"), 0);
9716        let totals = view.predicate_totals();
9717        assert_eq!(totals[0], ("knows".to_string(), 2)); // sorted by count desc
9718    }
9719
9720    #[test]
9721    fn query_patterns_resolve_to_terms() {
9722        let rete = Rete::open(&build_image()).unwrap();
9723
9724        // All triples.
9725        assert_eq!(rete.query(None, None, None).len(), 3);
9726
9727        // Subject bound.
9728        let mut alice = rete.query(Some("Alice"), None, None);
9729        alice.sort();
9730        assert_eq!(
9731            alice,
9732            vec![
9733                ("Alice".into(), "age".into(), "30".into()),
9734                ("Alice".into(), "knows".into(), "Bob".into()),
9735            ]
9736        );
9737
9738        // Predicate bound.
9739        assert_eq!(rete.query(None, Some("knows"), None).len(), 2);
9740
9741        // Full triple, present and absent.
9742        assert_eq!(
9743            rete.query(Some("Bob"), Some("knows"), Some("Carol")),
9744            vec![("Bob".into(), "knows".into(), "Carol".into())]
9745        );
9746        assert!(rete.query(Some("Nobody"), None, None).is_empty());
9747        assert!(rete.query(None, Some("likes"), None).is_empty());
9748    }
9749
9750    #[test]
9751    fn query_provenance_reports_terms_ids_sections_and_index_choice() {
9752        let bytes = build_image();
9753        let rete = Rete::open(&bytes).unwrap();
9754
9755        let mut matches = rete.query_with_provenance(None, Some("knows"), None);
9756        matches.sort_by(|a, b| a.terms.cmp(&b.terms));
9757
9758        assert_eq!(matches.len(), 2);
9759        assert_eq!(
9760            matches[0].terms,
9761            ("Alice".into(), "knows".into(), "Bob".into())
9762        );
9763        assert_eq!(
9764            matches[0].ids,
9765            rete.dictionary().encode("Alice", "knows", "Bob").unwrap()
9766        );
9767        assert_eq!(matches[0].graph.as_deref(), None);
9768        assert_eq!(
9769            matches[0].matched_pattern,
9770            (None, Some(matches[0].ids.1), None)
9771        );
9772        assert_eq!(
9773            matches[0].index_permutation,
9774            crate::index::IndexPermutation::Pos
9775        );
9776
9777        let h = rete.header();
9778        assert_eq!(matches[0].dictionary_range.offset, h.dictionary_offset);
9779        assert_eq!(matches[0].dictionary_range.len, h.dictionary_len);
9780        assert_eq!(matches[0].index_range.offset, h.root_dir_offset);
9781        assert_eq!(matches[0].index_range.len, h.root_dir_len);
9782        assert!(
9783            matches[0].index_section_range.offset > h.root_dir_offset,
9784            "POS is section 1, so its payload starts after the container header and SPO payload"
9785        );
9786        assert!(matches[0].index_section_range.len > 0);
9787        assert!(matches[0].index_section_range.end() <= matches[0].index_range.end());
9788        assert!(matches[0].index_section_range.len < matches[0].index_range.len);
9789        assert_eq!(
9790            matches[0].pyramid_range.as_ref().map(|r| (r.offset, r.len)),
9791            Some((h.pyramid_meta_offset, h.pyramid_meta_len))
9792        );
9793        // Tiled (v0.2) files report the physical tile holding the match; its
9794        // compressed byte range nests inside the selected section payload.
9795        let tile_range = matches[0].tile_range.expect("tiled file reports a tile");
9796        assert!(matches[0]
9797            .tile
9798            .as_deref()
9799            .unwrap()
9800            .starts_with(matches[0].index_permutation.name()));
9801        assert!(matches[0].index_section_range.offset <= tile_range.offset);
9802        assert!(tile_range.end() <= matches[0].index_section_range.end());
9803    }
9804
9805    /// Build an in-memory `.rete` with `n` labeled subjects: each carries an
9806    /// `rdfs:label` literal drawn from `WORDS` (a word prefix selects ~1/|WORDS|
9807    /// of them) plus one extra edge so the subject has a degree to rank by.
9808    fn build_labeled(n: usize) -> Vec<u8> {
9809        const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
9810        const WORDS: &[&str] = &[
9811            "alanine",
9812            "benzene",
9813            "glucose",
9814            "dextrose",
9815            "ethanol",
9816            "formate",
9817            "heptane",
9818            "isoleucine",
9819        ];
9820        let triples: Vec<(String, String, String)> = (0..n)
9821            .flat_map(|i| {
9822                let s = format!("<http://ex/e{i}>");
9823                let w = WORDS[i % WORDS.len()];
9824                [
9825                    (s.clone(), LABEL.to_string(), format!("\"{w}-{i:06}\"")),
9826                    (
9827                        s,
9828                        "<http://ex/p>".to_string(),
9829                        format!("<http://ex/c{}>", i % 64),
9830                    ),
9831                ]
9832            })
9833            .collect();
9834        let mut db = DictionaryBuilder::new();
9835        for (s, p, o) in &triples {
9836            db.observe(s, p, o);
9837        }
9838        let dict = db.build();
9839        let ids: Vec<(u32, u32, u32)> = triples
9840            .iter()
9841            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
9842            .collect();
9843        let mut ib = GraphIndexBuilder::new();
9844        for &t in &ids {
9845            ib.push(t);
9846        }
9847        let (meta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
9848        write_file(&dict, &ib.build(), false, &meta, levels)
9849    }
9850
9851    #[test]
9852    fn prefix_search_matches_a_filter_scan() {
9853        // 800 < the 8192 label-index cap, so the index is COMPLETE — every label
9854        // is present and the two paths must return the exact same subject set.
9855        let bytes = build_labeled(800);
9856        let rete = Rete::open(&bytes).unwrap();
9857        let idx_subjects: std::collections::BTreeSet<String> = rete
9858            .prefix_search("glucose", 10_000)
9859            .into_iter()
9860            .map(|(_label, subject)| subject)
9861            .collect();
9862        // The same selection via a SPARQL FILTER scan over every label literal.
9863        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
9864                 FILTER(STRSTARTS(LCASE(?l), \"glucose\")) }";
9865        let crate::QueryOutput::Select(_, rows) = crate::eval_query(&rete, q).unwrap() else {
9866            panic!("expected SELECT");
9867        };
9868        let scan_subjects: std::collections::BTreeSet<String> =
9869            rows.iter().map(|r| r.get("s").cloned().unwrap()).collect();
9870        assert_eq!(idx_subjects, scan_subjects, "index agrees with the scan");
9871        assert_eq!(
9872            idx_subjects.len(),
9873            100,
9874            "800/8 words = 100 glucose-* labels"
9875        );
9876    }
9877
9878    /// Latency: the binary-search label index vs the FILTER scan it replaces.
9879    /// Ignored by default (timing-sensitive); run with
9880    /// `cargo test -p rete-core -- --ignored --nocapture bench_prefix_search`.
9881    #[test]
9882    #[ignore]
9883    fn bench_prefix_search_vs_filter_scan() {
9884        use std::time::Instant;
9885        let n = 6000; // < the 8192 cap, so both paths return identical sets
9886        let bytes = build_labeled(n);
9887        let rete = Rete::open(&bytes).unwrap();
9888        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
9889                 FILTER(STRSTARTS(LCASE(?l), \"glucose\")) }";
9890        let reps = 200;
9891        let idx_n = rete.prefix_search("glucose", 100_000).len();
9892        let t = Instant::now();
9893        for _ in 0..reps {
9894            std::hint::black_box(rete.prefix_search("glucose", 100_000));
9895        }
9896        let idx_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
9897        let t = Instant::now();
9898        for _ in 0..reps {
9899            let _ = std::hint::black_box(crate::eval_query(&rete, q).unwrap());
9900        }
9901        let scan_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
9902        println!(
9903            "label prefix search over {n} labeled subjects ({idx_n} matches): \
9904             index {idx_ms:.4} ms vs FILTER scan {scan_ms:.3} ms ({:.0}× faster)",
9905            scan_ms / idx_ms
9906        );
9907    }
9908
9909    /// Latency: the TEXT_INDEX word search vs the `FILTER(CONTAINS(?l, …))` scan
9910    /// it replaces. Ignored by default (timing-sensitive); run with
9911    /// `cargo test -p rete-core -- --ignored --nocapture bench_text_search`.
9912    #[test]
9913    #[ignore]
9914    fn bench_text_search_vs_contains_scan() {
9915        use std::time::Instant;
9916        const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
9917        const WORDS: &[&str] = &[
9918            "alanine",
9919            "benzene",
9920            "glucose",
9921            "dextrose",
9922            "ethanol",
9923            "formate",
9924            "heptane",
9925            "isoleucine",
9926        ];
9927        let n = 6000;
9928        let triples: Vec<(String, String, String)> = (0..n)
9929            .map(|i| {
9930                (
9931                    format!("<http://ex/e{i}>"),
9932                    LABEL.to_string(),
9933                    format!("\"{} sample number {i:06}\"", WORDS[i % WORDS.len()]),
9934                )
9935            })
9936            .collect();
9937        let mut db = DictionaryBuilder::new();
9938        for (s, p, o) in &triples {
9939            db.observe(s, p, o);
9940        }
9941        let dict = db.build();
9942        let ids: Vec<(u32, u32, u32)> = triples
9943            .iter()
9944            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
9945            .collect();
9946        let mut ib = GraphIndexBuilder::new();
9947        for &t in &ids {
9948            ib.push(t);
9949        }
9950        let ti = compute_text_index(&dict, &ids);
9951        let bytes = write_dataset_with_metadata(&dict, &ib.build(), &[], false, &[], 0, &[], &ti);
9952        let rete = Rete::open(&bytes).unwrap();
9953
9954        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
9955                 FILTER(CONTAINS(LCASE(?l), \"glucose\")) }";
9956        let reps = 200;
9957        let idx_n = rete.text_search(&["glucose"], None, 100_000).len();
9958        let t = Instant::now();
9959        for _ in 0..reps {
9960            std::hint::black_box(rete.text_search(&["glucose"], None, 100_000));
9961        }
9962        let idx_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
9963        let t = Instant::now();
9964        for _ in 0..reps {
9965            let _ = std::hint::black_box(crate::eval_query(&rete, q).unwrap());
9966        }
9967        let scan_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
9968        println!(
9969            "text search over {n} literals ({idx_n} matches): \
9970             index {idx_ms:.4} ms vs FILTER(CONTAINS) scan {scan_ms:.3} ms ({:.0}× faster)",
9971            scan_ms / idx_ms
9972        );
9973    }
9974
9975    /// Operational debugging harness for a REAL on-disk file (ignored; driven
9976    /// by env vars): step-by-step dump of a bound (p, o) POS routing.
9977    ///   RETE_DEBUG_FILE=<path.rete> RETE_DEBUG_P=<iri> RETE_DEBUG_O=<iri>
9978    #[test]
9979    #[ignore = "operational tool, driven by RETE_DEBUG_* env vars"]
9980    fn debug_bound_po_routing() {
9981        struct FR(std::fs::File);
9982        impl crate::RangeReader for FR {
9983            fn len(&self) -> u64 {
9984                self.0.metadata().map(|m| m.len()).unwrap_or(0)
9985            }
9986            fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
9987                use std::os::unix::fs::FileExt;
9988                let mut buf = vec![0u8; len as usize];
9989                self.0.read_exact_at(&mut buf, offset)?;
9990                Ok(buf)
9991            }
9992        }
9993        let path = std::env::var("RETE_DEBUG_FILE").expect("RETE_DEBUG_FILE");
9994        let p_iri = std::env::var("RETE_DEBUG_P").expect("RETE_DEBUG_P");
9995        let o_iri = std::env::var("RETE_DEBUG_O").expect("RETE_DEBUG_O");
9996        let rete =
9997            Rete::open_ranged_lazy(std::sync::Arc::new(FR(std::fs::File::open(&path).unwrap())))
9998                .unwrap();
9999        let pid = rete.dict.predicate_id(&p_iri).expect("p resolves");
10000        let oid = rete.dict.object_id(&o_iri).expect("o resolves");
10001        eprintln!("pid={pid} oid={oid}");
10002        let pattern = (None, Some(pid), Some(oid));
10003        let perm = GraphIndex::best_permutation(pattern);
10004        eprintln!("best_permutation = {}", perm.name());
10005        let si = perm.section_index();
10006        let tiles = &rete.index.sections[si];
10007        eprintln!("section {} tiles = {}", perm.name(), tiles.len());
10008        let [pa, pb, pc] = perm.order_pattern(pattern);
10009        eprintln!("permuted pattern pa={pa:?} pb={pb:?} pc={pc:?}");
10010        let (start, end) = rete.index.tile_span(si, pa);
10011        eprintln!("tile_span = [{start}, {end}) -> {} tiles", end - start);
10012        let mut admitted = 0usize;
10013        for (ti, t) in tiles.iter().enumerate().take(end).skip(start) {
10014            if t.syn_admits(pb, pc) {
10015                admitted += 1;
10016                if admitted <= 10 {
10017                    let (lo, hi) = t.leading_range();
10018                    eprintln!("  admit tile {ti}: a=[{lo},{hi}] syn={:?}", t.syn);
10019                }
10020            }
10021        }
10022        eprintln!("admitted {admitted} tile(s) by synopsis");
10023        let n = rete.index.scan_iter(pattern).count();
10024        eprintln!("scan_iter matches = {n}");
10025        let hi_res = rete.query(None, Some(&p_iri), Some(&o_iri));
10026        eprintln!("high-level query matches = {}", hi_res.len());
10027    }
10028
10029    /// The header segment's LABEL and the span it describes must agree. They
10030    /// did not from v0.3 (which grew the header to 1 KiB) until this test: the
10031    /// label still said "fixed 128 bytes", the pre-v0.3 size, while the segment
10032    /// covered `HEADER_LEN` = 1024 — and that label is exactly what the file
10033    /// explorer shows someone inspecting a real file. Reading the size back out
10034    /// of the string is the point: it fails if anyone retypes the number
10035    /// instead of deriving it from `HEADER_LEN`.
10036    #[test]
10037    fn layout_header_label_matches_its_span() {
10038        let mut db = DictionaryBuilder::new();
10039        let mut triples = Vec::new();
10040        for i in 0..24u32 {
10041            let (s, p, o) = (
10042                format!("<http://ex/s{}>", i % 5),
10043                format!("<http://ex/p{}>", i % 3),
10044                format!("<http://ex/o{i}>"),
10045            );
10046            db.observe(&s, &p, &o);
10047            triples.push((s, p, o));
10048        }
10049        let dict = db.build();
10050        let ids: Vec<(u32, u32, u32)> = triples
10051            .iter()
10052            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
10053            .collect();
10054        let index = GraphIndexBuilder::from_triples(ids).build();
10055        let image = write_dataset_with_metadata(
10056            &dict,
10057            &index,
10058            &[],
10059            false,
10060            &[],
10061            0,
10062            br#"{"name":"layout-label-fixture"}"#,
10063            &[],
10064        );
10065        let rete = Rete::open(&image).unwrap();
10066
10067        let layout = rete.file_layout();
10068        let header = layout
10069            .iter()
10070            .find(|s| s.kind == "header")
10071            .expect("every layout starts with a header segment");
10072
10073        assert_eq!(
10074            header.offset, 0,
10075            "the header is the first thing in the file"
10076        );
10077        assert_eq!(
10078            header.len, HEADER_LEN as u64,
10079            "the header segment must span exactly HEADER_LEN"
10080        );
10081
10082        // Pull the number back out of the human-readable label and hold it to
10083        // the span. A hard-coded literal that drifts from HEADER_LEN dies here.
10084        let digits: String = header
10085            .label
10086            .chars()
10087            .skip_while(|c| !c.is_ascii_digit())
10088            .take_while(|c| c.is_ascii_digit())
10089            .collect();
10090        assert!(
10091            !digits.is_empty(),
10092            "header label {:?} states no size at all",
10093            header.label
10094        );
10095        assert_eq!(
10096            digits.parse::<u64>().unwrap(),
10097            header.len,
10098            "header label {:?} disagrees with the {}-byte span it describes",
10099            header.label,
10100            header.len
10101        );
10102
10103        // The layout must also stay sorted and start at byte zero, which is
10104        // what makes "the first segment is the header" meaningful.
10105        assert!(
10106            layout.windows(2).all(|w| w[0].offset <= w[1].offset),
10107            "file_layout must return segments sorted by offset"
10108        );
10109    }
10110}