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 what `zstd::bulk::compress` writes: the
445/// encoder is told the source size up front (`set_pledged_src_size`), so it
446/// picks the same small-input parameters and records the same content size,
447/// without holding the payload. In both cases the encoder cuts blocks from its
448/// own input buffer, not from how the input was handed to it, so the bytes are
449/// [`compress`]'s. `text_index_spill` pins that, for both frame shapes.
450#[cfg(not(target_arch = "wasm32"))]
451pub(crate) fn compress_to(
452    codec: u8,
453    src_len: u64,
454    src: &mut dyn std::io::Read,
455    dst: &mut dyn std::io::Write,
456) -> std::io::Result<()> {
457    match codec {
458        #[cfg(feature = "compression")]
459        CODEC_ZSTD => {
460            let mut enc = zstd::stream::Encoder::new(dst, crate::write_opts::zstd_level())?;
461            if crate::write_opts::frame_shape() == crate::write_opts::FrameShape::Sized {
462                enc.set_pledged_src_size(Some(src_len))?;
463                enc.include_contentsize(true)?;
464            }
465            let copied = std::io::copy(src, &mut enc)?;
466            if copied != src_len {
467                return Err(std::io::Error::other(format!(
468                    "compress_to: {copied} bytes read, {src_len} announced"
469                )));
470            }
471            enc.finish()?;
472            Ok(())
473        }
474        _ => {
475            let _ = src_len;
476            std::io::copy(src, dst).map(|_| ())
477        }
478    }
479}
480
481pub(crate) fn decompress(codec: u8, bytes: &[u8]) -> Result<Vec<u8>, FileError> {
482    match codec {
483        CODEC_NONE => Ok(bytes.to_vec()),
484        // Native (the `compression` feature — always on for the CLI, off for
485        // wasm) decodes through the C `zstd` library, the same one
486        // [`compress`] encodes with. It is roughly 2× the pure-Rust decoder
487        // and speeds every read path, not just export; the multi-pass tight
488        // budget of a bounded export (`--memory-budget-mb`) re-decodes the
489        // dictionary, so a faster decoder is the single largest lever there.
490        // A valid zstd frame decodes to the same bytes under any conformant
491        // decoder, so the output is byte-for-byte what `ruzstd` produced.
492        #[cfg(feature = "compression")]
493        CODEC_ZSTD => zstd::decode_all(bytes).map_err(FileError::Decompress),
494        // Pure-Rust decode so a target WITHOUT a C toolchain (wasm, or a
495        // `--no-default-features` build) can still read compressed files.
496        // This is the ONLY decode path wasm ever compiles — the C `zstd`
497        // crate is gated behind `compression`, which wasm never enables.
498        #[cfg(not(feature = "compression"))]
499        CODEC_ZSTD => {
500            use std::io::Read;
501            let mut dec = ruzstd::decoding::StreamingDecoder::new(bytes)
502                .map_err(|e| FileError::Decompress(std::io::Error::other(e.to_string())))?;
503            let mut out = Vec::new();
504            dec.read_to_end(&mut out).map_err(FileError::Decompress)?;
505            Ok(out)
506        }
507        other => Err(FileError::UnknownCodec(other)),
508    }
509}
510
511/// Bytes this close are cheaper fetched as one read than as two round trips:
512/// tiles are laid back-to-back so this only ever bridges a tile already made
513/// resident by an earlier window — keep it tight to avoid re-fetching it.
514const TILE_COALESCE_GAP: u64 = 4096;
515
516/// The dictionary chunks a query's output terms touch are scattered across the
517/// section (terms are sorted, output ids are not), so byte-adjacency is rare.
518/// A wider gap trades a little over-fetch for far fewer round trips — the right
519/// call on a latency-bound remote read, where one skipped 64 KiB chunk is much
520/// cheaper than another request's RTT.
521const DICT_COALESCE_GAP: u64 = 64 * 1024;
522
523/// The default `rete export --memory-budget-mb` when the flag is omitted — the
524/// same number `rete build`/`merge` default to, so one figure governs both the
525/// build and the export of a file. Comfortably above a sub-4-GB-dictionary
526/// file's decompressed dictionary (those export in a single pass at today's
527/// speed), and far under a large VM's RAM so a 50-GB-class file is bounded
528/// rather than OOM. See [`split_memory_budget`].
529pub const DEFAULT_EXPORT_BUDGET_MB: u64 = 4096;
530
531/// The default memory budget, in MiB, of a lazily-opened file — the bound on
532/// its dictionary chunk cache, index tile cache and raw block cache, split as
533/// [`split_memory_budget`] describes. `rete sparql --memory-budget-mb` (and
534/// the other query commands) default to it too, and so does every client that
535/// opens through [`Rete::open_ranged_lazy`] (Python, R, Java, the relay), so a
536/// long-lived session no longer grows with every tile a query ever touched.
537/// The same figure as [`DEFAULT_EXPORT_BUDGET_MB`]: one number governs reading
538/// a big file whatever the command.
539pub const DEFAULT_QUERY_BUDGET_MB: u64 = DEFAULT_EXPORT_BUDGET_MB;
540
541/// The [`Rete::spill_threshold`] a memory budget implies: an eighth of it. A
542/// spilling `COUNT(DISTINCT …)` buffer can briefly occupy about twice its
543/// threshold while it sorts, so this keeps it within a quarter of the budget.
544pub fn spill_threshold_for(budget: u64) -> u64 {
545    budget / 8
546}
547
548/// The lowest byte cap the dictionary chunk cache is ever given, whatever the
549/// budget. It only has to guarantee forward progress and hold a handful of
550/// chunks (a single front-coded run can dwarf the 64 KiB chunk budget — a
551/// base64-embedded image, say). Correctness never depends on it: the cache's
552/// `Arc` handout keeps a chunk alive across its one decode even if it is
553/// evicted the instant it is inserted, so a full dump completes at *any* cap.
554/// This floor only keeps a pathologically small `--memory-budget-mb` from
555/// thrashing the whole dictionary chunk-by-chunk.
556const MIN_DICT_CACHE_CAP: u64 = 64 << 20; // 64 MiB
557
558/// Upper bound of the budget share [`split_memory_budget`] holds back for
559/// transient, uncached allocations (see the `reserve` there).
560const TRANSIENT_RESERVE_MAX: u64 = 128 << 20; // 128 MiB
561
562/// How a total `--memory-budget-mb` splits across the export's three bounded
563/// consumers. The three caps sum to the total less a small reserve (≤ 128 MiB)
564/// for the window's buffers and one window's in-flight reads, which no cache
565/// owns. [`Rete::set_memory_budget`] further subtracts the open's fixed
566/// directories ([`Rete::fixed_overhead`]) from the dictionary share, so the
567/// live heap of a dump stays within the total.
568#[derive(Debug, Clone, Copy)]
569pub struct MemoryBudget {
570    /// Raw file-block LRU (the `BlockCacheReader` that backs the ranged reader).
571    pub block_cache: u64,
572    /// Decompressed dictionary chunk bodies (the old ~4.3 GB export floor).
573    pub dict_cache: u64,
574    /// Decoded index tiles of the scanned permutation.
575    pub tile_cache: u64,
576}
577
578/// Split a total byte budget across the block cache, the dictionary chunk cache
579/// and the index tile cache (bounded-export phase 2, plan §5.2). `u64::MAX`
580/// (unlimited, e.g. `--in-memory` or `--memory-budget-mb 0`) passes straight
581/// through to every consumer.
582///
583/// The dictionary is the dominant consumer on scholar files (literal-heavy
584/// object section), and its chunks recur across windows, so it gets the bulk of
585/// the budget; the tile cache is small (a scanned permutation is far smaller
586/// than the dictionary) and the block cache stays modest, capped like every
587/// other ranged reader at [`crate::block_cache::DEFAULT_CACHE_CAP`].
588pub fn split_memory_budget(total: u64) -> MemoryBudget {
589    if total == u64::MAX {
590        return MemoryBudget {
591            block_cache: u64::MAX,
592            dict_cache: u64::MAX,
593            tile_cache: u64::MAX,
594        };
595    }
596    // Raw file blocks: min(256 MiB, total/8). Repeated directory/header reads
597    // must stay cheap, but this cache is not where the floor lived.
598    let block_cache = (total / 8).min(crate::block_cache::DEFAULT_CACHE_CAP);
599    // Scanned-permutation tiles: modest — tens of MB on epfl-class files. Cap
600    // at total/16 (≤ 256 MiB) so the dictionary keeps the rest.
601    let tile_cache = (total / 16).min(256 << 20);
602    // Held back for what no cache owns: the window's id and string buffers,
603    // the coalesced read spans and freshly decoded bodies of one window's
604    // prefetch before they enter the cache, and the program itself. Measured at
605    // ~80-120 MB on epfl and openaire-relation (dev/export-memory), so 1/32 of
606    // the budget, capped at 128 MiB.
607    let reserve = (total / 32).min(TRANSIENT_RESERVE_MAX);
608    // The dictionary chunk cache gets everything else, floored so a tiny budget
609    // still holds a working set of chunks.
610    let dict_cache = total
611        .saturating_sub(block_cache)
612        .saturating_sub(tile_cache)
613        .saturating_sub(reserve)
614        .max(MIN_DICT_CACHE_CAP);
615    MemoryBudget {
616        block_cache,
617        dict_cache,
618        tile_cache,
619    }
620}
621
622/// Heap an open `Rete` holds outside its bounded caches, by structure — see
623/// [`Rete::fixed_overhead`].
624#[derive(Debug, Clone, Copy, Default)]
625pub struct FixedOverhead {
626    /// The four dictionary sections' chunk directories.
627    pub dict_directory: u64,
628    /// The default graph's tile directories (every carried permutation).
629    pub index_directory: u64,
630    /// The named-graph directory plus the open graphs' tile directories.
631    pub named_graphs: u64,
632}
633
634impl FixedOverhead {
635    /// The sum of every part.
636    pub fn total(&self) -> u64 {
637        self.dict_directory + self.index_directory + self.named_graphs
638    }
639}
640
641/// Format a cache byte cap for `RETE_OPEN_DEBUG` (`unlimited` for `u64::MAX`).
642fn fmt_cap(cap: u64) -> String {
643    if cap == u64::MAX {
644        "unlimited".to_string()
645    } else {
646        format!("{}MiB", cap >> 20)
647    }
648}
649
650/// Fetch a set of ascending, disjoint byte ranges, coalescing ranges whose gap
651/// is at most `gap` into one span, then fetching the spans through
652/// [`RangeReader::read_many`] (which a parallelizable reader issues
653/// concurrently). Returns each requested range's bytes in order; `None` if any
654/// read fails.
655fn read_coalesced<R: RangeReader + ?Sized>(
656    reader: &R,
657    ranges: &[ByteRange],
658    gap: u64,
659) -> Option<Vec<Vec<u8>>> {
660    // Build the coalesced spans and remember which span each input range maps
661    // into, so the fetched span blobs can be sliced back apart in order.
662    let mut spans: Vec<(u64, u64)> = Vec::new();
663    let mut span_of: Vec<usize> = Vec::with_capacity(ranges.len());
664    let mut i = 0;
665    while i < ranges.len() {
666        let start = ranges[i].offset;
667        let mut end = ranges[i].offset.checked_add(ranges[i].len)?;
668        let mut j = i + 1;
669        while j < ranges.len() {
670            let r = &ranges[j];
671            if r.offset < end || r.offset - end > gap {
672                break;
673            }
674            end = r.offset.checked_add(r.len)?;
675            j += 1;
676        }
677        let si = spans.len();
678        spans.push((start, end - start));
679        for _ in i..j {
680            span_of.push(si);
681        }
682        i = j;
683    }
684    let blobs = reader.read_many(&spans).ok()?;
685    if blobs.len() != spans.len() {
686        return None;
687    }
688    let mut out = Vec::with_capacity(ranges.len());
689    for (k, r) in ranges.iter().enumerate() {
690        let (span_start, _) = spans[span_of[k]];
691        let blob = &blobs[span_of[k]];
692        let lo = (r.offset - span_start) as usize;
693        let hi = lo.checked_add(r.len as usize)?;
694        out.push(blob.get(lo..hi)?.to_vec());
695    }
696    Some(out)
697}
698
699/// Content hash (first 16 bytes of blake3) over the file payload sections.
700/// Identifies the immutable content independent of the header.
701fn content_hash(parts: &[&[u8]]) -> [u8; 16] {
702    let mut h = blake3::Hasher::new();
703    for p in parts {
704        h.update(p);
705    }
706    let mut out = [0u8; 16];
707    out.copy_from_slice(&h.finalize().as_bytes()[..16]);
708    out
709}
710
711/// A resolved triple as terms.
712pub type TermTriple = (String, String, String);
713
714/// What a filtered dump will cost, before it runs — see
715/// [`Rete::dump_plan`](crate::Rete::dump_plan).
716#[derive(Debug, Clone, Copy, PartialEq, Eq)]
717pub struct DumpPlan {
718    /// The routed scan this dump will run. `None` when it provably matches
719    /// nothing without touching the index: an absent graph IRI, or a bound term
720    /// the dictionary does not contain.
721    pub scan: Option<crate::index::ScanPlan>,
722    /// The file's whole dictionary section — the **ceiling** on what resolving
723    /// the matched rows' terms can fault, and on a literal-heavy file the
724    /// dominant term in a dump's real cost.
725    ///
726    /// It is deliberately not an estimate. A dump that matches one row faults
727    /// only the chunks that row's three terms live in, but nothing in the tile
728    /// directories says which chunks those are or how big they are, and a
729    /// preview that guessed would be worse than one that states the bound.
730    pub dictionary_bytes: u64,
731}
732
733/// One labelled byte region of a `.rete` file image (see
734/// [`Rete::file_layout`]). `kind` is a stable machine tag: `header`,
735/// `metadata`, `dictionary`, `directory`, `tile`, `pyramid`, `named-graphs`.
736#[derive(Debug, Clone)]
737pub struct LayoutSegment {
738    pub kind: &'static str,
739    pub label: String,
740    pub offset: u64,
741    pub len: u64,
742}
743
744/// A byte range in the `.rete` file image.
745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
746pub struct ByteRange {
747    pub offset: u64,
748    pub len: u64,
749}
750
751impl ByteRange {
752    pub fn end(self) -> u64 {
753        self.offset + self.len
754    }
755}
756
757/// Why a triple-pattern result is present in the file.
758#[derive(Debug, Clone, PartialEq, Eq)]
759pub struct TripleProvenance {
760    /// Matched triple resolved to canonical N-Triples tokens.
761    pub terms: TermTriple,
762    /// Matched triple in dictionary ID space.
763    pub ids: Triple,
764    /// Named graph IRI, or `None` for the default graph.
765    pub graph: Option<String>,
766    /// The resolved ID-space pattern that was matched.
767    pub matched_pattern: Pattern,
768    /// Permutation selected to answer the pattern.
769    pub index_permutation: IndexPermutation,
770    /// File byte range containing the dictionary container.
771    pub dictionary_range: ByteRange,
772    /// File byte range containing the permutation index container.
773    pub index_range: ByteRange,
774    /// File byte range containing the selected permutation payload inside the
775    /// index container.
776    pub index_section_range: ByteRange,
777    /// File byte range containing the pyramid metadata, when present.
778    pub pyramid_range: Option<ByteRange>,
779    /// Physical tile identifier, once tile directories are materialized.
780    /// Physical tile identifier (`PERM/index`, e.g. `POS/3`) for tiled (v0.2)
781    /// files; `None` for pre-tiling files.
782    pub tile: Option<String>,
783    /// File byte range of that (compressed) tile — the exact bytes a ranged
784    /// client would fetch to re-derive this match.
785    pub tile_range: Option<ByteRange>,
786}
787
788/// Encode a length-prefixed container of byte sections, each compressed with
789/// `codec` independently (so a range-reading client decompresses only the
790/// sections it fetches). Stored length is the *compressed* length.
791fn encode_container(sections: &[&[u8]], codec: u8) -> Vec<u8> {
792    let mut out = Vec::new();
793    write_uvarint(&mut out, sections.len() as u64);
794    for s in sections {
795        let payload = compress(codec, s);
796        write_uvarint(&mut out, payload.len() as u64);
797        out.extend_from_slice(&payload);
798    }
799    out
800}
801
802/// Decode a container into owned, decompressed sections.
803fn decode_container(bytes: &[u8], codec: u8) -> Result<Vec<Vec<u8>>, FileError> {
804    let (n, mut pos) = read_uvarint(bytes).ok_or(FileError::Container("truncated count"))?;
805    // `n` is untrusted; each section needs ≥1 byte, so cap the pre-allocation at
806    // the buffer length rather than trusting the count (avoids an OOM on a bogus
807    // header pointing at a small region).
808    let mut out = Vec::with_capacity((n as usize).min(bytes.len()));
809    for _ in 0..n {
810        let (len, used) =
811            read_uvarint(&bytes[pos..]).ok_or(FileError::Container("truncated length"))?;
812        pos += used;
813        let end = pos + len as usize;
814        if end > bytes.len() {
815            return Err(FileError::Container("section overruns buffer"));
816        }
817        out.push(decompress(codec, &bytes[pos..end])?);
818        pos = end;
819    }
820    Ok(out)
821}
822
823fn checked_end(off: u64, len: u64) -> Result<u64, FileError> {
824    off.checked_add(len)
825        .ok_or(FileError::Container("section range overflows"))
826}
827
828/// Per-chunk budget for dictionary section bodies — same reasoning as
829/// [`crate::index::INDEX_TILE_BUDGET`]: one fetch, one decompress per touch.
830const DICT_CHUNK_BUDGET: usize = crate::write_opts::DEFAULT_DICT_CHUNK_BYTES;
831
832/// Encode one dictionary section as a chunked payload (format v0.2):
833/// `[header_len, raw header (term_count/interval/restart table)]
834///  [num_chunks; per chunk: Δfirst_run, key_len, key, comp_len]
835///  [individually compressed run-aligned body slices]`.
836/// The header keeps its original encoding, so restart offsets stay valid in
837/// the section's coordinate space.
838///
839/// `key` is the chunk's **routing separator**, not its first term: the
840/// shortest string strictly above the previous chunk's last term and at most
841/// the chunk's own first term (empty for chunk 0). See
842/// [`crate::dict::SectionChunk::key`] for the invariant and
843/// [`crate::dict::shortest_separator`] for the choice. Storing the first term
844/// verbatim — what this wrote before — is the degenerate case of the same
845/// invariant, so readers of either vintage route both correctly; the
846/// separator is simply orders of magnitude smaller when the boundary term is a
847/// long literal.
848fn encode_chunked_dict_section(raw: &[u8], codec: u8) -> Vec<u8> {
849    let meta = crate::dict::parse_meta(raw).unwrap_or(crate::dict::SectionMeta {
850        term_count: 0,
851        restart_interval: 1,
852        restart_offsets: Vec::new(),
853    });
854    let body_start = meta
855        .restart_offsets
856        .first()
857        .copied()
858        .unwrap_or(raw.len() as u64);
859    let header = &raw[..(body_start.min(raw.len() as u64)) as usize];
860
861    // Split runs into chunks by body-byte budget (whole runs only).
862    let chunk_budget = crate::write_opts::dict_chunk_budget() as u64;
863    let n_runs = meta.restart_offsets.len();
864    let mut bounds: Vec<(usize, u64, u64)> = Vec::new(); // (first_run, start, end)
865    let mut r = 0;
866    while r < n_runs {
867        let start = meta.restart_offsets[r];
868        let mut r2 = r + 1;
869        while r2 < n_runs && meta.restart_offsets[r2] - start < chunk_budget {
870            r2 += 1;
871        }
872        let end = if r2 < n_runs {
873            meta.restart_offsets[r2]
874        } else {
875            raw.len() as u64
876        };
877        bounds.push((r, start, end));
878        r = r2;
879    }
880
881    // Chunks are independent: compress them across all cores, as the index
882    // tiles are (`par_iter` keeps the order, so the bytes are unchanged). At a
883    // high `--zstd-level` this serial loop was most of a build's wall time.
884    #[cfg(feature = "parallel")]
885    let compressed: Vec<Vec<u8>> = {
886        use rayon::prelude::*;
887        bounds
888            .par_iter()
889            .map(|&(_, s, e)| compress(codec, &raw[s as usize..e as usize]))
890            .collect()
891    };
892    #[cfg(not(feature = "parallel"))]
893    let compressed: Vec<Vec<u8>> = bounds
894        .iter()
895        .map(|&(_, s, e)| compress(codec, &raw[s as usize..e as usize]))
896        .collect();
897    let mut out = Vec::new();
898    write_uvarint(&mut out, header.len() as u64);
899    out.extend_from_slice(header);
900    write_uvarint(&mut out, bounds.len() as u64);
901    let mut prev_run = 0usize;
902    // The previous chunk's last term — the separator's lower bound. Decoding it
903    // walks at most `restart_interval` front-coded entries of one chunk, which
904    // is nothing beside the zstd pass over the same 64 KiB.
905    let mut prev_last: Option<Vec<u8>> = None;
906    for (i, (&(first_run, start, end), comp)) in bounds.iter().zip(&compressed).enumerate() {
907        let key = if i == 0 {
908            // Everything routes to chunk 0; it needs no separator at all.
909            Vec::new()
910        } else {
911            let first = crate::dict::run_first_term(raw, start as usize).unwrap_or_default();
912            match &prev_last {
913                // Undecodable predecessor: the verbatim first term is always a
914                // valid separator, just not a short one.
915                None => first,
916                Some(pl) => crate::dict::shortest_separator(pl, &first),
917            }
918        };
919        if i + 1 < bounds.len() {
920            // The last run of THIS chunk starts one run before the next chunk's.
921            let last_run_off = meta.restart_offsets[bounds[i + 1].0 - 1] as usize;
922            prev_last = crate::dict::run_last_term(raw, last_run_off, end as usize);
923        }
924        write_uvarint(&mut out, (first_run - prev_run) as u64);
925        write_uvarint(&mut out, key.len() as u64);
926        out.extend_from_slice(&key);
927        write_uvarint(&mut out, comp.len() as u64);
928        prev_run = first_run;
929    }
930    for comp in &compressed {
931        out.extend_from_slice(comp);
932    }
933    out
934}
935
936/// A parsed chunked-dict-section directory entry: the chunk's run/key/body
937/// coordinates plus its compressed byte range *within the payload*. `key` is a
938/// routing separator, not a term — see [`crate::dict::SectionChunk::key`].
939struct DictChunkEntry {
940    first_run: usize,
941    key: Vec<u8>,
942    body_start: u64,
943    start: u64,
944    end: u64,
945}
946
947/// Parse a chunked dictionary section's header + directory (not the chunks).
948/// `bytes` may be a prefix of the payload; compressed ranges validate against
949/// `total_len`.
950fn parse_chunked_dict_dir(
951    bytes: &[u8],
952    total_len: u64,
953) -> Result<(crate::dict::SectionMeta, Vec<DictChunkEntry>), FileError> {
954    let mut pos = 0usize;
955    let take = |pos: &mut usize| -> Result<u64, FileError> {
956        let (v, n) = read_uvarint(bytes.get(*pos..).unwrap_or(&[]))
957            .ok_or(FileError::Container("truncated dict chunk directory"))?;
958        *pos += n;
959        Ok(v)
960    };
961    let header_len = take(&mut pos)? as usize;
962    let header = bytes
963        .get(pos..pos.saturating_add(header_len))
964        .ok_or(FileError::Container("truncated dict header"))?;
965    let meta = crate::dict::parse_meta(header)
966        .map_err(|_| FileError::Container("malformed dict header"))?;
967    pos += header_len;
968
969    let num_chunks = take(&mut pos)? as usize;
970    let mut entries = Vec::with_capacity(num_chunks.min(bytes.len()));
971    let mut lens = Vec::with_capacity(num_chunks.min(bytes.len()));
972    let mut prev_run = 0usize;
973    for _ in 0..num_chunks {
974        let drun = take(&mut pos)? as usize;
975        let klen = take(&mut pos)? as usize;
976        let key = bytes
977            .get(pos..pos.saturating_add(klen))
978            .ok_or(FileError::Container("truncated dict chunk key"))?
979            .to_vec();
980        pos += klen;
981        let clen = take(&mut pos)?;
982        let first_run = prev_run + drun;
983        let body_start = meta
984            .restart_offsets
985            .get(first_run)
986            .copied()
987            .ok_or(FileError::Container("dict chunk run out of range"))?;
988        entries.push(DictChunkEntry {
989            first_run,
990            key,
991            body_start,
992            start: 0,
993            end: 0,
994        });
995        lens.push(clen);
996        prev_run = first_run;
997    }
998    let mut start = pos as u64;
999    for (e, len) in entries.iter_mut().zip(lens) {
1000        let end = start
1001            .checked_add(len)
1002            .filter(|&e| e <= total_len)
1003            .ok_or(FileError::Container("dict chunk overruns section"))?;
1004        e.start = start;
1005        e.end = end;
1006        start = end;
1007    }
1008    Ok((meta, entries))
1009}
1010
1011/// Fetch and parse a remote chunked dict section's header + directory: read a
1012/// small prefix and grow it geometrically until it parses, never fetching past
1013/// the section.
1014/// Read a chunked dictionary section's directory over a range reader WITHOUT
1015/// materializing the section-wide restart table. That table is one offset per
1016/// restart run — a 50 M-term section has ~3 M of them (~24 MiB resident), and
1017/// holding it is an iOS-Safari OOM on a big remote file. We read only the tiny
1018/// header prefix (term_count / interval) and the chunk directory, skipping the
1019/// restart-table bytes entirely; per-run offsets are derived per chunk on fault
1020/// (`SectionChunk::run_offsets`). The returned meta has an empty
1021/// `restart_offsets`, which the chunked lookups read as "derive per chunk".
1022fn read_dict_dir_ranged<R: RangeReader>(
1023    reader: &R,
1024    section: ByteRange,
1025) -> Result<(crate::dict::SectionMeta, Vec<DictChunkEntry>), FileError> {
1026    let total = section.len;
1027    // Initial prefix: the header prefix ([header_len][term_count][interval]) and,
1028    // for a *small* section, the whole chunk directory too — so those still cost
1029    // a single read. A big section has a huge restart table between the header
1030    // and the directory; we detect that (dir_start past the prefix) and range-
1031    // read only the directory below, never fetching the table.
1032    let init = 8192.min(total); // never over-read past the section (a tiny/empty
1033                                // section holds only its header + a stub directory)
1034    let head = reader.read_at(section.offset, init)?;
1035    let (header_len, n0) =
1036        read_uvarint(&head).ok_or(FileError::Container("truncated dict header len"))?;
1037    let hbase = n0; // first byte of the header body
1038    let (term_count, n1) = read_uvarint(head.get(hbase..).unwrap_or(&[]))
1039        .ok_or(FileError::Container("truncated dict term_count"))?;
1040    let (restart_interval, _n2) = read_uvarint(head.get(hbase + n1..).unwrap_or(&[]))
1041        .ok_or(FileError::Container("truncated dict interval"))?;
1042    if restart_interval == 0 {
1043        return Err(FileError::Container("zero restart interval"));
1044    }
1045    // The chunk directory begins right after the header body — i.e. past the
1046    // `header_len` bytes, which include the restart table we never materialize.
1047    let dir_start = (hbase as u64)
1048        .checked_add(header_len)
1049        .filter(|&d| d <= total)
1050        .ok_or(FileError::Container("dict header overruns section"))?;
1051    let dir_total = total - dir_start;
1052    let meta = crate::dict::SectionMeta {
1053        term_count: term_count as u32,
1054        restart_interval: restart_interval as u32,
1055        restart_offsets: Vec::new(),
1056    };
1057    let finish = |mut entries: Vec<DictChunkEntry>| {
1058        for e in &mut entries {
1059            e.start += dir_start; // dir-relative → section-relative
1060            e.end += dir_start;
1061        }
1062        (meta.clone(), entries)
1063    };
1064    // Fast path: the directory already sits in the prefix we read (small section
1065    // — its restart table is tiny, so the ~few KiB over-read is negligible).
1066    // A short prefix is not wasted: those bytes seed the probe below.
1067    let mut have: Vec<u8> = Vec::new();
1068    if dir_start < head.len() as u64 {
1069        match parse_chunk_dir_only(&head[dir_start as usize..], dir_total)? {
1070            ChunkDirParse::Done(entries) => return Ok(finish(entries)),
1071            ChunkDirParse::Truncated { .. } => have = head[dir_start as usize..].to_vec(),
1072        }
1073    }
1074    // Big section: range-read the directory on its own, skipping the table.
1075    //
1076    // Nothing records the directory's byte length, so it has to be probed. Two
1077    // things keep that probe near ONE directory's worth of bytes. Each round
1078    // **appends** to the bytes already held instead of re-reading from the
1079    // start — the previous loop re-read the whole prefix every time, so a
1080    // 234 MB directory (epfl-infoscience's object section) cost 537 MB of range
1081    // reads to fetch. And a truncated parse reports how many entries fit in how
1082    // many bytes, which extrapolates the rest instead of blindly doubling. The
1083    // extrapolation may only quadruple a round, so a wild guess on a directory
1084    // with wildly uneven entries (one stored term can be hundreds of KB) can
1085    // never fetch the section body.
1086    let mut want = 4096u64.min(dir_total).max(1);
1087    loop {
1088        let held = have.len() as u64;
1089        if want > held {
1090            let extra = reader.read_at(section.offset + dir_start + held, want - held)?;
1091            if extra.is_empty() {
1092                return Err(FileError::Container("truncated dict chunk directory"));
1093            }
1094            have.extend_from_slice(&extra);
1095        }
1096        let held = have.len() as u64;
1097        match parse_chunk_dir_only(&have, dir_total)? {
1098            ChunkDirParse::Done(entries) => return Ok(finish(entries)),
1099            ChunkDirParse::Truncated { .. } if held >= dir_total => {
1100                return Err(FileError::Container("truncated dict chunk directory"));
1101            }
1102            ChunkDirParse::Truncated {
1103                parsed,
1104                used,
1105                total,
1106            } => {
1107                // Extrapolate from a sample big enough to mean something;
1108                // otherwise just double. Undershooting only costs another
1109                // (append-only) round, so the estimate carries a small margin
1110                // rather than a generous one.
1111                let est = if parsed >= 16 && used > 0 {
1112                    let whole = ((used as u64) / (parsed as u64)).saturating_mul(total as u64);
1113                    whole.saturating_add(whole / 8).saturating_add(64)
1114                } else {
1115                    // Too small a sample to extrapolate from: double.
1116                    held.saturating_mul(2)
1117                };
1118                want = est
1119                    .max(held.saturating_add(1))
1120                    .min(held.saturating_mul(4))
1121                    .min(dir_total);
1122            }
1123        }
1124    }
1125}
1126
1127/// What [`parse_chunk_dir_only`] made of a chunk-directory prefix: either the
1128/// whole directory, or how far a truncated one got (the probe's step hint).
1129/// Truncation is not an error here — the caller is deliberately reading a
1130/// prefix and deciding how much more to fetch.
1131enum ChunkDirParse {
1132    Done(Vec<DictChunkEntry>),
1133    Truncated {
1134        /// Entries fully decoded from the prefix.
1135        parsed: usize,
1136        /// Bytes those entries occupied (directory-relative).
1137        used: usize,
1138        /// Chunk count the directory declares.
1139        total: usize,
1140    },
1141}
1142
1143/// Parse just the chunk directory (the bytes after a section header):
1144/// `[num_chunks][per chunk: Δfirst_run, key_len, key, comp_len]`.
1145/// Chunk byte ranges (`start`/`end`) come back relative to the directory's own
1146/// start; `body_start` is 0 (a lite section never uses it — lookups derive run
1147/// offsets per chunk). Bodies aren't needed here, so `dir` may end at the first
1148/// body as long as it covers the whole directory. A `dir` that stops mid-entry
1149/// is reported as [`ChunkDirParse::Truncated`], not an error: only bytes that
1150/// cannot be a directory at any length (a chunk range overrunning the section)
1151/// fail.
1152fn parse_chunk_dir_only(dir: &[u8], dir_total: u64) -> Result<ChunkDirParse, FileError> {
1153    let mut pos = 0usize;
1154    let take = |pos: &mut usize| -> Option<u64> {
1155        let (v, n) = read_uvarint(dir.get(*pos..).unwrap_or(&[]))?;
1156        *pos += n;
1157        Some(v)
1158    };
1159    let Some(num_chunks) = take(&mut pos) else {
1160        return Ok(ChunkDirParse::Truncated {
1161            parsed: 0,
1162            used: 0,
1163            total: 0,
1164        });
1165    };
1166    let num_chunks = num_chunks as usize;
1167    let mut entries = Vec::with_capacity(num_chunks.min(dir.len()));
1168    let mut lens: Vec<u64> = Vec::with_capacity(num_chunks.min(dir.len()));
1169    let mut prev_run = 0usize;
1170    for _ in 0..num_chunks {
1171        let entry_start = pos;
1172        let short = ChunkDirParse::Truncated {
1173            parsed: entries.len(),
1174            used: entry_start,
1175            total: num_chunks,
1176        };
1177        let (Some(drun), Some(klen)) = (take(&mut pos), take(&mut pos)) else {
1178            return Ok(short);
1179        };
1180        let (drun, klen) = (drun as usize, klen as usize);
1181        let Some(key) = dir.get(pos..pos.saturating_add(klen)) else {
1182            return Ok(short);
1183        };
1184        let key = key.to_vec();
1185        pos += klen;
1186        let Some(clen) = take(&mut pos) else {
1187            return Ok(short);
1188        };
1189        let first_run = prev_run + drun;
1190        entries.push(DictChunkEntry {
1191            first_run,
1192            key,
1193            body_start: 0,
1194            start: 0,
1195            end: 0,
1196        });
1197        lens.push(clen);
1198        prev_run = first_run;
1199    }
1200    let mut start = pos as u64;
1201    for (e, len) in entries.iter_mut().zip(lens) {
1202        let end = start
1203            .checked_add(len)
1204            .filter(|&e| e <= dir_total)
1205            .ok_or(FileError::Container("dict chunk overruns section"))?;
1206        e.start = start;
1207        e.end = end;
1208        start = end;
1209    }
1210    Ok(ChunkDirParse::Done(entries))
1211}
1212
1213/// Decode one chunked dictionary section payload into a resident
1214/// [`crate::dict::ChunkedSection`] (chunks decompressed up front — the local
1215/// open path).
1216fn decode_chunked_dict_section(
1217    payload: &[u8],
1218    codec: u8,
1219    cache: std::sync::Arc<crate::chunk_cache::ChunkCache>,
1220    section_index: u8,
1221) -> Result<crate::dict::ChunkedSection, FileError> {
1222    let (meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64)?;
1223    let chunks = entries
1224        .into_iter()
1225        .map(|e| {
1226            let body = decompress(codec, &payload[e.start as usize..e.end as usize])?;
1227            Ok((
1228                crate::dict::SectionChunk::new(e.first_run, e.key, e.body_start),
1229                body,
1230            ))
1231        })
1232        .collect::<Result<Vec<_>, FileError>>()?;
1233    Ok(crate::dict::ChunkedSection::resident(
1234        meta,
1235        chunks,
1236        cache,
1237        section_index,
1238    ))
1239}
1240
1241fn decode_dictionary_container(bytes: &[u8], codec: u8) -> Result<Dictionary, FileError> {
1242    let dsecs = decode_container(bytes, CODEC_NONE)?;
1243    if dsecs.len() != 4 {
1244        return Err(FileError::Container("expected 4 dictionary sections"));
1245    }
1246    // The four sections share one (unlimited) cache — residency is identical to
1247    // the previous per-chunk `OnceLock`s.
1248    let cache = crate::chunk_cache::ChunkCache::unlimited_arc();
1249    let mut sections = Vec::with_capacity(4);
1250    for (si, sec) in dsecs.iter().enumerate() {
1251        sections.push(decode_chunked_dict_section(
1252            sec,
1253            codec,
1254            cache.clone(),
1255            si as u8,
1256        )?);
1257    }
1258    let arr: [crate::dict::ChunkedSection; 4] = sections
1259        .try_into()
1260        .map_err(|_| FileError::Container("expected 4 dictionary sections"))?;
1261    Ok(Dictionary::from_chunked_sections(arr))
1262}
1263
1264/// Encode one permutation's tiled section payload (format v0.2):
1265/// `[num_tiles][per tile: delta(min_a), max_a - min_a, compressed_len][tiles…]`,
1266/// each tile compressed independently with `codec` so a ranged reader can
1267/// fetch and decompress exactly the tiles a query routes to. The directory
1268/// itself is uncompressed (it must be readable before any tile).
1269fn encode_tiled_section(index: &GraphIndex, si: usize, codec: u8) -> Vec<u8> {
1270    let tiles = index.tile_sections()[si];
1271    let n = tiles.len();
1272    // Tile bodies live in the index's chunk cache (keyed by (section, tile));
1273    // fetch each as a shared handle. For a locally-built index every tile is
1274    // resident, so this is a cheap `Arc` clone under an uncontended lock.
1275    // Per-tile compression is the bulk of serialization time on a large graph and
1276    // the tiles are independent, so compress them across all cores. `par_iter`
1277    // preserves order, so the output is byte-identical to the serial map.
1278    #[cfg(feature = "parallel")]
1279    let compressed: Vec<Vec<u8>> = {
1280        use rayon::prelude::*;
1281        (0..n)
1282            .into_par_iter()
1283            .map(|ti| compress(codec, &index.tile_body(si, ti)))
1284            .collect()
1285    };
1286    #[cfg(not(feature = "parallel"))]
1287    let compressed: Vec<Vec<u8>> = (0..n)
1288        .map(|ti| compress(codec, &index.tile_body(si, ti)))
1289        .collect();
1290    let mut out = Vec::new();
1291    write_uvarint(&mut out, tiles.len() as u64);
1292    let mut prev_min = 0u32;
1293    for (tile, comp) in tiles.iter().zip(&compressed) {
1294        let (min_a, max_a) = tile.leading_range();
1295        write_uvarint(&mut out, (min_a - prev_min) as u64);
1296        write_uvarint(&mut out, (max_a - min_a) as u64);
1297        write_uvarint(&mut out, comp.len() as u64);
1298        prev_min = min_a;
1299    }
1300    for comp in &compressed {
1301        out.extend_from_slice(comp);
1302    }
1303    // Tile-synopsis trailer (FLAG_TILE_SYNOPSIS): per tile, the inclusive min/max
1304    // of the two non-leading columns `(min_b, span_b, min_c, span_c)`, derived
1305    // from each tile's zone map. Appended **after** the tile payloads so a reader
1306    // that predates the flag — which locates tiles by length and stops — never
1307    // reads it (backward-compatible). A reader honoring the flag reads it from the
1308    // section tail. On the (impossible for a built tile) parse failure, emit a
1309    // full range so nothing is ever wrongly pruned.
1310    for ti in 0..n {
1311        let body = index.tile_body(si, ti);
1312        let (min_b, max_b, min_c, max_c) = match crate::triples::TripleBlock::parse(&body) {
1313            Ok(b) => {
1314                let z = b.zone();
1315                (z.min_b, z.max_b, z.min_c, z.max_c)
1316            }
1317            Err(_) => (0, u32::MAX, 0, u32::MAX),
1318        };
1319        write_uvarint(&mut out, min_b as u64);
1320        write_uvarint(&mut out, (max_b - min_b) as u64);
1321        write_uvarint(&mut out, min_c as u64);
1322        write_uvarint(&mut out, (max_c - min_c) as u64);
1323    }
1324    out
1325}
1326
1327/// A parsed v0.2 tile-directory entry: leading-id range plus the tile's byte
1328/// range *within the section payload*.
1329struct TileDirEntry {
1330    min_a: u32,
1331    max_a: u32,
1332    start: u64,
1333    end: u64,
1334}
1335
1336/// One tile's synopsis: inclusive min/max of the two non-leading columns.
1337type TileSynopsis = (u32, u32, u32, u32);
1338
1339/// Parse the **tile-synopsis trailer** (when [`FLAG_TILE_SYNOPSIS`] is set): the
1340/// `num_tiles × (min_b, span_b, min_c, span_c)` records that follow the last tile
1341/// payload, starting at `trailer_start` within `payload`. Returns one synopsis per
1342/// tile, in directory order. `payload` may be just the trailer slice (remote) or
1343/// the whole section (local); `trailer_start` is the offset of the trailer within
1344/// it. A short/garbled trailer yields `None` (the caller keeps `None` synopses —
1345/// pruning simply doesn't fire, never a wrong result).
1346fn parse_tile_synopsis(
1347    payload: &[u8],
1348    trailer_start: usize,
1349    num_tiles: usize,
1350) -> Option<Vec<TileSynopsis>> {
1351    let mut pos = trailer_start;
1352    let take = |pos: &mut usize| -> Option<u32> {
1353        let (v, n) = read_uvarint(payload.get(*pos..)?)?;
1354        *pos += n;
1355        u32::try_from(v).ok()
1356    };
1357    let mut out = Vec::with_capacity(num_tiles.min(payload.len()));
1358    for _ in 0..num_tiles {
1359        let min_b = take(&mut pos)?;
1360        let max_b = min_b.checked_add(take(&mut pos)?)?;
1361        let min_c = take(&mut pos)?;
1362        let max_c = min_c.checked_add(take(&mut pos)?)?;
1363        out.push((min_b, max_b, min_c, max_c));
1364    }
1365    Some(out)
1366}
1367
1368/// Parse a tiled section payload's directory (not the tiles). `bytes` may be a
1369/// **prefix** of the payload (a ranged reader fetches the directory before any
1370/// tile); tile byte ranges are validated against `total_len`, the full payload
1371/// length. Every length is untrusted.
1372fn parse_tile_directory(bytes: &[u8], total_len: u64) -> Result<Vec<TileDirEntry>, FileError> {
1373    let mut pos = 0usize;
1374    let take = |pos: &mut usize| -> Result<u64, FileError> {
1375        let (v, n) = read_uvarint(bytes.get(*pos..).unwrap_or(&[]))
1376            .ok_or(FileError::Container("truncated tile directory"))?;
1377        *pos += n;
1378        Ok(v)
1379    };
1380    let num_tiles = take(&mut pos)? as usize;
1381    let mut entries = Vec::with_capacity(num_tiles.min(bytes.len()));
1382    let mut prev_min = 0u32;
1383    let mut lens = Vec::with_capacity(num_tiles.min(bytes.len()));
1384    for _ in 0..num_tiles {
1385        let dmin = take(&mut pos)? as u32;
1386        let span = take(&mut pos)? as u32;
1387        let len = take(&mut pos)?;
1388        let min_a = prev_min.wrapping_add(dmin);
1389        entries.push(TileDirEntry {
1390            min_a,
1391            max_a: min_a.wrapping_add(span),
1392            start: 0,
1393            end: 0,
1394        });
1395        lens.push(len);
1396        prev_min = min_a;
1397    }
1398    let mut start = pos as u64;
1399    for (e, len) in entries.iter_mut().zip(lens) {
1400        let end = start
1401            .checked_add(len)
1402            .filter(|&e| e <= total_len)
1403            .ok_or(FileError::Container("tile overruns section"))?;
1404        e.start = start;
1405        e.end = end;
1406        start = end;
1407    }
1408    Ok(entries)
1409}
1410
1411/// Fetch and parse a remote tiled section's directory: read a small prefix and
1412/// grow it geometrically until the directory parses, never fetching past the
1413/// section. A directory that still fails on the whole section is corrupt.
1414fn read_tile_directory_ranged<R: RangeReader>(
1415    reader: &R,
1416    section: ByteRange,
1417) -> Result<Vec<TileDirEntry>, FileError> {
1418    let total = section.len;
1419    let mut prefetch = 4096u64.min(total);
1420    loop {
1421        let prefix = reader.read_at(section.offset, prefetch)?;
1422        match parse_tile_directory(&prefix, total) {
1423            Ok(dir) => return Ok(dir),
1424            Err(_) if prefetch < total => prefetch = prefetch.saturating_mul(2).min(total),
1425            Err(e) => return Err(e),
1426        }
1427    }
1428}
1429
1430/// Fetch and parse a remote section's **tile-synopsis trailer** (only when the
1431/// header's [`FLAG_TILE_SYNOPSIS`] is set): one targeted range read of the bytes
1432/// past the last tile, parsed into one synopsis per tile (directory order). A
1433/// missing/short/garbled trailer degrades to all-`None` — pruning simply doesn't
1434/// fire, never a wrong result. The directory gives the trailer's start (the last
1435/// tile's end).
1436fn read_tile_synopsis_ranged<R: RangeReader>(
1437    reader: &R,
1438    section: ByteRange,
1439    dir: &[TileDirEntry],
1440) -> Vec<Option<TileSynopsis>> {
1441    let n = dir.len();
1442    let none = vec![None; n];
1443    let trailer_start = dir.iter().map(|e| e.end).max().unwrap_or(0);
1444    let total = section.len;
1445    if n == 0 || trailer_start >= total {
1446        return none; // no trailer bytes present
1447    }
1448    let trailer_len = total - trailer_start;
1449    let Ok(bytes) = reader.read_at(section.offset + trailer_start, trailer_len) else {
1450        return none;
1451    };
1452    match parse_tile_synopsis(&bytes, 0, n) {
1453        Some(v) => v.into_iter().map(Some).collect(),
1454        None => none,
1455    }
1456}
1457
1458/// Per-tile absolute file ranges of each permutation section, for provenance.
1459/// A malformed directory yields an empty section (provenance degrades, queries
1460/// are unaffected).
1461fn tile_file_ranges(
1462    index_bytes: &[u8],
1463    container_offset: u64,
1464    section_ranges: &[ByteRange; NUM_PERMS],
1465) -> [Vec<(u32, u32, ByteRange)>; NUM_PERMS] {
1466    let mut out: [Vec<(u32, u32, ByteRange)>; NUM_PERMS] = Default::default();
1467    for (section, range) in out.iter_mut().zip(section_ranges) {
1468        // A permutation the file does not carry has a zeroed range. Its
1469        // `offset - container_offset` underflows, and before this guard it
1470        // panicked the whole `open_ranged` path on the first lean file.
1471        if range.len == 0 || range.offset < container_offset {
1472            continue;
1473        }
1474        let start = (range.offset - container_offset) as usize;
1475        let Some(payload) = index_bytes.get(start..start + range.len as usize) else {
1476            continue;
1477        };
1478        if let Ok(dir) = parse_tile_directory(payload, payload.len() as u64) {
1479            *section = dir
1480                .into_iter()
1481                .map(|e| {
1482                    (
1483                        e.min_a,
1484                        e.max_a,
1485                        ByteRange {
1486                            offset: range.offset + e.start,
1487                            len: (e.end - e.start),
1488                        },
1489                    )
1490                })
1491                .collect();
1492        }
1493    }
1494    out
1495}
1496
1497/// Decode a tiled section payload into `(min_a, max_a, uncompressed tile)`
1498/// triples.
1499fn decode_tiled_section(payload: &[u8], codec: u8) -> Result<Vec<(u32, u32, Vec<u8>)>, FileError> {
1500    parse_tile_directory(payload, payload.len() as u64)?
1501        .into_iter()
1502        .map(|e| {
1503            Ok((
1504                e.min_a,
1505                e.max_a,
1506                decompress(codec, &payload[e.start as usize..e.end as usize])?,
1507            ))
1508        })
1509        .collect()
1510}
1511
1512/// Decode the index container: one raw tiled section payload per permutation
1513/// the file carries (`perms`, from the header — [`PermSet::ALL`] for every file
1514/// written before the mask existed), each tile compressed individually.
1515///
1516/// The section count is checked against `perms.len()`, so a file whose header
1517/// and container disagree is rejected rather than silently short-read. A reader
1518/// that does not know about lean files at all reaches the same conclusion by a
1519/// different route: it passes six, the container says three, and this errors —
1520/// which is why a lean file cannot be misread as an empty one.
1521fn decode_index_container(
1522    bytes: &[u8],
1523    codec: u8,
1524    perms: PermSet,
1525) -> Result<GraphIndex, FileError> {
1526    let isecs = decode_container(bytes, CODEC_NONE)?;
1527    if isecs.len() != perms.len() {
1528        return Err(FileError::Container(
1529            "index container section count does not match the header permutation mask",
1530        ));
1531    }
1532    decode_index_sections(isecs, codec, perms)
1533}
1534
1535fn decode_index_sections(
1536    mut isecs: Vec<Vec<u8>>,
1537    codec: u8,
1538    perms: PermSet,
1539) -> Result<GraphIndex, FileError> {
1540    let mut sections: [Vec<(u32, u32, Vec<u8>)>; NUM_PERMS] = Default::default();
1541    for (perm, sec) in perms.iter().zip(isecs.iter_mut()) {
1542        sections[perm.section_index()] = decode_tiled_section(sec, codec)?;
1543    }
1544    Ok(GraphIndex::from_tiles(sections, perms))
1545}
1546
1547/// Decode the file's **root** index container (the default graph): the
1548/// permutation sections, plus — on a quad-index file — the trailing seal, which
1549/// is checked and set aside. Returns the seal's digest of the QUAD_INDEX
1550/// section when there is one.
1551fn decode_root_index_container(
1552    bytes: &[u8],
1553    header: &Header,
1554) -> Result<(GraphIndex, Option<[u8; 16]>), FileError> {
1555    if !header.has_quad_index() {
1556        return Ok((
1557            decode_index_container(bytes, header.block_codec, header.perms)?,
1558            None,
1559        ));
1560    }
1561    let mut isecs = decode_container(bytes, CODEC_NONE)?;
1562    if isecs.len() != header.index_container_sections() {
1563        return Err(FileError::Container(
1564            "index container section count does not match the header permutation mask",
1565        ));
1566    }
1567    let seal = isecs.pop().unwrap_or_default();
1568    let digest = crate::quad::parse_seal(&seal)?;
1569    Ok((
1570        decode_index_sections(isecs, header.block_codec, header.perms)?,
1571        Some(digest),
1572    ))
1573}
1574
1575/// The named-graph layout a header announces must be the one its directory
1576/// holds: the quad flag with a QUAD_INDEX section and no NAMED_GRAPHS section,
1577/// or no flag and no QUAD_INDEX section. Anything else is a corrupt (or a
1578/// hand-edited) header, refused before either layout is read.
1579fn check_named_layout(header: &Header) -> Result<(), FileError> {
1580    let quad = header.quad_index_section().is_some();
1581    if header.has_quad_index() {
1582        if !quad {
1583            return Err(FileError::Container(
1584                "quad-index flag set but the file has no quad-index section",
1585            ));
1586        }
1587        if header.named_graphs_len > 0 {
1588            return Err(FileError::Container(
1589                "a file cannot carry both a quad index and a named-graphs section",
1590            ));
1591        }
1592        crate::quad::check_perms(header.perms)?;
1593    } else if quad {
1594        return Err(FileError::Container(
1595            "quad-index section present but the header does not flag it",
1596        ));
1597    }
1598    Ok(())
1599}
1600
1601fn container_section_payload_ranges(
1602    bytes: &[u8],
1603    container_offset: u64,
1604    expected_sections: usize,
1605) -> Result<Vec<ByteRange>, FileError> {
1606    let (section_count, mut pos) =
1607        read_uvarint(bytes).ok_or(FileError::Container("truncated count"))?;
1608    let section_count = usize::try_from(section_count)
1609        .map_err(|_| FileError::Container("section count too large"))?;
1610    if section_count != expected_sections {
1611        return Err(FileError::Container("unexpected section count"));
1612    }
1613
1614    let mut ranges = Vec::with_capacity(section_count);
1615    for _ in 0..section_count {
1616        let remaining = bytes
1617            .get(pos..)
1618            .ok_or(FileError::Container("truncated length"))?;
1619        let (payload_len, used) =
1620            read_uvarint(remaining).ok_or(FileError::Container("truncated length"))?;
1621        pos = pos
1622            .checked_add(used)
1623            .ok_or(FileError::Container("section range overflows"))?;
1624        let payload_len_usize = usize::try_from(payload_len)
1625            .map_err(|_| FileError::Container("section length too large"))?;
1626        let payload_end = pos
1627            .checked_add(payload_len_usize)
1628            .ok_or(FileError::Container("section range overflows"))?;
1629        if payload_end > bytes.len() {
1630            return Err(FileError::Container("section overruns buffer"));
1631        }
1632        ranges.push(ByteRange {
1633            offset: checked_end(container_offset, pos as u64)?,
1634            len: payload_len,
1635        });
1636        pos = payload_end;
1637    }
1638
1639    Ok(ranges)
1640}
1641
1642/// The permutation payload ranges of an index container holding `sections`
1643/// sections (the permutations, then — on a quad-index root — the seal, which
1644/// is not a permutation and is left out).
1645fn decode_index_section_ranges(
1646    bytes: &[u8],
1647    container_offset: u64,
1648    perms: PermSet,
1649    sections: usize,
1650) -> Result<[ByteRange; NUM_PERMS], FileError> {
1651    let ranges = container_section_payload_ranges(bytes, container_offset, sections)?;
1652    let mut out = [ByteRange { offset: 0, len: 0 }; NUM_PERMS];
1653    for (perm, range) in perms.iter().zip(ranges) {
1654        out[perm.section_index()] = range;
1655    }
1656    Ok(out)
1657}
1658
1659fn read_uvarint_at<R: RangeReader + ?Sized>(
1660    reader: &R,
1661    absolute_offset: u64,
1662    container_end: u64,
1663) -> Result<(u64, u64), FileError> {
1664    if absolute_offset >= container_end {
1665        return Err(FileError::Container("truncated container varint"));
1666    }
1667    let remaining = container_end - absolute_offset;
1668    let probe_len = remaining.min(10);
1669    let bytes = reader.read_at(absolute_offset, probe_len)?;
1670    read_uvarint(&bytes)
1671        .map(|(value, used)| (value, used as u64))
1672        .ok_or(FileError::Container("truncated container varint"))
1673}
1674
1675/// Locate one section's payload byte range inside a remote container, walking
1676/// only the (tiny) varint framing — no payload bytes are fetched.
1677fn locate_container_section_ranged<R: RangeReader + ?Sized>(
1678    reader: &R,
1679    container_offset: u64,
1680    container_len: u64,
1681    section_index: usize,
1682    expected_sections: u64,
1683) -> Result<ByteRange, FileError> {
1684    let container_end = checked_end(container_offset, container_len)?;
1685    let (section_count, used) = read_uvarint_at(reader, container_offset, container_end)?;
1686    if section_count != expected_sections {
1687        return Err(FileError::Container("unexpected container section count"));
1688    }
1689    if section_index >= section_count as usize {
1690        return Err(FileError::Container(
1691            "container section index out of bounds",
1692        ));
1693    }
1694
1695    let mut pos = checked_end(container_offset, used)?;
1696    for i in 0..section_count as usize {
1697        let (payload_len, len_used) = read_uvarint_at(reader, pos, container_end)?;
1698        pos = checked_end(pos, len_used)?;
1699        let payload_end = checked_end(pos, payload_len)?;
1700        if payload_end > container_end {
1701            return Err(FileError::Container("section overruns buffer"));
1702        }
1703        if i == section_index {
1704            return Ok(ByteRange {
1705                offset: pos,
1706                len: payload_len,
1707            });
1708        }
1709        pos = payload_end;
1710    }
1711    Err(FileError::Container("container section not found"))
1712}
1713
1714/// Open one index container (six tiled permutation sections) **lazily** over a
1715/// range reader: fetch each section's tile directory (and synopsis trailer,
1716/// when the file has one) but no tile payloads — tiles fault in on first scan.
1717/// Returns the index plus its section and tile file ranges (provenance).
1718///
1719/// This is the machinery `open_ranged_lazy` always used for the default
1720/// graph's container at `root_dir_offset`; a named graph's container is the
1721/// same format at a different offset, so the lazy named-graphs path opens a
1722/// LARGE graph through this too instead of decoding it resident.
1723#[allow(clippy::type_complexity)]
1724fn open_index_container_lazy(
1725    reader: &std::sync::Arc<dyn RangeReader + Send + Sync>,
1726    container: ByteRange,
1727    block_codec: u8,
1728    has_synopsis: bool,
1729    read_concurrency: usize,
1730    perms: PermSet,
1731    sections: usize,
1732) -> Result<
1733    (
1734        GraphIndex,
1735        [ByteRange; NUM_PERMS],
1736        [Vec<(u32, u32, ByteRange)>; NUM_PERMS],
1737    ),
1738    FileError,
1739> {
1740    let mut index_section_ranges = [ByteRange { offset: 0, len: 0 }; NUM_PERMS];
1741    let mut tile_ranges: [Vec<(u32, u32, ByteRange)>; NUM_PERMS] = Default::default();
1742    #[allow(clippy::type_complexity)]
1743    let mut directories: [Vec<(u32, u32, Option<TileSynopsis>)>; NUM_PERMS] = Default::default();
1744    for (pos, perm) in perms.iter().enumerate() {
1745        let si = perm.section_index();
1746        let section = locate_container_section_ranged(
1747            reader,
1748            container.offset,
1749            container.len,
1750            pos,
1751            sections as u64,
1752        )?;
1753        index_section_ranges[si] = section;
1754        let dir = read_tile_directory_ranged(reader, section)?;
1755        // Tile synopses (one extra small tail read per section) let a routed
1756        // scan prune a tile by a bound secondary component before faulting it.
1757        let syn = if has_synopsis {
1758            read_tile_synopsis_ranged(reader, section, &dir)
1759        } else {
1760            vec![None; dir.len()]
1761        };
1762        directories[si] = dir
1763            .iter()
1764            .zip(syn)
1765            .map(|(e, s)| (e.min_a, e.max_a, s))
1766            .collect();
1767        tile_ranges[si] = dir
1768            .into_iter()
1769            .map(|e| {
1770                (
1771                    e.min_a,
1772                    e.max_a,
1773                    ByteRange {
1774                        offset: section.offset + e.start,
1775                        len: (e.end - e.start),
1776                    },
1777                )
1778            })
1779            .collect();
1780    }
1781
1782    // The loader fetches and decompresses one tile per call; the bulk
1783    // loader serves multi-tile scans by coalescing adjacent tile ranges
1784    // into single range reads (tiles are back-to-back in their section,
1785    // so a full-section scan is typically one request).
1786    let codec = block_codec;
1787    let loader_ranges = tile_ranges.clone();
1788    let loader_reader = reader.clone();
1789    let loader: crate::index::TileLoader = Box::new(move |si, ti| {
1790        let (_, _, range) = loader_ranges.get(si)?.get(ti)?;
1791        let bytes = loader_reader.read_at(range.offset, range.len).ok()?;
1792        decompress(codec, &bytes).ok()
1793    });
1794    let bulk_ranges = tile_ranges.clone();
1795    let bulk_reader = reader.clone();
1796    let bulk: crate::index::TileBulkLoader = Box::new(move |si, tis| {
1797        let section = bulk_ranges.get(si)?;
1798        let want: Option<Vec<ByteRange>> = tis
1799            .iter()
1800            .map(|&ti| section.get(ti).map(|&(_, _, r)| r))
1801            .collect();
1802        let blobs = read_coalesced(bulk_reader.as_ref(), &want?, TILE_COALESCE_GAP)?;
1803        blobs.iter().map(|b| decompress(codec, b).ok()).collect()
1804    });
1805    let mut index =
1806        GraphIndex::from_remote_directories(directories, perms, loader).with_bulk_loader(bulk);
1807    // Per-tile encoded lengths (from the directory) feed the join planner's
1808    // fatness gates — free here, unavailable later without a fetch.
1809    index.set_tile_lens(std::array::from_fn(|si| {
1810        tile_ranges[si]
1811            .iter()
1812            .map(|&(_, _, r)| r.len.min(u32::MAX as u64) as u32)
1813            .collect()
1814    }));
1815    // The reader's fan-out widens the planner's remote probe budget: a
1816    // desktop/CLI reader overlapping 16 range reads probes far more cheaply
1817    // than a phone's serial sync-XHR path.
1818    index.set_read_concurrency(read_concurrency);
1819    Ok((index, index_section_ranges, tile_ranges))
1820}
1821
1822/// Serialize a complete `.rete` file image from a dictionary, index, and an
1823/// (optionally empty) encoded pyramid-meta section. `pyramid_levels` records the
1824/// number of dendrogram rounds the pyramid spans (0 if no pyramid).
1825pub fn write_file(
1826    dict: &Dictionary,
1827    index: &GraphIndex,
1828    has_quads: bool,
1829    pyramid_meta: &[u8],
1830    pyramid_levels: u16,
1831) -> Vec<u8> {
1832    write_dataset(dict, index, &[], has_quads, pyramid_meta, pyramid_levels)
1833}
1834
1835/// Encode an index container (v0.2): one raw tiled section payload per
1836/// permutation the index carries, in [`crate::index::ALL_PERMS`] order, tiles
1837/// compressed individually with `codec`. A three-permutation index writes three
1838/// sections — not six with three empty, which would be indistinguishable from
1839/// an empty graph to a reader that does not check the header mask.
1840pub(crate) fn encode_index_container(index: &GraphIndex, codec: u8) -> Vec<u8> {
1841    encode_index_container_sealed(index, codec, None)
1842}
1843
1844/// [`encode_index_container`] with an optional trailing **seal** section —
1845/// the root container of a quad-index file (see `quad.rs`). The seal is what
1846/// makes every reader that predates the quad index refuse the file: they all
1847/// check the container's section count before reading a payload.
1848fn encode_index_container_sealed(index: &GraphIndex, codec: u8, seal: Option<&[u8]>) -> Vec<u8> {
1849    let payloads: Vec<Vec<u8>> = index
1850        .perms()
1851        .iter()
1852        .map(|perm| encode_tiled_section(index, perm.section_index(), codec))
1853        .collect();
1854    let mut refs: Vec<&[u8]> = payloads.iter().map(|p| p.as_slice()).collect();
1855    if let Some(seal) = seal {
1856        refs.push(seal);
1857    }
1858    encode_container(&refs, CODEC_NONE)
1859}
1860
1861/// Encode the named-graphs section: each graph as `(iri, permutation container)`.
1862fn encode_named_graphs(named: &[(String, GraphIndex)], codec: u8) -> Vec<u8> {
1863    let mut out = Vec::new();
1864    write_uvarint(&mut out, named.len() as u64);
1865    for (iri, index) in named {
1866        write_uvarint(&mut out, iri.len() as u64);
1867        out.extend_from_slice(iri.as_bytes());
1868        let container = encode_index_container(index, codec);
1869        write_uvarint(&mut out, container.len() as u64);
1870        out.extend_from_slice(&container);
1871    }
1872    out
1873}
1874
1875fn decode_named_graphs(
1876    bytes: &[u8],
1877    codec: u8,
1878    perms: PermSet,
1879) -> Result<Vec<(String, GraphIndex)>, FileError> {
1880    let (n, mut pos) = read_uvarint(bytes).ok_or(FileError::Container("truncated graph count"))?;
1881    // Bounds-checked slice within this (already bounded) section. Lengths read
1882    // below are untrusted, so every range is validated before indexing.
1883    let bound = |start: usize, len: u64| -> Result<usize, FileError> {
1884        start
1885            .checked_add(len as usize)
1886            .filter(|&e| e <= bytes.len())
1887            .ok_or(FileError::Container("named-graph field overruns buffer"))
1888    };
1889    let mut out = Vec::with_capacity((n as usize).min(bytes.len()));
1890    for _ in 0..n {
1891        let (ilen, u1) = read_uvarint(bytes.get(pos..).unwrap_or(&[]))
1892            .ok_or(FileError::Container("truncated iri len"))?;
1893        pos += u1;
1894        let iend = bound(pos, ilen)?;
1895        let iri = String::from_utf8_lossy(&bytes[pos..iend]).into_owned();
1896        pos = iend;
1897        let (clen, u2) = read_uvarint(bytes.get(pos..).unwrap_or(&[]))
1898            .ok_or(FileError::Container("truncated container len"))?;
1899        pos += u2;
1900        let cend = bound(pos, clen)?;
1901        let index = decode_index_container(&bytes[pos..cend], codec, perms)?;
1902        out.push((iri, index));
1903        pos = cend;
1904    }
1905    Ok(out)
1906}
1907
1908/// Serialize a full RDF *dataset*: the default-graph index plus zero or more
1909/// named graphs `(iri, index)`, all sharing one dictionary.
1910pub fn write_dataset(
1911    dict: &Dictionary,
1912    default_index: &GraphIndex,
1913    named: &[(String, GraphIndex)],
1914    has_quads: bool,
1915    pyramid_meta: &[u8],
1916    pyramid_levels: u16,
1917) -> Vec<u8> {
1918    write_dataset_with_metadata(
1919        dict,
1920        default_index,
1921        named,
1922        has_quads,
1923        pyramid_meta,
1924        pyramid_levels,
1925        &[],
1926        &[],
1927    )
1928}
1929
1930/// Serialize a dictionary to its on-file container bytes (4 front-coded, chunked
1931/// sections). Exposed so a low-RAM build can serialize **and drop** the live
1932/// `Dictionary` before building the permutation index — the index build works on
1933/// id-triples and never needs the dictionary.
1934pub(crate) fn encode_dict_container(dict: &Dictionary, codec: u8) -> Vec<u8> {
1935    let raw_sections = dict.sections();
1936    let dict_payloads: Vec<Vec<u8>> = raw_sections
1937        .iter()
1938        .map(|raw| encode_chunked_dict_section(raw, codec))
1939        .collect();
1940    encode_container(
1941        &[
1942            dict_payloads[0].as_slice(),
1943            dict_payloads[1].as_slice(),
1944            dict_payloads[2].as_slice(),
1945            dict_payloads[3].as_slice(),
1946        ],
1947        CODEC_NONE,
1948    )
1949}
1950
1951/// Serialize a dataset with an opaque **metadata** payload occupying the file's
1952/// metadata section (the application layer defines its meaning — the CLI stores a
1953/// JSON Dataset Card there). The section sits immediately after the header and
1954/// before the dictionary, so `metadata_offset` stays at `HEADER_LEN` and every
1955/// downstream section shifts by `metadata.len()`. The payload is folded into the
1956/// `content_hash`, so `verify` covers it and it is tamper-evident.
1957///
1958/// Passing an empty `metadata` is byte-identical to [`write_dataset`]: the section
1959/// is omitted (`metadata_len = 0`, `dictionary_offset = HEADER_LEN`) and the hash
1960/// is computed over exactly the same parts (a zero-length hash update is a no-op).
1961#[allow(clippy::too_many_arguments)]
1962pub fn write_dataset_with_metadata(
1963    dict: &Dictionary,
1964    default_index: &GraphIndex,
1965    named: &[(String, GraphIndex)],
1966    has_quads: bool,
1967    pyramid_meta: &[u8],
1968    pyramid_levels: u16,
1969    metadata: &[u8],
1970    text_index: &[u8],
1971) -> Vec<u8> {
1972    let codec = writer_codec();
1973    let dict_container = encode_dict_container(dict, codec);
1974    // This entry point takes a finished section, so it reads the section's own
1975    // scope block to pick the directory kind (6, or 11 for a partial index) and
1976    // the header's named-graphs bit.
1977    let scope = (!text_index.is_empty())
1978        .then(|| crate::text_index::TextIndex::from_section(text_index, codec).ok())
1979        .flatten()
1980        .map(|ti| ti.scope().clone());
1981    let text_index_partial = scope
1982        .as_ref()
1983        .is_some_and(crate::text_index::TextIndexScope::restricts_predicates);
1984    let text_index_named = scope
1985        .as_ref()
1986        .is_some_and(crate::text_index::TextIndexScope::covers_named_graphs);
1987    write_dataset_from_parts(
1988        &dict_container,
1989        dict.term_count() as u64,
1990        default_index,
1991        NamedPart::PerGraph(named),
1992        has_quads,
1993        dict.has_quoted_triples(),
1994        pyramid_meta,
1995        pyramid_levels,
1996        metadata,
1997        text_index,
1998        text_index_partial,
1999        text_index_named,
2000        codec,
2001    )
2002}
2003
2004/// How the named graphs reach the writer: one index per graph (the
2005/// NAMED_GRAPHS section, kind 5), or an encoded quad index (kind 10).
2006pub(crate) enum NamedPart<'a> {
2007    PerGraph(&'a [(String, GraphIndex)]),
2008    Quad(&'a crate::quad::EncodedQuadIndex),
2009}
2010
2011/// Assemble the final file image from an **already-serialized** dictionary
2012/// container (so the caller can drop the live `Dictionary` before calling this)
2013/// plus the permutation index and optional sections. The byte output is identical
2014/// to serializing the dictionary inline.
2015#[allow(clippy::too_many_arguments)]
2016pub(crate) fn write_dataset_from_parts(
2017    dict_container: &[u8],
2018    term_count: u64,
2019    default_index: &GraphIndex,
2020    named: NamedPart<'_>,
2021    has_quads: bool,
2022    has_quoted_triples: bool,
2023    pyramid_meta: &[u8],
2024    pyramid_levels: u16,
2025    metadata: &[u8],
2026    text_index: &[u8],
2027    text_index_partial: bool,
2028    text_index_named: bool,
2029    codec: u8,
2030) -> Vec<u8> {
2031    // The named graphs: either the per-graph section (kind 5) or the quad index
2032    // (kind 10), whose digest the root container's seal carries. An empty quad
2033    // index is written as no named graphs at all.
2034    let (named_section, quad, named_quads) = match named {
2035        NamedPart::PerGraph(named) => (
2036            if named.is_empty() {
2037                Vec::new()
2038            } else {
2039                encode_named_graphs(named, codec)
2040            },
2041            None,
2042            named
2043                .iter()
2044                .map(|(_, idx)| idx.triple_count() as u64)
2045                .sum::<u64>(),
2046        ),
2047        NamedPart::Quad(q) if q.graph_count == 0 => (Vec::new(), None, 0),
2048        NamedPart::Quad(q) => (Vec::new(), Some(q), q.quad_count),
2049    };
2050    let seal = quad.map(|q| crate::quad::encode_seal(crate::quad::digest(&q.bytes)));
2051    let index_container = encode_index_container_sealed(default_index, codec, seal.as_deref());
2052    // A predicate-restricted index (kind 11) is outside the content hash, like
2053    // kinds 8 and 9, and starts with its own digest instead: a reader older
2054    // than 0.4.0 does not see kind 11, so a hashed one would make its `verify`
2055    // call every such file corrupt. A kind-6 index is hashed, as it always was.
2056    let text_index_partial = text_index_partial && !text_index.is_empty();
2057    let digested;
2058    let text_index: &[u8] = if text_index_partial {
2059        digested = crate::text_index::with_partial_digest(text_index);
2060        &digested
2061    } else {
2062        text_index
2063    };
2064
2065    // The metadata section (if any) sits between the header and the dictionary,
2066    // so the dictionary — and everything after it — shifts forward by its length.
2067    let meta_section_len = metadata.len() as u64;
2068    let dict_offset = HEADER_LEN as u64 + meta_section_len;
2069    let dict_len = dict_container.len() as u64;
2070    let index_offset = dict_offset + dict_len;
2071    let index_len = index_container.len() as u64;
2072    let pyr_offset = index_offset + index_len;
2073    let pyr_len = pyramid_meta.len() as u64;
2074    // Optional full-text index between the pyramid and the named graphs.
2075    let text_offset = pyr_offset + pyr_len;
2076    let text_len = text_index.len() as u64;
2077    let named_offset = text_offset + text_len;
2078    let named_len = named_section.len() as u64;
2079    // The quad index sits where the named-graphs section would (the two are
2080    // exclusive).
2081    let quad_len = quad.map_or(0, |q| q.bytes.len() as u64);
2082
2083    // Hash parts in physical order, with the metadata payload prepended when
2084    // present. Omitting it entirely (rather than hashing an empty slice) keeps the
2085    // no-metadata output's hash byte-identical to the pre-metadata writer.
2086    // `verify()` rebuilds this exact list from the header — any section added
2087    // here must be added there too (and covered by a tamper test).
2088    let mut parts: Vec<&[u8]> = Vec::with_capacity(5);
2089    if meta_section_len > 0 {
2090        parts.push(metadata);
2091    }
2092    parts.push(dict_container);
2093    parts.push(&index_container);
2094    parts.push(pyramid_meta);
2095    if text_len > 0 && !text_index_partial {
2096        parts.push(text_index);
2097    }
2098    if named_len > 0 {
2099        parts.push(&named_section);
2100    }
2101
2102    // Length of the trailing schema-pyramid block (0 if none), so a reader can
2103    // fetch just that block for an index/dictionary/summary-free Tier-0 read.
2104    let schema_meta_len = crate::meta::schema_block_len(pyramid_meta);
2105
2106    // The QUAD_INDEX section is not a hash part of its own: the seal inside the
2107    // index container carries its digest, so the content hash covers it — and a
2108    // reader that predates it computes the same hash over the same parts.
2109    let header = Header {
2110        version: crate::header::CURRENT_FORMAT_VERSION,
2111        flags: FLAG_TILE_SYNOPSIS
2112            | if has_quads { FLAG_HAS_QUADS } else { 0 }
2113            | if quad.is_some() {
2114                crate::header::FLAG_QUAD_INDEX
2115            } else {
2116                0
2117            }
2118            | if has_quoted_triples {
2119                FLAG_HAS_QUOTED_TRIPLES
2120            } else {
2121                0
2122            }
2123            | if text_len > 0 && text_index_named {
2124                crate::header::FLAG_TEXT_INDEX_NAMED_GRAPHS
2125            } else {
2126                0
2127            },
2128        metadata_offset: HEADER_LEN as u64,
2129        metadata_len: meta_section_len,
2130        dictionary_offset: dict_offset,
2131        dictionary_len: dict_len,
2132        root_dir_offset: index_offset,
2133        root_dir_len: index_len,
2134        pyramid_meta_offset: if pyr_len > 0 { pyr_offset } else { 0 },
2135        pyramid_meta_len: pyr_len,
2136        dict_codec: codec,
2137        block_codec: codec,
2138        pyramid_levels,
2139        // Every container in the file — default graph and named graphs alike —
2140        // is written from an index built with the same set, so the mask is a
2141        // file-level fact and the default index is its source of truth.
2142        perms: default_index.perms(),
2143        quad_count: default_index.triple_count() as u64 + named_quads,
2144        term_count,
2145        content_hash: content_hash(&parts),
2146        named_graphs_offset: if named_len > 0 { named_offset } else { 0 },
2147        named_graphs_len: named_len,
2148        schema_meta_len,
2149        text_index_offset: if text_len > 0 { text_offset } else { 0 },
2150        text_index_len: text_len,
2151        text_index_partial: text_len > 0 && text_index_partial,
2152        build_info_offset: 0,
2153        build_info_len: 0,
2154        extra_sections: if quad_len > 0 {
2155            vec![crate::header::Section {
2156                kind: crate::header::SectionKind::QuadIndex,
2157                flags: 0,
2158                offset: named_offset,
2159                length: quad_len,
2160            }]
2161        } else {
2162            Vec::new()
2163        },
2164    };
2165
2166    let mut out = Vec::with_capacity(
2167        HEADER_LEN
2168            + metadata.len()
2169            + dict_container.len()
2170            + index_container.len()
2171            + pyramid_meta.len()
2172            + text_index.len()
2173            + named_section.len()
2174            + quad_len as usize
2175            + MAGIC.len(),
2176    );
2177    out.extend_from_slice(&header.to_bytes());
2178    if meta_section_len > 0 {
2179        out.extend_from_slice(metadata);
2180    }
2181    out.extend_from_slice(dict_container);
2182    out.extend_from_slice(&index_container);
2183    out.extend_from_slice(pyramid_meta);
2184    if text_len > 0 {
2185        out.extend_from_slice(text_index);
2186    }
2187    if named_len > 0 {
2188        out.extend_from_slice(&named_section);
2189    }
2190    if let Some(q) = quad {
2191        out.extend_from_slice(&q.bytes);
2192    }
2193    out.extend_from_slice(&MAGIC); // footer marker
2194    out
2195}
2196
2197/// `rdf:type` — the predicate that assigns a class to a resource.
2198pub const RDF_TYPE: &str = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
2199
2200/// An **ontology-aware** coarse graph: instead of structural communities, group
2201/// entities by their `rdf:type` class and aggregate relations between classes.
2202/// Returns `(subject_class, predicate, object_class, count)` over the default
2203/// graph. Entities with no type are `(untyped)`; literals are `(literal)`.
2204/// `rdf:type` triples themselves define the classes and are not counted as
2205/// relations. This is the dataset's effective schema with instance volumes.
2206pub fn schema_summary(rete: &Rete) -> Vec<(String, String, String, u32)> {
2207    use std::collections::{BTreeMap, HashMap};
2208    let triples = rete.dump(None);
2209
2210    let mut class_of: HashMap<&str, &str> = HashMap::new();
2211    for (s, p, o) in &triples {
2212        if p == RDF_TYPE {
2213            class_of.insert(s.as_str(), o.as_str());
2214        }
2215    }
2216    let classify = |t: &str| -> String {
2217        if let Some(c) = class_of.get(t) {
2218            (*c).to_string()
2219        } else if t.starts_with('"') {
2220            "(literal)".to_string()
2221        } else {
2222            "(untyped)".to_string()
2223        }
2224    };
2225
2226    let mut counts: BTreeMap<(String, String, String), u32> = BTreeMap::new();
2227    for (s, p, o) in &triples {
2228        if p == RDF_TYPE {
2229            continue; // type assertions define classes, not data relations
2230        }
2231        *counts
2232            .entry((classify(s), p.clone(), classify(o)))
2233            .or_default() += 1;
2234    }
2235    counts
2236        .into_iter()
2237        .map(|((a, p, b), c)| (a, p, b, c))
2238        .collect()
2239}
2240
2241/// Class populations: the number of resources of each `rdf:type` class in the
2242/// default graph, descending by count. The instance-count companion to
2243/// [`schema_summary`].
2244pub fn schema_classes(rete: &Rete) -> Vec<(String, u32)> {
2245    use std::collections::BTreeMap;
2246    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
2247    for (_s, p, o) in rete.dump(None) {
2248        if p == RDF_TYPE {
2249            *counts.entry(o).or_default() += 1;
2250        }
2251    }
2252    let mut out: Vec<(String, u32)> = counts.into_iter().collect();
2253    out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2254    out
2255}
2256
2257/// Fetch **only** the metadata section (the opaque Dataset Card blob) via a
2258/// [`RangeReader`]: read the 1 KiB header, then the metadata byte range —
2259/// nothing else. This is the index-free CARD tier of the exploration model: a
2260/// remote/S3 client learns the dataset's self-description in **two small range
2261/// requests**, never touching the dictionary, index, or pyramid. Returns `None`
2262/// when the file carries no metadata.
2263///
2264/// Companion to [`Rete::open_ranged`] (which deliberately *skips* the card to
2265/// keep the query path minimal); this is the explicit "I want the card" path.
2266pub fn read_metadata_ranged<R: RangeReader>(reader: &R) -> Result<Option<Vec<u8>>, FileError> {
2267    let head = reader.read_at(0, HEADER_LEN as u64)?;
2268    let header = Header::from_bytes(&head)?;
2269    if header.metadata_len == 0 {
2270        return Ok(None);
2271    }
2272    let bytes = reader.read_at(header.metadata_offset, header.metadata_len)?;
2273    Ok(Some(bytes))
2274}
2275
2276/// Fetch the metadata (Dataset Card) **and** build-info sections via a
2277/// [`RangeReader`] in the fewest requests: the 1 KiB header, then — because the
2278/// writer lays the build-info section immediately after the metadata — **one**
2279/// coalesced range covering both. A file with only one of the two costs the
2280/// same two requests; a file with neither costs just the header read. This
2281/// keeps "card + build conditions" within the CARD tier's 1 header + 1 range
2282/// budget instead of adding a third request.
2283///
2284/// Returns `(metadata, build_info)`, each `None` when absent.
2285#[allow(clippy::type_complexity)]
2286pub fn read_card_and_build_info_ranged<R: RangeReader>(
2287    reader: &R,
2288) -> Result<(Option<Vec<u8>>, Option<Vec<u8>>), FileError> {
2289    read_card_and_build_info_with_header(reader).map(|(_, m, b)| (m, b))
2290}
2291
2292/// [`read_card_and_build_info_ranged`], also returning the parsed [`Header`] it
2293/// had to read anyway.
2294///
2295/// The header is not a bonus: it carries the content hash and the section
2296/// directory — including whether the file has a TEXT_INDEX section, which the
2297/// card does not state (see
2298/// [`read_text_index_token_table_len_ranged`]). A caller that wants both and
2299/// used the tuple-only form would issue a **second** 1 KiB request for bytes it
2300/// already had, turning the CARD tier's documented "one header + one coalesced
2301/// range" into three requests.
2302#[allow(clippy::type_complexity)]
2303pub fn read_card_and_build_info_with_header<R: RangeReader>(
2304    reader: &R,
2305) -> Result<(Header, Option<Vec<u8>>, Option<Vec<u8>>), FileError> {
2306    let head = reader.read_at(0, HEADER_LEN as u64)?;
2307    let header = Header::from_bytes(&head)?;
2308    let meta = (header.metadata_offset, header.metadata_len);
2309    let build = (header.build_info_offset, header.build_info_len);
2310    if meta.1 > 0 && build.1 > 0 && build.0 == meta.0 + meta.1 {
2311        // Adjacent (the layout this crate writes): one read spans both.
2312        let both = reader.read_at(meta.0, meta.1 + build.1)?;
2313        let (m, b) = both.split_at(meta.1 as usize);
2314        return Ok((header, Some(m.to_vec()), Some(b.to_vec())));
2315    }
2316    let fetch = |off: u64, len: u64| -> Result<Option<Vec<u8>>, FileError> {
2317        if len == 0 {
2318            return Ok(None);
2319        }
2320        Ok(Some(reader.read_at(off, len)?))
2321    };
2322    Ok((header, fetch(meta.0, meta.1)?, fetch(build.0, build.1)?))
2323}
2324
2325/// The build-info section of a whole file image, or `None` if absent. The
2326/// payload is an opaque application-layer blob (the CLI stores build-conditions
2327/// JSON there); it is **not** covered by the content hash — see
2328/// [`attach_build_info`].
2329pub fn read_build_info(bytes: &[u8]) -> Result<Option<Vec<u8>>, FileError> {
2330    let header = Header::from_bytes(bytes)?;
2331    if header.build_info_len == 0 {
2332        return Ok(None);
2333    }
2334    let start = header.build_info_offset as usize;
2335    let end = start
2336        .checked_add(header.build_info_len as usize)
2337        .filter(|&e| e <= bytes.len())
2338        .ok_or(FileError::Container("build-info section overruns buffer"))?;
2339    Ok(Some(bytes[start..end].to_vec()))
2340}
2341
2342/// Everything needed to splice a build-info section into a finished file
2343/// **without holding the file in memory**: the rewritten header, where the
2344/// section goes, and where the bytes that do not move resume.
2345///
2346/// See [`plan_build_info`] for what the fields mean together; the layout is
2347/// always `[header][metadata][build info][everything else, verbatim]`.
2348#[derive(Debug, Clone, PartialEq, Eq)]
2349pub struct BuildInfoPlan {
2350    /// The header bytes to write at offset 0.
2351    pub header: [u8; HEADER_LEN],
2352    /// Offset the new section is written at — equivalently, the length of the
2353    /// unchanged `[header][metadata]` prefix.
2354    pub insert: u64,
2355    /// Offset in the **old** file where the bytes that do not move resume. The
2356    /// span `insert..tail_start` is the old build-info section, if any, and is
2357    /// the only part of the old file the new one drops.
2358    pub tail_start: u64,
2359    /// Length of the file this plan produces.
2360    pub new_len: u64,
2361}
2362
2363/// Plan the splice of a build-info section of `info_len` bytes into a file
2364/// whose first [`HEADER_LEN`] bytes are `head` and whose total length is
2365/// `file_len`.
2366///
2367/// The section goes immediately after the metadata section — adjacent, so
2368/// [`read_card_and_build_info_ranged`] fetches card + build info in one
2369/// coalesced range — and every later section's offset shifts by the length
2370/// delta. That shift is the whole of the arithmetic, and it lives here so the
2371/// in-memory splice ([`attach_build_info`]) and any streaming rewriter derive
2372/// the same header from the same rule instead of each carrying a copy that can
2373/// drift.
2374///
2375/// The content hash is **deliberately left untouched**: build info records the
2376/// facts that differ between two builds of identical data (timestamp, builder,
2377/// measured costs), and folding them into the hash would break the
2378/// reproducible-hash property. `verify` accordingly ignores this section, on an
2379/// old reader (which sees an unknown kind-7 entry) and a new one alike — so a
2380/// file that gains, loses or changes a build record **keeps its content
2381/// identity**.
2382///
2383/// `info_len` of 0 plans the removal of an existing section.
2384pub fn plan_build_info(
2385    head: &[u8],
2386    file_len: u64,
2387    info_len: u64,
2388) -> Result<BuildInfoPlan, FileError> {
2389    let mut header = Header::from_bytes(head)?;
2390    // The insert point: immediately after the metadata section. The writers in
2391    // this crate always place metadata (when present) at HEADER_LEN.
2392    let insert = HEADER_LEN as u64 + header.metadata_len;
2393    let old_len = header.build_info_len;
2394    if old_len > 0 && header.build_info_offset != insert {
2395        return Err(FileError::Container(
2396            "existing build-info section is not adjacent to the metadata",
2397        ));
2398    }
2399    let tail_start = insert.saturating_add(old_len);
2400    if tail_start > file_len || insert > file_len {
2401        return Err(FileError::Container("build-info splice out of bounds"));
2402    }
2403
2404    // Shift every section that lives at or after the old tail.
2405    let shift = |off: &mut u64, len: u64| {
2406        if len > 0 && *off >= tail_start {
2407            *off = *off - old_len + info_len;
2408        }
2409    };
2410    shift(&mut header.dictionary_offset, header.dictionary_len);
2411    shift(&mut header.root_dir_offset, header.root_dir_len);
2412    shift(&mut header.pyramid_meta_offset, header.pyramid_meta_len);
2413    shift(&mut header.named_graphs_offset, header.named_graphs_len);
2414    shift(&mut header.text_index_offset, header.text_index_len);
2415    for s in &mut header.extra_sections {
2416        if s.length > 0 && s.offset >= tail_start {
2417            s.offset = s.offset - old_len + info_len;
2418        }
2419    }
2420    if info_len == 0 {
2421        header.build_info_offset = 0;
2422        header.build_info_len = 0;
2423    } else {
2424        header.build_info_offset = insert;
2425        header.build_info_len = info_len;
2426    }
2427
2428    Ok(BuildInfoPlan {
2429        header: header.to_bytes(),
2430        insert,
2431        tail_start,
2432        new_len: file_len - old_len + info_len,
2433    })
2434}
2435
2436/// Attach (or replace) a **build-info** section in a finished file image,
2437/// returning the new image. Layout and hash semantics: [`plan_build_info`].
2438///
2439/// Passing an empty `info` removes an existing section (or returns the image
2440/// unchanged when there is none).
2441pub fn attach_build_info(image: &[u8], info: &[u8]) -> Result<Vec<u8>, FileError> {
2442    let plan = plan_build_info(image, image.len() as u64, info.len() as u64)?;
2443    let mut out = Vec::with_capacity(plan.new_len as usize);
2444    out.extend_from_slice(&plan.header);
2445    out.extend_from_slice(&image[HEADER_LEN..plan.insert as usize]);
2446    out.extend_from_slice(info);
2447    out.extend_from_slice(&image[plan.tail_start as usize..]);
2448    debug_assert_eq!(out.len() as u64, plan.new_len);
2449    Ok(out)
2450}
2451
2452/// Replace the **metadata** section (the Dataset Card blob) of a finished file
2453/// image, returning the new image. The section is the first one after the
2454/// header, so every later section's offset shifts by the length delta.
2455///
2456/// Unlike [`attach_build_info`], the metadata payload **is** inside the content
2457/// hash — it is part of what the file says about itself — so the hash is
2458/// recomputed here. The result is byte-identical to what the writer would have
2459/// produced had it been handed this payload in the first place
2460/// (`write_dataset_with_metadata`): the section sits at `HEADER_LEN`, every
2461/// later offset is derived from its length, and the hash folds the same parts
2462/// in the same order.
2463///
2464/// Why splice rather than rebuild: the only caller that needs this is a build
2465/// that has already assembled the file and then learned something about it by
2466/// **running** its own starter queries against it. Re-deriving the whole image
2467/// to correct a metadata blob would mean re-parsing the input; a splice costs
2468/// one memcpy and one rehash of an image that is already resident.
2469///
2470/// Passing an empty payload removes the section.
2471pub fn replace_metadata(image: &[u8], metadata: &[u8]) -> Result<Vec<u8>, FileError> {
2472    let mut header = Header::from_bytes(image)?;
2473    // The writers in this crate always place metadata (when present) at
2474    // HEADER_LEN; anything else is a file this function must not reshape.
2475    let insert = HEADER_LEN as u64;
2476    if header.metadata_len > 0 && header.metadata_offset != insert {
2477        return Err(FileError::Container(
2478            "metadata section does not sit immediately after the header",
2479        ));
2480    }
2481    let old_len = header.metadata_len;
2482    let tail_start = (insert + old_len) as usize;
2483    if tail_start > image.len() {
2484        return Err(FileError::Container("metadata splice out of bounds"));
2485    }
2486    let new_len = metadata.len() as u64;
2487
2488    let shift = |off: &mut u64, len: u64| {
2489        if len > 0 && *off >= insert + old_len {
2490            *off = *off - old_len + new_len;
2491        }
2492    };
2493    shift(&mut header.dictionary_offset, header.dictionary_len);
2494    shift(&mut header.root_dir_offset, header.root_dir_len);
2495    shift(&mut header.pyramid_meta_offset, header.pyramid_meta_len);
2496    shift(&mut header.named_graphs_offset, header.named_graphs_len);
2497    shift(&mut header.text_index_offset, header.text_index_len);
2498    shift(&mut header.build_info_offset, header.build_info_len);
2499    for s in &mut header.extra_sections {
2500        if s.length > 0 && s.offset >= insert + old_len {
2501            s.offset = s.offset - old_len + new_len;
2502        }
2503    }
2504    header.metadata_offset = insert;
2505    header.metadata_len = new_len;
2506
2507    let mut out = Vec::with_capacity(image.len() - old_len as usize + metadata.len());
2508    out.extend_from_slice(&header.to_bytes());
2509    out.extend_from_slice(metadata);
2510    out.extend_from_slice(&image[tail_start..]);
2511    // The payload changed, so the hash must: recompute it over the same part
2512    // list `verify` will rebuild from this very header, then re-serialize the
2513    // header carrying it.
2514    header.content_hash = hash_of_sections(&header, &out)?;
2515    out[..HEADER_LEN].copy_from_slice(&header.to_bytes());
2516    Ok(out)
2517}
2518
2519/// The content hash of a file image, folded over exactly the parts the writer
2520/// hashed (see `write_dataset_from_parts`): the metadata payload when present,
2521/// then dict, index, pyramid-meta, and — when present — the text index and the
2522/// named graphs. [`verify`] compares this against the header; [`replace_metadata`]
2523/// writes it.
2524fn hash_of_sections(header: &Header, bytes: &[u8]) -> Result<[u8; 16], FileError> {
2525    let slice = |off: u64, len: u64| -> Result<&[u8], FileError> {
2526        // `off` and `len` come STRAIGHT FROM THE FILE HEADER, so a crafted or
2527        // corrupt .rete can set them near u64::MAX and make `off + len` overflow
2528        // — which panics in debug and wraps in release, BEFORE `.get()` gets the
2529        // chance to reject the range. Found by the weekly fuzz run
2530        // (fuzz_targets/open.rs, "attempt to add with overflow"). Saturating the
2531        // sum turns both cases into the same clean Container error.
2532        let end = off.saturating_add(len);
2533        bytes
2534            .get(off as usize..end as usize)
2535            .ok_or(FileError::Container("section overruns buffer"))
2536    };
2537    let parts = content_hash_parts(header)
2538        .into_iter()
2539        .map(|(off, len)| slice(off, len))
2540        .collect::<Result<Vec<&[u8]>, FileError>>()?;
2541    Ok(content_hash(&parts))
2542}
2543
2544/// The byte ranges the content hash folds, **in the order it folds them**: the
2545/// metadata payload when present, then dictionary, index, pyramid-meta, and —
2546/// when present — a kind-6 text index and the named graphs. Build info, the
2547/// self-digested kinds (8 LABEL_INDEX, 9 SCHEMA, 11 predicate-restricted text
2548/// index) and any section kind this reader does not know are deliberately
2549/// outside it. The quad index (kind 10) is not a range here either: its digest
2550/// is the seal at the end of the index container, so it is hashed by reference.
2551///
2552/// The one list [`verify`], [`verify_ranged`] and [`replace_metadata`] hash
2553/// over, and what a streaming rewriter (`rete recard`) feeds a hasher while it
2554/// copies — so a section a future format adds to the hash is added here, once,
2555/// and every one of them follows.
2556pub fn content_hash_parts(header: &Header) -> Vec<(u64, u64)> {
2557    let mut parts: Vec<(u64, u64)> = Vec::with_capacity(6);
2558    if header.metadata_len > 0 {
2559        parts.push((header.metadata_offset, header.metadata_len));
2560    }
2561    parts.push((header.dictionary_offset, header.dictionary_len));
2562    parts.push((header.root_dir_offset, header.root_dir_len));
2563    // A zero-length pyramid folds nothing either way; it is listed only when
2564    // present so the ranges a rewriter walks are all real sections.
2565    if header.pyramid_meta_len > 0 {
2566        parts.push((header.pyramid_meta_offset, header.pyramid_meta_len));
2567    }
2568    // A kind-11 (predicate-restricted) text index is self-digested instead.
2569    if header.text_index_len > 0 && !header.text_index_partial {
2570        parts.push((header.text_index_offset, header.text_index_len));
2571    }
2572    if header.named_graphs_len > 0 {
2573        parts.push((header.named_graphs_offset, header.named_graphs_len));
2574    }
2575    parts
2576}
2577
2578/// Recompute the content hash from a file image and check it against the header
2579/// — detects corruption or truncation of the payload sections. The
2580/// LABEL_INDEX, SCHEMA and predicate-restricted TEXT_INDEX sections (kinds 8,
2581/// 9, 11) are outside that hash — an older reader's `verify` must keep passing
2582/// on a file that carries them — so each is checked against its own leading
2583/// digest instead. The QUAD_INDEX section (kind 10) is hashed by reference: its
2584/// digest is the seal inside the (hashed) index container, checked here.
2585pub fn verify(bytes: &[u8]) -> Result<bool, FileError> {
2586    let header = Header::from_bytes(bytes)?;
2587    if hash_of_sections(&header, bytes)? != header.content_hash {
2588        return Ok(false);
2589    }
2590    // The QUAD_INDEX section is covered through the seal's digest (inside the
2591    // hashed index container), so it is checked against that.
2592    if header.has_quad_index() || header.quad_index_section().is_some() {
2593        check_named_layout(&header)?;
2594        let slice = |off: u64, len: u64| -> Result<&[u8], FileError> {
2595            bytes
2596                .get(off as usize..off.saturating_add(len) as usize)
2597                .ok_or(FileError::Container("section overruns buffer"))
2598        };
2599        let root = slice(header.root_dir_offset, header.root_dir_len)?;
2600        let ranges = container_section_payload_ranges(
2601            root,
2602            header.root_dir_offset,
2603            header.index_container_sections(),
2604        )?;
2605        let seal = ranges
2606            .last()
2607            .ok_or(FileError::Container("missing quad-index seal"))?;
2608        let want = crate::quad::parse_seal(slice(seal.offset, seal.len)?)?;
2609        let q = header
2610            .quad_index_section()
2611            .ok_or(FileError::Container("missing quad-index section"))?;
2612        if crate::quad::digest(slice(q.offset, q.length)?) != want {
2613            return Ok(false);
2614        }
2615    }
2616    for s in self_digested_sections(&header) {
2617        let end = s.offset.saturating_add(s.length);
2618        let section = bytes
2619            .get(s.offset as usize..end as usize)
2620            .ok_or(FileError::Container("section overruns buffer"))?;
2621        if !crate::label_index::digest_ok(section) {
2622            return Ok(false);
2623        }
2624    }
2625    Ok(true)
2626}
2627
2628/// The sections that carry their own leading 16-byte digest rather than being
2629/// folded into the header content hash: LABEL_INDEX (8), SCHEMA (9) and a
2630/// predicate-restricted TEXT_INDEX (11).
2631fn self_digested_sections(header: &Header) -> Vec<Section> {
2632    [
2633        header.label_index_section(),
2634        header.schema_section(),
2635        header
2636            .section(crate::header::SectionKind::TextIndexPartial)
2637            .filter(|s| s.length > 0),
2638    ]
2639    .into_iter()
2640    .flatten()
2641    .collect()
2642}
2643
2644/// Append LABEL_INDEX / SCHEMA sections (kinds 8, 9) to a finished file image,
2645/// just before its 4-byte footer, and return the new image.
2646///
2647/// They sit outside the content hash, so nothing before them moves and the
2648/// hash is unchanged: only the header's directory gains entries. An empty
2649/// payload is skipped. Each kind may be appended once; a second append of a
2650/// kind the file already carries is an error, as is any other kind.
2651pub fn append_sections(
2652    image: &[u8],
2653    sections: &[(SectionKind, &[u8])],
2654) -> Result<Vec<u8>, FileError> {
2655    let lens: Vec<(SectionKind, u64)> =
2656        sections.iter().map(|(k, b)| (*k, b.len() as u64)).collect();
2657    let (header, body_end) = plan_append(image, image.len() as u64, &lens)?;
2658    let extra: usize = sections.iter().map(|(_, b)| b.len()).sum();
2659    let mut out = Vec::with_capacity(image.len() + extra);
2660    out.extend_from_slice(&header.to_bytes());
2661    out.extend_from_slice(&image[HEADER_LEN..body_end as usize]);
2662    for (_, b) in sections {
2663        out.extend_from_slice(b);
2664    }
2665    out.extend_from_slice(&MAGIC);
2666    Ok(out)
2667}
2668
2669/// The header an append produces, and where the old footer starts.
2670fn plan_append(
2671    head: &[u8],
2672    file_len: u64,
2673    sections: &[(SectionKind, u64)],
2674) -> Result<(Header, u64), FileError> {
2675    let mut header = Header::from_bytes(head)?;
2676    if header.expected_file_len() != Some(file_len) {
2677        return Err(FileError::Container(
2678            "cannot append sections: the file length does not match its header",
2679        ));
2680    }
2681    let body_end = file_len - MAGIC.len() as u64;
2682    let mut at = body_end;
2683    for &(kind, len) in sections {
2684        if !matches!(kind, SectionKind::LabelIndex | SectionKind::Schema) {
2685            return Err(FileError::Container(
2686                "only LABEL_INDEX and SCHEMA sections can be appended",
2687            ));
2688        }
2689        if len == 0 {
2690            continue;
2691        }
2692        if header.section(kind).is_some_and(|s| s.length > 0) {
2693            return Err(FileError::Container(
2694                "the file already carries that section",
2695            ));
2696        }
2697        header = header.with_section(kind, at, len);
2698        at += len;
2699    }
2700    Ok((header, body_end))
2701}
2702
2703/// One piece of a section appended by [`append_sections_to_file`].
2704#[cfg(not(target_arch = "wasm32"))]
2705pub(crate) enum PayloadPart<'a> {
2706    /// Bytes in memory.
2707    Bytes(&'a [u8]),
2708    /// A whole file, streamed.
2709    File(&'a std::path::Path),
2710}
2711
2712/// [`append_sections`] on a file on disk, in place: the sections overwrite the
2713/// footer and are followed by a new one, then the header is rewritten. Each
2714/// section is the concatenation of its parts, so a large one streams from a
2715/// spill file. The memory-bounded build uses this after writing its output.
2716#[cfg(not(target_arch = "wasm32"))]
2717pub(crate) fn append_sections_to_file(
2718    path: &std::path::Path,
2719    sections: &[(SectionKind, Vec<PayloadPart<'_>>)],
2720) -> Result<(), FileError> {
2721    use std::io::{Read, Seek, SeekFrom, Write};
2722    let part_len = |p: &PayloadPart<'_>| -> std::io::Result<u64> {
2723        match p {
2724            PayloadPart::Bytes(b) => Ok(b.len() as u64),
2725            PayloadPart::File(f) => Ok(std::fs::metadata(f)?.len()),
2726        }
2727    };
2728    let mut lens = Vec::with_capacity(sections.len());
2729    for (kind, parts) in sections {
2730        let mut n = 0u64;
2731        for p in parts {
2732            n += part_len(p)?;
2733        }
2734        lens.push((*kind, n));
2735    }
2736    let mut f = std::fs::OpenOptions::new()
2737        .read(true)
2738        .write(true)
2739        .open(path)?;
2740    let len = f.metadata()?.len();
2741    let mut head = vec![0u8; HEADER_LEN];
2742    f.read_exact(&mut head)?;
2743    let (header, body_end) = plan_append(&head, len, &lens)?;
2744    f.seek(SeekFrom::Start(body_end))?;
2745    let mut w = std::io::BufWriter::with_capacity(1 << 20, &mut f);
2746    for (_, parts) in sections {
2747        for p in parts {
2748            match p {
2749                PayloadPart::Bytes(b) => w.write_all(b)?,
2750                PayloadPart::File(src) => {
2751                    std::io::copy(&mut std::fs::File::open(src)?, &mut w)?;
2752                }
2753            }
2754        }
2755    }
2756    w.write_all(&MAGIC)?;
2757    w.flush()?;
2758    drop(w);
2759    f.seek(SeekFrom::Start(0))?;
2760    f.write_all(&header.to_bytes())?;
2761    f.flush()?;
2762    Ok(())
2763}
2764
2765/// Open a file's dictionary over a [`RangeReader`] with only its chunk
2766/// directories resident — for the memory-bounded build, which resolves the
2767/// SCHEMA section's few class and predicate ids through the file it just
2768/// wrote.
2769#[cfg(not(target_arch = "wasm32"))]
2770pub(crate) fn open_dictionary_lazy<R: RangeReader + Send + Sync + 'static>(
2771    reader: R,
2772) -> Result<Dictionary, FileError> {
2773    let head = reader.read_at(0, HEADER_LEN as u64)?;
2774    let header = Header::from_bytes(&head)?;
2775    ranged_chunked_dictionary(&std::sync::Arc::new(reader), &header, [true; 4])
2776}
2777
2778/// [`verify`] over a [`RangeReader`](crate::reader::RangeReader), streaming:
2779/// the hashed sections are read in bounded slices and folded into one BLAKE3
2780/// state, so peak memory is one slice, not the file. The same sections, in
2781/// the same order, as `hash_of_sections`; on a 49 GB file the in-memory form
2782/// needs 49 GB.
2783pub fn verify_ranged(reader: &dyn crate::reader::RangeReader) -> Result<bool, FileError> {
2784    /// Read size per step: large enough that a local or remote read is
2785    /// throughput-bound, small enough to be negligible next to any budget.
2786    const SLICE: u64 = 8 << 20;
2787    let len = reader.len();
2788    if len < HEADER_LEN as u64 {
2789        return Err(FileError::Container("file shorter than its header"));
2790    }
2791    let header = Header::from_bytes(&reader.read_at(0, HEADER_LEN as u64)?)?;
2792    let mut h = blake3::Hasher::new();
2793    for (off, n) in content_hash_parts(&header) {
2794        // Offsets come straight from the header: reject an overrun (or an
2795        // overflowing sum) before reading, as `hash_of_sections` does.
2796        if off.checked_add(n).is_none_or(|end| end > len) {
2797            return Err(FileError::Container("section overruns buffer"));
2798        }
2799        let mut at = off;
2800        while at < off + n {
2801            let take = (off + n - at).min(SLICE);
2802            h.update(&reader.read_at(at, take)?);
2803            at += take;
2804        }
2805    }
2806    let mut out = [0u8; 16];
2807    out.copy_from_slice(&h.finalize().as_bytes()[..16]);
2808    if out != header.content_hash {
2809        return Ok(false);
2810    }
2811    // The QUAD_INDEX section, against the digest in the index container's seal
2812    // (see `verify`), streamed the same way.
2813    if header.has_quad_index() || header.quad_index_section().is_some() {
2814        check_named_layout(&header)?;
2815        let sections = header.index_container_sections();
2816        let seal = locate_container_section_ranged(
2817            reader,
2818            header.root_dir_offset,
2819            header.root_dir_len,
2820            sections - 1,
2821            sections as u64,
2822        )?;
2823        let want = crate::quad::parse_seal(&reader.read_at(seal.offset, seal.len)?)?;
2824        let q = header
2825            .quad_index_section()
2826            .ok_or(FileError::Container("missing quad-index section"))?;
2827        if q.offset.checked_add(q.length).is_none_or(|end| end > len) {
2828            return Err(FileError::Container("section overruns buffer"));
2829        }
2830        let mut h = blake3::Hasher::new();
2831        let mut at = q.offset;
2832        while at < q.offset + q.length {
2833            let take = (q.offset + q.length - at).min(SLICE);
2834            h.update(&reader.read_at(at, take)?);
2835            at += take;
2836        }
2837        let mut got = [0u8; 16];
2838        got.copy_from_slice(&h.finalize().as_bytes()[..16]);
2839        if got != want {
2840            return Ok(false);
2841        }
2842    }
2843    // The self-digested sections (kinds 8, 9, 11), streamed the same way.
2844    for s in self_digested_sections(&header) {
2845        if s.length < 16 || s.offset.checked_add(s.length).is_none_or(|end| end > len) {
2846            return Err(FileError::Container("section overruns buffer"));
2847        }
2848        let want = reader.read_at(s.offset, 16)?;
2849        let mut h = blake3::Hasher::new();
2850        let mut at = s.offset + 16;
2851        while at < s.offset + s.length {
2852            let take = (s.offset + s.length - at).min(SLICE);
2853            h.update(&reader.read_at(at, take)?);
2854            at += take;
2855        }
2856        if h.finalize().as_bytes()[..16] != want[..] {
2857            return Ok(false);
2858        }
2859    }
2860    Ok(true)
2861}
2862
2863/// Faults the pyramid meta in on first access. `None` = the fetch failed.
2864type PyramidLoader = Box<dyn Fn() -> Option<PyramidMeta> + Send + Sync>;
2865
2866/// The pyramid-meta section, held either resident (eager opens) or deferred
2867/// (the lazy remote open). SPARQL never touches the pyramid, but on a Wikidata
2868/// file it can be tens of MB (114k communities, millions of superedges), so a
2869/// remote SPARQL query must not pay to fetch it — it faults in only when a
2870/// community/pyramid query actually calls [`Rete::pyramid`].
2871enum PyramidSlot {
2872    Resident(Option<PyramidMeta>),
2873    Lazy {
2874        loader: PyramidLoader,
2875        cell: std::sync::OnceLock<Option<PyramidMeta>>,
2876    },
2877}
2878
2879/// A section held resident (eager opens) or faulted in on first use (lazy
2880/// opens) — the LABEL_INDEX and SCHEMA sections. `None` inside means the file
2881/// has no such section, or it could not be read or parsed: both are optional
2882/// and derived, so a bad one degrades to "absent" (and `verify` reports it).
2883pub(crate) enum SectionSlot<T> {
2884    Resident(Option<T>),
2885    Lazy {
2886        loader: Box<dyn Fn() -> Option<T> + Send + Sync>,
2887        cell: std::sync::OnceLock<Option<T>>,
2888    },
2889}
2890
2891impl<T> SectionSlot<T> {
2892    /// The section, faulting it in on the lazy path.
2893    pub(crate) fn get(&self) -> Option<&T> {
2894        match self {
2895            SectionSlot::Resident(v) => v.as_ref(),
2896            SectionSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
2897        }
2898    }
2899
2900    /// The section only if it is resident or already faulted — never a read.
2901    pub(crate) fn get_if_loaded(&self) -> Option<&T> {
2902        match self {
2903            SectionSlot::Resident(v) => v.as_ref(),
2904            SectionSlot::Lazy { cell, .. } => cell.get().and_then(|o| o.as_ref()),
2905        }
2906    }
2907}
2908
2909/// The LABEL_INDEX of a file image held in memory.
2910fn resident_label_slot<'a>(
2911    header: &Header,
2912    region: &dyn Fn(u64, u64) -> Result<&'a [u8], FileError>,
2913) -> SectionSlot<crate::label_index::LabelIndex> {
2914    SectionSlot::Resident(header.label_index_section().and_then(|s| {
2915        let bytes = region(s.offset, s.length).ok()?;
2916        crate::label_index::LabelIndex::from_section(bytes, header.block_codec).ok()
2917    }))
2918}
2919
2920/// The SCHEMA section of a file image held in memory.
2921fn resident_schema_slot<'a>(
2922    header: &Header,
2923    region: &dyn Fn(u64, u64) -> Result<&'a [u8], FileError>,
2924) -> SectionSlot<crate::schema_section::SchemaMeta> {
2925    SectionSlot::Resident(header.schema_section().and_then(|s| {
2926        crate::schema_section::SchemaMeta::decode(region(s.offset, s.length).ok()?).ok()
2927    }))
2928}
2929
2930/// The deferred LABEL_INDEX of a ranged open: the first search reads the
2931/// section head (preamble + root, ≤ [`crate::label_index::HEAD_READ`] bytes),
2932/// then one directory node per level and the data blocks it needs.
2933fn ranged_label_slot<R: RangeReader + Send + Sync + 'static>(
2934    reader: &std::sync::Arc<R>,
2935    header: &Header,
2936) -> SectionSlot<crate::label_index::LabelIndex> {
2937    let Some(s) = header.label_index_section() else {
2938        return SectionSlot::Resident(None);
2939    };
2940    let r = reader.clone();
2941    let codec = header.block_codec;
2942    SectionSlot::Lazy {
2943        loader: Box::new(move || {
2944            let head = r
2945                .read_at(s.offset, crate::label_index::HEAD_READ.min(s.length))
2946                .ok()?;
2947            let rr = r.clone();
2948            let loader = Box::new(move |off: u64, len: u64| rr.read_at(s.offset + off, len).ok());
2949            crate::label_index::LabelIndex::from_head(&head, s.length, codec, loader).ok()
2950        }),
2951        cell: std::sync::OnceLock::new(),
2952    }
2953}
2954
2955/// The deferred SCHEMA section of a ranged open: one range read, on first use.
2956fn ranged_schema_slot<R: RangeReader + Send + Sync + 'static>(
2957    reader: &std::sync::Arc<R>,
2958    header: &Header,
2959) -> SectionSlot<crate::schema_section::SchemaMeta> {
2960    let Some(s) = header.schema_section() else {
2961        return SectionSlot::Resident(None);
2962    };
2963    let r = reader.clone();
2964    SectionSlot::Lazy {
2965        loader: Box::new(move || {
2966            let bytes = r.read_at(s.offset, s.length).ok()?;
2967            crate::schema_section::SchemaMeta::decode(&bytes).ok()
2968        }),
2969        cell: std::sync::OnceLock::new(),
2970    }
2971}
2972
2973/// Read and decode a file's SCHEMA section over a [`RangeReader`] — the
2974/// header and one range. `Ok(None)` when the file has none (or it is
2975/// malformed: the section is optional and derived).
2976pub fn read_schema_ranged<R: RangeReader + ?Sized>(
2977    reader: &R,
2978) -> Result<Option<crate::schema_section::SchemaMeta>, FileError> {
2979    let head = reader.read_at(0, HEADER_LEN as u64)?;
2980    let header = Header::from_bytes(&head)?;
2981    Ok(read_schema_with_header(reader, &header))
2982}
2983
2984fn read_schema_with_header<R: RangeReader + ?Sized>(
2985    reader: &R,
2986    header: &Header,
2987) -> Option<crate::schema_section::SchemaMeta> {
2988    let s = header.schema_section()?;
2989    let bytes = reader.read_at(s.offset, s.length).ok()?;
2990    crate::schema_section::SchemaMeta::decode(&bytes).ok()
2991}
2992
2993/// Faults the text index in on first search. `None` = the file has none, or the
2994/// fetch/parse failed.
2995type TextIndexLoader = Box<dyn Fn() -> Option<crate::text_index::TextIndex> + Send + Sync>;
2996
2997/// Answers the token table's byte length from the section's first ≤10 bytes —
2998/// the length varint alone, never the table it measures.
2999type TokenTableProbe = Box<dyn Fn() -> Option<u64> + Send + Sync>;
3000
3001/// The TEXT_INDEX section, held either resident (eager opens decode the whole
3002/// thing) or deferred (the lazy remote open keeps only a loader that fetches the
3003/// token table on first search, then faults posting lists one at a time). SPARQL
3004/// never touches it, so the lazy remote path keeps its small range budget.
3005///
3006/// Both variants can also answer how long the section's leading **token table**
3007/// is without faulting it — the figure a caller needs to state what a first
3008/// search will cost, since `header.text_index_len` counts the postings blob too
3009/// and overstates it by several times on a literal-heavy graph.
3010enum TextIndexSlot {
3011    Resident {
3012        index: Option<crate::text_index::TextIndex>,
3013        /// Measured from the section bytes this open already held; `None` when
3014        /// the file carries no text index.
3015        token_table_len: Option<u64>,
3016    },
3017    Lazy {
3018        loader: TextIndexLoader,
3019        cell: std::sync::OnceLock<Option<crate::text_index::TextIndex>>,
3020        /// One ≤10-byte range read, memoized in `token_table_cell`.
3021        token_table: TokenTableProbe,
3022        token_table_cell: std::sync::OnceLock<Option<u64>>,
3023    },
3024}
3025
3026impl TextIndexSlot {
3027    /// The parsed index, faulting it in (token table first) on the lazy path.
3028    fn index(&self) -> Option<&crate::text_index::TextIndex> {
3029        match self {
3030            TextIndexSlot::Resident { index, .. } => index.as_ref(),
3031            TextIndexSlot::Lazy { loader, cell, .. } => cell.get_or_init(loader).as_ref(),
3032        }
3033    }
3034
3035    /// Byte length of the section's leading token table — see
3036    /// [`Rete::text_index_token_table_len`].
3037    fn token_table_len(&self) -> Option<u64> {
3038        match self {
3039            TextIndexSlot::Resident {
3040                token_table_len, ..
3041            } => *token_table_len,
3042            TextIndexSlot::Lazy {
3043                token_table,
3044                token_table_cell,
3045                ..
3046            } => *token_table_cell.get_or_init(token_table),
3047        }
3048    }
3049}
3050
3051/// Decode a whole TEXT_INDEX section held in memory, measuring its token table
3052/// on the way past. `section` is `None` when the file carries no text index.
3053fn resident_text_index_slot(section: Option<&[u8]>, codec: u8) -> Result<TextIndexSlot, FileError> {
3054    let Some(section) = section else {
3055        return Ok(TextIndexSlot::Resident {
3056            index: None,
3057            token_table_len: None,
3058        });
3059    };
3060    Ok(TextIndexSlot::Resident {
3061        index: Some(
3062            crate::text_index::TextIndex::from_section(section, codec)
3063                .map_err(|_| FileError::Container("malformed text index"))?,
3064        ),
3065        token_table_len: crate::text_index::TextIndex::postings_base(section).map(|b| b as u64),
3066    })
3067}
3068
3069/// Byte length of the TEXT_INDEX section's leading **token table**, from a
3070/// header a caller has already parsed — the prefix range a first
3071/// [`Rete::text_search`] fetches, and the honest figure to quote as its cost
3072/// (`header.text_index_len` counts the postings blob too and overstates it
3073/// several-fold). `None` when the file carries no text index, or its first
3074/// bytes could not be read.
3075///
3076/// Costs **one range read of ≤10 bytes** — the section's leading length varint
3077/// and nothing else, never the table it measures. This is the free-standing
3078/// companion to [`Rete::text_index_token_table_len`], for readers that hold a
3079/// [`Header`] and a [`RangeReader`] but never open the file (the CARD tier:
3080/// `rete card`, `rete card-url`).
3081pub fn read_text_index_token_table_len_ranged<R: RangeReader + ?Sized>(
3082    reader: &R,
3083    header: &Header,
3084) -> Option<u64> {
3085    let (ti_off, ti_len) = header.text_index_payload();
3086    if ti_len == 0 {
3087        return None;
3088    }
3089    read_token_table_len(reader, ti_off, ti_len)
3090}
3091
3092/// Byte length of the TEXT_INDEX section's leading token table — its length
3093/// varint plus the compressed table, i.e. the prefix range a first search
3094/// fetches. Reads the section's first ≤10 bytes and nothing else.
3095fn read_token_table_len<R: RangeReader + ?Sized>(reader: &R, off: u64, len: u64) -> Option<u64> {
3096    let head = reader.read_at(off, 10u64.min(len)).ok()?;
3097    let (ttlen, n) = crate::varint::read_uvarint(&head)?;
3098    Some((n as u64 + ttlen).min(len))
3099}
3100
3101/// A named graph's container decodes RESIDENT (one range read, one decode) up
3102/// to this size on the lazy path; a larger container opens as a remote-lazy
3103/// tile index instead, so one selective `GRAPH <g>` query over a multi-GB
3104/// graph faults only the tile directories plus the tiles it touches.
3105const NAMED_GRAPH_RESIDENT_MAX: u64 = 1 << 20; // 1 MiB
3106
3107/// Largest quad-index graph (in quads) a many-graph walk evaluates through an
3108/// owned, dropped view ([`Rete::named_graph_transient`]) rather than a memoised
3109/// one. A view is ~1–2 KB of descriptors whatever the graph's size, so keeping
3110/// one per graph is what grows with the graph count; past this size a graph is
3111/// worth memoising, and its rows are no longer worth materializing per graph.
3112pub const TRANSIENT_GRAPH_QUADS: u64 = 1024;
3113
3114/// Entries per lazily-allocated named-graph slab. The outer table is sized by
3115/// the section's leading count varint, which is UNTRUSTED — slabbing keeps a
3116/// hostile count from ballooning the allocation (the outer table costs a few
3117/// bytes per claimed slab; entry storage only materializes when touched).
3118const NAMED_SLAB: usize = 1024;
3119
3120/// First read of the named-graph walk, and the floor its ramp resets to. One
3121/// chunk covers a few dozen small-graph records, so a `LIMIT`ed `GRAPH ?g`
3122/// pays exactly one small read — the targeted-query win this size protects.
3123const NAMED_WALK_CHUNK: u64 = 64 * 1024;
3124
3125/// Ceiling of the walk's geometric read ramp — the read size an exhaustive
3126/// walk converges to (and starts at, when the demand is known exhaustive).
3127///
3128/// Sized from measurement, not taste. Against the nkod reference file
3129/// (67.9 MB, 31,974 named graphs, 65.4 MB section) the fixed 64 KiB walk cost
3130/// 262 requests / ~16.5 s in Chromium where the old eager whole-section fetch
3131/// cost ~8.5 s — i.e. ~31 ms of per-request overhead on ~8 s of transfer
3132/// (~7.7 MB/s). At 8 MiB per read a chunk transfers in ~1 s, putting the
3133/// request overhead near 3% of total time; halving the cap to 4 MiB doubles
3134/// that overhead, doubling the cap to 16 MiB buys back under 2% while doubling
3135/// both the walk buffer a wasm32 session must hold resident and the worst-case
3136/// over-read past the point a query stops. 8 MiB is the knee.
3137const NAMED_WALK_CHUNK_MAX: u64 = 8 * 1024 * 1024;
3138
3139/// One named graph, walked lazily out of the NAMED_GRAPHS section.
3140#[derive(Default)]
3141struct NamedEntry {
3142    /// `(iri, absolute byte range of the graph's index container)` — filled
3143    /// when the sequential directory walk reaches this slot.
3144    meta: std::sync::OnceLock<(String, ByteRange)>,
3145    /// The graph's permutation index, opened on first access. Boxed: most
3146    /// entries of a many-graph dataset are never touched by a given query.
3147    index: std::sync::OnceLock<Box<GraphIndex>>,
3148}
3149
3150/// Sequential position of the lazy named-graphs walk. The section has no
3151/// random-access directory — each entry's offset is the previous entry's end —
3152/// so entries are parsed strictly in order and memoised.
3153#[derive(Default)]
3154struct NamedWalk {
3155    /// Next entry index to parse.
3156    next: usize,
3157    /// Absolute file offset of that entry's header; `0` = the leading count
3158    /// varint has not been consumed yet (offset 0 is the file header, never
3159    /// inside a section).
3160    pos: u64,
3161    /// Read buffer carried ACROSS walk calls: `buf` holds the section bytes at
3162    /// `[buf_off, buf_off + buf.len())`. Entry-by-entry iteration (the
3163    /// `GRAPH ?g` path advances one entry per call) parses ~30 small-graph
3164    /// headers per 64 KiB chunk instead of re-reading a chunk per entry.
3165    /// [`LazyNamedGraphs::open_graph`] also serves whole small containers out
3166    /// of it, so a bulk chunk's ride-along payloads are never re-fetched.
3167    buf: Vec<u8>,
3168    buf_off: u64,
3169    /// Current read size of the geometric ramp (`0` = not started; treated as
3170    /// [`NAMED_WALK_CHUNK`]). Doubles on every buffer refill up to
3171    /// [`NAMED_WALK_CHUNK_MAX`], so a walk that keeps going converges to bulk
3172    /// reads after fetching at most as many bytes as it already used (the
3173    /// doubling bound: wasted read-ahead ≤ useful bytes). Reset to the floor
3174    /// whenever an oversized container is parsed — see `big_seen`.
3175    chunk: u64,
3176    /// A container larger than [`NAMED_GRAPH_RESIDENT_MAX`] has been parsed.
3177    /// Such graphs open tile-lazily (their payload bytes are NOT wanted by the
3178    /// walk), so ride-along prefetch stops paying off: the ramp resets and the
3179    /// exhaustive-demand hint stops seeding full-size reads. Without this, a
3180    /// header walk over a file of multi-MB containers would fetch the payloads
3181    /// it exists to hop over.
3182    big_seen: bool,
3183}
3184
3185/// The NAMED_GRAPHS section opened lazily over a [`RangeReader`]: nothing is
3186/// fetched at open. The section is a count varint followed by
3187/// `iri_len | iri | container_len | container` records, so the directory is
3188/// walked forward on demand — header bytes only, skipping container payloads —
3189/// and each touched graph's index is decoded (or opened tile-lazily) on first
3190/// access. A `LIMIT`ed `GRAPH ?g` query therefore reads a prefix of the
3191/// section; only a query that genuinely touches every graph (e.g. a full
3192/// `COUNT` over `GRAPH ?g`) walks it all — which is exactly what the eager
3193/// open used to fetch unconditionally.
3194///
3195/// **Failure contract** (same as lazy tiles/chunks): a failed read or a
3196/// malformed record sets a sticky flag and the accessor returns `None`;
3197/// nothing failed is ever memoised, so a later evaluation retries.
3198struct LazyNamedGraphs {
3199    reader: std::sync::Arc<dyn RangeReader + Send + Sync>,
3200    /// Absolute byte range of the NAMED_GRAPHS section.
3201    section: ByteRange,
3202    codec: u8,
3203    has_synopsis: bool,
3204    read_concurrency: usize,
3205    /// The file's permutation set — every graph container in a file carries
3206    /// the same one, so it comes from the header rather than per graph.
3207    perms: PermSet,
3208    /// `(count, slab table)` — set once the leading count varint is read.
3209    /// Slabs allocate on first touch; boxed slices never move, so `&` handed
3210    /// out to entries stay valid for `&self`'s lifetime with no unsafe.
3211    #[allow(clippy::type_complexity)]
3212    dir: std::sync::OnceLock<(usize, Box<[std::sync::OnceLock<Box<[NamedEntry]>>]>)>,
3213    walk: std::sync::Mutex<NamedWalk>,
3214    failed: std::sync::atomic::AtomicBool,
3215    /// Tile-cache byte cap applied to each LARGE (lazily-opened, loader-bearing)
3216    /// named graph as it is opened (bounded-export phase 2). `u64::MAX` =
3217    /// unlimited. Small resident graphs (no loader, ≤ 1 MiB) are never capped —
3218    /// an evicted tile could not be reloaded — but they are bounded by size and
3219    /// released per slot, so they need no cap.
3220    tile_cap: std::sync::atomic::AtomicU64,
3221}
3222
3223impl LazyNamedGraphs {
3224    fn new(
3225        reader: std::sync::Arc<dyn RangeReader + Send + Sync>,
3226        section: ByteRange,
3227        codec: u8,
3228        has_synopsis: bool,
3229        read_concurrency: usize,
3230        perms: PermSet,
3231    ) -> Self {
3232        LazyNamedGraphs {
3233            reader,
3234            section,
3235            codec,
3236            has_synopsis,
3237            read_concurrency,
3238            perms,
3239            dir: std::sync::OnceLock::new(),
3240            walk: std::sync::Mutex::new(NamedWalk::default()),
3241            failed: std::sync::atomic::AtomicBool::new(false),
3242            tile_cap: std::sync::atomic::AtomicU64::new(u64::MAX),
3243        }
3244    }
3245
3246    /// Set the tile-cache cap for large named graphs: records it for graphs
3247    /// opened later, and re-caps every large graph already opened. Small
3248    /// resident (loaderless) graphs are left unlimited.
3249    fn set_tile_cap(&self, cap: u64) {
3250        self.tile_cap
3251            .store(cap, std::sync::atomic::Ordering::Relaxed);
3252        self.for_each_opened(|g| {
3253            if g.is_lazy() {
3254                g.set_cache_cap(cap);
3255            }
3256        });
3257    }
3258
3259    /// Record a fetch/parse failure (checked by `index_incomplete`).
3260    fn fail(&self) {
3261        self.failed
3262            .store(true, std::sync::atomic::Ordering::Relaxed);
3263    }
3264
3265    /// The directory scaffold, reading the leading count varint on first call.
3266    #[allow(clippy::type_complexity)]
3267    fn directory(&self) -> Option<&(usize, Box<[std::sync::OnceLock<Box<[NamedEntry]>>]>)> {
3268        if let Some(d) = self.dir.get() {
3269            return Some(d);
3270        }
3271        let end = self.section.offset.checked_add(self.section.len)?;
3272        let (n, used) = match read_uvarint_at(&self.reader, self.section.offset, end) {
3273            Ok(v) => v,
3274            Err(_) => {
3275                self.fail();
3276                return None;
3277            }
3278        };
3279        // Each record needs at least 2 bytes of framing (two length varints),
3280        // so a count beyond len/2 is malformed — refuse before sizing by it.
3281        if n > self.section.len / 2 {
3282            self.fail();
3283            return None;
3284        }
3285        let n = n as usize;
3286        let slabs = n.div_ceil(NAMED_SLAB);
3287        let table: Box<[std::sync::OnceLock<Box<[NamedEntry]>>]> =
3288            (0..slabs).map(|_| std::sync::OnceLock::new()).collect();
3289        let _ = self.dir.set((n, table));
3290        {
3291            // Idempotent under an init race: both racers computed the same
3292            // `used` from the same bytes.
3293            let mut w = self.walk.lock().unwrap();
3294            if w.pos == 0 {
3295                w.pos = self.section.offset + used;
3296            }
3297        }
3298        self.dir.get()
3299    }
3300
3301    fn count(&self) -> usize {
3302        self.directory().map(|(n, _)| *n).unwrap_or(0)
3303    }
3304
3305    /// The `i`-th entry's cell (allocating its slab on first touch).
3306    fn entry(&self, i: usize) -> Option<&NamedEntry> {
3307        let (n, table) = self.directory()?;
3308        if i >= *n {
3309            return None;
3310        }
3311        let slab = table[i / NAMED_SLAB].get_or_init(|| {
3312            let len = NAMED_SLAB.min(n - (i / NAMED_SLAB) * NAMED_SLAB);
3313            (0..len).map(|_| NamedEntry::default()).collect()
3314        });
3315        slab.get(i % NAMED_SLAB)
3316    }
3317
3318    /// The next walk read's length, advancing the geometric ramp: at least
3319    /// `need` (a long IRI's header), at most the section remainder, and
3320    /// otherwise the current ramp value — which doubles per refill up to
3321    /// [`NAMED_WALK_CHUNK_MAX`]. The doubling keeps a stopping walk's wasted
3322    /// read-ahead bounded by the bytes it already used, while a walk that
3323    /// keeps going converges to bulk reads: the runtime safety net for
3324    /// exhaustive-looking queries the demand analysis could not prove.
3325    fn next_read_len(w: &mut NamedWalk, pos: u64, end: u64, need: u64) -> u64 {
3326        let cur = w.chunk.max(NAMED_WALK_CHUNK);
3327        w.chunk = (cur * 2).min(NAMED_WALK_CHUNK_MAX);
3328        cur.max(need).min(end - pos)
3329    }
3330
3331    /// Walk entry headers forward until `entries[upto].meta` is set. Reads the
3332    /// section in chunks and hops over container payloads, so the walk fetches
3333    /// O(headers) — not O(section) — where containers are large; small
3334    /// containers ride along in the same chunk and cost nothing extra.
3335    ///
3336    /// `exhaustive` is the demand hint from the query engine: the caller has
3337    /// PROVEN it will visit and open every named graph (an unrestricted
3338    /// `GRAPH ?g` under a consumer that drains the pipeline), so the walk
3339    /// starts at full-size reads instead of ramping up to them — the whole
3340    /// section arrives in `section/8 MiB` requests instead of hundreds. The
3341    /// hint is per-call, never memoised: a later targeted query on the same
3342    /// handle walks small again. It also defers to the layout: once an
3343    /// oversized (tile-lazy) container is seen, ride-along bytes stop being
3344    /// useful and the hint no longer seeds bulk reads.
3345    fn ensure_meta(&self, upto: usize, exhaustive: bool) -> Option<()> {
3346        let target = self.entry(upto)?;
3347        if target.meta.get().is_some() {
3348            return Some(());
3349        }
3350        let end = self.section.offset.checked_add(self.section.len)?;
3351        let mut w = self.walk.lock().unwrap();
3352        if exhaustive && !w.big_seen {
3353            w.chunk = NAMED_WALK_CHUNK_MAX;
3354        }
3355        while w.next <= upto {
3356            let pos = w.pos;
3357            if pos >= end {
3358                // The count varint promised more entries than the bytes hold.
3359                self.fail();
3360                return None;
3361            }
3362            // Make sure the whole header (two varints + the IRI, ≤ 20+iri_len
3363            // bytes) is buffered; refetch a larger window when the IRI is long.
3364            let have =
3365                |b: &[u8], off: u64, need: u64| pos >= off && pos + need <= off + b.len() as u64;
3366            if !have(&w.buf, w.buf_off, 20.min(end - pos)) {
3367                let len = Self::next_read_len(&mut w, pos, end, 20.min(end - pos));
3368                w.buf = match self.reader.read_at(pos, len) {
3369                    Ok(b) => b,
3370                    Err(_) => {
3371                        self.fail();
3372                        return None;
3373                    }
3374                };
3375                w.buf_off = pos;
3376            }
3377            let rel = (pos - w.buf_off) as usize;
3378            let Some((ilen, u1)) = read_uvarint(&w.buf[rel..]) else {
3379                self.fail();
3380                return None;
3381            };
3382            let header_need = u1 as u64 + ilen + 10; // iri_len + iri + container_len varint
3383            if pos + u1 as u64 + ilen > end {
3384                self.fail(); // IRI overruns the section: malformed
3385                return None;
3386            }
3387            if !have(&w.buf, w.buf_off, header_need.min(end - pos)) {
3388                let len = Self::next_read_len(&mut w, pos, end, header_need);
3389                w.buf = match self.reader.read_at(pos, len) {
3390                    Ok(b) => b,
3391                    Err(_) => {
3392                        self.fail();
3393                        return None;
3394                    }
3395                };
3396                w.buf_off = pos;
3397            }
3398            let rel = (pos - w.buf_off) as usize;
3399            let istart = rel + u1;
3400            let iend = istart + ilen as usize;
3401            let iri = String::from_utf8_lossy(&w.buf[istart..iend]).into_owned();
3402            let Some((clen, u2)) = read_uvarint(&w.buf[iend..]) else {
3403                self.fail();
3404                return None;
3405            };
3406            let cstart = pos + u1 as u64 + ilen + u2 as u64;
3407            let cend = match cstart.checked_add(clen) {
3408                Some(e) if e <= end => e,
3409                _ => {
3410                    self.fail(); // container overruns the section: malformed
3411                    return None;
3412                }
3413            };
3414            if clen > NAMED_GRAPH_RESIDENT_MAX {
3415                // This graph opens tile-lazily: the walk exists to HOP its
3416                // payload, so big read-ahead here is pure over-fetch. Drop the
3417                // ramp back to the floor and stop the exhaustive hint from
3418                // re-seeding it (headers stay cheap on payload-heavy files).
3419                w.big_seen = true;
3420                w.chunk = NAMED_WALK_CHUNK;
3421            }
3422            let range = ByteRange {
3423                offset: cstart,
3424                len: clen,
3425            };
3426            if let Some(e) = self.entry(w.next) {
3427                let _ = e.meta.set((iri, range));
3428            }
3429            w.pos = cend;
3430            w.next += 1;
3431        }
3432        Some(())
3433    }
3434
3435    /// The `i`-th graph's IRI — a header walk, never an index decode.
3436    /// `exhaustive`: see [`ensure_meta`](Self::ensure_meta).
3437    fn name_at(&self, i: usize, exhaustive: bool) -> Option<&str> {
3438        self.ensure_meta(i, exhaustive)?;
3439        self.entry(i)?.meta.get().map(|(iri, _)| iri.as_str())
3440    }
3441
3442    /// The `i`-th graph as `(iri, index)`, opening the index on first access.
3443    /// `exhaustive`: see [`ensure_meta`](Self::ensure_meta).
3444    fn graph_at(&self, i: usize, exhaustive: bool) -> Option<(&str, &GraphIndex)> {
3445        self.ensure_meta(i, exhaustive)?;
3446        let e = self.entry(i)?;
3447        let (iri, range) = e.meta.get()?;
3448        if e.index.get().is_none() {
3449            let opened = self.open_graph(*range)?;
3450            let _ = e.index.set(Box::new(opened));
3451        }
3452        Some((iri.as_str(), e.index.get()?.as_ref()))
3453    }
3454
3455    /// A small container's bytes: out of the walk buffer when the last chunk
3456    /// already carried them (bulk chunks always do — that is their point),
3457    /// else one targeted range read. Never called for tile-lazy containers.
3458    fn container_bytes(&self, range: ByteRange) -> Option<Vec<u8>> {
3459        {
3460            let w = self.walk.lock().unwrap();
3461            let buf_end = w.buf_off + w.buf.len() as u64;
3462            if range.offset >= w.buf_off && range.offset + range.len <= buf_end {
3463                let a = (range.offset - w.buf_off) as usize;
3464                return Some(w.buf[a..a + range.len as usize].to_vec());
3465            }
3466        }
3467        match self.reader.read_at(range.offset, range.len) {
3468            Ok(b) => Some(b),
3469            Err(_) => {
3470                self.fail();
3471                None
3472            }
3473        }
3474    }
3475
3476    /// Open one graph's index container: small ones resident (one read),
3477    /// large ones as a remote-lazy tile index — the same machinery as the
3478    /// default graph, so a selective query over a huge named graph fetches
3479    /// only the tile directories plus the tiles it touches.
3480    fn open_graph(&self, range: ByteRange) -> Option<GraphIndex> {
3481        if range.len <= NAMED_GRAPH_RESIDENT_MAX {
3482            let bytes = self.container_bytes(range)?;
3483            match decode_index_container(&bytes, self.codec, self.perms) {
3484                Ok(g) => Some(g),
3485                Err(_) => {
3486                    self.fail();
3487                    None
3488                }
3489            }
3490        } else {
3491            match open_index_container_lazy(
3492                &self.reader,
3493                range,
3494                self.codec,
3495                self.has_synopsis,
3496                self.read_concurrency,
3497                self.perms,
3498                self.perms.len(),
3499            ) {
3500                Ok((g, _, _)) => {
3501                    // A large graph faults its tiles through a loader, so bound
3502                    // its tile residency to the budget's share (an evicted tile
3503                    // simply re-faults). No-op when unlimited.
3504                    let cap = self.tile_cap.load(std::sync::atomic::Ordering::Relaxed);
3505                    if cap != u64::MAX {
3506                        g.set_cache_cap(cap);
3507                    }
3508                    Some(g)
3509                }
3510                Err(_) => {
3511                    self.fail();
3512                    None
3513                }
3514            }
3515        }
3516    }
3517
3518    /// Drop the decoded index of the graph named `iri`, if it has been opened,
3519    /// so its tiles and per-graph tile cache are freed. Takes `&mut self`:
3520    /// `OnceLock::take` needs unique access, which the export loop has between
3521    /// slots. Leaves the entry's `meta` (iri + range) so the graph re-opens
3522    /// lazily if touched again. A no-op if the graph is absent or never opened.
3523    fn release(&mut self, iri: &str) {
3524        let Some((_, table)) = self.dir.get_mut() else {
3525            return;
3526        };
3527        for slab in table.iter_mut() {
3528            let Some(entries) = slab.get_mut() else {
3529                continue;
3530            };
3531            for e in entries.iter_mut() {
3532                let matches = e
3533                    .meta
3534                    .get()
3535                    .map(|(name, _)| name.as_str() == iri)
3536                    .unwrap_or(false);
3537                if matches {
3538                    e.index.take();
3539                    return;
3540                }
3541            }
3542        }
3543    }
3544
3545    /// Visit every already-opened graph index (never triggers a fetch).
3546    fn for_each_opened(&self, mut f: impl FnMut(&GraphIndex)) {
3547        if let Some((_, table)) = self.dir.get() {
3548            for slab in table.iter().filter_map(|s| s.get()) {
3549                for e in slab.iter() {
3550                    if let Some(g) = e.index.get() {
3551                        f(g);
3552                    }
3553                }
3554            }
3555        }
3556    }
3557}
3558
3559/// The NAMED_GRAPHS section, held resident (in-memory and eager ranged opens —
3560/// unchanged) or walked lazily (the lazy ranged open, where fetching and
3561/// decoding every graph's index up front defeated remote laziness: 67 MB
3562/// fetched and ~32k indexes built before the first query on a many-graph file).
3563enum NamedGraphsSlot {
3564    Resident(Vec<(String, GraphIndex)>),
3565    Lazy(LazyNamedGraphs),
3566    /// The quad index (kind 10), on every open path: graphs are views over
3567    /// shared tiles, found through the dictionary and the sorted directory.
3568    Quad(crate::quad::QuadNamed),
3569}
3570
3571impl NamedGraphsSlot {
3572    /// Heap bytes the named-graph directory holds outside any cache: every
3573    /// walked entry's IRI and range, the walk buffer, and the directories of
3574    /// the graph indexes currently open (see [`GraphIndex::directory_bytes`]).
3575    fn directory_bytes(&self) -> u64 {
3576        match self {
3577            NamedGraphsSlot::Resident(v) => v
3578                .iter()
3579                .map(|(iri, g)| iri.capacity() as u64 + g.directory_bytes())
3580                .sum(),
3581            NamedGraphsSlot::Lazy(l) => {
3582                let mut bytes = l.walk.lock().map(|w| w.buf.capacity() as u64).unwrap_or(0);
3583                if let Some((_, table)) = l.dir.get() {
3584                    bytes += table.len() as u64
3585                        * std::mem::size_of::<std::sync::OnceLock<Box<[NamedEntry]>>>() as u64;
3586                    for slab in table.iter().filter_map(|s| s.get()) {
3587                        bytes += slab.len() as u64 * std::mem::size_of::<NamedEntry>() as u64;
3588                        for e in slab.iter() {
3589                            if let Some((iri, _)) = e.meta.get() {
3590                                bytes += iri.capacity() as u64;
3591                            }
3592                            if let Some(g) = e.index.get() {
3593                                bytes += g.directory_bytes();
3594                            }
3595                        }
3596                    }
3597                }
3598                bytes
3599            }
3600            NamedGraphsSlot::Quad(q) => q.directory_bytes(),
3601        }
3602    }
3603
3604    fn count(&self) -> usize {
3605        match self {
3606            NamedGraphsSlot::Resident(v) => v.len(),
3607            NamedGraphsSlot::Lazy(l) => l.count(),
3608            NamedGraphsSlot::Quad(q) => q.count(),
3609        }
3610    }
3611
3612    /// `exhaustive` is the walk-demand hint (resident opens ignore it): see
3613    /// [`LazyNamedGraphs::ensure_meta`]. `dict` names the graphs of a quad
3614    /// index (their names are dictionary terms there).
3615    fn name_at<'a>(&'a self, dict: &Dictionary, i: usize, exhaustive: bool) -> Option<&'a str> {
3616        match self {
3617            NamedGraphsSlot::Resident(v) => v.get(i).map(|(iri, _)| iri.as_str()),
3618            NamedGraphsSlot::Lazy(l) => l.name_at(i, exhaustive),
3619            NamedGraphsSlot::Quad(q) => q.name_at(dict, i, exhaustive),
3620        }
3621    }
3622
3623    fn graph_at<'a>(
3624        &'a self,
3625        dict: &Dictionary,
3626        i: usize,
3627        exhaustive: bool,
3628    ) -> Option<(&'a str, &'a GraphIndex)> {
3629        match self {
3630            NamedGraphsSlot::Resident(v) => v.get(i).map(|(iri, g)| (iri.as_str(), g)),
3631            NamedGraphsSlot::Lazy(l) => l.graph_at(i, exhaustive),
3632            NamedGraphsSlot::Quad(q) => q.graph_at(dict, i, exhaustive),
3633        }
3634    }
3635
3636    fn find<'a>(&'a self, dict: &Dictionary, iri: &str) -> Option<&'a GraphIndex> {
3637        match self {
3638            NamedGraphsSlot::Quad(q) => q.find(dict, iri),
3639            NamedGraphsSlot::Resident(v) => v.iter().find(|(name, _)| name == iri).map(|(_, g)| g),
3640            NamedGraphsSlot::Lazy(l) => {
3641                // Walk headers only until the IRI matches; decode just that
3642                // graph's index. An absent IRI costs a full header walk — the
3643                // layout has no by-name directory. A point lookup is targeted
3644                // demand: never hint the walk exhaustive.
3645                for i in 0..l.count() {
3646                    if l.name_at(i, false)? == iri {
3647                        return l.graph_at(i, false).map(|(_, g)| g);
3648                    }
3649                }
3650                None
3651            }
3652        }
3653    }
3654
3655    fn load_incomplete(&self) -> bool {
3656        match self {
3657            NamedGraphsSlot::Resident(v) => v.iter().any(|(_, g)| g.load_incomplete()),
3658            NamedGraphsSlot::Lazy(l) => {
3659                if l.failed.load(std::sync::atomic::Ordering::Relaxed) {
3660                    return true;
3661                }
3662                let mut bad = false;
3663                l.for_each_opened(|g| bad |= g.load_incomplete());
3664                bad
3665            }
3666            NamedGraphsSlot::Quad(q) => q.load_incomplete(),
3667        }
3668    }
3669
3670    fn reset_load_failures(&self) {
3671        match self {
3672            NamedGraphsSlot::Resident(v) => {
3673                for (_, g) in v {
3674                    g.reset_load_failure();
3675                }
3676            }
3677            NamedGraphsSlot::Lazy(l) => {
3678                l.failed.store(false, std::sync::atomic::Ordering::Relaxed);
3679                l.for_each_opened(|g| g.reset_load_failure());
3680            }
3681            NamedGraphsSlot::Quad(q) => q.reset_load_failures(),
3682        }
3683    }
3684
3685    /// The named-graph slot of a file opened with all its bytes in hand
3686    /// (`Rete::open`, `open_ranged`): the per-graph section decoded resident,
3687    /// or the quad index read through an owned copy of its section.
3688    fn resident(
3689        header: &Header,
3690        quad_section: impl FnOnce(u64, u64) -> Result<Vec<u8>, FileError>,
3691        per_graph: impl FnOnce() -> Result<Vec<(String, GraphIndex)>, FileError>,
3692    ) -> Result<Self, FileError> {
3693        check_named_layout(header)?;
3694        if let Some(q) = header.quad_index_section() {
3695            let bytes = quad_section(q.offset, q.length)?;
3696            return Ok(NamedGraphsSlot::Quad(crate::quad::QuadNamed::resident(
3697                bytes,
3698                header.block_codec,
3699                header.perms,
3700            )));
3701        }
3702        Ok(NamedGraphsSlot::Resident(if header.named_graphs_len > 0 {
3703            per_graph()?
3704        } else {
3705            Vec::new()
3706        }))
3707    }
3708}
3709
3710/// A read-only, in-memory view over a `.rete` file image.
3711pub struct Rete {
3712    header: Header,
3713    dict: Dictionary,
3714    index: GraphIndex,
3715    index_section_ranges: [ByteRange; NUM_PERMS],
3716    /// Per-permutation tile directories as absolute file ranges
3717    /// (`(min_a, max_a, compressed-tile range)`), for provenance. Empty for
3718    /// pre-tiling (v0.1) files.
3719    tile_ranges: [Vec<(u32, u32, ByteRange)>; NUM_PERMS],
3720    pyramid: PyramidSlot,
3721    text_index: TextIndexSlot,
3722    /// The LABEL_INDEX section (kind 8), if any.
3723    labels: SectionSlot<crate::label_index::LabelIndex>,
3724    /// The SCHEMA section (kind 9), if any.
3725    schema: SectionSlot<crate::schema_section::SchemaMeta>,
3726    named_graphs: NamedGraphsSlot,
3727    /// Raw bytes of the metadata section (empty if the file has none). The
3728    /// application layer decodes this (the CLI stores a JSON Dataset Card here).
3729    /// Only [`Rete::open`] populates it; [`Rete::open_ranged`] leaves it empty to
3730    /// preserve its minimal-fetch budget.
3731    metadata: Vec<u8>,
3732    /// Executes remote `SERVICE` blocks (SPARQL 1.1 federated query) — attached
3733    /// by the host via [`Rete::set_service_client`]; `None` means a non-SILENT
3734    /// `SERVICE` fails the query. Like the range readers, the engine never does
3735    /// I/O itself.
3736    service_client: Option<Box<dyn crate::service::ServiceClient>>,
3737    /// First failed non-SILENT `SERVICE` call of the current query. The row
3738    /// pipeline is infallible (the same contract as lazy tile fetches), so the
3739    /// failure is recorded here and taken by the top-level eval entry points.
3740    service_error: std::sync::Mutex<Option<String>>,
3741    /// Bytes a query's `COUNT(DISTINCT …)` set may hold before it spills to
3742    /// disk — see [`Rete::spill_threshold`].
3743    spill_threshold: std::sync::atomic::AtomicU64,
3744}
3745
3746impl Rete {
3747    /// Parse a full file image (v0 loads everything; a range-reading client
3748    /// will fetch only the sections it needs — same container format).
3749    pub fn open(bytes: &[u8]) -> Result<Self, FileError> {
3750        let header = Header::from_bytes(bytes)?;
3751
3752        // Header offsets/lengths are untrusted (a `.rete` may be fetched truncated
3753        // or corrupt from an arbitrary URL). Slice through a checked helper so a
3754        // bad region yields an error instead of panicking on an OOB index.
3755        let region = |off: u64, len: u64| -> Result<&[u8], FileError> {
3756            let start = off as usize;
3757            let end = start
3758                .checked_add(len as usize)
3759                .filter(|&e| e <= bytes.len())
3760                .ok_or(FileError::Container("section range out of bounds"))?;
3761            Ok(&bytes[start..end])
3762        };
3763
3764        let dict = decode_dictionary_container(
3765            region(header.dictionary_offset, header.dictionary_len)?,
3766            header.dict_codec,
3767        )?;
3768
3769        check_named_layout(&header)?;
3770        let index_bytes = region(header.root_dir_offset, header.root_dir_len)?;
3771        let (index, _) = decode_root_index_container(index_bytes, &header)?;
3772        let index_section_ranges = decode_index_section_ranges(
3773            index_bytes,
3774            header.root_dir_offset,
3775            header.perms,
3776            header.index_container_sections(),
3777        )?;
3778
3779        let pyramid = PyramidSlot::Resident(if header.pyramid_meta_len > 0 {
3780            Some(
3781                PyramidMeta::decode(region(header.pyramid_meta_offset, header.pyramid_meta_len)?)
3782                    .map_err(|_| FileError::Container("malformed pyramid meta"))?,
3783            )
3784        } else {
3785            None
3786        });
3787
3788        // The TEXT_INDEX section (opt-in `--text-index`); decode the whole thing
3789        // resident on a full-image open.
3790        let (ti_off, ti_len) = header.text_index_payload();
3791        let text_index = resident_text_index_slot(
3792            if ti_len > 0 {
3793                Some(region(ti_off, ti_len)?)
3794            } else {
3795                None
3796            },
3797            header.block_codec,
3798        )?;
3799        let labels = resident_label_slot(&header, &region);
3800        let schema = resident_schema_slot(&header, &region);
3801
3802        let named_graphs = NamedGraphsSlot::resident(
3803            &header,
3804            |off, len| Ok(region(off, len)?.to_vec()),
3805            || {
3806                decode_named_graphs(
3807                    region(header.named_graphs_offset, header.named_graphs_len)?,
3808                    header.block_codec,
3809                    header.perms,
3810                )
3811            },
3812        )?;
3813
3814        let metadata = if header.metadata_len > 0 {
3815            region(header.metadata_offset, header.metadata_len)?.to_vec()
3816        } else {
3817            Vec::new()
3818        };
3819
3820        let tile_ranges =
3821            tile_file_ranges(index_bytes, header.root_dir_offset, &index_section_ranges);
3822        Ok(Self {
3823            header,
3824            dict,
3825            index,
3826            index_section_ranges,
3827            tile_ranges,
3828            pyramid,
3829            text_index,
3830            labels,
3831            schema,
3832            named_graphs,
3833            metadata,
3834            service_client: None,
3835            service_error: std::sync::Mutex::new(None),
3836            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
3837        })
3838    }
3839
3840    /// Attach the client that executes `SERVICE <endpoint> { … }` blocks
3841    /// (SPARQL 1.1 federated query) against remote SPARQL endpoints. Without
3842    /// one, a non-SILENT `SERVICE` fails the query with a clear error and a
3843    /// `SERVICE SILENT` degrades to one empty solution, per the spec.
3844    pub fn set_service_client(&mut self, client: Box<dyn crate::service::ServiceClient>) {
3845        self.service_client = Some(client);
3846    }
3847
3848    pub(crate) fn service_client(&self) -> Option<&dyn crate::service::ServiceClient> {
3849        self.service_client.as_deref()
3850    }
3851
3852    /// Record a failed non-SILENT `SERVICE` call (first error wins).
3853    pub(crate) fn record_service_error(&self, msg: &str) {
3854        let mut e = self.service_error.lock().unwrap();
3855        if e.is_none() {
3856            *e = Some(msg.to_string());
3857        }
3858    }
3859
3860    /// Take (and clear) the pending `SERVICE` failure — called by every
3861    /// top-level eval entry so it can never leak into a later query.
3862    pub(crate) fn take_service_error(&self) -> Option<String> {
3863        self.service_error.lock().unwrap().take()
3864    }
3865
3866    pub fn header(&self) -> &Header {
3867        &self.header
3868    }
3869
3870    /// The file's byte layout, for visualization: header, metadata,
3871    /// dictionary, each index permutation's tile directory and individual
3872    /// tiles, pyramid summary, and named graphs — sorted by offset. Bytes not
3873    /// covered by any segment are container framing (section directories and
3874    /// length fields).
3875    pub fn file_layout(&self) -> Vec<LayoutSegment> {
3876        let h = &self.header;
3877        let seg = |kind: &'static str, label: String, offset: u64, len: u64| LayoutSegment {
3878            kind,
3879            label,
3880            offset,
3881            len,
3882        };
3883        // The size in the label is DERIVED from the constant that defines the
3884        // span, never typed out: this line read "fixed 128 bytes" — the
3885        // pre-v0.3 header size — while emitting a 1024-byte span, and it is
3886        // what the file explorer shows a reader inspecting a real file.
3887        // `layout_header_label_matches_its_span` pins the two together.
3888        let mut out = vec![seg(
3889            "header",
3890            format!("header (fixed {} bytes)", crate::header::HEADER_LEN),
3891            0,
3892            crate::header::HEADER_LEN as u64,
3893        )];
3894        if h.metadata_len > 0 {
3895            out.push(seg(
3896                "metadata",
3897                "metadata (dataset card)".into(),
3898                h.metadata_offset,
3899                h.metadata_len,
3900            ));
3901        }
3902        out.push(seg(
3903            "dictionary",
3904            "dictionary (4 front-coded term sections)".into(),
3905            h.dictionary_offset,
3906            h.dictionary_len,
3907        ));
3908        for (si, perm) in crate::index::ALL_PERMS.into_iter().enumerate() {
3909            let sec = self.index_section_ranges[si];
3910            if sec.len == 0 {
3911                continue;
3912            }
3913            let first_tile = self.tile_ranges[si]
3914                .first()
3915                .map(|&(_, _, r)| r.offset)
3916                .unwrap_or(sec.offset + sec.len);
3917            if first_tile > sec.offset {
3918                out.push(seg(
3919                    "directory",
3920                    format!("{} tile directory", perm.name()),
3921                    sec.offset,
3922                    first_tile - sec.offset,
3923                ));
3924            }
3925            for (ti, &(min_a, max_a, r)) in self.tile_ranges[si].iter().enumerate() {
3926                out.push(seg(
3927                    "tile",
3928                    format!("{} tile {ti} (leading ids {min_a}..{max_a})", perm.name()),
3929                    r.offset,
3930                    r.len,
3931                ));
3932            }
3933        }
3934        if h.pyramid_meta_len > 0 {
3935            out.push(seg(
3936                "pyramid",
3937                "pyramid summary (communities + superedges)".into(),
3938                h.pyramid_meta_offset,
3939                h.pyramid_meta_len,
3940            ));
3941        }
3942        if h.named_graphs_len > 0 {
3943            out.push(seg(
3944                "named-graphs",
3945                format!("named graphs ({})", self.named_graphs.count()),
3946                h.named_graphs_offset,
3947                h.named_graphs_len,
3948            ));
3949        }
3950        if let Some(s) = h.label_index_section() {
3951            let label = match self.labels.get_if_loaded() {
3952                Some(li) => format!("label index ({} entries)", li.entry_count()),
3953                None => "label index".to_string(),
3954            };
3955            out.push(seg("label-index", label, s.offset, s.length));
3956        }
3957        if let Some(s) = h.schema_section() {
3958            out.push(seg(
3959                "schema",
3960                "schema (classes, query_stats, shapes)".into(),
3961                s.offset,
3962                s.length,
3963            ));
3964        }
3965        if let Some(q) = h.quad_index_section() {
3966            out.push(seg(
3967                "quad-index",
3968                format!("quad index ({} named graphs)", self.named_graphs.count()),
3969                q.offset,
3970                q.length,
3971            ));
3972        }
3973        out.sort_by_key(|s| s.offset);
3974        out
3975    }
3976
3977    /// Raw bytes of the file's metadata section, or `None` if it has none. The
3978    /// CLI stores a JSON Dataset Card here; `rete-core` treats it as opaque.
3979    /// Populated by [`Rete::open`] only — an [`Rete::open_ranged`] view returns
3980    /// `None` here (the card is not fetched on the minimal query path).
3981    pub fn metadata(&self) -> Option<&[u8]> {
3982        if self.metadata.is_empty() {
3983            None
3984        } else {
3985            Some(&self.metadata)
3986        }
3987    }
3988
3989    pub fn dictionary(&self) -> &Dictionary {
3990        &self.dict
3991    }
3992
3993    /// The pyramid metadata (summary graph + tiles), if the file has a pyramid.
3994    pub fn pyramid(&self) -> Option<&PyramidMeta> {
3995        match &self.pyramid {
3996            PyramidSlot::Resident(p) => p.as_ref(),
3997            // Faults the (possibly large) pyramid section on first access only.
3998            PyramidSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
3999        }
4000    }
4001
4002    /// The pyramid metadata **only if already resident or previously faulted** —
4003    /// never triggers a lazy range read. The query planner uses this for
4004    /// cardinality estimation so it is free for an in-memory file and never adds
4005    /// a fetch on the lazy remote path (which defers the pyramid by design).
4006    pub fn pyramid_if_loaded(&self) -> Option<&PyramidMeta> {
4007        match &self.pyramid {
4008            PyramidSlot::Resident(p) => p.as_ref(),
4009            PyramidSlot::Lazy { cell, .. } => cell.get().and_then(|o| o.as_ref()),
4010        }
4011    }
4012
4013    /// The SCHEMA section (kind 9): class hierarchy, level rollups and links,
4014    /// coherence axioms, `query_stats`, characteristic sets. Faults it in (one
4015    /// small range read) on the lazy path. `None` when the file has none — a
4016    /// file built before it existed keeps those fields in its pyramid-meta
4017    /// ([`schema_or_pyramid`](Self::schema_or_pyramid) reads either).
4018    pub fn schema(&self) -> Option<&crate::schema_section::SchemaMeta> {
4019        self.schema.get()
4020    }
4021
4022    /// The schema fields from the SCHEMA section when the file has one, else
4023    /// from the pyramid-meta (faulting either on the lazy path). `None` when
4024    /// the file carries neither.
4025    pub fn schema_or_pyramid(
4026        &self,
4027    ) -> Option<std::borrow::Cow<'_, crate::schema_section::SchemaMeta>> {
4028        if let Some(s) = self.schema() {
4029            return Some(std::borrow::Cow::Borrowed(s));
4030        }
4031        self.pyramid()
4032            .map(|p| std::borrow::Cow::Owned(crate::schema_section::SchemaMeta::from_pyramid(p)))
4033    }
4034
4035    /// Per-predicate planner statistics (`query_stats`): from the SCHEMA
4036    /// section (faulting it on the lazy path — it is small), else from the
4037    /// pyramid-meta when that is resident. Empty when the file has neither.
4038    /// See [`crate::meta::PredStat`].
4039    pub fn predicate_stats(&self) -> &[crate::meta::PredStat] {
4040        if let Some(s) = self.schema().filter(|s| !s.predicate_stats.is_empty()) {
4041            return &s.predicate_stats;
4042        }
4043        self.pyramid_if_loaded()
4044            .map(|p| p.predicate_stats.as_slice())
4045            .unwrap_or(&[])
4046    }
4047
4048    /// The statistics the planner may use **without a read it was not going to
4049    /// make**: the SCHEMA section if it is resident or already faulted — or,
4050    /// with `fault`, after faulting it (one small range, once per session) —
4051    /// else the resident pyramid-meta's block.
4052    pub(crate) fn planner_stats(&self, fault: bool) -> &[crate::meta::PredStat] {
4053        let schema = if fault {
4054            self.schema.get()
4055        } else {
4056            self.schema.get_if_loaded()
4057        };
4058        if let Some(s) = schema.filter(|s| !s.predicate_stats.is_empty()) {
4059            return &s.predicate_stats;
4060        }
4061        self.pyramid_if_loaded()
4062            .map(|p| p.predicate_stats.as_slice())
4063            .unwrap_or(&[])
4064    }
4065
4066    /// The entity shapes (characteristic sets): from the SCHEMA section, else
4067    /// from a resident pyramid-meta. See [`crate::meta::CharSet`].
4068    pub fn char_sets(&self) -> &[crate::meta::CharSet] {
4069        if let Some(s) = self.schema().filter(|s| !s.char_sets.is_empty()) {
4070            return &s.char_sets;
4071        }
4072        self.pyramid_if_loaded()
4073            .map(|p| p.char_sets.as_slice())
4074            .unwrap_or(&[])
4075    }
4076
4077    /// The **legacy** label block of a pyramid-meta (≤ 8,192 entries) — empty
4078    /// when the file has none or the pyramid isn't resident. Files built with
4079    /// `--label-index` carry a LABEL_INDEX section instead; see
4080    /// [`label_index_section`](Self::label_index_section). See
4081    /// [`crate::meta::LabelEntry`].
4082    pub fn label_index(&self) -> &[crate::meta::LabelEntry] {
4083        self.pyramid_if_loaded()
4084            .map(|p| p.label_index.as_slice())
4085            .unwrap_or(&[])
4086    }
4087
4088    /// Whether the file carries a LABEL_INDEX section (kind 8). Header only.
4089    pub fn has_label_index(&self) -> bool {
4090        self.header.label_index_section().is_some()
4091    }
4092
4093    /// The LABEL_INDEX section, faulting its head in on the lazy path.
4094    pub fn label_index_section(&self) -> Option<&crate::label_index::LabelIndex> {
4095        self.labels.get()
4096    }
4097
4098    /// Prefix-search the labels: the subjects whose label starts with `prefix`
4099    /// (case-insensitive), as `(label, subject term)`, capped at `limit`.
4100    /// Reads the LABEL_INDEX section when the file has one (every labelled
4101    /// subject, no dictionary read); otherwise the legacy pyramid-meta block,
4102    /// which **faults the pyramid** on the lazy path. Empty when the file
4103    /// carries neither.
4104    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<(String, String)> {
4105        if let Some(li) = self.labels.get() {
4106            return li.prefix_search(prefix, limit);
4107        }
4108        let Some(pyr) = self.pyramid() else {
4109            return Vec::new();
4110        };
4111        pyr.prefix_search(prefix, limit)
4112            .into_iter()
4113            .filter_map(|e| {
4114                self.dict
4115                    .subject_term(e.subject)
4116                    .map(|iri| (e.label.clone(), iri))
4117            })
4118            .collect()
4119    }
4120
4121    /// The full-text index (TEXT_INDEX section), faulting it in on first access
4122    /// on the lazy remote path. `None` when the file carries no text index.
4123    pub(crate) fn text_index(&self) -> Option<&crate::text_index::TextIndex> {
4124        self.text_index.index()
4125    }
4126
4127    /// Whether this file carries a full-text (TEXT_INDEX) section, i.e. it was
4128    /// built with `--text-index`. Cheap — reads the header, never faults.
4129    pub fn has_text_index(&self) -> bool {
4130        self.header.text_index_len > 0
4131    }
4132
4133    /// What the full-text index covers (graphs, predicates) — `None` when the
4134    /// file has none. Faults the token table on the lazy remote path, as a
4135    /// search does; the scope block lives in it.
4136    pub fn text_index_scope(&self) -> Option<crate::text_index::TextIndexScope> {
4137        self.text_index().map(|ti| ti.scope().clone())
4138    }
4139
4140    /// Byte length of the TEXT_INDEX section's leading **token table** — the
4141    /// length varint plus the compressed table, which is exactly what a first
4142    /// [`text_search`](Self::text_search) faults. `None` when the file carries
4143    /// no text index (or its head could not be read).
4144    ///
4145    /// This is the number to quote as the cost of a first search, NOT
4146    /// `header().text_index_len`: that counts the postings blob too, which is
4147    /// the bulk of the section and is only ever fetched one posting list at a
4148    /// time. On the published `epfl-infoscience.rete` the section is 195 MB and
4149    /// its token table 29 MB — a 6.5× difference.
4150    ///
4151    /// Costs nothing on a resident open (measured while the bytes were in hand);
4152    /// a lazy/ranged open pays one ≤10-byte range read, memoized, and still
4153    /// never faults the table itself.
4154    pub fn text_index_token_table_len(&self) -> Option<u64> {
4155        self.text_index.token_table_len()
4156    }
4157
4158    /// Full-text search over the literals: subject IRIs that carry **every** word
4159    /// in `words` (whole-word, case-insensitive — AND semantics), optionally also
4160    /// requiring a word that **starts with** `prefix` (token-prefix). Results are
4161    /// ordered by subject id and capped at `limit` (0 = uncapped). Empty when the
4162    /// file has no text index or nothing matches.
4163    ///
4164    /// Like [`prefix_search`](Self::prefix_search), this **faults** the index on
4165    /// the lazy remote path — a search is an explicit read, and only the queried
4166    /// posting lists are fetched, not the whole index.
4167    pub fn text_search(&self, words: &[&str], prefix: Option<&str>, limit: usize) -> Vec<String> {
4168        text_search_in(self.text_index(), &self.dict, words, prefix, limit)
4169    }
4170
4171    /// The default-graph permutation index.
4172    pub fn default_index(&self) -> &GraphIndex {
4173        &self.index
4174    }
4175
4176    /// Resolve every triple of a graph (`None` = default graph) back to terms.
4177    pub fn dump(&self, graph: Option<&str>) -> Vec<TermTriple> {
4178        let index = match graph {
4179            None => &self.index,
4180            Some(g) => match self.graph_index(g) {
4181                Some(i) => i,
4182                None => return Vec::new(),
4183            },
4184        };
4185        index
4186            .match_pattern((None, None, None))
4187            .into_iter()
4188            .filter_map(|(s, p, o)| {
4189                Some((
4190                    self.dict.subject_term(s)?,
4191                    self.dict.predicate_term(p)?,
4192                    self.dict.object_term(o)?,
4193                ))
4194            })
4195            .collect()
4196    }
4197
4198    /// Stream every triple of a graph (`None` = default) to `f`, resolving terms
4199    /// a batch at a time — no full `Vec` materialization, so it is safe on graphs far
4200    /// larger than RAM. `rete export` uses this to serialize 100M+ triple files
4201    /// that `dump()` (which collects every term into a `Vec<String>`) would OOM on.
4202    ///
4203    /// On a lazy/ranged open the dictionary is **not** prefetched whole: each
4204    /// batch faults only the chunks its own terms live in. Dumping ONE named
4205    /// graph therefore costs that graph's terms, not the file's. Dumping
4206    /// *everything* still reads every dictionary byte and leaves every chunk
4207    /// resident — that is inherent, and it, not the prefetch, is what sets the
4208    /// peak of a full export. See the comment in the body for both measurements.
4209    ///
4210    /// To dump a *slice* — one predicate, one subject, one object — call
4211    /// [`dump_filtered_each`](Self::dump_filtered_each), which is this method
4212    /// with a triple pattern and prunes tiles instead of filtering rows.
4213    pub fn dump_each<F: FnMut(&str, &str, &str)>(&self, graph: Option<&str>, f: F) {
4214        self.dump_filtered_each(graph, None, None, None, f)
4215    }
4216
4217    /// [`dump_each`](Self::dump_each) restricted to a triple **pattern** — the
4218    /// filtered dump. Any of `s` / `p` / `o` may be bound (canonical N-Triples
4219    /// term tokens); all three unbound is exactly `dump_each`.
4220    ///
4221    /// # Why this is cheap, and how much
4222    ///
4223    /// It is the same access path a routed *query* takes, not a full scan with a
4224    /// predicate test bolted on: [`GraphIndex::scan_iter`] picks the permutation
4225    /// with the longest bound prefix, binary-searches that section's tile
4226    /// directory down to the span a bound leading id can live in, and then drops
4227    /// every remaining tile whose recorded synopsis proves it cannot hold a
4228    /// bound secondary component — **all from directories, without fetching a
4229    /// tile**. So a filtered dump pays for the slice, which is the whole point
4230    /// of issue #117.
4231    ///
4232    /// Measured on `cordis.rete` (801 MB, a 417 MB dictionary, 26.4M quads,
4233    /// six named graphs), lazily opened; "before" is the only way to get a
4234    /// predicate slice out of a dump previously — dump the graph and throw away
4235    /// what does not match, in the consumer:
4236    ///
4237    /// ```text
4238    ///   graph=results  p=s66#doi     -> 337,811 rows
4239    ///     before  375.8 MB · 1797 req · 2105.8 MB RSS · 12.8 s · first row 409 ms
4240    ///     after    16.0 MB ·  105 req ·  182.6 MB RSS ·  0.41 s · first row  14 ms
4241    ///   graph=results  p=s66#isbn    ->  40,761 rows
4242    ///     before  375.8 MB · 1797 req · 2105.7 MB RSS · 12.8 s
4243    ///     after    15.5 MB ·  104 req ·  155.2 MB RSS ·  0.23 s
4244    /// ```
4245    ///
4246    /// # Where it does NOT help
4247    ///
4248    /// The index is pruned; the **dictionary is not**. Term resolution still
4249    /// faults whichever chunks the surviving rows' terms live in, and on a file
4250    /// whose payload *is* the literals those chunks are most of the file. Same
4251    /// file, a predicate whose objects are the long abstracts:
4252    ///
4253    /// ```text
4254    ///   graph=projects p=s66#abstract ->  80,206 rows
4255    ///     before  260.7 MB · 1142 req · 1496.9 MB RSS · 5.97 s
4256    ///     after   213.4 MB · 1606 req · 1144.4 MB RSS · 2.91 s   (1.2x, not 23x)
4257    /// ```
4258    ///
4259    /// An *unfiltered* dump is unchanged by construction — it is the floor, and
4260    /// its peak is the resident dictionary, not this. Two ceilings sit above
4261    /// both: a faulted dictionary chunk is a `OnceCell` nothing ever evicts, and
4262    /// on a literal-heavy file the chunk directory alone can be a third of the
4263    /// file before any dump work at all (#198).
4264    ///
4265    /// # Permutations
4266    ///
4267    /// Every one of the eight bound/unbound shapes routes inside
4268    /// [`PermSet::CORE`] — `{SPO, POS, OSP}`, the minimum a legal file carries
4269    /// (see `perm_routing_never_leaves_core`). A file built with
4270    /// `--permutations 3` therefore prunes *identically* to a six-permutation
4271    /// one: same tiles, same rows, no fallback path to get wrong.
4272    ///
4273    /// A bound term the dictionary does not know, or an absent graph IRI, yields
4274    /// nothing without touching the index.
4275    ///
4276    /// # Order
4277    ///
4278    /// Rows arrive in the **routed permutation's** order, which is the price of
4279    /// routing at all. Unfiltered (and subject-bound) that is SPO — canonical
4280    /// `(s, p, o)`, unchanged from [`dump_each`](Self::dump_each). A bound
4281    /// predicate routes to POS and streams `(p, o, s)`; a bound object routes to
4282    /// OSP. The *set* is identical to the unfiltered dump filtered; the order is
4283    /// not, so a consumer that needs canonical order must sort. N-Quads, the
4284    /// format `rete export` writes, is order-independent.
4285    ///
4286    /// [`PermSet::CORE`]: crate::index::PermSet::CORE
4287    pub fn dump_filtered_each<F: FnMut(&str, &str, &str)>(
4288        &self,
4289        graph: Option<&str>,
4290        s: Option<&str>,
4291        p: Option<&str>,
4292        o: Option<&str>,
4293        mut f: F,
4294    ) {
4295        let index = match graph {
4296            None => Some(&self.index),
4297            Some(g) => self.graph_index(g),
4298        };
4299        let Some(index) = index else { return };
4300        // A bound term the dictionary does not know can match nothing, and the
4301        // index is never touched — same contract as `query_iter`/`query_batch`.
4302        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
4303            return;
4304        };
4305        let dict = &self.dict;
4306        // Resolve in fixed-size batches with ONE coalesced dictionary fault per
4307        // batch — the same `prefetch_terms` call `dump_batch` makes, for the
4308        // same reason.
4309        //
4310        // This used to be `Dictionary::prefetch_all` up front, on the argument
4311        // that a full dump reaches every term anyway so the bytes are owed
4312        // either way. That argument only holds for a dump of EVERYTHING, and
4313        // this takes a graph AND a pattern. Measured on `cordis.rete` (801 MB,
4314        // a 417 MB dictionary, six named graphs), lazily opened:
4315        //
4316        //   dump_each(Some(fundingschemes))  417 MB read, 2510 MB peak RSS
4317        //                              →     173 MB read,  619 MB peak RSS
4318        //   dump_each(None) — the EMPTY default graph, which is the first thing
4319        //   `rete export` calls on a quads file:
4320        //                                    415 MB read, 2508 MB peak RSS
4321        //                              →       0 MB read,   95 MB peak RSS
4322        //
4323        // The 2.5 GB comes from asking for every chunk at once: the bulk loader
4324        // coalesces adjacent chunks into one span, so the whole dictionary
4325        // section is materialized three times over before the first triple —
4326        // the fetched span, the per-chunk copies sliced out of it, and the
4327        // decompressed bodies.
4328        //
4329        // Be clear about what this does NOT buy. A dump of the whole default
4330        // graph still faults every chunk, and chunks stay resident once
4331        // faulted, so `rete export` of a single-graph file is unchanged:
4332        // `figshare.rete` (222 MB, a 126 MB dictionary) peaks at 874 MB lazily
4333        // either way. The resident dictionary is the floor there, not this.
4334        //
4335        // Within each window, term resolution is CHUNK-ORDERED, not row-ordered
4336        // (`Dictionary::resolve_window`, bounded-export phase 1): the window's
4337        // ids are grouped by the `(section, chunk)` they live in and each chunk
4338        // is decoded once and its runs walked once, filling every slot that
4339        // chunk owns, then the rows are emitted in the original scan order. Two
4340        // things follow. First, a window's objects — which scatter across the
4341        // object-only section for one subject — touch each chunk a single time
4342        // instead of re-faulting it per row. Second, the per-id re-walk from a
4343        // run's start that the old row-order path did for *every* id collapses
4344        // to one monotonic forward pass per run. The output is byte-for-byte
4345        // identical (same bytes per id, same emission order, same
4346        // all-three-resolved emit rule); the cap is still unlimited, so this is
4347        // structural preparation for the real bound (phase 2), plus a small
4348        // free speedup. The coalesced bulk fault (`prefetch_terms`) is
4349        // unchanged, so the read pattern and peak residency are as before.
4350        const WINDOW: usize = 4096;
4351        // The whole-window coalesced prefetch (`prefetch_terms`) materializes
4352        // every distinct chunk the window touches at once — up to ~2·WINDOW
4353        // chunks for scattered objects. Under a GENEROUS budget that transient
4354        // fits and the coalesced reads are the fast path (small/normal files,
4355        // `--in-memory`, unlimited). Under a TIGHT budget it would blow the cap
4356        // and thrash (insert-then-evict every chunk, then re-fault it in
4357        // `resolve_window`), so skip it: `resolve_window` is chunk-ordered and
4358        // holds one chunk `Arc` at a time (plan §3.5), keeping the window's
4359        // working set to a single chunk regardless of how the objects scatter.
4360        // Output is byte-identical either way — prefetch only warms the cache.
4361        let cap = dict.cache_cap();
4362        let prefetch_window =
4363            cap == u64::MAX || cap >= (WINDOW as u64) * 2 * (DICT_CHUNK_BUDGET as u64);
4364        // Under a finite budget the prefetch also goes in batches of
4365        // PREFETCH_BATCH chunks: one bulk read holds everything it fetched
4366        // (span, slices, decoded bodies) until it is all cached, which for a
4367        // scattered window was thousands of chunks — up to ~1 GB on
4368        // openaire-relation — outside the cache cap. Unlimited keeps one read
4369        // per section (the latency-optimal shape for a remote file).
4370        const PREFETCH_BATCH: usize = 64;
4371        let prefetch_batch = if cap == u64::MAX {
4372            usize::MAX
4373        } else {
4374            PREFETCH_BATCH
4375        };
4376        let mut ids: Vec<(u32, u32, u32)> = Vec::with_capacity(WINDOW);
4377        let mut nodes: Vec<u32> = Vec::with_capacity(WINDOW * 2);
4378        let mut preds: Vec<u32> = Vec::with_capacity(WINDOW);
4379        let mut resolver = crate::dictionary::WindowResolver::default();
4380        let mut flush = |ids: &mut Vec<(u32, u32, u32)>,
4381                         resolver: &mut crate::dictionary::WindowResolver,
4382                         f: &mut F| {
4383            if ids.is_empty() {
4384                return;
4385            }
4386            // Coalesced bulk fault of exactly the chunks this window needs —
4387            // only under a generous budget (see `prefetch_window` above); a
4388            // tight budget faults chunk-by-chunk inside `resolve_window`.
4389            if prefetch_window {
4390                nodes.clear();
4391                preds.clear();
4392                for &(s, p, o) in ids.iter() {
4393                    nodes.push(dict.subject_node(s));
4394                    nodes.push(dict.object_node(o));
4395                    preds.push(p);
4396                }
4397                dict.prefetch_terms_batched(&nodes, &preds, prefetch_batch);
4398            }
4399            dict.resolve_window(ids, resolver, |s, p, o| f(s, p, o));
4400            ids.clear();
4401        };
4402        for t in index.scan_iter(pattern) {
4403            ids.push(t);
4404            if ids.len() == WINDOW {
4405                flush(&mut ids, &mut resolver, &mut f);
4406            }
4407        }
4408        flush(&mut ids, &mut resolver, &mut f);
4409    }
4410
4411    /// What a [`dump_filtered_each`](Self::dump_filtered_each) over this graph
4412    /// and pattern will fetch, **before** it starts — the dump twin of
4413    /// `rete cost`'s query preview.
4414    ///
4415    /// Costed from the tile directories, so it fetches no tile. It does read the
4416    /// dictionary far enough to resolve the bound terms (that is a real,
4417    /// unavoidable cost of the dump too) and to learn whether the graph exists.
4418    pub fn dump_plan(
4419        &self,
4420        graph: Option<&str>,
4421        s: Option<&str>,
4422        p: Option<&str>,
4423        o: Option<&str>,
4424    ) -> DumpPlan {
4425        let index = match graph {
4426            None => Some(&self.index),
4427            Some(g) => self.graph_index(g),
4428        };
4429        let scan = index
4430            .zip(self.resolve_query_pattern(s, p, o))
4431            .map(|(ix, pattern)| ix.scan_plan(pattern));
4432        DumpPlan {
4433            scan,
4434            dictionary_bytes: self.header.dictionary_len,
4435        }
4436    }
4437
4438    /// The **pull** twin of [`Rete::dump_each`](Self::dump_each): every triple of a
4439    /// graph (`None` = default) as a lazy iterator of resolved terms.
4440    ///
4441    /// Same constant-memory scan — one triple decoded and resolved per `next()`,
4442    /// never a `Vec` of the whole graph — but the caller drives it, so it can
4443    /// stop early or be **suspended and resumed** across a foreign-function
4444    /// boundary. That is what the wasm/JS client's batched quad cursor needs:
4445    /// a callback cannot be paused mid-scan to hand control back to JavaScript,
4446    /// an iterator can.
4447    ///
4448    /// The dictionary is NOT prefetched: terms fault in as they are resolved, so
4449    /// stopping after a handful of triples costs a handful of dictionary reads
4450    /// rather than the whole dictionary — which on a lazy remote open of a large
4451    /// file would be gigabytes before the first triple, defeating the point of a
4452    /// pull API. [`dump_each`](Self::dump_each) does not prefetch it whole
4453    /// either; it batches the same per-term faults. Peak memory is
4454    /// O(faulted dictionary chunks + faulted index tiles), *not* O(triples).
4455    pub fn dump_iter(&self, graph: Option<&str>) -> impl Iterator<Item = TermTriple> + '_ {
4456        self.query_iter(graph, None, None, None)
4457    }
4458
4459    /// [`dump_iter`](Self::dump_iter) generalized to a triple **pattern**: the
4460    /// lazy pull form of [`query_in_graph`](Self::query_in_graph). A bound term
4461    /// unknown to the dictionary, or an unknown graph IRI, yields nothing.
4462    ///
4463    /// Same constant-memory guarantee — one triple decoded and resolved per
4464    /// `next()`, never a `Vec` of every match — and the same absence of a
4465    /// dictionary prefetch, so stopping after a handful of matches costs a
4466    /// handful of dictionary reads. `query_in_graph` is the eager twin; prefer
4467    /// this one wherever the consumer can stop early (`ASK`, `LIMIT`, an RDF4J
4468    /// `getStatements` that is about to be sliced).
4469    pub fn query_iter(
4470        &self,
4471        graph: Option<&str>,
4472        s: Option<&str>,
4473        p: Option<&str>,
4474        o: Option<&str>,
4475    ) -> impl Iterator<Item = TermTriple> + '_ {
4476        let index = match graph {
4477            None => Some(&self.index),
4478            Some(g) => self.graph_index(g),
4479        };
4480        index
4481            .zip(self.resolve_query_pattern(s, p, o))
4482            .into_iter()
4483            .flat_map(|(ix, pattern)| ix.scan_iter(pattern))
4484            .filter_map(move |(s, p, o)| {
4485                Some((
4486                    self.dict.subject_term(s)?,
4487                    self.dict.predicate_term(p)?,
4488                    self.dict.object_term(o)?,
4489                ))
4490            })
4491    }
4492
4493    /// One bounded, **resumable** slice of a pattern's matches inside one graph
4494    /// (`None` = default): `(triples, next_cursor, done)`. Start at `cursor = 0`
4495    /// and feed the returned cursor back until `done`.
4496    ///
4497    /// This is [`query_in_graph`](Self::query_in_graph) made streamable across a
4498    /// boundary that cannot hold a Rust borrow — the wasm/JVM one. The engine
4499    /// keeps no iterator alive between calls; the entire resume state is the
4500    /// opaque `u64` (see [`GraphIndex::scan_batch`]), so a client's cursor
4501    /// survives being suspended, and a cursor that is abandoned leaks nothing
4502    /// but a `u64`.
4503    ///
4504    /// The dictionary is faulted **per batch**, not whole: taking ten matches
4505    /// off the front of a 9.8-billion-quad file reads ten matches' worth of
4506    /// terms. That is the difference between `LIMIT 1` answering in bounded time
4507    /// and it reading a 30 GB dictionary first.
4508    ///
4509    /// `max_quads` is a floor (batches end on an a-group boundary), and every
4510    /// call either returns at least one row or reports `done`.
4511    pub fn query_batch(
4512        &self,
4513        graph: Option<&str>,
4514        s: Option<&str>,
4515        p: Option<&str>,
4516        o: Option<&str>,
4517        cursor: u64,
4518        max_quads: usize,
4519    ) -> (Vec<TermTriple>, u64, bool) {
4520        let index = match graph {
4521            None => &self.index,
4522            // An unknown graph IRI is an empty scan, not an error — same as
4523            // `query_in_graph` and `dump_batch`.
4524            Some(g) => match self.graph_index(g) {
4525                Some(i) => i,
4526                None => return (Vec::new(), cursor, true),
4527            },
4528        };
4529        // A bound term the dictionary does not know can match nothing, and the
4530        // index is never touched.
4531        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
4532            return (Vec::new(), cursor, true);
4533        };
4534        let (ids, next, done) = index.scan_batch(pattern, cursor, max_quads);
4535        // One coalesced dictionary fault for THIS batch — the same call
4536        // `query_in_graph` and `dump_batch` make, and the reason a partial walk
4537        // costs in proportion to what it actually read.
4538        if !ids.is_empty() {
4539            let mut nodes = Vec::with_capacity(ids.len() * 2);
4540            let mut preds = Vec::with_capacity(ids.len());
4541            for &(s, p, o) in &ids {
4542                nodes.push(self.dict.subject_node(s));
4543                nodes.push(self.dict.object_node(o));
4544                preds.push(p);
4545            }
4546            self.dict.prefetch_terms(&nodes, &preds);
4547        }
4548        let triples = ids
4549            .into_iter()
4550            .filter_map(|(s, p, o)| {
4551                Some((
4552                    self.dict.subject_term(s)?,
4553                    self.dict.predicate_term(p)?,
4554                    self.dict.object_term(o)?,
4555                ))
4556            })
4557            .collect();
4558        (triples, next, done)
4559    }
4560
4561    /// Resolve one bounded slice of a graph's triples. Returns
4562    /// `(triples, next_cursor, done)`; start with `cursor = 0` and feed the
4563    /// returned cursor back until `done`.
4564    ///
4565    /// This is the PULL half of the dump, and it exists because [`Rete::dump_each`] is
4566    /// push-based: it drives the scan itself and hands each triple to a
4567    /// callback. A client that wants to pull (a Python generator, a JS async
4568    /// iterator) has to hold the scan's stack between calls — which means a
4569    /// thread (unavailable on Pyodide and in wasm) or a self-referential struct
4570    /// holding an iterator that borrows the `Rete` stored next to it, whose
4571    /// soundness rests on drop order plus an unstated promise that this crate's
4572    /// lazily-faulted caches stay write-once. Neither is worth it for a dump.
4573    ///
4574    /// Instead the scan is made RESUMABLE: SPO tiles are ordered by subject id,
4575    /// so "every triple of subject `sid`, for ascending `sid`" visits exactly the
4576    /// tiles a full scan visits, in the same order, and the whole resume state
4577    /// collapses to one `u32`. No thread, no `unsafe`, no borrow held across
4578    /// calls — and unlike `(offset, limit)` it is O(n) overall rather than
4579    /// O(n²/limit), because nothing is ever re-scanned.
4580    ///
4581    /// `max_quads` is the FLOOR of a batch, not a hard cut: a batch always ends
4582    /// on a subject boundary, so no subject is split across two calls. Work per
4583    /// call is bounded by a probe budget too — a sparse named graph whose few
4584    /// subjects are spread over a huge id space returns early with `done = false`
4585    /// rather than grinding through absent ids, and the caller just asks again.
4586    pub fn dump_batch(
4587        &self,
4588        graph: Option<&str>,
4589        cursor: u32,
4590        max_quads: usize,
4591    ) -> (Vec<TermTriple>, u32, bool) {
4592        /// The first subject id at or after `sid` that any SPO tile can hold, or
4593        /// `None` when `sid` is past the last tile. Tiles ascend by leading
4594        /// range, so this is a binary search; it returns `sid` itself when a tile
4595        /// already covers it (an ordinary "this subject has no triples" step).
4596        fn next_tile_start(tiles: &[crate::index::Tile], sid: u32) -> Option<u32> {
4597            let i = tiles.partition_point(|t| t.leading_range().1 < sid);
4598            tiles.get(i).map(|t| t.leading_range().0.max(sid))
4599        }
4600
4601        let index = match graph {
4602            None => self.default_index(),
4603            // An unknown graph IRI is an empty dump, not an error — same as
4604            // `dump_each`.
4605            Some(g) => match self.graph_index(g) {
4606                Some(i) => i,
4607                None => return (Vec::new(), cursor, true),
4608            },
4609        };
4610        // Tile leading ranges live in the section directory, so both the span and
4611        // the gap jumps below are free on a lazy/remote open — no tile is fetched
4612        // to decide where to look.
4613        let tiles = index.tile_sections()[IndexPermutation::Spo.section_index()];
4614        let (Some(first), Some(last)) = (
4615            tiles.first().map(|t| t.leading_range().0),
4616            tiles.last().map(|t| t.leading_range().1),
4617        ) else {
4618            return (Vec::new(), cursor, true); // empty graph
4619        };
4620
4621        let mut sid = cursor.max(first);
4622        let mut probes = max_quads.saturating_mul(4).max(1 << 16);
4623        let mut ids: Vec<(u32, u32, u32)> = Vec::new();
4624        let mut exhausted = false;
4625        while sid <= last && probes > 0 {
4626            probes -= 1;
4627            let hits = index.match_pattern((Some(sid), None, None));
4628            if hits.is_empty() {
4629                // No triples at this id: jump to the next subject the index
4630                // holds (the tile's group directory knows it), instead of
4631                // stepping through absent ids one at a time. That is what keeps
4632                // a quad-index view — whose tile shared with other graphs has
4633                // the conservative range `0..=u32::MAX` — from probing four
4634                // billion ids. Only when that lookup cannot answer (a failed
4635                // fetch, already recorded) does it fall back to the directory:
4636                // if the id falls in a hole BETWEEN tiles, jump to the next
4637                // tile's first id.
4638                match index.next_leading(IndexPermutation::Spo.section_index(), sid) {
4639                    Some(next) if next > sid => {
4640                        sid = next;
4641                        continue;
4642                    }
4643                    None if !index.load_incomplete() => {
4644                        exhausted = true;
4645                        break;
4646                    }
4647                    _ => {}
4648                }
4649                match next_tile_start(tiles, sid) {
4650                    None => {
4651                        exhausted = true;
4652                        break;
4653                    }
4654                    Some(next) if next > sid => {
4655                        sid = next;
4656                        continue;
4657                    }
4658                    Some(_) => {}
4659                }
4660            } else {
4661                ids.extend(hits);
4662            }
4663            if sid == u32::MAX {
4664                exhausted = true;
4665                break;
4666            }
4667            sid += 1;
4668            if ids.len() >= max_quads {
4669                break;
4670            }
4671        }
4672        let done = exhausted || sid > last;
4673
4674        // One coalesced dictionary fault for this batch, then resolve.
4675        // Deliberately NOT `Dictionary::prefetch_all` (what `dump_each` uses):
4676        // on a lazy open that pulls the ENTIRE dictionary — gigabytes on a big
4677        // file — before the first quad, so taking five quads off the front would
4678        // cost as much as a full dump. Per-batch prefetch keeps a partial walk
4679        // proportional to what it actually read.
4680        let dict = self.dictionary();
4681        if !ids.is_empty() {
4682            let mut nodes = Vec::with_capacity(ids.len() * 2);
4683            let mut preds = Vec::with_capacity(ids.len());
4684            for &(s, p, o) in &ids {
4685                nodes.push(dict.subject_node(s));
4686                nodes.push(dict.object_node(o));
4687                preds.push(p);
4688            }
4689            dict.prefetch_terms(&nodes, &preds);
4690        }
4691        let triples = ids
4692            .into_iter()
4693            .filter_map(|(s, p, o)| {
4694                Some((
4695                    dict.subject_term(s)?,
4696                    dict.predicate_term(p)?,
4697                    dict.object_term(o)?,
4698                ))
4699            })
4700            .collect();
4701        (triples, sid, done)
4702    }
4703
4704    /// How many named graphs this dataset has. On a lazy ranged open this
4705    /// reads only the section's leading count varint — never the graphs.
4706    pub fn named_graph_count(&self) -> usize {
4707        self.named_graphs.count()
4708    }
4709
4710    /// The `i`-th named graph's quad count, when the file records it: a
4711    /// quad-index file keeps it in its graph directory, so this reads one
4712    /// directory block and never the graph's index. `None` for a per-graph
4713    /// (NAMED_GRAPHS section) file, or past the last graph.
4714    pub fn named_graph_quad_count(&self, i: usize) -> Option<u64> {
4715        match &self.named_graphs {
4716            NamedGraphsSlot::Quad(q) => q.quad_count_at(i),
4717            _ => None,
4718        }
4719    }
4720
4721    /// The `i`-th named graph as an **owned** index that is not memoised,
4722    /// when the file can make one cheaply: a quad-index file's graph of at
4723    /// most [`TRANSIENT_GRAPH_QUADS`] quads. A walk over every graph (`GRAPH
4724    /// ?g`, the union default graph, `query_quads`) evaluates each such graph
4725    /// and drops it, instead of keeping one index per graph for the life of
4726    /// the open — 1.5 M of them on the OpenCitations sample. `None` for a
4727    /// larger graph, a per-graph file, or a failed read: use
4728    /// [`named_graph_at`](Self::named_graph_at) then.
4729    pub fn named_graph_transient(&self, i: usize) -> Option<GraphIndex> {
4730        match &self.named_graphs {
4731            NamedGraphsSlot::Quad(q) => q.transient_view(i, TRANSIENT_GRAPH_QUADS),
4732            _ => None,
4733        }
4734    }
4735
4736    /// Deduplicated quads across every named graph, when the file records it
4737    /// (a quad-index file, from its preamble: one small read). `None` for a
4738    /// per-graph file — there it is the header's `quad_count` minus the
4739    /// default graph's triples.
4740    pub fn named_quad_count(&self) -> Option<u64> {
4741        match &self.named_graphs {
4742            NamedGraphsSlot::Quad(q) => Some(q.quad_count()),
4743            _ => None,
4744        }
4745    }
4746
4747    /// The `i`-th named graph's IRI (stored order). On a lazy ranged open this
4748    /// walks entry HEADERS up to `i` — it never decodes a graph's index.
4749    pub fn named_graph_name_at(&self, i: usize) -> Option<&str> {
4750        self.named_graphs.name_at(&self.dict, i, false)
4751    }
4752
4753    /// The `i`-th named graph as `(iri, index)`. On a lazy ranged open the
4754    /// index is fetched and decoded on first access and memoised; check
4755    /// [`index_incomplete`](Self::index_incomplete) after evaluating.
4756    pub fn named_graph_at(&self, i: usize) -> Option<(&str, &GraphIndex)> {
4757        self.named_graphs.graph_at(&self.dict, i, false)
4758    }
4759
4760    /// [`named_graph_name_at`](Self::named_graph_name_at) under a declared
4761    /// demand: `exhaustive = true` tells a lazy walk the caller will visit
4762    /// every graph, so it may read the section in bulk chunks. Purely a fetch
4763    /// strategy — results are identical either way. Only the SPARQL evaluator
4764    /// sets it, from shapes where full consumption is provable.
4765    pub(crate) fn named_graph_name_at_demand(&self, i: usize, exhaustive: bool) -> Option<&str> {
4766        self.named_graphs.name_at(&self.dict, i, exhaustive)
4767    }
4768
4769    /// [`named_graph_at`](Self::named_graph_at) under a declared demand: see
4770    /// [`named_graph_name_at_demand`](Self::named_graph_name_at_demand).
4771    pub(crate) fn named_graph_at_demand(
4772        &self,
4773        i: usize,
4774        exhaustive: bool,
4775    ) -> Option<(&str, &GraphIndex)> {
4776        self.named_graphs.graph_at(&self.dict, i, exhaustive)
4777    }
4778
4779    /// IRIs of the named graphs in this dataset (the default graph is unnamed).
4780    /// On a lazy ranged open this walks the whole directory (headers only) —
4781    /// prefer [`named_graph_count`](Self::named_graph_count) when the number
4782    /// is all that's needed.
4783    pub fn graph_names(&self) -> Vec<&str> {
4784        (0..self.named_graphs.count())
4785            .filter_map(|i| self.named_graphs.name_at(&self.dict, i, false))
4786            .collect()
4787    }
4788
4789    /// The permutation index of a named graph, or `None` if absent.
4790    pub fn graph_index(&self, iri: &str) -> Option<&GraphIndex> {
4791        self.named_graphs.find(&self.dict, iri)
4792    }
4793
4794    /// Match a triple pattern in dictionary-ID space (subject/predicate/object
4795    /// IDs), returning integer triples — the fast path used by the BGP engine.
4796    pub fn match_ids(
4797        &self,
4798        pattern: (Option<u32>, Option<u32>, Option<u32>),
4799    ) -> Vec<(u32, u32, u32)> {
4800        self.index.match_pattern(pattern)
4801    }
4802
4803    /// All `(subject_node, object_node)` pairs for a predicate, as unified node
4804    /// IDs — no term resolution. The fast path for graph traversal.
4805    pub fn predicate_pairs(&self, predicate: &str) -> Vec<(u32, u32)> {
4806        let pid = match self.dict.predicate_id(predicate) {
4807            Some(p) => p,
4808            None => return Vec::new(),
4809        };
4810        self.index
4811            .match_pattern((None, Some(pid), None))
4812            .into_iter()
4813            .map(|(s, _p, o)| (self.dict.subject_node(s), self.dict.object_node(o)))
4814            .collect()
4815    }
4816
4817    /// Open via a [`RangeReader`], fetching only the header and the named
4818    /// section ranges — never a linear scan of the whole resource. A full query
4819    /// open touches at most 4 ranges (header, dictionary, index, pyramid-meta).
4820    pub fn open_ranged<R: RangeReader>(reader: &R) -> Result<Self, FileError> {
4821        let head = reader.read_at(0, HEADER_LEN as u64)?;
4822        let header = Header::from_bytes(&head)?;
4823
4824        let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
4825        let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
4826
4827        check_named_layout(&header)?;
4828        let index_bytes = reader.read_at(header.root_dir_offset, header.root_dir_len)?;
4829        let (index, _) = decode_root_index_container(&index_bytes, &header)?;
4830        let index_section_ranges = decode_index_section_ranges(
4831            &index_bytes,
4832            header.root_dir_offset,
4833            header.perms,
4834            header.index_container_sections(),
4835        )?;
4836
4837        let pyramid = PyramidSlot::Resident(if header.pyramid_meta_len > 0 {
4838            let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
4839            Some(
4840                PyramidMeta::decode(&mb)
4841                    .map_err(|_| FileError::Container("malformed pyramid meta"))?,
4842            )
4843        } else {
4844            None
4845        });
4846
4847        // Fetch the whole TEXT_INDEX section resident (this opener does one range
4848        // read per section; the lazy opener below is the one that defers it).
4849        let (ti_off, ti_len) = header.text_index_payload();
4850        let text_index_bytes = if ti_len > 0 {
4851            Some(reader.read_at(ti_off, ti_len)?)
4852        } else {
4853            None
4854        };
4855        let text_index = resident_text_index_slot(text_index_bytes.as_deref(), header.block_codec)?;
4856        // LABEL_INDEX and SCHEMA: one range read each, held resident like the
4857        // other sections of this opener.
4858        let labels = SectionSlot::Resident(header.label_index_section().and_then(|s| {
4859            let b = reader.read_at(s.offset, s.length).ok()?;
4860            crate::label_index::LabelIndex::from_section(&b, header.block_codec).ok()
4861        }));
4862        let schema = SectionSlot::Resident(read_schema_with_header(reader, &header));
4863
4864        let named_graphs = NamedGraphsSlot::resident(
4865            &header,
4866            |off, len| Ok(reader.read_at(off, len)?),
4867            || {
4868                let nb = reader.read_at(header.named_graphs_offset, header.named_graphs_len)?;
4869                decode_named_graphs(&nb, header.block_codec, header.perms)
4870            },
4871        )?;
4872
4873        // The metadata section (Dataset Card) is deliberately NOT fetched here:
4874        // a ranged query open keeps to its small range budget. Use `Rete::open`
4875        // (or a dedicated card fetch) when the card is actually needed.
4876        let tile_ranges =
4877            tile_file_ranges(&index_bytes, header.root_dir_offset, &index_section_ranges);
4878        Ok(Self {
4879            header,
4880            dict,
4881            index,
4882            index_section_ranges,
4883            tile_ranges,
4884            pyramid,
4885            text_index,
4886            labels,
4887            schema,
4888            named_graphs,
4889            metadata: Vec::new(),
4890            service_client: None,
4891            service_error: std::sync::Mutex::new(None),
4892            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
4893        })
4894    }
4895
4896    /// Open via an **owned** [`RangeReader`] with lazy tile faulting (tiled
4897    /// v0.2 files): fetches the header, dictionary, pyramid meta, named graphs,
4898    /// and each permutation's tile **directory** — but no default-graph tile
4899    /// payloads. Tiles fault in (one range request each) the first time a scan
4900    /// touches them, so a selective SPARQL query fetches O(touched tiles)
4901    /// bytes instead of the whole index.
4902    ///
4903    /// **Failure contract:** scans are infallible by design, so a failed tile
4904    /// fetch yields an empty tile and sets a sticky flag — after evaluating,
4905    /// callers MUST check [`index_incomplete`](Self::index_incomplete) and
4906    /// surface an error instead of the (possibly partial) results.
4907    pub fn open_ranged_lazy<R: RangeReader + Send + Sync + 'static>(
4908        reader: R,
4909    ) -> Result<Self, FileError> {
4910        let head = reader.read_at(0, HEADER_LEN as u64)?;
4911        let header = Header::from_bytes(&head)?;
4912        check_named_layout(&header)?;
4913        let reader = std::sync::Arc::new(reader);
4914        // Captured before the loader closures take the Arc: the reader's
4915        // concurrent-range fan-out, stamped onto the index for the planner.
4916        let read_concurrency = reader.concurrency();
4917
4918        // Lazily-chunked dictionary: locate the four sections, fetch each
4919        // section's header + restart table + chunk directory (small), and
4920        // fault the chunk bodies in on first term lookup.
4921        let open_dict = || ranged_chunked_dictionary(&reader, &header, [true; 4]);
4922
4923        // Locate the six index section payloads (container framing only) and
4924        // fetch just their tile directories — shared with the per-named-graph
4925        // lazy opener, which opens a large graph's container the same way.
4926        let reader_dyn: std::sync::Arc<dyn RangeReader + Send + Sync> = reader.clone();
4927        let open_index = || {
4928            open_index_container_lazy(
4929                &reader_dyn,
4930                ByteRange {
4931                    offset: header.root_dir_offset,
4932                    len: header.root_dir_len,
4933                },
4934                header.block_codec,
4935                header.has_tile_synopsis(),
4936                read_concurrency,
4937                header.perms,
4938                header.index_container_sections(),
4939            )
4940        };
4941
4942        // Each of the two is a chain of small dependent reads (a container's
4943        // sections are found by walking their length prefixes), ~8 round trips
4944        // apiece on a real file, and the header already says where both
4945        // containers start. So a reader that can keep several reads in flight
4946        // walks them at the same time: the open costs the longer chain, not
4947        // the sum. Same reads, same result; only their overlap changes.
4948        let (dict, index) = open_side_by_side(read_concurrency, open_dict, open_index);
4949        let dict = dict?;
4950        let (index, index_section_ranges, tile_ranges) = index?;
4951
4952        // The pyramid meta is large on real graphs (tens of MB) and SPARQL never
4953        // reads it, so defer its fetch: it faults in only if `pyramid()` is
4954        // called (community / pyramid_tree / inspect queries).
4955        let pyramid = ranged_pyramid_slot(&reader, &header);
4956
4957        // The TEXT_INDEX section is also deferred: a text search faults the token
4958        // table on first call (the leading varint then its compressed bytes), then
4959        // fetches individual posting lists by `(offset, len)` — never the whole
4960        // postings blob. A SPARQL query, which never searches, pays nothing.
4961        let text_index = ranged_text_index_slot(&reader, &header);
4962
4963        // LABEL_INDEX: the first prefix search reads its head and walks down.
4964        // SCHEMA: read once, on first use — a stats question, or the planner
4965        // ordering a join that needs a measured selectivity.
4966        let labels = ranged_label_slot(&reader, &header);
4967        let schema = ranged_schema_slot(&reader, &header);
4968
4969        // Named graphs are the last eagerly-fetched section standing on this
4970        // path — and on a many-graph file they dwarf everything else (67 MB
4971        // fetched and ~32k graph indexes decoded before the first query, on a
4972        // file whose queries then touch a handful of graphs). Defer them like
4973        // the pyramid and text index: nothing is read at open; a query walks
4974        // the directory and decodes only the graphs it touches.
4975        let named_graphs = if let Some(q) = header.quad_index_section() {
4976            // The quad index: nothing read at open either; the preamble on the
4977            // first named-graph touch, then only the routing parts and tiles
4978            // a query uses.
4979            NamedGraphsSlot::Quad(crate::quad::QuadNamed::new(
4980                reader_dyn,
4981                ByteRange {
4982                    offset: q.offset,
4983                    len: q.length,
4984                },
4985                header.block_codec,
4986                header.perms,
4987                read_concurrency,
4988            ))
4989        } else if header.named_graphs_len > 0 {
4990            NamedGraphsSlot::Lazy(LazyNamedGraphs::new(
4991                reader_dyn,
4992                ByteRange {
4993                    offset: header.named_graphs_offset,
4994                    len: header.named_graphs_len,
4995                },
4996                header.block_codec,
4997                header.has_tile_synopsis(),
4998                read_concurrency,
4999                header.perms,
5000            ))
5001        } else {
5002            NamedGraphsSlot::Resident(Vec::new())
5003        };
5004
5005        let rete = Self {
5006            header,
5007            dict,
5008            index,
5009            index_section_ranges,
5010            tile_ranges,
5011            pyramid,
5012            text_index,
5013            labels,
5014            schema,
5015            named_graphs,
5016            metadata: Vec::new(),
5017            service_client: None,
5018            service_error: std::sync::Mutex::new(None),
5019            spill_threshold: std::sync::atomic::AtomicU64::new(u64::MAX),
5020        };
5021        // A lazy open is the path for files too big to read whole, and the
5022        // one a long-lived session (relay, Python/R/Java clients, `rete serve`)
5023        // keeps resident across queries. Unbounded, its dictionary and tile
5024        // caches grow with everything any query ever touched: a GROUP BY over
5025        // a 9.5-billion-triple file ran a 16 GB container out of memory. Cap
5026        // them by default; a caller that wants another figure (or unlimited)
5027        // calls `set_memory_budget` again. Not on wasm32, whose 4 GiB address
5028        // space makes the default meaningless and whose callers bound their own
5029        // range budgets.
5030        #[cfg(not(target_arch = "wasm32"))]
5031        rete.set_memory_budget(Some(DEFAULT_QUERY_BUDGET_MB << 20));
5032        Ok(rete)
5033    }
5034
5035    /// Did any lazy fetch (index tile or dictionary chunk) fail since this
5036    /// `Rete` was opened? When true, query results may be silently incomplete —
5037    /// callers using [`Rete::open_ranged_lazy`] must check this after
5038    /// evaluating and turn it into an error.
5039    pub fn index_incomplete(&self) -> bool {
5040        self.index.load_incomplete()
5041            || self.dict.load_incomplete()
5042            || self.named_graphs.load_incomplete()
5043    }
5044
5045    /// Forget recorded lazy-fetch failures — the start-of-evaluation reset for
5046    /// a RESIDENT session (a browser worker holding one `Rete` across many
5047    /// queries): it makes [`index_incomplete`](Self::index_incomplete) a
5048    /// per-query verdict instead of a per-open one, so a single transient
5049    /// network failure no longer fails every subsequent query on the session.
5050    /// Sound because failed tiles/chunks are never cached — the next
5051    /// evaluation simply retries the fetch.
5052    pub fn reset_load_failures(&self) {
5053        self.index.reset_load_failure();
5054        self.dict.reset_load_failure();
5055        self.named_graphs.reset_load_failures();
5056    }
5057
5058    /// Bound peak resident memory to roughly `budget` bytes by capping the
5059    /// evictable dictionary chunk cache and the default-graph index tile cache
5060    /// (bounded-export phase 2). `None` or `u64::MAX` means unlimited — nothing
5061    /// is evicted and residency is identical to a plain open, which is what
5062    /// `--in-memory` and every query/wasm path use.
5063    ///
5064    /// Sound at any cap: the dictionary and the default-graph index both fault
5065    /// their chunks/tiles through a loader, so an evicted body is simply
5066    /// re-faulted on its next touch; the export dump reads each needed body
5067    /// while holding it (the cache `Arc` handout invariant), so eviction never
5068    /// drops bytes in use. Named-graph indexes are **not** capped here — a small
5069    /// resident graph has no loader and could never reload an evicted tile — so
5070    /// they stay unlimited and are released whole per slot instead (see
5071    /// [`release_named_graph`](Self::release_named_graph)).
5072    ///
5073    /// The open's **fixed** heap — chunk and tile directories, the named-graph
5074    /// directory walked so far ([`fixed_overhead`](Self::fixed_overhead)) — is
5075    /// paid whatever the caches hold, so it comes out of the dictionary share:
5076    /// the caps plus the fixed heap stay within `budget`. It grows with the
5077    /// file's chunk/tile/graph count (~18 MB of directories on the 14.75 GiB
5078    /// openaire-relation, 88 MB with its tile directories; 224 MB on a pre-#198
5079    /// epfl-infoscience whose chunk keys are whole boundary terms). Calling
5080    /// this again after a walk of the named-graph directory (as `rete export`
5081    /// does once it has listed the graph slots) re-subtracts the larger figure.
5082    /// The dictionary share never drops below its 64 MiB floor, which is the
5083    /// only way the total can be exceeded: a file whose directories alone
5084    /// approach the budget.
5085    ///
5086    /// Returns the split actually applied (dictionary share net of the fixed
5087    /// heap), for `RETE_OPEN_DEBUG` logging.
5088    pub fn set_memory_budget(&self, budget: Option<u64>) -> MemoryBudget {
5089        let total = budget.unwrap_or(u64::MAX);
5090        self.set_spill_threshold(budget.map(spill_threshold_for));
5091        let mut split = split_memory_budget(total);
5092        let fixed = if total == u64::MAX {
5093            0
5094        } else {
5095            self.fixed_overhead().total()
5096        };
5097        if split.dict_cache != u64::MAX {
5098            split.dict_cache = split
5099                .dict_cache
5100                .saturating_sub(fixed)
5101                .max(MIN_DICT_CACHE_CAP);
5102        }
5103        self.dict.set_cache_cap(split.dict_cache);
5104        self.index.set_cache_cap(split.tile_cache);
5105        // Large named graphs (opened lazily, loader-bearing) get the same tile
5106        // cap; small resident ones stay unlimited (bounded by size + per-slot
5107        // release). Combined with `release_named_graph`, a many-graph dump holds
5108        // one graph's tiles at a time, each capped.
5109        match &self.named_graphs {
5110            NamedGraphsSlot::Lazy(lazy) => lazy.set_tile_cap(split.tile_cache),
5111            // Every quad-index view, and the tiles they share, re-fault
5112            // through loaders — on every open path — so they take the cap too.
5113            NamedGraphsSlot::Quad(q) => q.set_tile_cap(split.tile_cache),
5114            NamedGraphsSlot::Resident(_) => {}
5115        }
5116        if std::env::var("RETE_OPEN_DEBUG").is_ok() {
5117            eprintln!(
5118                "[memory-budget] total={} dict_cache={} tile_cache={} block_cache={} fixed={:.1}MiB",
5119                if total == u64::MAX {
5120                    "unlimited".to_string()
5121                } else {
5122                    format!("{}MiB", total >> 20)
5123                },
5124                fmt_cap(split.dict_cache),
5125                fmt_cap(split.tile_cache),
5126                fmt_cap(split.block_cache),
5127                fixed as f64 / (1u64 << 20) as f64,
5128            );
5129        }
5130        split
5131    }
5132
5133    /// How many bytes a query's `COUNT(DISTINCT …)` keeps in memory before it
5134    /// writes sorted runs to the temporary directory (`TMPDIR`) and counts by
5135    /// merging them; the count is exact either way. `u64::MAX` (the default of
5136    /// an eager open) never spills. [`set_memory_budget`](Self::set_memory_budget)
5137    /// sets it to an eighth of the budget, so a lazy open spills past 512 MiB
5138    /// by default.
5139    pub fn spill_threshold(&self) -> u64 {
5140        self.spill_threshold
5141            .load(std::sync::atomic::Ordering::Relaxed)
5142    }
5143
5144    /// Set [`spill_threshold`](Self::spill_threshold) without touching the
5145    /// caches — for an eager (resident) open, whose caches cannot be capped.
5146    /// `None` = never spill.
5147    pub fn set_spill_threshold(&self, bytes: Option<u64>) {
5148        self.spill_threshold.store(
5149            bytes.unwrap_or(u64::MAX),
5150            std::sync::atomic::Ordering::Relaxed,
5151        );
5152    }
5153
5154    /// The dictionary chunk cache's observability counters (decoded chunks and
5155    /// bytes, hits/misses, evictions, resident bytes) — for the bounded-export
5156    /// work-accounting tests and `RETE_OPEN_DEBUG`.
5157    pub fn dict_cache_stats(&self) -> crate::chunk_cache::CacheStats {
5158        self.dict.cache_stats()
5159    }
5160
5161    /// Heap bytes this open holds outside the bounded caches — the dictionary
5162    /// chunk directories, the index tile directories and the named-graph
5163    /// directory — for `RETE_OPEN_DEBUG` / `RETE_MEM_TRACE` accounting.
5164    pub fn fixed_overhead(&self) -> FixedOverhead {
5165        FixedOverhead {
5166            dict_directory: self.dict.directory_bytes(),
5167            index_directory: self.index.directory_bytes(),
5168            named_graphs: self.named_graphs.directory_bytes(),
5169        }
5170    }
5171
5172    /// The default-graph index tile cache's observability counters.
5173    pub fn index_cache_stats(&self) -> crate::chunk_cache::CacheStats {
5174        self.index.cache_stats()
5175    }
5176
5177    /// The current dictionary chunk-cache byte cap (`u64::MAX` = unlimited).
5178    pub fn dict_cache_cap(&self) -> u64 {
5179        self.dict.cache_cap()
5180    }
5181
5182    /// Release a named graph's decoded index after its slot has been exported,
5183    /// dropping its `GraphIndex` (and, with it, that graph's decoded tiles and
5184    /// its own tile cache) so a many-graph dump does not accumulate one resident
5185    /// index per graph — the next linear floor on many-graph files once the
5186    /// dictionary is bounded (plan §6). A no-op for the default graph, an
5187    /// unknown IRI, a resident (in-memory/eager) open, or a graph never opened.
5188    ///
5189    /// Takes `&mut self`: the export loop finishes one slot's dump (which only
5190    /// borrows `&self`) before releasing, so no outstanding borrow of the index
5191    /// exists. Correct because a released index is simply re-opened (headers +
5192    /// tile directory) on a later access — the same lazy contract as a chunk
5193    /// that was never faulted.
5194    pub fn release_named_graph(&mut self, iri: &str) {
5195        match &mut self.named_graphs {
5196            NamedGraphsSlot::Lazy(lazy) => lazy.release(iri),
5197            NamedGraphsSlot::Quad(q) => q.release(&self.dict, iri),
5198            NamedGraphsSlot::Resident(_) => {}
5199        }
5200    }
5201
5202    fn resolve_query_pattern(
5203        &self,
5204        s: Option<&str>,
5205        p: Option<&str>,
5206        o: Option<&str>,
5207    ) -> Option<Pattern> {
5208        let sid = match s {
5209            Some(t) => match self.dict.subject_id(t) {
5210                Some(id) => Some(id),
5211                None => return None,
5212            },
5213            None => None,
5214        };
5215        let pid = match p {
5216            Some(t) => match self.dict.predicate_id(t) {
5217                Some(id) => Some(id),
5218                None => return None,
5219            },
5220            None => None,
5221        };
5222        let oid = match o {
5223            Some(t) => match self.dict.object_id(t) {
5224                Some(id) => Some(id),
5225                None => return None,
5226            },
5227            None => None,
5228        };
5229        Some((sid, pid, oid))
5230    }
5231
5232    /// Evaluate a triple pattern and include the file/index provenance for every
5233    /// matched result. A bound term that is unknown to the dictionary yields no
5234    /// matches.
5235    pub fn query_with_provenance(
5236        &self,
5237        s: Option<&str>,
5238        p: Option<&str>,
5239        o: Option<&str>,
5240    ) -> Vec<TripleProvenance> {
5241        let pattern = match self.resolve_query_pattern(s, p, o) {
5242            Some(pattern) => pattern,
5243            None => return Vec::new(),
5244        };
5245
5246        let index_permutation = GraphIndex::best_permutation_in(self.header.perms, pattern);
5247        let dictionary_range = ByteRange {
5248            offset: self.header.dictionary_offset,
5249            len: self.header.dictionary_len,
5250        };
5251        let index_range = ByteRange {
5252            offset: self.header.root_dir_offset,
5253            len: self.header.root_dir_len,
5254        };
5255        let index_section_range = self.index_section_ranges[index_permutation.section_index()];
5256        let pyramid_range = (self.header.pyramid_meta_len > 0).then_some(ByteRange {
5257            offset: self.header.pyramid_meta_offset,
5258            len: self.header.pyramid_meta_len,
5259        });
5260
5261        let tiles = &self.tile_ranges[index_permutation.section_index()];
5262        self.index
5263            .match_pattern(pattern)
5264            .into_iter()
5265            .filter_map(|(s, p, o)| {
5266                let terms = (
5267                    self.dict.subject_term(s)?,
5268                    self.dict.predicate_term(p)?,
5269                    self.dict.object_term(o)?,
5270                );
5271                // The physical tile holding this match: the one whose
5272                // leading-id range covers the match's permuted leading id.
5273                let a = index_permutation.forward((s, p, o)).0;
5274                let ti = tiles.partition_point(|&(_, max_a, _)| max_a < a);
5275                let (tile, tile_range) = match tiles.get(ti) {
5276                    Some(&(min_a, _, range)) if min_a <= a => (
5277                        Some(format!("{}/{ti}", index_permutation.name())),
5278                        Some(range),
5279                    ),
5280                    _ => (None, None),
5281                };
5282                Some(TripleProvenance {
5283                    terms,
5284                    ids: (s, p, o),
5285                    graph: None,
5286                    matched_pattern: pattern,
5287                    index_permutation,
5288                    dictionary_range,
5289                    index_range,
5290                    index_section_range,
5291                    pyramid_range,
5292                    tile,
5293                    tile_range,
5294                })
5295            })
5296            .collect()
5297    }
5298
5299    /// Evaluate a triple pattern given as optional term strings, returning
5300    /// matching triples resolved back to terms. A bound term that is unknown to
5301    /// the dictionary yields no matches.
5302    pub fn query(&self, s: Option<&str>, p: Option<&str>, o: Option<&str>) -> Vec<TermTriple> {
5303        self.query_with_provenance(s, p, o)
5304            .into_iter()
5305            .map(|m| m.terms)
5306            .collect()
5307    }
5308
5309    /// Match a triple pattern **within a single graph** — `None` is the default
5310    /// graph, `Some(iri)` a named graph — resolving matches to canonical terms.
5311    /// This is [`Rete::query`] (default-graph only) generalized to any graph: the
5312    /// graph-scoped primitive a quad-aware consumer (e.g. an RDF4J `Sail`'s
5313    /// `getStatements`) needs. An unknown graph IRI, or a bound term absent from
5314    /// the shared dictionary, yields an empty result. All graphs share one
5315    /// dictionary, so the pattern resolves once against that ID space.
5316    pub fn query_in_graph(
5317        &self,
5318        graph: Option<&str>,
5319        s: Option<&str>,
5320        p: Option<&str>,
5321        o: Option<&str>,
5322    ) -> Vec<TermTriple> {
5323        let pattern = match self.resolve_query_pattern(s, p, o) {
5324            Some(pattern) => pattern,
5325            None => return Vec::new(),
5326        };
5327        let index = match graph {
5328            None => &self.index,
5329            Some(g) => match self.graph_index(g) {
5330                Some(i) => i,
5331                None => return Vec::new(),
5332            },
5333        };
5334        self.resolve_matches(index.match_pattern(pattern))
5335    }
5336
5337    /// Resolve matched id-triples to terms, with one coalesced dictionary
5338    /// fault for exactly their terms.
5339    fn resolve_matches(&self, ids: Vec<Triple>) -> Vec<TermTriple> {
5340        // One coalesced dictionary fault for THIS pattern's output, not the
5341        // whole dictionary. `prefetch_all` used to sit here, which made a
5342        // bounded `(s, p, o, g)` lookup — the primitive an RDF4J `Sail`'s
5343        // `getStatements` calls, once per named graph via `query_quads` — pay
5344        // for every term in the file: on `cordis.rete` (801 MB, a 417 MB
5345        // dictionary) a 15-row answer read 416 MB and peaked at 2.5 GB RSS.
5346        // Same call `dump_batch` makes, for the same reason.
5347        if !ids.is_empty() {
5348            let mut nodes = Vec::with_capacity(ids.len() * 2);
5349            let mut preds = Vec::with_capacity(ids.len());
5350            for &(s, p, o) in &ids {
5351                nodes.push(self.dict.subject_node(s));
5352                nodes.push(self.dict.object_node(o));
5353                preds.push(p);
5354            }
5355            self.dict.prefetch_terms(&nodes, &preds);
5356        }
5357        ids.into_iter()
5358            .filter_map(|(s, p, o)| {
5359                Some((
5360                    self.dict.subject_term(s)?,
5361                    self.dict.predicate_term(p)?,
5362                    self.dict.object_term(o)?,
5363                ))
5364            })
5365            .collect()
5366    }
5367
5368    /// Match a triple pattern across the default graph **and every named graph**,
5369    /// tagging each match with its graph (`None` = default). The quad-level
5370    /// companion to [`Rete::query`]; default-graph matches come first, then each
5371    /// named graph in stored order.
5372    pub fn query_quads(
5373        &self,
5374        s: Option<&str>,
5375        p: Option<&str>,
5376        o: Option<&str>,
5377    ) -> Vec<(TermTriple, Option<String>)> {
5378        let mut out: Vec<(TermTriple, Option<String>)> = self
5379            .query_in_graph(None, s, p, o)
5380            .into_iter()
5381            .map(|t| (t, None))
5382            .collect();
5383        let Some(pattern) = self.resolve_query_pattern(s, p, o) else {
5384            return out;
5385        };
5386        for i in 0..self.named_graphs.count() {
5387            // A quad dump visits every graph by construction — exhaustive
5388            // demand, so a lazy walk may read the section in bulk chunks.
5389            let Some(iri) = self.named_graphs.name_at(&self.dict, i, true) else {
5390                continue;
5391            };
5392            // A small quad-index graph through an owned view, dropped here:
5393            // the walk holds one view at a time, not one per graph.
5394            let owned = self.named_graph_transient(i);
5395            let index = match &owned {
5396                Some(view) => view,
5397                None => match self.named_graphs.graph_at(&self.dict, i, true) {
5398                    Some((_, g)) => g,
5399                    None => continue,
5400                },
5401            };
5402            for triple in self.resolve_matches(index.match_pattern(pattern)) {
5403                out.push((triple, Some(iri.to_string())));
5404            }
5405        }
5406        out
5407    }
5408
5409    /// Evaluate one triple pattern through a [`RangeReader`] by fetching only
5410    /// the header, the dictionary, and — for a tiled (v0.2) file — the
5411    /// selected permutation section's tile **directory** plus the tile(s) the
5412    /// bound leading id routes to; an unbound leading id fetches the section's
5413    /// tile body in one request. v0.1 files fetch the whole selected section.
5414    /// Unknown bound terms return an empty result before touching the index.
5415    pub fn query_ranged<R: RangeReader>(
5416        reader: &R,
5417        s: Option<&str>,
5418        p: Option<&str>,
5419        o: Option<&str>,
5420    ) -> Result<Vec<TermTriple>, FileError> {
5421        let routed = match route_pattern(reader, s, p, o)? {
5422            Some(routed) => routed,
5423            None => return Ok(Vec::new()),
5424        };
5425        let matches = fetch_routed_matches(reader, &routed)?;
5426        Ok(matches
5427            .into_iter()
5428            .filter_map(|(s, p, o)| {
5429                Some((
5430                    routed.dict.subject_term(s)?,
5431                    routed.dict.predicate_term(p)?,
5432                    routed.dict.object_term(o)?,
5433                ))
5434            })
5435            .collect())
5436    }
5437
5438    /// Route one triple pattern to its permutation section without fetching
5439    /// any payload bytes. Returns `false` when a bound term is unknown and the
5440    /// index was skipped.
5441    pub fn route_pattern_ranged<R: RangeReader>(
5442        reader: &R,
5443        s: Option<&str>,
5444        p: Option<&str>,
5445        o: Option<&str>,
5446    ) -> Result<bool, FileError> {
5447        Ok(route_pattern(reader, s, p, o)?.is_some())
5448    }
5449}
5450
5451/// A pattern routed to its permutation section: everything needed to fetch
5452/// matches, with no payload bytes read yet.
5453struct RoutedPattern {
5454    dict: Dictionary,
5455    pattern: Pattern,
5456    permutation: IndexPermutation,
5457    header: Header,
5458    /// Absolute byte range of the selected section's payload.
5459    section: ByteRange,
5460}
5461
5462/// Resolve a pattern against the remote dictionary and locate its permutation
5463/// section (header + dictionary + container framing only).
5464fn route_pattern<R: RangeReader>(
5465    reader: &R,
5466    s: Option<&str>,
5467    p: Option<&str>,
5468    o: Option<&str>,
5469) -> Result<Option<RoutedPattern>, FileError> {
5470    let head = reader.read_at(0, HEADER_LEN as u64)?;
5471    let header = Header::from_bytes(&head)?;
5472
5473    let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
5474    let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
5475
5476    let Some(pattern) = resolve_query_pattern(&dict, s, p, o) else {
5477        return Ok(None);
5478    };
5479    let permutation = GraphIndex::best_permutation_in(header.perms, pattern);
5480    let section = locate_container_section_ranged(
5481        reader,
5482        header.root_dir_offset,
5483        header.root_dir_len,
5484        header
5485            .perms
5486            .position(permutation)
5487            .ok_or(FileError::Container("routed to an absent permutation"))?,
5488        header.index_container_sections() as u64,
5489    )?;
5490    Ok(Some(RoutedPattern {
5491        dict,
5492        pattern,
5493        permutation,
5494        header,
5495        section,
5496    }))
5497}
5498
5499/// Fetch and scan a routed pattern's matches: read the tile directory, then only
5500/// the matching tile byte ranges (the run of covering tiles for a bound leading
5501/// id — one for ordinary groups, several for a split mega-group — the
5502/// O(matching bytes) promise).
5503fn fetch_routed_matches<R: RangeReader>(
5504    reader: &R,
5505    routed: &RoutedPattern,
5506) -> Result<Vec<Triple>, FileError> {
5507    let dir = read_tile_directory_ranged(reader, routed.section)?;
5508    let [pa, _, _] = routed.permutation.order_pattern(routed.pattern);
5509    let codec = routed.header.block_codec;
5510    let mut out = Vec::new();
5511    match pa {
5512        // Bound leading id: the run of covering tiles (several for a split
5513        // mega-group; one otherwise).
5514        Some(a) => {
5515            for e in dir.iter().filter(|e| e.min_a <= a && a <= e.max_a) {
5516                let bytes = reader.read_at(routed.section.offset + e.start, e.end - e.start)?;
5517                let tile = decompress(codec, &bytes)?;
5518                out.extend(GraphIndex::match_serialized_block(
5519                    &tile,
5520                    routed.permutation,
5521                    routed.pattern,
5522                ));
5523            }
5524        }
5525        // Unbound leading id: every tile matters — fetch the contiguous tile
5526        // body in one request and slice it.
5527        None => {
5528            if let (Some(first), Some(last)) = (dir.first(), dir.last()) {
5529                let base = first.start;
5530                let body = reader.read_at(routed.section.offset + base, last.end - base)?;
5531                for e in &dir {
5532                    let tile = decompress(
5533                        codec,
5534                        &body[(e.start - base) as usize..(e.end - base) as usize],
5535                    )?;
5536                    out.extend(GraphIndex::match_serialized_block(
5537                        &tile,
5538                        routed.permutation,
5539                        routed.pattern,
5540                    ));
5541                }
5542            }
5543        }
5544    }
5545    out.sort_unstable();
5546    Ok(out)
5547}
5548
5549fn resolve_query_pattern(
5550    dict: &Dictionary,
5551    s: Option<&str>,
5552    p: Option<&str>,
5553    o: Option<&str>,
5554) -> Option<Pattern> {
5555    let sid = match s {
5556        Some(t) => Some(dict.subject_id(t)?),
5557        None => None,
5558    };
5559    let pid = match p {
5560        Some(t) => Some(dict.predicate_id(t)?),
5561        None => None,
5562    };
5563    let oid = match o {
5564        Some(t) => Some(dict.object_id(t)?),
5565        None => None,
5566    };
5567    Some((sid, pid, oid))
5568}
5569
5570/// The lazily-chunked dictionary of a ranged open: per section, fetch the
5571/// header + chunk directory and fault the chunk bodies in on first term lookup.
5572///
5573/// `want` selects which of the four sections (0 shared, 1 subject-only,
5574/// 2 object-only, 3 predicates) are really read; a skipped one becomes an empty
5575/// section whose terms resolve to `None`. On a file written before #198 a
5576/// **directory is not small**: it carried each chunk's first term verbatim, so
5577/// the object-only section of a dataset that stores abstracts can run to
5578/// hundreds of megabytes, and fetching it is most of what a remote open costs.
5579/// Current builds key it by the shortest separator instead (a few bytes per
5580/// chunk), but that is write-side, so every already-published file still pays.
5581/// A reader that only ever resolves subjects (see [`SearchView`]) skips
5582/// sections 2 and 3 and pays none of it. [`Rete::open_ranged_lazy`] wants all
5583/// four.
5584fn ranged_chunked_dictionary<R: RangeReader + Send + Sync + 'static>(
5585    reader: &std::sync::Arc<R>,
5586    header: &Header,
5587    want: [bool; 4],
5588) -> Result<Dictionary, FileError> {
5589    // One (unlimited) cache shared across the four sections, keyed by
5590    // (section_index, chunk_index).
5591    let cache = crate::chunk_cache::ChunkCache::unlimited_arc();
5592    let mut dict_sections: Vec<crate::dict::ChunkedSection> = Vec::with_capacity(4);
5593    let mut section_ranges: [std::sync::Arc<Vec<ByteRange>>; 4] = Default::default();
5594    for si in 0..4 {
5595        if !want[si] {
5596            dict_sections.push(crate::dict::ChunkedSection::from_parts(
5597                crate::dict::SectionMeta {
5598                    term_count: 0,
5599                    restart_interval: 1,
5600                    restart_offsets: Vec::new(),
5601                },
5602                Vec::new(),
5603                None,
5604                cache.clone(),
5605                si as u8,
5606            ));
5607            continue;
5608        }
5609        let section = locate_container_section_ranged(
5610            reader.as_ref(),
5611            header.dictionary_offset,
5612            header.dictionary_len,
5613            si,
5614            4,
5615        )?;
5616        let (meta, entries) = read_dict_dir_ranged(reader.as_ref(), section)?;
5617        // One copy of the chunk ranges, shared by this section's loaders and
5618        // the dictionary's cross-section batch loader.
5619        let ranges: std::sync::Arc<Vec<ByteRange>> = std::sync::Arc::new(
5620            entries
5621                .iter()
5622                .map(|e| ByteRange {
5623                    offset: section.offset + e.start,
5624                    len: (e.end - e.start),
5625                })
5626                .collect(),
5627        );
5628        section_ranges[si] = ranges.clone();
5629        let chunks: Vec<crate::dict::SectionChunk> = entries
5630            .into_iter()
5631            .map(|e| crate::dict::SectionChunk::new(e.first_run, e.key, e.body_start))
5632            .collect();
5633        let chunk_reader = reader.clone();
5634        let codec = header.dict_codec;
5635        let loader_ranges = ranges.clone();
5636        let loader: crate::dict::ChunkLoader = Box::new(move |ci| {
5637            let range = loader_ranges.get(ci)?;
5638            let bytes = chunk_reader.read_at(range.offset, range.len).ok()?;
5639            decompress(codec, &bytes).ok()
5640        });
5641        // Full-section sweeps (export/dump) batch their chunk fetches:
5642        // adjacent chunk ranges coalesce into a handful of range reads.
5643        let bulk_reader = reader.clone();
5644        let bulk: crate::dict::ChunkBulkLoader = Box::new(move |cis| {
5645            let want: Option<Vec<ByteRange>> =
5646                cis.iter().map(|&ci| ranges.get(ci).copied()).collect();
5647            let blobs = read_coalesced(bulk_reader.as_ref(), &want?, DICT_COALESCE_GAP)?;
5648            blobs.iter().map(|b| decompress(codec, b).ok()).collect()
5649        });
5650        dict_sections.push(
5651            crate::dict::ChunkedSection::from_parts(
5652                meta,
5653                chunks,
5654                Some(loader),
5655                cache.clone(),
5656                si as u8,
5657            )
5658            .with_bulk_loader(bulk),
5659        );
5660    }
5661    let dict_arr: [crate::dict::ChunkedSection; 4] = dict_sections
5662        .try_into()
5663        .map_err(|_| FileError::Container("expected 4 dictionary sections"))?;
5664    // Decoding a result touches several sections at once; this fetches all
5665    // their missing chunks in one batch (pairs arrive section by section in
5666    // ascending chunk order, so ascending in the file, as coalescing wants).
5667    let multi_reader = reader.clone();
5668    let codec = header.dict_codec;
5669    let multi: crate::dict::MultiChunkBulkLoader = Box::new(move |pairs| {
5670        let want: Option<Vec<ByteRange>> = pairs
5671            .iter()
5672            .map(|&(si, ci)| section_ranges.get(si as usize)?.get(ci).copied())
5673            .collect();
5674        let blobs = read_coalesced(multi_reader.as_ref(), &want?, DICT_COALESCE_GAP)?;
5675        blobs.iter().map(|b| decompress(codec, b).ok()).collect()
5676    });
5677    Ok(Dictionary::from_chunked_sections(dict_arr).with_multi_bulk_loader(multi))
5678}
5679
5680/// Run two independent steps of an open: at the same time when the reader can
5681/// keep several reads in flight (`concurrency > 1`) on a target with threads,
5682/// else one after the other. Either way both run to completion and their
5683/// results come back as they would inline; a panic in either propagates.
5684#[cfg(not(target_arch = "wasm32"))]
5685fn open_side_by_side<A, B: Send>(
5686    concurrency: usize,
5687    a: impl FnOnce() -> A,
5688    b: impl FnOnce() -> B + Send,
5689) -> (A, B) {
5690    if concurrency <= 1 {
5691        return (a(), b());
5692    }
5693    std::thread::scope(|scope| {
5694        let hb = scope.spawn(b);
5695        let ra = a();
5696        let rb = hb
5697            .join()
5698            .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
5699        (ra, rb)
5700    })
5701}
5702
5703/// wasm32 has no threads here: always one after the other.
5704#[cfg(target_arch = "wasm32")]
5705fn open_side_by_side<A, B>(
5706    _concurrency: usize,
5707    a: impl FnOnce() -> A,
5708    b: impl FnOnce() -> B,
5709) -> (A, B) {
5710    (a(), b())
5711}
5712
5713/// The pyramid meta is large on real graphs (tens of MB) and SPARQL never reads
5714/// it, so a ranged open defers its fetch: it faults in only if `pyramid()` is
5715/// called (community / pyramid_tree / label-prefix queries).
5716fn ranged_pyramid_slot<R: RangeReader + Send + Sync + 'static>(
5717    reader: &std::sync::Arc<R>,
5718    header: &Header,
5719) -> PyramidSlot {
5720    if header.pyramid_meta_len == 0 {
5721        return PyramidSlot::Resident(None);
5722    }
5723    let pyr_reader = reader.clone();
5724    let pyr_off = header.pyramid_meta_offset;
5725    let pyr_len = header.pyramid_meta_len;
5726    PyramidSlot::Lazy {
5727        loader: Box::new(move || {
5728            let mb = pyr_reader.read_at(pyr_off, pyr_len).ok()?;
5729            PyramidMeta::decode(&mb).ok()
5730        }),
5731        cell: std::sync::OnceLock::new(),
5732    }
5733}
5734
5735/// The deferred TEXT_INDEX of a ranged open: a text search faults the token
5736/// table on first call (the leading varint then its compressed bytes), then
5737/// fetches individual posting lists by `(offset, len)` — never the whole
5738/// postings blob. A caller that never searches pays nothing.
5739fn ranged_text_index_slot<R: RangeReader + Send + Sync + 'static>(
5740    reader: &std::sync::Arc<R>,
5741    header: &Header,
5742) -> TextIndexSlot {
5743    let (ti_off, ti_len) = header.text_index_payload();
5744    if ti_len == 0 {
5745        return TextIndexSlot::Resident {
5746            index: None,
5747            token_table_len: None,
5748        };
5749    }
5750    let ti_reader = reader.clone();
5751    let probe_reader = reader.clone();
5752    let codec = header.block_codec;
5753    TextIndexSlot::Lazy {
5754        loader: Box::new(move || {
5755            // The section opens with `varint token_table_len`; read enough to
5756            // decode it (a uvarint is ≤ 10 bytes), then fetch the varint + the
5757            // compressed token table as one prefix range.
5758            let prefix_len = read_token_table_len(&*ti_reader, ti_off, ti_len)?;
5759            let prefix = ti_reader.read_at(ti_off, prefix_len).ok()?;
5760            let postings_base = crate::text_index::TextIndex::postings_base(&prefix)? as u64;
5761            let postings_abs = ti_off + postings_base;
5762            let pr = ti_reader.clone();
5763            let posting_loader =
5764                Box::new(move |off: u64, len: u64| pr.read_at(postings_abs + off, len).ok());
5765            crate::text_index::TextIndex::from_token_table(&prefix, codec, posting_loader).ok()
5766        }),
5767        cell: std::sync::OnceLock::new(),
5768        // The same leading varint the loader starts from, asked on its own so a
5769        // caller can *state* the cost of a first search without paying it.
5770        token_table: Box::new(move || read_token_table_len(&*probe_reader, ti_off, ti_len)),
5771        token_table_cell: std::sync::OnceLock::new(),
5772    }
5773}
5774
5775/// Full-text search over `ti`, resolving the matched subject ids through `dict`.
5776/// The engine behind [`Rete::text_search`] and [`SearchView::text_search`].
5777fn text_search_in(
5778    ti: Option<&crate::text_index::TextIndex>,
5779    dict: &Dictionary,
5780    words: &[&str],
5781    prefix: Option<&str>,
5782    limit: usize,
5783) -> Vec<String> {
5784    let Some(ti) = ti else {
5785        return Vec::new();
5786    };
5787    // Each query word is tokenized exactly as at build time (so "Glucose"
5788    // matches the stored "glucose"); a word that splits into several tokens
5789    // requires all of them. AND across every required token + the prefix.
5790    let mut acc: Option<Vec<u32>> = None;
5791    if let Some(p) = prefix {
5792        acc = Some(ti.prefix(&p.to_lowercase()));
5793    }
5794    for w in words {
5795        for tok in crate::text_index::tokenize(w) {
5796            let posting = ti.lookup(&tok);
5797            acc = Some(match acc {
5798                Some(a) => intersect_sorted(&a, &posting),
5799                None => posting,
5800            });
5801            if acc.as_ref().is_some_and(|a| a.is_empty()) {
5802                return Vec::new();
5803            }
5804        }
5805    }
5806    let ids = acc.unwrap_or_default();
5807    let mut out = Vec::with_capacity(if limit > 0 {
5808        limit.min(ids.len())
5809    } else {
5810        ids.len()
5811    });
5812    // The hits' subject IRIs are scattered over many dictionary chunks: fault
5813    // the ones a page can return in one batch (on a remote file, one round
5814    // trip instead of one per chunk). Only warms the cache.
5815    let page = if limit > 0 {
5816        limit.min(ids.len())
5817    } else {
5818        ids.len()
5819    };
5820    dict.prefetch_subject_terms(&ids[..page]);
5821    for id in ids {
5822        if let Some(iri) = dict.subject_term(id) {
5823            out.push(iri);
5824            if limit > 0 && out.len() >= limit {
5825                break;
5826            }
5827        }
5828    }
5829    out
5830}
5831
5832/// A `.rete` opened for **search only**: the dictionary's chunk directories, and
5833/// the TEXT_INDEX / pyramid deferred behind their lazy slots. Nothing else.
5834///
5835/// What makes this cheap is skipping dictionary sections 2 and 3 (object-only
5836/// and predicates) — NOT skipping the index. Measured on the published
5837/// `epfl-infoscience.rete` (1.64 GB), a full [`Rete::open_ranged_lazy`] reads
5838/// 536,947,344 B, of which the six permutation tile directories are **49,940 B
5839/// in 40 reads** and the dictionary is 536,896,380 B in 36 reads: the
5840/// object-only chunk directory alone is 234,400,728 B, because that file stores
5841/// every chunk's first term verbatim. Files built since #198 key it by the
5842/// shortest separator (~600 KB on this graph); the published one still does
5843/// not, and only a rebuild changes that. This view opens sections 0 and 1
5844/// only — both search modes return subject IRIs — and costs **21,554 B in 9
5845/// reads** on the same file. It also skips the index container and defers the
5846/// TEXT_INDEX and pyramid, so it pays only for what it reads: the token table
5847/// on the first search, then one range per posting list and per dictionary
5848/// chunk holding a matched subject. This is what backs `rete search-url`.
5849///
5850/// On a file with a LABEL_INDEX section (kind 8) the dictionary is not opened
5851/// at all until a full-text search needs it: the section stores each subject's
5852/// term, so a prefix search costs the header, the section head, one directory
5853/// node per level and one data block.
5854pub struct SearchView {
5855    header: Header,
5856    dict: SectionSlot<Dictionary>,
5857    pyramid: PyramidSlot,
5858    text_index: TextIndexSlot,
5859    labels: SectionSlot<crate::label_index::LabelIndex>,
5860}
5861
5862impl SearchView {
5863    /// Open for search over any [`RangeReader`] (HTTP or local): header +
5864    /// dictionary chunk directories only (none at all on a file with a
5865    /// LABEL_INDEX, until a text search needs them), with the text index,
5866    /// label index and pyramid faulting in on first use.
5867    pub fn open_ranged<R: RangeReader + Send + Sync + 'static>(
5868        reader: R,
5869    ) -> Result<Self, FileError> {
5870        let head = reader.read_at(0, HEADER_LEN as u64)?;
5871        let header = Header::from_bytes(&head)?;
5872        let reader = std::sync::Arc::new(reader);
5873        // Sections 0 (shared) and 1 (subject-only) are the only ones a search
5874        // resolves — both modes return subject IRIs. Skipping 2 and 3 is what
5875        // makes this open cheap where a full one is not.
5876        let dict = if header.label_index_section().is_some() {
5877            let (r, h) = (reader.clone(), header.clone());
5878            SectionSlot::Lazy {
5879                loader: Box::new(move || {
5880                    ranged_chunked_dictionary(&r, &h, [true, true, false, false]).ok()
5881                }),
5882                cell: std::sync::OnceLock::new(),
5883            }
5884        } else {
5885            SectionSlot::Resident(Some(ranged_chunked_dictionary(
5886                &reader,
5887                &header,
5888                [true, true, false, false],
5889            )?))
5890        };
5891        let pyramid = ranged_pyramid_slot(&reader, &header);
5892        let text_index = ranged_text_index_slot(&reader, &header);
5893        let labels = ranged_label_slot(&reader, &header);
5894        Ok(Self {
5895            header,
5896            dict,
5897            pyramid,
5898            text_index,
5899            labels,
5900        })
5901    }
5902
5903    /// Whether the file can answer a prefix search: it carries a LABEL_INDEX
5904    /// section or a pyramid (whose meta may hold the legacy label block). A
5905    /// header read, no fault.
5906    pub fn has_labels(&self) -> bool {
5907        self.header.label_index_section().is_some() || self.header.pyramid_meta_len > 0
5908    }
5909
5910    /// Whether the file carries a LABEL_INDEX section (kind 8).
5911    pub fn has_label_index(&self) -> bool {
5912        self.header.label_index_section().is_some()
5913    }
5914
5915    /// The file header (already fetched by [`open_ranged`](Self::open_ranged)).
5916    pub fn header(&self) -> &Header {
5917        &self.header
5918    }
5919
5920    /// Whether the file carries a TEXT_INDEX section — a header read, no fault.
5921    pub fn has_text_index(&self) -> bool {
5922        self.header.text_index_len > 0
5923    }
5924
5925    /// Whether the file carries a pyramid (where the label index lives) — a
5926    /// header read, no fault.
5927    pub fn has_pyramid(&self) -> bool {
5928        self.header.pyramid_meta_len > 0
5929    }
5930
5931    /// Full-text search — see [`Rete::text_search`] for the semantics. Faults
5932    /// the token table on the first call, then only the queried postings.
5933    pub fn text_search(&self, words: &[&str], prefix: Option<&str>, limit: usize) -> Vec<String> {
5934        let Some(dict) = self.dict.get() else {
5935            return Vec::new();
5936        };
5937        text_search_in(self.text_index.index(), dict, words, prefix, limit)
5938    }
5939
5940    /// What that first search will cost — see
5941    /// [`Rete::text_index_token_table_len`]. One ≤10-byte range read, so a
5942    /// caller can quote the figure before deciding to search.
5943    pub fn text_index_token_table_len(&self) -> Option<u64> {
5944        self.text_index.token_table_len()
5945    }
5946
5947    /// Label-prefix search — see [`Rete::prefix_search`]. Reads the
5948    /// LABEL_INDEX section when the file has one; otherwise faults the pyramid
5949    /// (which carries the legacy label block), the pricier path.
5950    pub fn prefix_search(&self, prefix: &str, limit: usize) -> Vec<(String, String)> {
5951        if let Some(li) = self.labels.get() {
5952            return li.prefix_search(prefix, limit);
5953        }
5954        let pyr = match &self.pyramid {
5955            PyramidSlot::Resident(p) => p.as_ref(),
5956            PyramidSlot::Lazy { loader, cell } => cell.get_or_init(loader).as_ref(),
5957        };
5958        let (Some(pyr), Some(dict)) = (pyr, self.dict.get()) else {
5959            return Vec::new();
5960        };
5961        pyr.prefix_search(prefix, limit)
5962            .into_iter()
5963            .filter_map(|e| {
5964                dict.subject_term(e.subject)
5965                    .map(|iri| (e.label.clone(), iri))
5966            })
5967            .collect()
5968    }
5969}
5970
5971/// A lightweight, overview-only view of a file: the pyramid summary graph plus
5972/// just enough dictionary to label predicates. Fetched via ranges *without*
5973/// touching the (large) triple index — the "load the coarse graph first" path
5974/// from SPEC.md §7.2.
5975#[must_use]
5976pub struct SummaryView {
5977    pub round: u32,
5978    pub summary: Vec<SuperEdge>,
5979    /// The shipped `subClassOf` hierarchy (v2 schema pyramid; empty on v1 files).
5980    pub class_hierarchy: Vec<ClassNode>,
5981    /// Per-level type rollups — the leveled legend, read index-free.
5982    pub level_rollups: Vec<LevelRollup>,
5983    /// Per-level lateral class-relation graph (the non-`is-a` connections).
5984    pub level_links: Vec<LevelLinks>,
5985    /// Per-community descriptors (Phase 4 progressive refinement; may be empty).
5986    pub descriptors: Vec<CommunityDescriptor>,
5987    /// `subClassOf` cycles (v2.1; empty on older files).
5988    pub subclass_cycles: Vec<Vec<String>>,
5989    /// `owl:disjointWith` class pairs (v2.1; empty on older files).
5990    pub disjoint_pairs: Vec<(String, String)>,
5991    /// `owl:equivalentClass` class pairs (v2.1; empty on older files).
5992    pub equivalent_pairs: Vec<(String, String)>,
5993    dict: Dictionary,
5994}
5995
5996impl SummaryView {
5997    /// Read header → dictionary → pyramid-meta only (skips the index container).
5998    pub fn open_ranged<R: RangeReader>(reader: &R) -> Result<Option<Self>, FileError> {
5999        let head = reader.read_at(0, HEADER_LEN as u64)?;
6000        let header = Header::from_bytes(&head)?;
6001        if header.pyramid_meta_len == 0 {
6002            return Ok(None);
6003        }
6004
6005        let dict_bytes = reader.read_at(header.dictionary_offset, header.dictionary_len)?;
6006        let dict = decode_dictionary_container(&dict_bytes, header.dict_codec)?;
6007
6008        let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
6009        let mut meta =
6010            PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
6011        // A file whose schema moved to its own section (kind 9) keeps only the
6012        // community material here: take the schema fields from that section.
6013        if let Some(s) = read_schema_with_header(reader, &header) {
6014            meta.class_hierarchy = s.class_hierarchy;
6015            meta.level_rollups = s.level_rollups;
6016            meta.level_links = s.level_links;
6017            meta.subclass_cycles = s.subclass_cycles;
6018            meta.disjoint_pairs = s.disjoint_pairs;
6019            meta.equivalent_pairs = s.equivalent_pairs;
6020        }
6021
6022        Ok(Some(SummaryView {
6023            round: meta.round,
6024            summary: meta.summary,
6025            class_hierarchy: meta.class_hierarchy,
6026            level_rollups: meta.level_rollups,
6027            level_links: meta.level_links,
6028            descriptors: meta.descriptors,
6029            subclass_cycles: meta.subclass_cycles,
6030            disjoint_pairs: meta.disjoint_pairs,
6031            equivalent_pairs: meta.equivalent_pairs,
6032            dict,
6033        }))
6034    }
6035
6036    /// A schema-only view of a file **without a pyramid**, from its SCHEMA
6037    /// section (kind 9): the hierarchy, levels and axioms, with an empty
6038    /// community summary. Reads the header and the SCHEMA section only — the
6039    /// section carries its own class and predicate strings, so no dictionary.
6040    /// `Ok(None)` when the file has no SCHEMA section. Callers that need the
6041    /// summary (`cost`, federation source selection, progressive queries) use
6042    /// [`open_ranged`](Self::open_ranged), which keeps requiring a pyramid.
6043    pub fn open_schema_ranged<R: RangeReader>(reader: &R) -> Result<Option<Self>, FileError> {
6044        let head = reader.read_at(0, HEADER_LEN as u64)?;
6045        let header = Header::from_bytes(&head)?;
6046        let Some(s) = read_schema_with_header(reader, &header) else {
6047            return Ok(None);
6048        };
6049        let dict = crate::DictionaryBuilder::new().build();
6050        Ok(Some(SummaryView {
6051            round: 0,
6052            summary: Vec::new(),
6053            class_hierarchy: s.class_hierarchy,
6054            level_rollups: s.level_rollups,
6055            level_links: s.level_links,
6056            descriptors: Vec::new(),
6057            subclass_cycles: s.subclass_cycles,
6058            disjoint_pairs: s.disjoint_pairs,
6059            equivalent_pairs: s.equivalent_pairs,
6060            dict,
6061        }))
6062    }
6063
6064    /// Number of semantic-zoom levels in the schema pyramid (0 if none shipped).
6065    pub fn level_count(&self) -> usize {
6066        self.level_rollups.len()
6067    }
6068
6069    /// The type rollup at semantic level `k` (0 = coarsest/most abstract), or
6070    /// `None` if `k` is out of range. Index-free — answered from the pyramid-meta.
6071    pub fn level_rollup(&self, k: usize) -> Option<&LevelRollup> {
6072        self.level_rollups.get(k)
6073    }
6074
6075    /// Resolve a predicate ID in the summary to its term.
6076    pub fn predicate_term(&self, id: u32) -> Option<String> {
6077        self.dict.predicate_term(id)
6078    }
6079
6080    /// Exact number of triples using `predicate`, summed from the summary's
6081    /// superedge counts — answered without ever reading the triple index.
6082    pub fn predicate_total(&self, predicate: &str) -> u32 {
6083        match self.dict.predicate_id(predicate) {
6084            Some(pid) => self
6085                .summary
6086                .iter()
6087                .filter(|e| e.predicate == pid)
6088                .map(|e| e.count)
6089                .sum(),
6090            None => 0,
6091        }
6092    }
6093
6094    /// All predicates with their exact triple totals, descending by count.
6095    pub fn predicate_totals(&self) -> Vec<(String, u32)> {
6096        let mut by_pred: std::collections::BTreeMap<u32, u32> = std::collections::BTreeMap::new();
6097        for e in &self.summary {
6098            *by_pred.entry(e.predicate).or_default() += e.count;
6099        }
6100        let mut out: Vec<(String, u32)> = by_pred
6101            .into_iter()
6102            .filter_map(|(pid, c)| self.dict.predicate_term(pid).map(|t| (t, c)))
6103            .collect();
6104        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
6105        out
6106    }
6107
6108    /// Number of communities the summary spans (distinct supernode endpoints).
6109    pub fn community_count(&self) -> usize {
6110        let mut comms = std::collections::BTreeSet::new();
6111        for e in &self.summary {
6112            comms.insert(e.s_comm);
6113            comms.insert(e.o_comm);
6114        }
6115        comms.len()
6116    }
6117
6118    /// **Index-free T-Box coherence (Tier-0).** Detect schema-level incoherent
6119    /// points purely from the shipped schema pyramid — no triple index, no
6120    /// instance data, O(ontology) regardless of graph size:
6121    /// - `subclass-cycle`: a set of classes that are mutually `rdfs:subClassOf`.
6122    /// - `unsatisfiable-class`: a class whose ancestor closure (over all parents,
6123    ///   folded through `owl:equivalentClass`) contains both ends of an
6124    ///   `owl:disjointWith` pair, so no individual can ever be one.
6125    ///
6126    /// Soundness is bounded by what the pyramid ships: the `subClassOf` hierarchy
6127    /// is capped (`MAX_HIERARCHY` in `schema_pyramid`), so on a very large ontology
6128    /// a pruned ancestor can hide an unsatisfiable class (a false *coherent*, never
6129    /// a false *incoherent*). Instance-level clashes (a node typed into disjoint
6130    /// classes, functional-property clashes) are NOT visible here — they need the
6131    /// A-Box (Tier-1/Tier-2 `reason`).
6132    pub fn tbox_coherence(&self) -> Vec<crate::reason::Inconsistency> {
6133        schema_coherence(
6134            &self.class_hierarchy,
6135            &self.subclass_cycles,
6136            &self.disjoint_pairs,
6137            &self.equivalent_pairs,
6138        )
6139    }
6140
6141    /// True when [`tbox_coherence`](Self::tbox_coherence) finds no schema-level
6142    /// incoherent point.
6143    pub fn tbox_is_coherent(&self) -> bool {
6144        self.tbox_coherence().is_empty()
6145    }
6146}
6147
6148/// Compute T-Box coherence points from the schema-pyramid fields alone — no
6149/// dictionary, no index, no instance data. Shared by [`SummaryView::tbox_coherence`]
6150/// and the dictionary-free [`read_schema_coherence_ranged`]. Emits `subclass-cycle`
6151/// and `unsatisfiable-class` (a class whose ancestor closure — over all parents,
6152/// folded through `owl:equivalentClass` — contains both ends of a disjoint pair).
6153pub fn schema_coherence(
6154    class_hierarchy: &[ClassNode],
6155    subclass_cycles: &[Vec<String>],
6156    disjoint_pairs: &[(String, String)],
6157    equivalent_pairs: &[(String, String)],
6158) -> Vec<crate::reason::Inconsistency> {
6159    use crate::reason::Inconsistency;
6160    use std::collections::{BTreeMap, BTreeSet, VecDeque};
6161    const MAX_REACH: usize = 100_000;
6162
6163    let mut out: Vec<Inconsistency> = Vec::new();
6164
6165    for cyc in subclass_cycles {
6166        let detail = if cyc.len() == 1 {
6167            format!("{} is rdfs:subClassOf itself (a cycle)", cyc[0])
6168        } else {
6169            format!(
6170                "classes {{{}}} are mutually rdfs:subClassOf (a cycle)",
6171                cyc.join(", ")
6172            )
6173        };
6174        out.push(Inconsistency {
6175            kind: "subclass-cycle",
6176            detail,
6177        });
6178    }
6179
6180    if !disjoint_pairs.is_empty() {
6181        // Upward adjacency: subClassOf parents + bidirectional equivalence.
6182        let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
6183        for n in class_hierarchy {
6184            let e = adj.entry(n.class.as_str()).or_default();
6185            for p in &n.parents {
6186                e.push(p.as_str());
6187            }
6188        }
6189        for (a, b) in equivalent_pairs {
6190            adj.entry(a.as_str()).or_default().push(b.as_str());
6191            adj.entry(b.as_str()).or_default().push(a.as_str());
6192        }
6193
6194        // Candidate focus classes: every class named anywhere in the schema.
6195        let mut focuses: BTreeSet<&str> =
6196            class_hierarchy.iter().map(|n| n.class.as_str()).collect();
6197        for (a, b) in disjoint_pairs.iter().chain(equivalent_pairs) {
6198            focuses.insert(a.as_str());
6199            focuses.insert(b.as_str());
6200        }
6201
6202        let mut seen: BTreeSet<&str> = BTreeSet::new();
6203        for &c in &focuses {
6204            // reach(c) = {c} ∪ ancestors (capped BFS over `adj`).
6205            let mut reach: BTreeSet<&str> = BTreeSet::new();
6206            let mut q: VecDeque<&str> = VecDeque::new();
6207            reach.insert(c);
6208            q.push_back(c);
6209            while let Some(x) = q.pop_front() {
6210                if reach.len() > MAX_REACH {
6211                    break;
6212                }
6213                if let Some(ns) = adj.get(x) {
6214                    for &p in ns {
6215                        if reach.insert(p) {
6216                            q.push_back(p);
6217                        }
6218                    }
6219                }
6220            }
6221            for (x, y) in disjoint_pairs {
6222                if reach.contains(x.as_str()) && reach.contains(y.as_str()) && seen.insert(c) {
6223                    out.push(Inconsistency {
6224                        kind: "unsatisfiable-class",
6225                        detail: format!(
6226                            "{c} is a subclass of both {x} and {y}, which are \
6227                             owl:disjointWith — no individual can be a {c}"
6228                        ),
6229                    });
6230                    break;
6231                }
6232            }
6233        }
6234    }
6235
6236    out.sort_by(|a, b| (a.kind, &a.detail).cmp(&(b.kind, &b.detail)));
6237    out
6238}
6239
6240/// **Dictionary-free Tier-0 coherence read.** Fetch only the header and the
6241/// pyramid-meta range (2 small range reads) and run [`schema_coherence`] over the
6242/// schema pyramid — never touching the **dictionary** (which a literal-heavy file
6243/// makes large) or the triple index. `Ok(None)` if the file ships no pyramid.
6244///
6245/// This is what makes the Tier-0 check cheap on big graphs: the schema pyramid
6246/// carries its own class-string table, so coherence needs none of the dictionary.
6247pub fn read_schema_coherence_ranged<R: RangeReader>(
6248    reader: &R,
6249) -> Result<Option<Vec<crate::reason::Inconsistency>>, FileError> {
6250    let head = reader.read_at(0, HEADER_LEN as u64)?;
6251    let header = Header::from_bytes(&head)?;
6252    // The SCHEMA section (kind 9) first: one range, and no pyramid needed.
6253    if let Some(s) = read_schema_with_header(reader, &header) {
6254        return Ok(Some(schema_coherence(
6255            &s.class_hierarchy,
6256            &s.subclass_cycles,
6257            &s.disjoint_pairs,
6258            &s.equivalent_pairs,
6259        )));
6260    }
6261    if header.pyramid_meta_len == 0 {
6262        return Ok(None);
6263    }
6264    // Fast path: the header records the trailing schema block's length, so read ONLY
6265    // that block (at the end of pyramid-meta) — never the community summary, the
6266    // dictionary, or the index. This is what makes Tier-0 flat at any graph size.
6267    if header.schema_meta_len > 0 && (header.schema_meta_len as u64) <= header.pyramid_meta_len {
6268        let off =
6269            header.pyramid_meta_offset + header.pyramid_meta_len - header.schema_meta_len as u64;
6270        let block = reader.read_at(off, header.schema_meta_len as u64)?;
6271        let (hierarchy, cycles, disjoint, equivalent) = crate::meta::decode_schema_block(&block)
6272            .map_err(|_| FileError::Container("malformed schema block"))?;
6273        return Ok(Some(schema_coherence(
6274            &hierarchy,
6275            &cycles,
6276            &disjoint,
6277            &equivalent,
6278        )));
6279    }
6280    // Fallback (pre-v0.2.1 files with no header field): decode the whole pyramid-meta.
6281    let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
6282    let meta =
6283        PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?;
6284    Ok(Some(schema_coherence(
6285        &meta.class_hierarchy,
6286        &meta.subclass_cycles,
6287        &meta.disjoint_pairs,
6288        &meta.equivalent_pairs,
6289    )))
6290}
6291
6292/// The **schema summary** (per-class histogram + class relations at the finest
6293/// level) read over a [`RangeReader`] from the schema pyramid alone — the
6294/// index-free, range-readable source for a Schema view of a remote graph. Returns
6295/// `(classes, relations)` with `classes = [(class_iri, count)]` and `relations =
6296/// [(s_class, predicate, o_class, count)]`; `None` when the file has no schema
6297/// pyramid. Like [`read_schema_coherence_ranged`], it reads only the trailing
6298/// schema block, so it stays flat at any graph size.
6299#[allow(clippy::type_complexity)]
6300pub fn read_schema_summary_ranged<R: RangeReader>(
6301    reader: &R,
6302) -> Result<Option<(Vec<(String, u64)>, Vec<(String, String, String, u64)>)>, FileError> {
6303    let head = reader.read_at(0, HEADER_LEN as u64)?;
6304    let header = Header::from_bytes(&head)?;
6305    // The SCHEMA section (kind 9) first: one range, and no pyramid needed.
6306    let meta = if let Some(s) = read_schema_with_header(reader, &header) {
6307        PyramidMeta::new(0, Vec::new(), &[]).with_schema(
6308            Vec::new(),
6309            s.level_rollups,
6310            s.level_links,
6311            Vec::new(),
6312            Vec::new(),
6313            Vec::new(),
6314            Vec::new(),
6315        )
6316    } else {
6317        if header.pyramid_meta_len == 0 {
6318            return Ok(None);
6319        }
6320        if header.schema_meta_len > 0 && (header.schema_meta_len as u64) <= header.pyramid_meta_len
6321        {
6322            let off = header.pyramid_meta_offset + header.pyramid_meta_len
6323                - header.schema_meta_len as u64;
6324            let block = reader.read_at(off, header.schema_meta_len as u64)?;
6325            let summary = crate::meta::decode_schema_block_summary(&block)
6326                .map_err(|_| FileError::Container("malformed schema block"))?;
6327            return Ok(Some(summary));
6328        }
6329        // Fallback (pre-v0.2.1 files): decode the whole pyramid-meta, pull finest levels.
6330        let mb = reader.read_at(header.pyramid_meta_offset, header.pyramid_meta_len)?;
6331        PyramidMeta::decode(&mb).map_err(|_| FileError::Container("malformed pyramid meta"))?
6332    };
6333    if meta.level_rollups.is_empty() && meta.level_links.is_empty() {
6334        return Ok(None);
6335    }
6336    let classes = meta
6337        .level_rollups
6338        .iter()
6339        .max_by_key(|r| r.depth)
6340        .map(|r| r.classes.clone())
6341        .unwrap_or_default();
6342    let relations = meta
6343        .level_links
6344        .iter()
6345        .max_by_key(|l| l.depth)
6346        .map(|l| {
6347            l.links
6348                .iter()
6349                .map(|c| {
6350                    (
6351                        c.s_class.clone(),
6352                        c.predicate.clone(),
6353                        c.o_class.clone(),
6354                        c.count,
6355                    )
6356                })
6357                .collect()
6358        })
6359        .unwrap_or_default();
6360    Ok(Some((classes, relations)))
6361}
6362
6363/// Every dictionary section's chunk **routing keys**, exactly as stored, from a
6364/// finished file image. Test-only: it exists so a test in another module of
6365/// this crate (`extbuild`) can assert on the directory the writers produce
6366/// without any of this becoming public surface.
6367#[cfg(test)]
6368pub(crate) fn dict_chunk_keys_for_test(image: &[u8]) -> Vec<Vec<Vec<u8>>> {
6369    let header = Header::from_bytes(&image[..HEADER_LEN]).unwrap();
6370    let s = header.dictionary_offset as usize;
6371    let e = s + header.dictionary_len as usize;
6372    decode_container(&image[s..e], CODEC_NONE)
6373        .unwrap()
6374        .iter()
6375        .map(|payload| {
6376            let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
6377            entries.into_iter().map(|c| c.key).collect()
6378        })
6379        .collect()
6380}
6381
6382#[cfg(test)]
6383mod tests {
6384    use super::*;
6385    use crate::dictionary::DictionaryBuilder;
6386    use crate::index::GraphIndexBuilder;
6387
6388    /// The pure-Rust decode path (wasm, `--no-default-features`) reads a zstd
6389    /// frame back byte for byte. Guards the `ruzstd` API across upgrades.
6390    #[cfg(not(feature = "compression"))]
6391    #[test]
6392    fn ruzstd_decodes_a_zstd_frame() {
6393        let data: Vec<u8> = (0..50_000u32)
6394            .flat_map(|i| (i % 251).to_le_bytes())
6395            .collect();
6396        let frame = ruzstd::encoding::compress_to_vec(
6397            &data[..],
6398            ruzstd::encoding::CompressionLevel::Fastest,
6399        );
6400        assert!(frame.len() < data.len());
6401        assert_eq!(decompress(CODEC_ZSTD, &frame).unwrap(), data);
6402    }
6403
6404    /// A three-permutation file must survive every opener — resident, ranged,
6405    /// ranged-lazy — and answer every pattern shape with the rows the
6406    /// six-permutation twin gives, including through a named graph's own
6407    /// container. The mask is a file-level fact, so getting it to the named
6408    /// graphs' decoder is a separate thing that can be forgotten.
6409    #[test]
6410    fn three_permutation_file_round_trips_every_open_path() {
6411        use crate::index::PermSet;
6412        use crate::reader::SliceReader;
6413
6414        let build = |perms: PermSet| {
6415            let mut db = crate::DictionaryBuilder::new();
6416            let mut triples = Vec::new();
6417            for i in 0..60u32 {
6418                let (s, p, o) = (
6419                    format!("<http://ex/s{}>", i % 11),
6420                    format!("<http://ex/p{}>", i % 3),
6421                    format!("<http://ex/o{}>", i % 7),
6422                );
6423                db.observe(&s, &p, &o);
6424                triples.push((s, p, o));
6425            }
6426            let dict = db.build();
6427            let ids: Vec<(u32, u32, u32)> = triples
6428                .iter()
6429                .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
6430                .collect();
6431            let def = GraphIndexBuilder::from_triples(ids.clone())
6432                .with_tile_budget(96)
6433                .with_perms(perms)
6434                .build();
6435            let named = GraphIndexBuilder::from_triples(ids)
6436                .with_tile_budget(96)
6437                .with_perms(perms)
6438                .build();
6439            write_dataset(
6440                &dict,
6441                &def,
6442                &[("<http://ex/g>".to_string(), named)],
6443                true,
6444                &[],
6445                0,
6446            )
6447        };
6448
6449        let six = build(PermSet::ALL);
6450        let three = build(PermSet::CORE);
6451        assert!(three.len() < six.len());
6452        assert_eq!(Header::from_bytes(&three).unwrap().perms, PermSet::CORE);
6453        assert_eq!(Header::from_bytes(&six).unwrap().perms, PermSet::ALL);
6454
6455        let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>)> = vec![
6456            (None, None, None),
6457            (Some("<http://ex/s3>"), None, None),
6458            (None, Some("<http://ex/p1>"), None),
6459            (None, None, Some("<http://ex/o5>")),
6460            (Some("<http://ex/s3>"), Some("<http://ex/p1>"), None),
6461            (Some("<http://ex/s3>"), None, Some("<http://ex/o5>")),
6462            (None, Some("<http://ex/p1>"), Some("<http://ex/o5>")),
6463            (
6464                Some("<http://ex/s3>"),
6465                Some("<http://ex/p1>"),
6466                Some("<http://ex/o5>"),
6467            ),
6468        ];
6469
6470        for (image, tag) in [(&six, "six"), (&three, "three")] {
6471            let resident = Rete::open(image).unwrap();
6472            let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
6473            let ranged = Rete::open_ranged(&SliceReader::new(leaked)).unwrap();
6474            let lazy = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
6475            for (s, p, o) in &shapes {
6476                let want: Vec<_> = {
6477                    let mut v = Rete::open(&six).unwrap().query(*s, *p, *o);
6478                    v.sort();
6479                    v
6480                };
6481                for (got, path) in [
6482                    (resident.query(*s, *p, *o), "resident"),
6483                    (ranged.query(*s, *p, *o), "ranged"),
6484                    (lazy.query(*s, *p, *o), "lazy"),
6485                ] {
6486                    let mut got = got;
6487                    got.sort();
6488                    assert_eq!(got, want, "{tag}/{path} disagreed on {:?}", (s, p, o));
6489                }
6490                // The named graph's container carries the same mask.
6491                let mut g = resident.query_in_graph(Some("<http://ex/g>"), *s, *p, *o);
6492                g.sort();
6493                assert_eq!(g, want, "{tag}/named disagreed on {:?}", (s, p, o));
6494            }
6495        }
6496
6497        // And the single-pattern ROUTED read (the `query-url` path) locates its
6498        // section by container position, not by the format's fixed six-wide
6499        // slot — the one place the two indexes differ.
6500        let leaked: &'static [u8] = Box::leak(three.clone().into_boxed_slice());
6501        let reader = SliceReader::new(leaked);
6502        let routed = Rete::query_ranged(&reader, None, None, Some("<http://ex/o5>")).unwrap();
6503        let mut want = Rete::open(&six)
6504            .unwrap()
6505            .query(None, None, Some("<http://ex/o5>"));
6506        want.sort();
6507        let mut got = routed;
6508        got.sort();
6509        assert_eq!(got, want, "routed single-pattern read on a lean file");
6510    }
6511
6512    #[test]
6513    fn read_coalesced_merges_within_gap_and_splits_beyond() {
6514        use crate::reader::{CountingReader, SliceReader};
6515        let bytes = vec![0u8; 4096];
6516        // Three 16-byte ranges: A..B gap = 32, B..C gap = 1024.
6517        let ranges = [
6518            ByteRange { offset: 0, len: 16 },
6519            ByteRange {
6520                offset: 48,
6521                len: 16,
6522            },
6523            ByteRange {
6524                offset: 1088,
6525                len: 16,
6526            },
6527        ];
6528        // Tight gap (16): nothing merges → one read per range.
6529        let r = CountingReader::new(SliceReader::new(&bytes));
6530        let out = read_coalesced(&r, &ranges, 16).unwrap();
6531        assert_eq!(out.len(), 3);
6532        assert_eq!(r.requests(), 3);
6533        // Gap 64 merges A+B (gap 32) but not C (gap 1024) → two reads.
6534        let r = CountingReader::new(SliceReader::new(&bytes));
6535        read_coalesced(&r, &ranges, 64).unwrap();
6536        assert_eq!(r.requests(), 2);
6537        // Gap 4096 merges all three into one read, over-fetching the gaps.
6538        let r = CountingReader::new(SliceReader::new(&bytes));
6539        read_coalesced(&r, &ranges, 4096).unwrap();
6540        assert_eq!(r.requests(), 1);
6541    }
6542
6543    fn build_image() -> Vec<u8> {
6544        let triples = [
6545            ("Alice", "knows", "Bob"),
6546            ("Bob", "knows", "Carol"),
6547            ("Alice", "age", "30"),
6548        ];
6549        let mut db = DictionaryBuilder::new();
6550        for (s, p, o) in triples {
6551            db.observe(s, p, o);
6552        }
6553        let dict = db.build();
6554
6555        let mut ib = GraphIndexBuilder::new();
6556        for (s, p, o) in triples {
6557            ib.push(dict.encode(s, p, o).unwrap());
6558        }
6559        let index = ib.build();
6560
6561        let (meta, levels) = build_pyramid_meta(&dict, &triples_ids(&dict), DEFAULT_TILE_BUDGET);
6562        write_file(&dict, &index, false, &meta, levels)
6563    }
6564
6565    fn triples_ids(dict: &Dictionary) -> Vec<(u32, u32, u32)> {
6566        [
6567            ("Alice", "knows", "Bob"),
6568            ("Bob", "knows", "Carol"),
6569            ("Alice", "age", "30"),
6570        ]
6571        .iter()
6572        .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
6573        .collect()
6574    }
6575
6576    #[test]
6577    fn file_round_trips_header_and_counts() {
6578        let bytes = build_image();
6579        let rete = Rete::open(&bytes).unwrap();
6580        assert_eq!(rete.header().quad_count, 3);
6581        assert!(rete.header().term_count >= 5);
6582        let expected_codec = writer_codec();
6583        assert_eq!(rete.header().dict_codec, expected_codec);
6584        assert_eq!(rete.header().block_codec, expected_codec);
6585        assert_eq!(&bytes[bytes.len() - 4..], &MAGIC); // footer marker
6586    }
6587
6588    /// A file whose index was built with a tiny tile budget (forcing many
6589    /// tiles per permutation) must round-trip through write/open and answer
6590    /// every query shape identically — through both the in-memory and the
6591    /// routed ranged read paths.
6592    #[test]
6593    fn multi_tile_file_round_trips_and_routes() {
6594        let triples: Vec<(String, String, String)> = (0..200)
6595            .map(|i| {
6596                (
6597                    format!("<http://ex/s/{i}>"),
6598                    format!("<http://ex/p/{}>", i % 5),
6599                    format!("<http://ex/o/{}>", i % 23),
6600                )
6601            })
6602            .collect();
6603        let mut db = DictionaryBuilder::new();
6604        for (s, p, o) in &triples {
6605            db.observe(s, p, o);
6606        }
6607        let dict = db.build();
6608        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6609        for (s, p, o) in &triples {
6610            ib.push(dict.encode(s, p, o).unwrap());
6611        }
6612        let index = ib.build();
6613        assert!(
6614            index.tile_sections()[0].len() > 3,
6615            "tiny budget must force many tiles"
6616        );
6617        let bytes = write_file(&dict, &index, false, &[], 0);
6618
6619        let rete = Rete::open(&bytes).unwrap();
6620        assert_eq!(rete.header().version, crate::header::CURRENT_FORMAT_VERSION);
6621        assert_eq!(rete.query(None, None, None).len(), 200);
6622        assert_eq!(rete.query(Some("<http://ex/s/7>"), None, None).len(), 1);
6623        assert_eq!(
6624            rete.query(None, Some("<http://ex/p/3>"), None).len(),
6625            40,
6626            "predicate extent spans tiles"
6627        );
6628        assert_eq!(
6629            rete.query(None, None, Some("<http://ex/o/22>")).len(),
6630            8 // 22, 45, 68, ... < 200
6631        );
6632
6633        // Routed ranged read must agree (and only decompress matching tiles).
6634        use crate::reader::SliceReader;
6635        let reader = SliceReader::new(&bytes);
6636        let routed = Rete::query_ranged(&reader, Some("<http://ex/s/7>"), None, None).unwrap();
6637        assert_eq!(routed.len(), 1);
6638        let routed = Rete::query_ranged(&reader, None, Some("<http://ex/p/3>"), None).unwrap();
6639        assert_eq!(routed.len(), 40);
6640        let routed = Rete::query_ranged(&reader, None, None, Some("<http://ex/o/22>")).unwrap();
6641        assert_eq!(routed.len(), 8);
6642    }
6643
6644    /// The tile-synopsis trailer round-trips through encode/parse, and each parsed
6645    /// synopsis is **exactly** the tile block's own b/c zone — so the directory
6646    /// can never prune a tile the tile itself would have matched.
6647    /// Section-internal byte offsets are u64: a directory whose tiles sit past
6648    /// 4 GiB must parse with exact offsets on EVERY platform. On wasm32 (32-bit
6649    /// usize) the old parse truncated a >4 GiB section length and rejected the
6650    /// tail ("dict chunk overruns section" on the first >4 GiB dictionary —
6651    /// crossref's 5.2 GB g.obj — the playground regression this guards).
6652    #[test]
6653    fn tile_directory_offsets_survive_past_4gib() {
6654        let mut dir = Vec::new();
6655        write_uvarint(&mut dir, 2); // two tiles
6656        write_uvarint(&mut dir, 5); // tile 1: Δmin_a
6657        write_uvarint(&mut dir, 0); //         span
6658        write_uvarint(&mut dir, 3 << 30); //   len = 3 GiB
6659        write_uvarint(&mut dir, 1); // tile 2: Δmin_a
6660        write_uvarint(&mut dir, 0);
6661        write_uvarint(&mut dir, 2 << 30); //   len = 2 GiB
6662        let total = dir.len() as u64 + (3u64 << 30) + (2u64 << 30) + 64;
6663        let entries = parse_tile_directory(&dir, total).unwrap();
6664        assert_eq!(entries.len(), 2);
6665        assert_eq!(entries[1].start, dir.len() as u64 + (3u64 << 30));
6666        assert!(
6667            entries[1].end > u32::MAX as u64,
6668            "tail tile sits past 4 GiB"
6669        );
6670        // a total smaller than the tiles must still reject the directory
6671        assert!(parse_tile_directory(&dir, 1 << 20).is_err());
6672    }
6673
6674    #[test]
6675    fn tile_synopsis_trailer_round_trips() {
6676        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6677        for i in 0..200u32 {
6678            ib.push((i, i % 7, i % 13));
6679        }
6680        let index = ib.build();
6681        let tile_count = index.tile_sections()[0].len();
6682        assert!(tile_count > 3, "tiny budget forces many tiles");
6683
6684        let payload = encode_tiled_section(&index, 0, CODEC_NONE);
6685        let dir = parse_tile_directory(&payload, payload.len() as u64).unwrap();
6686        assert_eq!(dir.len(), tile_count);
6687        // The trailer sits past the last tile; the old directory parse stops there.
6688        let trailer_start = dir.iter().map(|e| e.end).max().unwrap();
6689        assert!(
6690            trailer_start < payload.len() as u64,
6691            "a trailer follows the tiles"
6692        );
6693        for e in &dir {
6694            assert!(
6695                e.end <= payload.len() as u64,
6696                "tiles still located within the payload"
6697            );
6698        }
6699        let syn = parse_tile_synopsis(&payload, trailer_start as usize, dir.len()).unwrap();
6700        for (e, (min_b, max_b, min_c, max_c)) in dir.iter().zip(syn) {
6701            let block = decompress(CODEC_NONE, &payload[e.start as usize..e.end as usize]).unwrap();
6702            let z = *crate::triples::TripleBlock::parse(&block).unwrap().zone();
6703            assert_eq!(
6704                (min_b, max_b, min_c, max_c),
6705                (z.min_b, z.max_b, z.min_c, z.max_c),
6706                "synopsis equals the tile's own zone"
6707            );
6708        }
6709    }
6710
6711    /// End-to-end safety: a synopsis-carrying file, opened **lazily** (range
6712    /// reads), must return exactly the brute-force answer for every pattern shape
6713    /// — the synopsis prune may never drop a real match.
6714    #[test]
6715    fn tile_synopsis_lazy_matches_reference_every_shape() {
6716        use crate::reader::{CountingReader, SliceReader};
6717        let triples: Vec<(String, String, String)> = (0..200u32)
6718            .map(|i| {
6719                (
6720                    format!("<http://ex/s/{i:04}>"),
6721                    format!("<http://ex/p/{}>", i % 7),
6722                    format!("<http://ex/o/{:04}>", i % 13),
6723                )
6724            })
6725            .collect();
6726        let mut db = DictionaryBuilder::new();
6727        for (s, p, o) in &triples {
6728            db.observe(s, p, o);
6729        }
6730        let dict = db.build();
6731        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6732        for (s, p, o) in &triples {
6733            ib.push(dict.encode(s, p, o).unwrap());
6734        }
6735        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
6736
6737        let eager = Rete::open(&bytes).unwrap();
6738        assert!(
6739            eager.header().has_tile_synopsis(),
6740            "new files set the synopsis flag"
6741        );
6742
6743        // `open_ranged_lazy` needs a `'static` reader; leak the image (the test
6744        // process exits straight after).
6745        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
6746        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
6747        let lazy = Rete::open_ranged_lazy(reader).unwrap();
6748
6749        let brute = |s: Option<&str>, p: Option<&str>, o: Option<&str>| {
6750            let mut v: Vec<(String, String, String)> = triples
6751                .iter()
6752                .filter(|(a, b, c)| {
6753                    s.is_none_or(|x| x == a) && p.is_none_or(|x| x == b) && o.is_none_or(|x| x == c)
6754                })
6755                .cloned()
6756                .collect();
6757            v.sort();
6758            v
6759        };
6760        // Existing + absent terms in every position (and unbound) — 4×4×4 shapes.
6761        let sv = [
6762            None,
6763            Some("<http://ex/s/0007>"),
6764            Some("<http://ex/s/0130>"),
6765            Some("<http://ex/s/9999>"),
6766        ];
6767        let pv = [
6768            None,
6769            Some("<http://ex/p/3>"),
6770            Some("<http://ex/p/6>"),
6771            Some("<http://ex/p/999>"),
6772        ];
6773        let ov = [
6774            None,
6775            Some("<http://ex/o/0000>"),
6776            Some("<http://ex/o/0012>"),
6777            Some("<http://ex/o/9999>"),
6778        ];
6779        for &s in &sv {
6780            for &p in &pv {
6781                for &o in &ov {
6782                    let mut e = eager.query(s, p, o);
6783                    e.sort();
6784                    let mut l = lazy.query(s, p, o);
6785                    l.sort();
6786                    let r = brute(s, p, o);
6787                    assert_eq!(e, r, "eager {s:?} {p:?} {o:?}");
6788                    assert_eq!(l, r, "lazy {s:?} {p:?} {o:?} — synopsis over-pruned");
6789                }
6790            }
6791        }
6792        assert!(!lazy.index_incomplete(), "no lazy fetch failed");
6793    }
6794
6795    /// Byte-0 / polyglot experiment: a real `.rete` embedded behind a large HTML
6796    /// shell (so byte 0 is `<`, not `RETE`) still opens and queries LAZILY through
6797    /// an `OffsetReader`, touching only the graph's bytes and never the shell.
6798    #[test]
6799    fn polyglot_offset_reads_lazily() {
6800        use crate::reader::{
6801            detect_polyglot_base, CountingReader, OffsetReader, SliceReader, POLYGLOT_DIGITS,
6802            POLYGLOT_MARKER,
6803        };
6804
6805        // A real .rete image, hidden behind a 50 KB HTML shell that carries the
6806        // polyglot base-offset marker in its first bytes (a browser-ignored
6807        // comment). The .rete begins right after the shell.
6808        let image = build_image();
6809        let mut shell = Vec::new();
6810        shell.extend_from_slice(b"<!DOCTYPE html><html><head><!--");
6811        shell.extend_from_slice(POLYGLOT_MARKER);
6812        let digits_at = shell.len();
6813        shell.extend_from_slice(&[b'0'; POLYGLOT_DIGITS]); // patched below
6814        shell.extend_from_slice(b"--></head><body>a web page</body></html>\n");
6815        shell.resize(50_000, b' '); // >> the .rete, to prove the shell is untouched
6816        let base = shell.len() as u64;
6817        let digits = format!("{base:0width$}", width = POLYGLOT_DIGITS);
6818        shell[digits_at..digits_at + POLYGLOT_DIGITS].copy_from_slice(digits.as_bytes());
6819
6820        let mut poly = shell;
6821        poly.extend_from_slice(&image);
6822
6823        // byte 0 is a web page, not a .rete...
6824        assert_ne!(
6825            &poly[0..4],
6826            b"RETE",
6827            "polyglot must not start with the magic"
6828        );
6829        // ...but the marker in the first header window points at the embedded .rete.
6830        let detected = detect_polyglot_base(&poly[..crate::header::HEADER_LEN]).unwrap();
6831        assert_eq!(detected, base);
6832
6833        // Open it LAZILY through the offset shim and query it.
6834        let leaked: &'static [u8] = Box::leak(poly.into_boxed_slice());
6835        let counting = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
6836        let lazy = Rete::open_ranged_lazy(OffsetReader::new(counting.clone(), detected)).unwrap();
6837
6838        // The polyglot must answer exactly like the plain .rete it embeds.
6839        let plain = Rete::open(&image).unwrap();
6840        let mut want = plain.query(None, None, None);
6841        want.sort();
6842        let mut got = lazy.query(None, None, None);
6843        got.sort();
6844        assert_eq!(
6845            got, want,
6846            "polyglot lazy query differs from the plain .rete"
6847        );
6848        assert!(!got.is_empty(), "expected some triples");
6849        assert!(!lazy.index_incomplete(), "a lazy fetch failed");
6850
6851        // It was LAZY: it read fewer bytes than the HTML shell alone, i.e. it
6852        // never touched the prefix — only the embedded .rete's own bytes.
6853        let read = counting.bytes_read();
6854        eprintln!(
6855            "polyglot lazy read: HTML shell {base} B + .rete image {} B; \
6856             the query touched only {read} B (never the shell).",
6857            image.len()
6858        );
6859        assert!(
6860            read < base,
6861            "read {read} bytes; the HTML shell alone is {base} — the reader \
6862             touched the prefix instead of only the embedded .rete"
6863        );
6864    }
6865
6866    /// Build a small file whose objects are string literals, **with** a text
6867    /// index, and return `(image, triples)`. Shared by the text-index tests.
6868    #[cfg(test)]
6869    fn build_text_indexed(triples: &[(String, String, String)]) -> Vec<u8> {
6870        let mut db = DictionaryBuilder::new();
6871        for (s, p, o) in triples {
6872            db.observe(s, p, o);
6873        }
6874        let dict = db.build();
6875        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
6876        let mut id_triples: Vec<(u32, u32, u32)> = Vec::with_capacity(triples.len());
6877        for (s, p, o) in triples {
6878            let t = dict.encode(s, p, o).unwrap();
6879            ib.push(t);
6880            id_triples.push(t);
6881        }
6882        let index = ib.build();
6883        let text_index = compute_text_index(&dict, &id_triples);
6884        assert!(
6885            !text_index.is_empty(),
6886            "literals should produce a text index"
6887        );
6888        write_dataset_with_metadata(&dict, &index, &[], false, &[], 0, &[], &text_index)
6889    }
6890
6891    /// A `--text-index` build round-trips: `text_search` returns exactly the
6892    /// subjects whose literals contain the queried word(s), with AND across words
6893    /// and token-prefix — matching a brute-force scan of the literals.
6894    #[test]
6895    fn text_index_eager_matches_brute_force() {
6896        let triples: Vec<(String, String, String)> = vec![
6897            (
6898                "<http://ex/s0>",
6899                "<http://ex/label>",
6900                "\"alpha glucose phosphate\"",
6901            ),
6902            ("<http://ex/s1>", "<http://ex/label>", "\"beta Glucose\""),
6903            ("<http://ex/s2>", "<http://ex/label>", "\"gamma fructose\""),
6904            (
6905                "<http://ex/s3>",
6906                "<http://ex/note>",
6907                "\"einstein relativity\"",
6908            ),
6909            (
6910                "<http://ex/s4>",
6911                "<http://ex/ref>",
6912                "<http://ex/not-a-literal>",
6913            ),
6914        ]
6915        .into_iter()
6916        .map(|(s, p, o)| (s.to_string(), p.to_string(), o.to_string()))
6917        .collect();
6918        let bytes = build_text_indexed(&triples);
6919        let rete = Rete::open(&bytes).unwrap();
6920        assert!(rete.has_text_index());
6921
6922        // Brute-force reference: subjects whose literal objects contain all words.
6923        let brute = |words: &[&str]| -> Vec<String> {
6924            let mut v: Vec<String> = triples
6925                .iter()
6926                .filter(|(_, _, o)| {
6927                    crate::terms::is_literal(o)
6928                        && words.iter().all(|w| {
6929                            let wl = w.to_lowercase();
6930                            crate::terms::literal_lexical(o)
6931                                .unwrap()
6932                                .split(|c: char| !c.is_alphanumeric())
6933                                .any(|t| t.to_lowercase() == wl)
6934                        })
6935                })
6936                .map(|(s, _, _)| s.clone())
6937                .collect();
6938            v.sort();
6939            v.dedup();
6940            v
6941        };
6942
6943        let mut got = rete.text_search(&["glucose"], None, 0);
6944        got.sort();
6945        assert_eq!(got, brute(&["glucose"]), "case-insensitive single word");
6946
6947        // AND across two words: only s0 has both.
6948        let mut got = rete.text_search(&["glucose", "phosphate"], None, 0);
6949        got.sort();
6950        assert_eq!(got, brute(&["glucose", "phosphate"]));
6951
6952        // A word nobody has → empty.
6953        assert!(rete.text_search(&["zzznope"], None, 0).is_empty());
6954
6955        // Token-prefix: "ein…" matches "einstein".
6956        let got = rete.text_search(&[], Some("ein"), 0);
6957        assert_eq!(got, vec!["<http://ex/s3>".to_string()]);
6958
6959        // No text index → empty, has_text_index() false.
6960        let mut db = DictionaryBuilder::new();
6961        for (s, p, o) in &triples {
6962            db.observe(s, p, o);
6963        }
6964        let dict = db.build();
6965        let mut ib = GraphIndexBuilder::new();
6966        for (s, p, o) in &triples {
6967            ib.push(dict.encode(s, p, o).unwrap());
6968        }
6969        let plain = write_dataset(&dict, &ib.build(), &[], false, &[], 0);
6970        let plain_rete = Rete::open(&plain).unwrap();
6971        assert!(!plain_rete.has_text_index());
6972        assert!(plain_rete.text_search(&["glucose"], None, 0).is_empty());
6973    }
6974
6975    /// The lazy/remote path returns the same subjects as the eager path **and**
6976    /// faults only the token table + the queried posting list — never the whole
6977    /// postings blob. A `CountingReader` proves the byte budget stays small.
6978    #[test]
6979    fn text_index_lazy_faults_only_queried_postings() {
6980        use crate::reader::{CountingReader, SliceReader};
6981        // Many subjects so the postings blob is large relative to one posting:
6982        // every subject carries "common", but only a few carry "rare".
6983        let mut triples: Vec<(String, String, String)> = (0..300u32)
6984            .map(|i| {
6985                (
6986                    format!("<http://ex/s/{i:04}>"),
6987                    "<http://ex/label>".to_string(),
6988                    format!("\"common word number {i}\""),
6989                )
6990            })
6991            .collect();
6992        for i in [3u32, 77, 250] {
6993            triples.push((
6994                format!("<http://ex/s/{i:04}>"),
6995                "<http://ex/tag>".to_string(),
6996                "\"raretoken\"".to_string(),
6997            ));
6998        }
6999        let bytes = build_text_indexed(&triples);
7000        let eager = Rete::open(&bytes).unwrap();
7001        let mut want = eager.text_search(&["raretoken"], None, 0);
7002        want.sort();
7003        assert_eq!(want.len(), 3);
7004
7005        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7006        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7007        let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
7008        // Bytes pulled by the open itself (dict dirs, index dirs, named graphs) —
7009        // the text index is deferred and not touched yet.
7010        let before = reader.bytes_read();
7011        let mut got = lazy.text_search(&["raretoken"], None, 0);
7012        got.sort();
7013        assert_eq!(got, want, "lazy search matches eager");
7014        let pulled = reader.bytes_read() - before;
7015        // The search faulted the token table + the one "raretoken" posting; it must
7016        // be far less than the whole text-index section (300 "common" postings).
7017        let ti_len = eager.header().text_index_len;
7018        assert!(
7019            pulled < ti_len,
7020            "search pulled {pulled} B but the section is {ti_len} B — faulted too much"
7021        );
7022        assert!(!lazy.index_incomplete());
7023    }
7024
7025    /// `SearchView` answers exactly what a full open answers, having read
7026    /// strictly less: it skips the permutation tile directories and — the part
7027    /// that actually costs on a literal-heavy graph — the object-only
7028    /// dictionary's chunk directory.
7029    #[test]
7030    fn search_view_matches_full_open_for_far_fewer_bytes() {
7031        use crate::reader::{CountingReader, SliceReader};
7032        // Long object literals so the object-only dictionary (and its chunk
7033        // directory) dominates the file, as on a graph that stores abstracts.
7034        let filler = "lorem ipsum dolor sit amet consectetur adipiscing elit ".repeat(20);
7035        let mut triples: Vec<(String, String, String)> = (0..300u32)
7036            .map(|i| {
7037                (
7038                    format!("<http://ex/s/{i:04}>"),
7039                    "<http://ex/abstract>".to_string(),
7040                    format!("\"{filler} number {i}\""),
7041                )
7042            })
7043            .collect();
7044        for i in [3u32, 77, 250] {
7045            triples.push((
7046                format!("<http://ex/s/{i:04}>"),
7047                "<http://ex/tag>".to_string(),
7048                "\"raretoken\"".to_string(),
7049            ));
7050        }
7051        let bytes = build_text_indexed(&triples);
7052        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7053
7054        let full_reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7055        let full = Rete::open_ranged_lazy(full_reader.clone()).unwrap();
7056        let mut want = full.text_search(&["raretoken"], None, 0);
7057        want.sort();
7058        assert_eq!(want.len(), 3);
7059
7060        let search_reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7061        let view = SearchView::open_ranged(search_reader.clone()).unwrap();
7062        assert!(view.has_text_index());
7063        let mut got = view.text_search(&["raretoken"], None, 0);
7064        got.sort();
7065        assert_eq!(got, want, "search view matches the full open");
7066        assert!(
7067            search_reader.bytes_read() < full_reader.bytes_read(),
7068            "search read {} B, full open read {} B — the narrow open bought nothing",
7069            search_reader.bytes_read(),
7070            full_reader.bytes_read()
7071        );
7072
7073        // A file without a text index says so from the header alone, and the
7074        // label-prefix mode still works through the same view.
7075        let mut prefix_hits = view.prefix_search("", 5);
7076        prefix_hits.sort();
7077        assert!(!prefix_hits.is_empty() || !view.has_pyramid());
7078    }
7079
7080    /// Nothing records a dictionary chunk directory's byte length, so a ranged
7081    /// open probes for it. That probe must cost about ONE directory, not several
7082    /// — it used to re-read the whole prefix on every round, so the sum was
7083    /// ~2x the last (already over-sized) read: 537 MB of range reads to fetch
7084    /// epfl-infoscience's 234 MB object-only directory. Here the same probe runs
7085    /// against a file whose object literals are long enough that its directory
7086    /// is well past the 8 KiB header prefix, and must (a) stay inside the
7087    /// append-only bound of 2x the directory and (b) beat what the old
7088    /// re-reading loop would have spent.
7089    #[test]
7090    fn dict_chunk_directory_probe_costs_about_one_directory() {
7091        use crate::reader::{CountingReader, SliceReader};
7092        // Long literals that share a 3 KiB prefix with their neighbour, each
7093        // ending in a 1 KiB tail of its own: the WKT-polygon / JSON-blob shape
7094        // (#198's weak case). The directory stores the SHORTEST SEPARATOR per
7095        // chunk, and a separator has to reproduce the whole shared prefix — so
7096        // these keys are still ~3 KiB and the directory still runs well past
7097        // the 8 KiB header prefix, which is what gives the probe something to
7098        // measure.
7099        //
7100        // This fixture used to put the varying bytes FIRST (`"{i:04} xxxx…"`).
7101        // That shape is the one separators annihilate: its keys collapse to ~4
7102        // bytes, the whole directory to 192 B, it fits the header prefix, and
7103        // the probe never runs — the assertion below then fails naming itself.
7104        // Keep the shared prefix.
7105        let head = "x".repeat(3000);
7106        let triples: Vec<(String, String, String)> = (0..2000u32)
7107            .map(|i| {
7108                let tail = char::from(b'a' + (i % 26) as u8).to_string().repeat(1000);
7109                (
7110                    format!("<http://ex/s/{i:04}>"),
7111                    "<http://ex/abstract>".to_string(),
7112                    format!("\"{head}{i:04}{tail}\""),
7113                )
7114            })
7115            .collect();
7116        let mut db = DictionaryBuilder::new();
7117        for (s, p, o) in &triples {
7118            db.observe(s, p, o);
7119        }
7120        let dict = db.build();
7121        let mut ib = GraphIndexBuilder::new();
7122        for (s, p, o) in &triples {
7123            ib.push(dict.encode(s, p, o).unwrap());
7124        }
7125        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7126
7127        let reader = CountingReader::new(SliceReader::new(&bytes));
7128        let header = Header::from_bytes(&reader.read_at(0, HEADER_LEN as u64).unwrap()).unwrap();
7129        // Section 2 is object-only: the one carrying the long literals.
7130        let section = locate_container_section_ranged(
7131            &reader,
7132            header.dictionary_offset,
7133            header.dictionary_len,
7134            2,
7135            4,
7136        )
7137        .unwrap();
7138
7139        let before = reader.bytes_read();
7140        let (_meta, entries) = read_dict_dir_ranged(&reader, section).unwrap();
7141        let spent = reader.bytes_read() - before;
7142
7143        // The first chunk body starts where the directory ends.
7144        let dir_end = entries[0].start;
7145        let head = reader.read_at(section.offset, 64).unwrap();
7146        let (header_len, n0) = read_uvarint(&head).unwrap();
7147        let dir_start = n0 as u64 + header_len;
7148        let dir_len = dir_end - dir_start;
7149        let dir_total = section.len - dir_start;
7150        assert!(
7151            dir_start + dir_len > 8192,
7152            "directory fits the header prefix ({dir_len} B) — the probe never runs"
7153        );
7154
7155        // What the previous loop spent: every round re-read from the start.
7156        let mut old = 0u64;
7157        let mut p = 4096u64.min(dir_total).max(1);
7158        loop {
7159            let got = p.min(dir_total);
7160            old += got;
7161            if got >= dir_len || p >= dir_total {
7162                break;
7163            }
7164            p = p.saturating_mul(2).min(dir_total);
7165        }
7166        assert!(
7167            spent <= dir_len * 2,
7168            "probe read {spent} B for a {dir_len} B directory — past the append-only bound"
7169        );
7170        assert!(
7171            spent < old,
7172            "probe read {spent} B; the re-reading loop it replaced spent {old} B"
7173        );
7174    }
7175
7176    // ---------------------------------------------------------------------
7177    // Separator routing keys (#198 option (d))
7178    // ---------------------------------------------------------------------
7179
7180    /// A verbatim-keyed twin of a chunked dictionary section: same header, same
7181    /// chunking, byte-identical compressed bodies — only every directory key
7182    /// replaced by the chunk's actual first term. That is exactly what
7183    /// `encode_chunked_dict_section` wrote before separator keys, so it is the
7184    /// control for every comparison below (and for the proptest).
7185    fn verbatim_keyed_twin(payload: &[u8], codec: u8) -> Vec<u8> {
7186        let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
7187        let (header_len, n0) = read_uvarint(payload).unwrap();
7188        let mut out = Vec::new();
7189        write_uvarint(&mut out, header_len);
7190        out.extend_from_slice(&payload[n0..n0 + header_len as usize]);
7191        write_uvarint(&mut out, entries.len() as u64);
7192        let mut prev_run = 0usize;
7193        for e in &entries {
7194            let body = decompress(codec, &payload[e.start as usize..e.end as usize]).unwrap();
7195            let first = crate::dict::run_first_term(&body, 0).unwrap();
7196            write_uvarint(&mut out, (e.first_run - prev_run) as u64);
7197            write_uvarint(&mut out, first.len() as u64);
7198            out.extend_from_slice(&first);
7199            write_uvarint(&mut out, e.end - e.start);
7200            prev_run = e.first_run;
7201        }
7202        for e in &entries {
7203            out.extend_from_slice(&payload[e.start as usize..e.end as usize]);
7204        }
7205        out
7206    }
7207
7208    /// Each chunk's (first term, last term), decoded from the bodies.
7209    fn chunk_bounds_of(payload: &[u8], codec: u8) -> Vec<(Vec<u8>, Vec<u8>)> {
7210        let (_meta, entries) = parse_chunked_dict_dir(payload, payload.len() as u64).unwrap();
7211        entries
7212            .iter()
7213            .map(|e| {
7214                let body = decompress(codec, &payload[e.start as usize..e.end as usize]).unwrap();
7215                (
7216                    crate::dict::run_first_term(&body, 0).unwrap(),
7217                    crate::dict::run_last_term(&body, 0, body.len()).unwrap(),
7218                )
7219            })
7220            .collect()
7221    }
7222
7223    /// A term set built to hit every shape the corpus has: plain IRIs, literals
7224    /// sharing a long prefix with their neighbour (separators are LONG there —
7225    /// the WKT/`ohm-full` weak case), literals that diverge in their first bytes
7226    /// (separators are tiny — the `epfl-infoscience` case), literals larger than
7227    /// `DICT_CHUNK_BUDGET` on their own, and multibyte UTF-8.
7228    fn adversarial_terms() -> Vec<String> {
7229        let mut terms: Vec<String> = Vec::new();
7230        for i in 0..6_000 {
7231            terms.push(format!("<http://example.org/work/{i:06}>"));
7232        }
7233        let shared = "X".repeat(900);
7234        for i in 0..4_000 {
7235            terms.push(format!("\"{shared}-{i:05}-tail\""));
7236        }
7237        let deep = "Y".repeat(3_000); // shared prefix far past any short key
7238        for i in 0..120 {
7239            terms.push(format!("\"{deep}{i:03}\""));
7240        }
7241        for i in 0..6 {
7242            // one literal larger than the 64 KiB budget: a chunk of one run
7243            terms.push(format!("\"BIG-{i:02}-{}\"", "Z".repeat(90_000)));
7244        }
7245        for i in 0..800 {
7246            terms.push(format!(
7247                "\"h\u{e9}llo-{}-\u{1F600}{i:04}\"",
7248                "\u{e9}".repeat(40)
7249            ));
7250        }
7251        for i in 0..2_000 {
7252            terms.push(format!("\"{i:05}-{}\"", "T".repeat(1_800)));
7253        }
7254        terms.sort();
7255        terms.dedup();
7256        terms
7257    }
7258
7259    fn chunked_section_of(terms: &[String]) -> Vec<u8> {
7260        let mut b = crate::dict::DictSectionBuilder::new().with_restart_interval(16);
7261        for t in terms {
7262            b.push(t.clone());
7263        }
7264        b.build()
7265    }
7266
7267    /// The invariant, asserted against the real chunk contents: the stored key
7268    /// of chunk `i` is strictly above chunk `i-1`'s LAST term and at most chunk
7269    /// `i`'s own first term, chunk 0's key is empty, and the keys ascend — the
7270    /// three facts `ChunkedSection::id`'s `partition_point` relies on.
7271    ///
7272    /// Plus the anti-regression the rename exists for: on a section like this
7273    /// the key must NOT be the first term. A writer that quietly went back to
7274    /// storing terms would pass every other test in this file — dumps and
7275    /// `term(id)` route by `first_run` and never read the key.
7276    #[test]
7277    fn chunk_directory_keys_are_separators_not_terms() {
7278        let terms = adversarial_terms();
7279        let raw = chunked_section_of(&terms);
7280        let payload = encode_chunked_dict_section(&raw, CODEC_NONE);
7281        let (_meta, entries) = parse_chunked_dict_dir(&payload, payload.len() as u64).unwrap();
7282        let bounds = chunk_bounds_of(&payload, CODEC_NONE);
7283        assert!(
7284            entries.len() > 20,
7285            "need a many-chunk section, got {}",
7286            entries.len()
7287        );
7288
7289        assert!(entries[0].key.is_empty(), "chunk 0 needs no separator");
7290        let mut key_bytes = 0usize;
7291        let mut term_bytes = 0usize;
7292        let mut differ = 0usize;
7293        for (i, e) in entries.iter().enumerate() {
7294            key_bytes += e.key.len();
7295            term_bytes += bounds[i].0.len();
7296            if e.key != bounds[i].0 {
7297                differ += 1;
7298            }
7299            assert!(
7300                e.key <= bounds[i].0,
7301                "chunk {i}: key must be <= its own first term"
7302            );
7303            if i > 0 {
7304                assert!(
7305                    e.key.as_slice() > bounds[i - 1].1.as_slice(),
7306                    "chunk {i}: key must be > the previous chunk's last term"
7307                );
7308                assert!(
7309                    e.key > entries[i - 1].key,
7310                    "chunk {i}: keys must ascend for partition_point"
7311                );
7312                // shortest: one byte past the divergence from the predecessor
7313                assert_eq!(
7314                    e.key,
7315                    crate::dict::shortest_separator(&bounds[i - 1].1, &bounds[i].0),
7316                    "chunk {i}: key is not the SHORTEST separator"
7317                );
7318            }
7319        }
7320        assert!(
7321            differ > 0,
7322            "every key equalled its first term — the writer stores terms again"
7323        );
7324        assert!(
7325            key_bytes * 4 < term_bytes,
7326            "separators bought almost nothing: {key_bytes} B vs {term_bytes} B of first terms"
7327        );
7328        eprintln!(
7329            "chunks={} first-term keys={term_bytes} B  separator keys={key_bytes} B ({:.1}x)",
7330            entries.len(),
7331            term_bytes as f64 / key_bytes.max(1) as f64
7332        );
7333    }
7334
7335    /// A separator-keyed section answers **identically** to the verbatim-keyed
7336    /// one built over the same bodies, under the same unmodified reader: every
7337    /// id, every term, and every boundary probe — the first and last term of
7338    /// each chunk, one byte either side of each, strings that fall strictly
7339    /// *between* two chunks, truncations, the empty string, and probes below
7340    /// and above the whole section.
7341    ///
7342    /// Probing `id(term)` is the entire point. A broken key (a truncation, a
7343    /// hash) leaves `term(id)`, `dump` and `export` byte-perfect — they route by
7344    /// `first_run` — and only lookups lie.
7345    #[test]
7346    fn separator_keys_route_identically_to_verbatim_first_terms() {
7347        let terms = adversarial_terms();
7348        let raw = chunked_section_of(&terms);
7349        let sep_payload = encode_chunked_dict_section(&raw, CODEC_NONE);
7350        let ver_payload = verbatim_keyed_twin(&sep_payload, CODEC_NONE);
7351
7352        // Same chunking, byte-identical bodies — only the keys differ.
7353        let (_ms, es) = parse_chunked_dict_dir(&sep_payload, sep_payload.len() as u64).unwrap();
7354        let (_mv, ev) = parse_chunked_dict_dir(&ver_payload, ver_payload.len() as u64).unwrap();
7355        assert_eq!(es.len(), ev.len());
7356        for (a, b) in es.iter().zip(ev.iter()) {
7357            assert_eq!(a.first_run, b.first_run);
7358            assert_eq!(
7359                &sep_payload[a.start as usize..a.end as usize],
7360                &ver_payload[b.start as usize..b.end as usize],
7361                "chunk bodies must be identical"
7362            );
7363        }
7364        assert!(
7365            sep_payload.len() < ver_payload.len(),
7366            "separator payload is not smaller"
7367        );
7368
7369        let sec_s = decode_chunked_dict_section(
7370            &sep_payload,
7371            CODEC_NONE,
7372            crate::chunk_cache::ChunkCache::unlimited_arc(),
7373            0,
7374        )
7375        .unwrap();
7376        let sec_v = decode_chunked_dict_section(
7377            &ver_payload,
7378            CODEC_NONE,
7379            crate::chunk_cache::ChunkCache::unlimited_arc(),
7380            0,
7381        )
7382        .unwrap();
7383
7384        // 1. every present term → the same, correct id
7385        for (i, t) in terms.iter().enumerate() {
7386            let want = Some(i as u32 + 1);
7387            assert_eq!(sec_v.id(t), want, "verbatim id() lost a term");
7388            assert_eq!(sec_s.id(t), want, "separator id() lost a term");
7389        }
7390        // 2. every id → the same term
7391        for i in 0..terms.len() as u32 {
7392            assert_eq!(sec_s.term(i + 1), sec_v.term(i + 1));
7393            assert_eq!(
7394                sec_s.term(i + 1).as_deref(),
7395                Some(terms[i as usize].as_str())
7396            );
7397        }
7398        // 3. boundary probes, from the real chunk bounds
7399        let bounds = chunk_bounds_of(&sep_payload, CODEC_NONE);
7400        let mut probes: Vec<String> = Vec::new();
7401        let push = |bytes: &[u8], probes: &mut Vec<String>| {
7402            if let Ok(s) = std::str::from_utf8(bytes) {
7403                probes.push(s.to_string());
7404            }
7405        };
7406        for (first, last) in &bounds {
7407            for base in [first, last] {
7408                push(base, &mut probes); // present
7409                let mut after = base.clone();
7410                after.push(0x01); // immediately after — between chunks when base = last
7411                push(&after, &mut probes);
7412                let mut far = base.clone();
7413                far.push(b'~');
7414                push(&far, &mut probes);
7415                if !base.is_empty() {
7416                    push(&base[..base.len() - 1], &mut probes); // truncated
7417                    let mut down = base.clone();
7418                    *down.last_mut().unwrap() = down.last().unwrap().wrapping_sub(1);
7419                    push(&down, &mut probes);
7420                    let mut up = base.clone();
7421                    *up.last_mut().unwrap() = up.last().unwrap().wrapping_add(1);
7422                    push(&up, &mut probes);
7423                }
7424            }
7425        }
7426        // 4. the stored separators themselves, and the ends of the world
7427        for e in &es {
7428            push(&e.key, &mut probes);
7429            let mut plus = e.key.clone();
7430            plus.push(0);
7431            push(&plus, &mut probes);
7432        }
7433        probes.push(String::new()); // the empty key, i.e. chunk 0's
7434        probes.push("\u{1}".to_string()); // below every term
7435        probes.push("\u{10FFFF}\u{10FFFF}".to_string()); // above every term
7436        probes.push(terms.first().unwrap().clone());
7437        probes.push(terms.last().unwrap().clone());
7438
7439        let mut hits = 0usize;
7440        for p in &probes {
7441            let (a, b) = (sec_v.id(p), sec_s.id(p));
7442            assert_eq!(
7443                a,
7444                b,
7445                "probe diverged: {:?}",
7446                p.get(..p.len().min(48)).unwrap_or(p.as_str())
7447            );
7448            hits += usize::from(a.is_some());
7449        }
7450        assert!(
7451            probes.len() > 500 && hits > 50,
7452            "probe set is too thin: {} probes, {hits} of them present",
7453            probes.len()
7454        );
7455        eprintln!(
7456            "{} boundary probes, {hits} present, all identical",
7457            probes.len()
7458        );
7459    }
7460
7461    /// The same claim as a **property**: for arbitrary term sets and arbitrary
7462    /// probes, a separator-keyed section and the verbatim-keyed twin built from
7463    /// the same input are indistinguishable through the reader. The fixed test
7464    /// above picks the shapes I thought of; this one shrinks a counterexample
7465    /// out of the ones I did not.
7466    mod separator_props {
7467        use super::*;
7468        use proptest::prelude::*;
7469
7470        /// Sorted, deduplicated term sets mixing the three shapes that decide a
7471        /// separator's length: short IRIs, literals whose payload comes BEFORE
7472        /// the discriminating bytes (long shared prefixes ⇒ long separators),
7473        /// and literals that diverge immediately (⇒ one-byte separators). Sizes
7474        /// straddle `DICT_CHUNK_BUDGET`, so single- and many-chunk sections both
7475        /// occur.
7476        fn term_set() -> impl Strategy<Value = Vec<String>> {
7477            prop::collection::vec(("[a-c]{1,6}", 0u8..3, 300usize..1200), 60..250).prop_map(
7478                |specs| {
7479                    let mut terms: Vec<String> = specs
7480                        .into_iter()
7481                        .enumerate()
7482                        .map(|(i, (core, kind, pad))| {
7483                            let block = "z".repeat(pad);
7484                            match kind {
7485                                0 => format!("<http://ex/{core}/{i:05}>"),
7486                                1 => format!("\"{block}{core}{i:05}\""),
7487                                _ => format!("\"{core}{i:05}{block}\""),
7488                            }
7489                        })
7490                        .collect();
7491                    terms.sort();
7492                    terms.dedup();
7493                    terms
7494                },
7495            )
7496        }
7497
7498        proptest! {
7499            #[test]
7500            fn prop_separator_keyed_section_answers_like_verbatim(
7501                terms in term_set(),
7502                extra in prop::collection::vec(any::<String>(), 0..8),
7503            ) {
7504                let raw = chunked_section_of(&terms);
7505                let sep = encode_chunked_dict_section(&raw, CODEC_NONE);
7506                let ver = verbatim_keyed_twin(&sep, CODEC_NONE);
7507                prop_assert!(sep.len() <= ver.len());
7508
7509                // The separator invariant, against the real chunk contents.
7510                let (_m, es) = parse_chunked_dict_dir(&sep, sep.len() as u64).unwrap();
7511                let bounds = chunk_bounds_of(&sep, CODEC_NONE);
7512                prop_assert!(es[0].key.is_empty());
7513                for i in 1..es.len() {
7514                    prop_assert!(es[i].key.as_slice() > bounds[i - 1].1.as_slice());
7515                    prop_assert!(es[i].key <= bounds[i].0);
7516                    prop_assert!(es[i].key > es[i - 1].key);
7517                }
7518
7519                let sec_s = decode_chunked_dict_section(
7520                    &sep,
7521                    CODEC_NONE,
7522                    crate::chunk_cache::ChunkCache::unlimited_arc(),
7523                    0,
7524                )
7525                .unwrap();
7526                let sec_v = decode_chunked_dict_section(
7527                    &ver,
7528                    CODEC_NONE,
7529                    crate::chunk_cache::ChunkCache::unlimited_arc(),
7530                    0,
7531                )
7532                .unwrap();
7533
7534                for (i, t) in terms.iter().enumerate() {
7535                    prop_assert_eq!(sec_s.id(t), Some(i as u32 + 1));
7536                    prop_assert_eq!(sec_v.id(t), Some(i as u32 + 1));
7537                    prop_assert_eq!(sec_s.term(i as u32 + 1), sec_v.term(i as u32 + 1));
7538                }
7539
7540                // Probes: arbitrary strings, plus mutations of every boundary.
7541                let mut probes: Vec<String> = extra;
7542                probes.push(String::new());
7543                for (first, last) in &bounds {
7544                    for base in [first, last] {
7545                        if let Ok(s) = std::str::from_utf8(base) {
7546                            probes.push(s.to_string());
7547                            probes.push(format!("{s}\u{1}"));
7548                            probes.push(s[..s.len() - s.chars().next_back()
7549                                .map(char::len_utf8).unwrap_or(0)].to_string());
7550                        }
7551                    }
7552                }
7553                for e in &es {
7554                    if let Ok(s) = std::str::from_utf8(&e.key) {
7555                        probes.push(s.to_string());
7556                    }
7557                }
7558                for p in &probes {
7559                    prop_assert_eq!(sec_s.id(p), sec_v.id(p), "probe diverged: {:?}", p);
7560                }
7561            }
7562        }
7563    }
7564
7565    /// The TEXT_INDEX section is inside the content hash: a freshly built
7566    /// text-indexed file must pass `verify()`, and flipping a byte inside the
7567    /// section must break it. (Regression: `verify()` once rebuilt the hash
7568    /// without the text index, so every `--text-index` file failed as corrupt.)
7569    #[test]
7570    fn text_index_is_tamper_evident_and_verifies() {
7571        let triples: Vec<(String, String, String)> = vec![(
7572            "<http://ex/s0>".to_string(),
7573            "<http://ex/label>".to_string(),
7574            "\"alpha glucose phosphate\"".to_string(),
7575        )];
7576        let bytes = build_text_indexed(&triples);
7577        let header = Rete::open(&bytes).unwrap().header().clone();
7578        assert!(header.text_index_len > 0);
7579        assert!(verify(&bytes).unwrap(), "a text-indexed build must verify");
7580
7581        let mut tampered = bytes.clone();
7582        tampered[header.text_index_offset as usize] ^= 0xff;
7583        assert!(
7584            !verify(&tampered).unwrap(),
7585            "tampering with the text index must break verify()"
7586        );
7587    }
7588
7589    /// End-to-end win: on a remote (range-read) file, a lookup whose routed tile
7590    /// is ruled out by a bound secondary fetches **fewer bytes** with the synopsis
7591    /// than without it — and the answer is identical (empty) either way.
7592    #[test]
7593    fn synopsis_cuts_remote_fetch_bytes() {
7594        use crate::header::FLAG_TILE_SYNOPSIS;
7595        use crate::reader::{CountingReader, SliceReader};
7596
7597        // Zero-padded terms ⇒ dictionary ids are monotonic in i; subject s_i pairs
7598        // only with object o_i, so an OSP tile (routed by object) holds a
7599        // contiguous subject range — a subject from a far tile is provably absent.
7600        let triples: Vec<(String, String, String)> = (0..400u32)
7601            .map(|i| {
7602                (
7603                    format!("<http://ex/s/{i:04}>"),
7604                    "<http://ex/p>".to_string(),
7605                    format!("<http://ex/o/{i:04}>"),
7606                )
7607            })
7608            .collect();
7609        let mut db = DictionaryBuilder::new();
7610        for (s, p, o) in &triples {
7611            db.observe(s, p, o);
7612        }
7613        let dict = db.build();
7614        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
7615        for (s, p, o) in &triples {
7616            ib.push(dict.encode(s, p, o).unwrap());
7617        }
7618        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7619
7620        // (s_0395, ?, o_0005): routes OSP by the (early) object, secondary = the
7621        // (late) subject — outside that tile's subject range, so the synopsis
7622        // prunes the one routed tile.
7623        let q = (Some("<http://ex/s/0395>"), None, Some("<http://ex/o/0005>"));
7624        // Measure the bytes the QUERY pulls (after open) — isolating the per-query
7625        // saving from the one-time synopsis trailer reads done at open, which a
7626        // persistent remote session amortizes over many queries.
7627        let query_bytes = |image: &[u8]| -> (u64, usize) {
7628            let leaked: &'static [u8] = Box::leak(image.to_vec().into_boxed_slice());
7629            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
7630            let rete = Rete::open_ranged_lazy(reader.clone()).unwrap();
7631            let before = reader.bytes_read(); // after open (incl. trailer reads)
7632            let n = rete.query(q.0, q.1, q.2).len();
7633            assert!(!rete.index_incomplete());
7634            (reader.bytes_read() - before, n)
7635        };
7636
7637        let (on_bytes, on_n) = query_bytes(&bytes);
7638        // Same file with the synopsis flag cleared = an older reader's behavior.
7639        let mut off = bytes.clone();
7640        off[5] &= !FLAG_TILE_SYNOPSIS;
7641        let (off_bytes, off_n) = query_bytes(&off);
7642
7643        assert_eq!(on_n, 0, "the pair never co-occurs");
7644        assert_eq!(off_n, 0, "same answer without the synopsis");
7645        // Both pay the same dictionary-resolution bytes; the difference is the one
7646        // routed index tile that the synopsis skips (and the no-synopsis path
7647        // fetches only to have its zone map reject it).
7648        assert!(
7649            on_bytes < off_bytes,
7650            "synopsis skips the routed tile fetch: {on_bytes} < {off_bytes}"
7651        );
7652    }
7653
7654    /// A double-bound-object intersection (`?p P o1 ; P o2 ; label ?l`) — the
7655    /// shape whose REMOTE join strategy changed (scan + hash-join instead of
7656    /// probing each prefix row) — must return the SAME rows opened eagerly (in
7657    /// memory) and lazily (remote-style, `is_remote()` true). Strategy is a
7658    /// performance choice; the result multiset is invariant.
7659    #[test]
7660    fn double_bound_object_join_eager_matches_lazy() {
7661        use crate::reader::SliceReader;
7662        let occ = "<http://ex/occ>";
7663        let phys = "<http://ex/physicist>";
7664        let phil = "<http://ex/philosopher>";
7665        let label = "<http://www.w3.org/2000/01/rdf-schema#label>";
7666        // p00..p19 are physicists; p00..p09 are also philosophers (the answer).
7667        let mut triples: Vec<(String, String, String)> = Vec::new();
7668        for i in 0..20u32 {
7669            triples.push((format!("<http://ex/p/{i:02}>"), occ.into(), phys.into()));
7670            if i < 10 {
7671                triples.push((format!("<http://ex/p/{i:02}>"), occ.into(), phil.into()));
7672            }
7673            triples.push((
7674                format!("<http://ex/p/{i:02}>"),
7675                label.into(),
7676                format!("\"Name {i:02}\""),
7677            ));
7678        }
7679        let mut db = DictionaryBuilder::new();
7680        for (s, p, o) in &triples {
7681            db.observe(s, p, o);
7682        }
7683        let dict = db.build();
7684        let mut ib = GraphIndexBuilder::new().with_tile_budget(16);
7685        for (s, p, o) in &triples {
7686            ib.push(dict.encode(s, p, o).unwrap());
7687        }
7688        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7689
7690        let q = "SELECT ?l WHERE { \
7691            ?p <http://ex/occ> <http://ex/physicist> ; \
7692               <http://ex/occ> <http://ex/philosopher> ; \
7693               <http://www.w3.org/2000/01/rdf-schema#label> ?l }";
7694        let run = |rete: &Rete| -> Vec<String> {
7695            let (_, sols) = crate::eval_sparql(rete, q).unwrap();
7696            let mut v: Vec<String> = sols.iter().filter_map(|b| b.get("l").cloned()).collect();
7697            v.sort();
7698            v
7699        };
7700
7701        let eager_rows = run(&Rete::open(&bytes).unwrap());
7702        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
7703        let lazy = Rete::open_ranged_lazy(std::sync::Arc::new(SliceReader::new(leaked))).unwrap();
7704        let lazy_rows = run(&lazy);
7705        assert!(!lazy.index_incomplete());
7706
7707        assert_eq!(eager_rows.len(), 10, "the 10 physicist∩philosopher labels");
7708        assert_eq!(eager_rows, lazy_rows, "eager and lazy must agree exactly");
7709    }
7710
7711    /// A dictionary big enough to split into multiple chunks per section must
7712    /// round-trip every id↔term mapping through the chunked (v0.2) encoding —
7713    /// including terms at chunk boundaries and absent near-misses.
7714    #[test]
7715    fn multi_chunk_dictionary_round_trips() {
7716        let mut db = DictionaryBuilder::new();
7717        let term = |i: u32| format!("<http://example.org/some/long/prefix/entity/{i:06}>");
7718        for i in 0..6000u32 {
7719            db.observe(&term(i), "<http://ex/p>", &term(i + 1));
7720        }
7721        let dict = db.build();
7722        let mut ib = GraphIndexBuilder::new();
7723        for i in 0..6000u32 {
7724            ib.push(
7725                dict.encode(&term(i), "<http://ex/p>", &term(i + 1))
7726                    .unwrap(),
7727            );
7728        }
7729        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7730        let rete = Rete::open(&bytes).unwrap();
7731        let d = rete.dictionary();
7732        assert_eq!(d.term_count(), dict.term_count());
7733        for i in (0..6000).step_by(97).chain([0, 1, 5999, 6000]) {
7734            let t = term(i);
7735            let sid = dict.subject_id(&t);
7736            assert_eq!(d.subject_id(&t), sid, "subject_id({t})");
7737            if let Some(id) = sid {
7738                assert_eq!(d.subject_term(id).as_deref(), Some(t.as_str()));
7739            }
7740            let oid = dict.object_id(&t);
7741            assert_eq!(d.object_id(&t), oid, "object_id({t})");
7742        }
7743        assert_eq!(d.subject_id("<http://example.org/absent>"), None);
7744        assert_eq!(d.predicate_id("<http://ex/p>"), Some(1));
7745        assert_eq!(d.predicate_term(1).as_deref(), Some("<http://ex/p>"));
7746    }
7747
7748    #[test]
7749    #[cfg(feature = "compression")]
7750    fn compression_shrinks_repetitive_data() {
7751        // Many triples sharing IRI prefixes — exactly what front-coding + zstd
7752        // should crush. The compressed file must be far smaller than the raw
7753        // term bytes, and still query correctly.
7754        let mut db = DictionaryBuilder::new();
7755        let triples: Vec<(String, String, String)> = (0..500)
7756            .map(|i| {
7757                (
7758                    format!("<http://example.org/entity/{i}>"),
7759                    "<http://example.org/p/relatedTo>".to_string(),
7760                    format!("<http://example.org/entity/{}>", (i + 1) % 500),
7761                )
7762            })
7763            .collect();
7764        for (s, p, o) in &triples {
7765            db.observe(s, p, o);
7766        }
7767        let dict = db.build();
7768        let mut ib = GraphIndexBuilder::new();
7769        for (s, p, o) in &triples {
7770            ib.push(dict.encode(s, p, o).unwrap());
7771        }
7772        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
7773
7774        let raw: usize = triples
7775            .iter()
7776            .map(|(s, p, o)| s.len() + p.len() + o.len())
7777            .sum();
7778        assert!(
7779            bytes.len() < raw / 2,
7780            "expected strong compression: file {} vs raw terms {raw}",
7781            bytes.len()
7782        );
7783
7784        // Still queryable after compression.
7785        let rete = Rete::open(&bytes).unwrap();
7786        let r = rete.query(Some("<http://example.org/entity/0>"), None, None);
7787        assert_eq!(r.len(), 1);
7788        assert_eq!(r[0].2, "<http://example.org/entity/1>");
7789    }
7790
7791    fn big_file_with_pyramid() -> Vec<u8> {
7792        // A ring of 300 entities -> index dwarfs dict+meta.
7793        let triples: Vec<(String, String, String)> = (0..300)
7794            .map(|i| {
7795                (
7796                    format!("<http://ex/e{i}>"),
7797                    "<http://ex/next>".to_string(),
7798                    format!("<http://ex/e{}>", (i + 1) % 300),
7799                )
7800            })
7801            .collect();
7802        let mut db = DictionaryBuilder::new();
7803        for (s, p, o) in &triples {
7804            db.observe(s, p, o);
7805        }
7806        let dict = db.build();
7807        let ids: Vec<_> = triples
7808            .iter()
7809            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
7810            .collect();
7811        let mut ib = GraphIndexBuilder::new();
7812        for &t in &ids {
7813            ib.push(t);
7814        }
7815        let (meta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
7816        write_file(&dict, &ib.build(), false, &meta, levels)
7817    }
7818
7819    #[test]
7820    fn ranged_open_is_minimal_and_correct() {
7821        use crate::reader::{CountingReader, SliceReader};
7822        let bytes = big_file_with_pyramid();
7823
7824        let full = CountingReader::new(SliceReader::new(&bytes));
7825        let rete = Rete::open_ranged(&full).unwrap();
7826        // Full open touches at most 4 ranges (header, dict, index, meta).
7827        assert!(full.requests() <= 4, "requests = {}", full.requests());
7828        assert_eq!(
7829            rete.query(Some("<http://ex/e0>"), None, None)[0].2,
7830            "<http://ex/e1>"
7831        );
7832
7833        // Summary-only open skips the index → strictly fewer bytes than the file.
7834        let summ_reader = CountingReader::new(SliceReader::new(&bytes));
7835        let view = SummaryView::open_ranged(&summ_reader).unwrap().unwrap();
7836        assert!(!view.summary.is_empty());
7837        assert!(
7838            summ_reader.bytes_read() < bytes.len() as u64,
7839            "summary read {} of {} bytes",
7840            summ_reader.bytes_read(),
7841            bytes.len()
7842        );
7843        // And fewer than a full open, since it never fetched the index.
7844        assert!(summ_reader.bytes_read() < full.bytes_read());
7845    }
7846
7847    #[test]
7848    fn content_hash_is_set_and_verifies() {
7849        let bytes = build_image();
7850        let rete = Rete::open(&bytes).unwrap();
7851        assert_ne!(
7852            rete.header().content_hash,
7853            [0u8; 16],
7854            "hash must be populated"
7855        );
7856        assert!(verify(&bytes).unwrap(), "freshly built file verifies");
7857        assert!(
7858            verify_ranged(&crate::reader::SliceReader::new(&bytes)).unwrap(),
7859            "the streaming verify agrees"
7860        );
7861
7862        // Same data builds an identical hash (deterministic).
7863        assert_eq!(
7864            Rete::open(&build_image()).unwrap().header().content_hash,
7865            rete.header().content_hash
7866        );
7867
7868        // Corrupting a payload byte breaks verification.
7869        let mut tampered = bytes.clone();
7870        let last = tampered.len() - 5; // inside payload, before footer magic
7871        tampered[last] ^= 0xff;
7872        assert!(!verify(&tampered).unwrap());
7873        assert!(!verify_ranged(&crate::reader::SliceReader::new(&tampered)).unwrap());
7874        // A truncated file is an error, never a pass.
7875        let short = &bytes[..bytes.len() / 2];
7876        assert!(verify_ranged(&crate::reader::SliceReader::new(short)).is_err());
7877        assert!(verify_ranged(&crate::reader::SliceReader::new(&bytes[..10])).is_err());
7878    }
7879
7880    /// Build the standard 3-triple image with an opaque metadata payload.
7881    fn build_with_metadata(meta: &[u8]) -> Vec<u8> {
7882        let triples = [
7883            ("Alice", "knows", "Bob"),
7884            ("Bob", "knows", "Carol"),
7885            ("Alice", "age", "30"),
7886        ];
7887        let mut db = DictionaryBuilder::new();
7888        for (s, p, o) in triples {
7889            db.observe(s, p, o);
7890        }
7891        let dict = db.build();
7892        let ids: Vec<_> = triples
7893            .iter()
7894            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
7895            .collect();
7896        let mut ib = GraphIndexBuilder::new();
7897        for &t in &ids {
7898            ib.push(t);
7899        }
7900        let (pmeta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
7901        write_dataset_with_metadata(&dict, &ib.build(), &[], false, &pmeta, levels, meta, &[])
7902    }
7903
7904    #[test]
7905    fn metadata_round_trips_and_shifts_offsets() {
7906        let card = br#"{"title":"My Dataset"}"#;
7907        let bytes = build_with_metadata(card);
7908        let rete = Rete::open(&bytes).unwrap();
7909
7910        // The opaque payload reads back verbatim.
7911        assert_eq!(rete.metadata(), Some(card.as_slice()));
7912        let h = rete.header();
7913        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
7914        assert_eq!(h.metadata_len, card.len() as u64);
7915        // The dictionary (and everything after it) shifted forward by the card.
7916        assert_eq!(h.dictionary_offset, HEADER_LEN as u64 + card.len() as u64);
7917
7918        // The index still decodes correctly at its shifted offset.
7919        assert_eq!(
7920            rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
7921            1
7922        );
7923        // The card is inside the content hash, so the file still verifies.
7924        assert!(verify(&bytes).unwrap());
7925    }
7926
7927    #[test]
7928    fn empty_metadata_is_byte_identical_to_plain_writer() {
7929        // The `&[]` path must produce exactly the bytes of the metadata-free
7930        // writer for identical inputs — old files and outputs are unchanged.
7931        assert_eq!(
7932            build_with_metadata(&[]),
7933            build_image(),
7934            "empty-metadata output must equal the plain writer byte-for-byte"
7935        );
7936    }
7937
7938    /// The contract `replace_metadata` has to meet to be usable at the end of a
7939    /// build: swapping the card must land on **exactly** the bytes the writer
7940    /// would have produced with that card in the first place — shorter, longer,
7941    /// and gone — so a spliced file and a rebuilt one are the same file.
7942    #[test]
7943    fn replace_metadata_matches_a_direct_write() {
7944        let first = br#"{"title":"My Dataset","queries":[1,2,3]}"#;
7945        let bytes = build_with_metadata(first);
7946        for target in [
7947            br#"{"title":"My Dataset","queries":[1,2]}"#.as_slice(), // shorter
7948            br#"{"title":"My Dataset","queries":[1,2,3,4,5,6,7]}"#.as_slice(), // longer
7949            b"".as_slice(),                                          // removed
7950            first.as_slice(),                                        // unchanged
7951        ] {
7952            let spliced = replace_metadata(&bytes, target).unwrap();
7953            assert_eq!(
7954                spliced,
7955                build_with_metadata(target),
7956                "splicing {} bytes of metadata must equal a direct write",
7957                target.len()
7958            );
7959            assert!(
7960                verify(&spliced).unwrap(),
7961                "the new hash covers the new card"
7962            );
7963            let rete = Rete::open(&spliced).unwrap();
7964            assert_eq!(
7965                rete.metadata(),
7966                (!target.is_empty()).then_some(target),
7967                "the new payload reads back verbatim"
7968            );
7969            // The data is untouched by the surgery.
7970            assert_eq!(
7971                rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
7972                1
7973            );
7974        }
7975    }
7976
7977    /// A build-info section survives the splice, still adjacent to the card, and
7978    /// still outside the content hash.
7979    #[test]
7980    fn replace_metadata_keeps_build_info_adjacent() {
7981        let with_info =
7982            attach_build_info(&build_with_metadata(br#"{"a":1}"#), br#"{"builder":"x"}"#).unwrap();
7983        let spliced = replace_metadata(&with_info, br#"{"a":1,"b":2}"#).unwrap();
7984        let h = Header::from_bytes(&spliced).unwrap();
7985        assert_eq!(h.metadata_offset, HEADER_LEN as u64);
7986        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + h.metadata_len);
7987        assert_eq!(
7988            read_build_info(&spliced).unwrap().as_deref(),
7989            Some(br#"{"builder":"x"}"#.as_slice())
7990        );
7991        assert!(verify(&spliced).unwrap());
7992        // Same card, same content hash, whatever the build-info says.
7993        assert_eq!(
7994            h.content_hash,
7995            Header::from_bytes(&build_with_metadata(br#"{"a":1,"b":2}"#))
7996                .unwrap()
7997                .content_hash
7998        );
7999    }
8000
8001    #[test]
8002    fn metadata_is_tamper_evident() {
8003        let card = br#"{"title":"x"}"#;
8004        let mut bytes = build_with_metadata(card);
8005        assert!(verify(&bytes).unwrap());
8006        // The card occupies [HEADER_LEN .. HEADER_LEN+card_len); flip a byte in it.
8007        bytes[HEADER_LEN + 2] ^= 0xff;
8008        assert!(
8009            !verify(&bytes).unwrap(),
8010            "tampering with the card must break verify()"
8011        );
8012    }
8013
8014    #[test]
8015    fn ranged_opens_do_not_fetch_metadata() {
8016        use crate::reader::{CountingReader, SliceReader};
8017        let card = vec![0xABu8; 512]; // distinctive and sizable
8018        let bytes = build_with_metadata(&card);
8019        let total = bytes.len() as u64;
8020
8021        // A full ranged open never loads the card and never reads its byte range.
8022        let r = CountingReader::new(SliceReader::new(&bytes));
8023        let rete = Rete::open_ranged(&r).unwrap();
8024        assert!(
8025            rete.metadata().is_none(),
8026            "open_ranged must not load the card"
8027        );
8028        assert!(r.requests() <= 4, "requests = {}", r.requests());
8029        assert!(
8030            r.bytes_read() <= total - card.len() as u64,
8031            "read {} of {} bytes; the {}-byte card must be skipped",
8032            r.bytes_read(),
8033            total,
8034            card.len()
8035        );
8036
8037        // Summary-only open likewise ignores the card and still summarizes.
8038        let rs = CountingReader::new(SliceReader::new(&bytes));
8039        let view = SummaryView::open_ranged(&rs).unwrap().unwrap();
8040        assert!(!view.summary.is_empty());
8041        assert!(rs.bytes_read() <= total - card.len() as u64);
8042    }
8043
8044    #[test]
8045    fn metadata_ranged_fetches_only_header_and_card() {
8046        use crate::reader::{CountingReader, SliceReader};
8047        // The CARD tier: fetch the self-description over a RangeReader touching
8048        // only the header + metadata range — never the dictionary/index/pyramid.
8049        let card = vec![0xCDu8; 384];
8050        let bytes = build_with_metadata(&card);
8051
8052        let r = CountingReader::new(SliceReader::new(&bytes));
8053        let got = read_metadata_ranged(&r).unwrap().unwrap();
8054        assert_eq!(got, card, "the card reads back verbatim");
8055        assert_eq!(r.requests(), 2, "exactly header + metadata ranges");
8056        assert_eq!(
8057            r.bytes_read(),
8058            HEADER_LEN as u64 + card.len() as u64,
8059            "no dictionary/index/pyramid bytes are touched"
8060        );
8061
8062        // A cardless file resolves to None after a single header read.
8063        let plain = build_image();
8064        let rp = CountingReader::new(SliceReader::new(&plain));
8065        assert!(read_metadata_ranged(&rp).unwrap().is_none());
8066        assert_eq!(rp.requests(), 1, "header only for a cardless file");
8067        assert_eq!(rp.bytes_read(), HEADER_LEN as u64);
8068    }
8069
8070    #[test]
8071    fn build_info_attaches_outside_the_hash() {
8072        let card = br#"{"title":"My Dataset"}"#;
8073        let base = build_with_metadata(card);
8074        let base_hash = Header::from_bytes(&base).unwrap().content_hash;
8075
8076        let info = br#"{"built_at":"2026-08-04T00:00:00Z","builder":"rete-cli 0.3.2"}"#;
8077        let with = attach_build_info(&base, info).unwrap();
8078
8079        // The section reads back verbatim, adjacent to the metadata.
8080        assert_eq!(read_build_info(&with).unwrap().unwrap(), info);
8081        let h = Header::from_bytes(&with).unwrap();
8082        assert_eq!(h.build_info_offset, HEADER_LEN as u64 + card.len() as u64);
8083        assert_eq!(h.build_info_len, info.len() as u64);
8084        assert_eq!(h.expected_file_len(), Some(with.len() as u64));
8085
8086        // OUTSIDE the hash: the content hash is unchanged and the file still
8087        // verifies — two builds of identical data stay hash-equal even though
8088        // their build-info differs.
8089        assert_eq!(h.content_hash, base_hash);
8090        assert!(verify(&with).unwrap());
8091        let other = attach_build_info(&base, br#"{"built_at":"1999-01-01T00:00:00Z"}"#).unwrap();
8092        assert_eq!(Header::from_bytes(&other).unwrap().content_hash, base_hash);
8093
8094        // The graph still opens and answers at its shifted offsets.
8095        let rete = Rete::open(&with).unwrap();
8096        assert_eq!(
8097            rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
8098            1
8099        );
8100        assert_eq!(rete.metadata(), Some(card.as_slice()));
8101
8102        // Replacing an existing section works and removing it restores the
8103        // original image byte-for-byte (the strip-equality determinism proof).
8104        let replaced = attach_build_info(&with, br#"{"builder":"other"}"#).unwrap();
8105        assert_eq!(
8106            read_build_info(&replaced).unwrap().unwrap(),
8107            br#"{"builder":"other"}"#
8108        );
8109        assert!(verify(&replaced).unwrap());
8110        let stripped = attach_build_info(&replaced, &[]).unwrap();
8111        assert_eq!(stripped, base, "stripping build-info restores the image");
8112    }
8113
8114    #[test]
8115    fn build_info_attaches_on_a_metadata_free_file() {
8116        // No card: the section slots in right after the header.
8117        let base = build_image();
8118        let info = b"{\"builder\":\"x\"}";
8119        let with = attach_build_info(&base, info).unwrap();
8120        let h = Header::from_bytes(&with).unwrap();
8121        assert_eq!(h.build_info_offset, HEADER_LEN as u64);
8122        assert_eq!(h.metadata_len, 0);
8123        assert_eq!(read_build_info(&with).unwrap().unwrap(), info);
8124        assert!(verify(&with).unwrap());
8125        assert_eq!(
8126            h.content_hash,
8127            Header::from_bytes(&base).unwrap().content_hash
8128        );
8129        Rete::open(&with).unwrap();
8130    }
8131
8132    /// The contract a **streaming** rewriter needs: a plan is enough to rebuild
8133    /// the file from three spans — the header it hands back, the untouched
8134    /// `[HEADER_LEN, insert)` prefix, the new section, and the untouched
8135    /// `[tail_start, len)` tail — and the result is byte-identical to the
8136    /// in-memory splice. That is what lets a 17 GB file gain a build record
8137    /// with a 4 MiB buffer instead of 34 GB of RAM.
8138    #[test]
8139    fn a_plan_rebuilds_exactly_what_the_in_memory_splice_produces() {
8140        let card = br#"{"title":"My Dataset"}"#;
8141        let base = build_with_metadata(card);
8142        let base_hash = Header::from_bytes(&base).unwrap().content_hash;
8143        for start in [
8144            base.clone(),
8145            attach_build_info(&base, b"{\"a\":1}").unwrap(),
8146        ] {
8147            for info in [
8148                br#"{"builder":"rete-cli 0.3.2","query_costs":{}}"#.as_slice(), // longer
8149                b"{}".as_slice(),                                               // shorter
8150                b"".as_slice(),                                                 // removed
8151            ] {
8152                let plan = plan_build_info(&start, start.len() as u64, info.len() as u64).unwrap();
8153                let mut streamed = Vec::new();
8154                streamed.extend_from_slice(&plan.header);
8155                streamed.extend_from_slice(&start[HEADER_LEN..plan.insert as usize]);
8156                streamed.extend_from_slice(info);
8157                streamed.extend_from_slice(&start[plan.tail_start as usize..]);
8158                assert_eq!(streamed.len() as u64, plan.new_len);
8159                assert_eq!(
8160                    streamed,
8161                    attach_build_info(&start, info).unwrap(),
8162                    "a {}-byte section streamed must equal the same section spliced",
8163                    info.len()
8164                );
8165                // The point of the exercise: the file's identity is untouched.
8166                assert!(verify(&streamed).unwrap());
8167                let h = Header::from_bytes(&streamed).unwrap();
8168                assert_eq!(h.content_hash, base_hash);
8169                assert_eq!(h.expected_file_len(), Some(plan.new_len));
8170                let rete = Rete::open(&streamed).unwrap();
8171                assert_eq!(rete.metadata(), Some(card.as_slice()));
8172                assert_eq!(
8173                    rete.query(Some("Bob"), Some("knows"), Some("Carol")).len(),
8174                    1
8175                );
8176            }
8177        }
8178    }
8179
8180    /// A plan is refused rather than trusted when the header's own numbers put
8181    /// the section outside the file — the header is attacker-controlled input.
8182    #[test]
8183    fn a_plan_refuses_a_header_that_overruns_the_file() {
8184        let base = build_with_metadata(br#"{"title":"x"}"#);
8185        assert!(plan_build_info(&base, 8, 16).is_err());
8186        assert!(plan_build_info(&base[..HEADER_LEN], base.len() as u64, 16).is_ok());
8187    }
8188
8189    #[test]
8190    fn card_and_build_info_ranged_is_one_header_plus_one_range() {
8191        use crate::reader::{CountingReader, SliceReader};
8192        let card = vec![0xCDu8; 384];
8193        let info = vec![0xEFu8; 200];
8194        let bytes = attach_build_info(&build_with_metadata(&card), &info).unwrap();
8195
8196        // Both sections in ONE coalesced range after the header — the CARD
8197        // tier's 1 header + 1 range budget holds with build info included.
8198        let r = CountingReader::new(SliceReader::new(&bytes));
8199        let (m, b) = read_card_and_build_info_ranged(&r).unwrap();
8200        assert_eq!(m.unwrap(), card);
8201        assert_eq!(b.unwrap(), info);
8202        assert_eq!(r.requests(), 2, "header + one coalesced range");
8203        assert_eq!(
8204            r.bytes_read(),
8205            HEADER_LEN as u64 + card.len() as u64 + info.len() as u64
8206        );
8207
8208        // Card only: same two requests, no build info.
8209        let plain = build_with_metadata(&card);
8210        let rp = CountingReader::new(SliceReader::new(&plain));
8211        let (m, b) = read_card_and_build_info_ranged(&rp).unwrap();
8212        assert_eq!(m.unwrap(), card);
8213        assert!(b.is_none());
8214        assert_eq!(rp.requests(), 2);
8215        // Stated as the comparison every client depends on: carrying a build
8216        // record costs a reader NOTHING in requests. Any client that shows the
8217        // build record — the CLI, the browser card modal — can therefore do it
8218        // in the card's own budget, and does not need a second call.
8219        assert_eq!(
8220            r.requests(),
8221            rp.requests(),
8222            "build info must not cost an extra request",
8223        );
8224
8225        // Neither: the header read alone answers.
8226        let none = build_image();
8227        let rn = CountingReader::new(SliceReader::new(&none));
8228        let (m, b) = read_card_and_build_info_ranged(&rn).unwrap();
8229        assert!(m.is_none() && b.is_none());
8230        assert_eq!(rn.requests(), 1);
8231    }
8232
8233    #[test]
8234    fn schema_summary_groups_by_type() {
8235        let rt = RDF_TYPE;
8236        let bytes = build_from(&[
8237            ("Alice", rt, "Person"),
8238            ("Bob", rt, "Person"),
8239            ("NYC", rt, "City"),
8240            ("Alice", "knows", "Bob"),
8241            ("Alice", "livesIn", "NYC"),
8242            ("Alice", "name", "\"Alice\""),
8243        ]);
8244        let rete = Rete::open(&bytes).unwrap();
8245        let summary = schema_summary(&rete);
8246        // Expect class-level relations, rdf:type excluded.
8247        assert!(summary.contains(&("Person".into(), "knows".into(), "Person".into(), 1)));
8248        assert!(summary.contains(&("Person".into(), "livesIn".into(), "City".into(), 1)));
8249        assert!(summary.contains(&("Person".into(), "name".into(), "(literal)".into(), 1)));
8250        // No rdf:type relations in the summary.
8251        assert!(!summary.iter().any(|(_, p, _, _)| p == RDF_TYPE));
8252
8253        // Class populations: 2 People, 1 City, sorted by count desc.
8254        let classes = schema_classes(&rete);
8255        assert_eq!(
8256            classes,
8257            vec![("Person".into(), 2u32), ("City".into(), 1u32)]
8258        );
8259    }
8260
8261    fn build_from(triples: &[(&str, &str, &str)]) -> Vec<u8> {
8262        let mut db = DictionaryBuilder::new();
8263        for (s, p, o) in triples {
8264            db.observe(s, p, o);
8265        }
8266        let dict = db.build();
8267        let mut ib = GraphIndexBuilder::new();
8268        for (s, p, o) in triples {
8269            ib.push(dict.encode(s, p, o).unwrap());
8270        }
8271        write_file(&dict, &ib.build(), false, &[], 0)
8272    }
8273
8274    /// Build a file WITH a pyramid (so the schema pyramid + coherence axioms ship).
8275    fn build_with_pyramid(triples: &[(&str, &str, &str)]) -> Vec<u8> {
8276        let mut db = DictionaryBuilder::new();
8277        for (s, p, o) in triples {
8278            db.observe(s, p, o);
8279        }
8280        let dict = db.build();
8281        let encoded: Vec<_> = triples
8282            .iter()
8283            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
8284            .collect();
8285        let mut ib = GraphIndexBuilder::new();
8286        for t in &encoded {
8287            ib.push(*t);
8288        }
8289        let (meta, levels) = build_pyramid_meta(&dict, &encoded, DEFAULT_TILE_BUDGET);
8290        write_dataset(&dict, &ib.build(), &[], false, &meta, levels)
8291    }
8292
8293    #[test]
8294    fn tbox_coherence_flags_unsatisfiable_class_index_free() {
8295        use crate::reader::{CountingReader, SliceReader};
8296        let rt = RDF_TYPE;
8297        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
8298        let disj = "<http://www.w3.org/2002/07/owl#disjointWith>";
8299        // C ⊑ D, C ⊑ E, D disjointWith E ⇒ C unsatisfiable — schema-only, with one
8300        // instance present just so the schema pyramid gets built.
8301        let bytes = build_with_pyramid(&[
8302            ("<http://ex/C>", sub, "<http://ex/D>"),
8303            ("<http://ex/C>", sub, "<http://ex/E>"),
8304            ("<http://ex/D>", disj, "<http://ex/E>"),
8305            ("<http://ex/x>", rt, "<http://ex/C>"),
8306        ]);
8307
8308        let r = CountingReader::new(SliceReader::new(&bytes));
8309        let view = SummaryView::open_ranged(&r).unwrap().unwrap();
8310        let points = view.tbox_coherence();
8311        assert!(
8312            points
8313                .iter()
8314                .any(|i| i.kind == "unsatisfiable-class" && i.detail.contains("http://ex/C>")),
8315            "expected C unsatisfiable from the schema alone, got {points:?}"
8316        );
8317
8318        // Proven index-free: bytes read never reach the (root_dir) index section,
8319        // mirroring `schema_pyramid_round_trips_through_file_index_free`.
8320        let header = Header::from_bytes(&bytes[..HEADER_LEN]).unwrap();
8321        assert!(
8322            r.bytes_read() <= bytes.len() as u64 - header.root_dir_len,
8323            "tbox_coherence must not read the triple index"
8324        );
8325    }
8326
8327    #[test]
8328    fn schema_coherence_reads_only_the_schema_block() {
8329        use crate::reader::{CountingReader, SliceReader};
8330        let rt = RDF_TYPE;
8331        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
8332        let disj = "<http://www.w3.org/2002/07/owl#disjointWith>";
8333        // 500 instances with unique literals → a sizable dictionary + community
8334        // summary, so a whole-pyramid-meta read would be large; the schema block
8335        // (bounded by the tiny ontology) stays small.
8336        let mut triples: Vec<(String, String, String)> = vec![
8337            ("<http://ex/C>".into(), sub.into(), "<http://ex/D>".into()),
8338            ("<http://ex/C>".into(), sub.into(), "<http://ex/E>".into()),
8339            ("<http://ex/D>".into(), disj.into(), "<http://ex/E>".into()),
8340        ];
8341        for i in 0..500 {
8342            let s = format!("<http://ex/x{i}>");
8343            triples.push((s.clone(), rt.into(), "<http://ex/C>".into()));
8344            triples.push((
8345                s,
8346                "<http://ex/label>".into(),
8347                format!("\"unique label {i}\""),
8348            ));
8349        }
8350        let trefs: Vec<(&str, &str, &str)> = triples
8351            .iter()
8352            .map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
8353            .collect();
8354        let bytes = build_with_pyramid(&trefs);
8355
8356        let header = Header::from_bytes(&bytes[..HEADER_LEN]).unwrap();
8357        assert!(
8358            header.schema_meta_len > 0,
8359            "the writer recorded a schema-block length"
8360        );
8361        assert!(
8362            (header.schema_meta_len as u64) < header.pyramid_meta_len,
8363            "schema block ({}) should be far smaller than the whole pyramid-meta ({})",
8364            header.schema_meta_len,
8365            header.pyramid_meta_len
8366        );
8367
8368        let r = CountingReader::new(SliceReader::new(&bytes));
8369        let points = read_schema_coherence_ranged(&r).unwrap().unwrap();
8370        assert!(points.iter().any(|i| i.kind == "unsatisfiable-class"));
8371        // It read only the header + the schema block — not the summary or dictionary.
8372        assert!(
8373            r.bytes_read() <= HEADER_LEN as u64 + header.schema_meta_len as u64,
8374            "read {} bytes; expected <= header + schema block ({})",
8375            r.bytes_read(),
8376            HEADER_LEN as u64 + header.schema_meta_len as u64
8377        );
8378    }
8379
8380    #[test]
8381    fn tbox_coherence_clean_schema_is_coherent() {
8382        use crate::reader::SliceReader;
8383        let rt = RDF_TYPE;
8384        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
8385        let bytes = build_with_pyramid(&[
8386            ("<http://ex/Dog>", sub, "<http://ex/Animal>"),
8387            ("<http://ex/x>", rt, "<http://ex/Dog>"),
8388        ]);
8389        let view = SummaryView::open_ranged(&SliceReader::new(&bytes))
8390            .unwrap()
8391            .unwrap();
8392        assert!(view.tbox_is_coherent(), "a plain hierarchy is coherent");
8393    }
8394
8395    /// A quad-index file must be **refused** by every reader that predates it —
8396    /// an old reader ignores flag bit 3 and the unknown kind 10, so it would
8397    /// otherwise open the file as one without named graphs and answer `GRAPH`
8398    /// queries with nothing. The seal makes each of their open paths fail on the
8399    /// index container's section count, before any payload is read; their
8400    /// `verify` still passes, because the part list it hashes is unchanged.
8401    /// (Checked against real 0.3.x binaries too: QUAD-INDEX-DESIGN.md §9.)
8402    #[test]
8403    fn readers_before_the_quad_index_refuse_its_files() {
8404        for perms in [PermSet::ALL, PermSet::CORE] {
8405            let quads: Vec<crate::ingest::RawQuad> = (0..50)
8406                .map(|i| {
8407                    (
8408                        format!("<http://ex/s{i}>"),
8409                        "<http://ex/p>".to_string(),
8410                        format!("\"{i}\""),
8411                        Some(format!("<http://ex/g{}>", i % 7)),
8412                    )
8413                })
8414                .collect();
8415            let (bytes, _) = crate::ingest::assemble_dataset_with_layout(
8416                quads,
8417                false,
8418                false,
8419                None,
8420                crate::PyramidAlgo::Louvain,
8421                perms,
8422                crate::ingest::GraphLayout::Quad,
8423                |_, _| Vec::<u8>::new(),
8424            );
8425            let h = Header::from_bytes(&bytes).unwrap();
8426            assert!(h.has_quad_index());
8427            let root =
8428                &bytes[h.root_dir_offset as usize..(h.root_dir_offset + h.root_dir_len) as usize];
8429            // 0.3.4–0.3.7, resident (`Rete::open`, `open_ranged`): count != mask.
8430            assert!(decode_index_container(root, h.block_codec, h.perms).is_err());
8431            assert!(
8432                container_section_payload_ranges(root, h.root_dir_offset, h.perms.len()).is_err()
8433            );
8434            // 0.3.0–0.3.3 expected exactly six sections, whatever the mask.
8435            assert_ne!(decode_container(root, CODEC_NONE).unwrap().len(), NUM_PERMS);
8436            assert_ne!(
8437                decode_container(root, CODEC_NONE).unwrap().len(),
8438                h.perms.len()
8439            );
8440            // Every lazy / remote open (`open_ranged_lazy`, `query_ranged`).
8441            let r = crate::reader::SliceReader::new(&bytes);
8442            for expect in [h.perms.len(), NUM_PERMS] {
8443                assert!(locate_container_section_ranged(
8444                    &r,
8445                    h.root_dir_offset,
8446                    h.root_dir_len,
8447                    0,
8448                    expect as u64
8449                )
8450                .is_err());
8451            }
8452            // Their `verify` hashes the same parts the writer hashed.
8453            assert_eq!(hash_of_sections(&h, &bytes).unwrap(), h.content_hash);
8454            // And a current reader reads the file.
8455            let rete = Rete::open(&bytes).unwrap();
8456            assert_eq!(rete.named_graph_count(), 7);
8457            assert_eq!(rete.dump(Some("<http://ex/g3>")).len(), 7);
8458        }
8459    }
8460
8461    #[test]
8462    fn named_graphs_round_trip() {
8463        // One shared dictionary; default graph + a named graph "g1".
8464        let all = [
8465            ("Alice", "knows", "Bob"), // default
8466            ("Bob", "age", "30"),      // named g1
8467        ];
8468        let mut db = DictionaryBuilder::new();
8469        for (s, p, o) in all {
8470            db.observe(s, p, o);
8471        }
8472        let dict = db.build();
8473
8474        let mut def = GraphIndexBuilder::new();
8475        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
8476        let mut g1 = GraphIndexBuilder::new();
8477        g1.push(dict.encode("Bob", "age", "30").unwrap());
8478
8479        let named = vec![("http://ex/g1".to_string(), g1.build())];
8480        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8481
8482        assert!(verify(&bytes).unwrap());
8483        let rete = Rete::open(&bytes).unwrap();
8484        assert_eq!(rete.graph_names(), vec!["http://ex/g1"]);
8485        // The named graph contains Bob age 30, not the default-graph triple.
8486        let gi = rete.graph_index("http://ex/g1").unwrap();
8487        assert_eq!(gi.triple_count(), 1);
8488        assert!(rete.graph_index("http://ex/missing").is_none());
8489
8490        // quad_count counts ALL quads — default graph + named graphs (1 + 1),
8491        // not just the default index (which would report 1).
8492        assert_eq!(rete.header().quad_count, 2);
8493
8494        // Default-graph query path is unchanged.
8495        assert_eq!(rete.query(Some("Alice"), None, None).len(), 1);
8496
8497        // dump() round-trips each graph back to terms.
8498        assert_eq!(
8499            rete.dump(None),
8500            vec![("Alice".into(), "knows".into(), "Bob".into())]
8501        );
8502        assert_eq!(
8503            rete.dump(Some("http://ex/g1")),
8504            vec![("Bob".into(), "age".into(), "30".into())]
8505        );
8506    }
8507
8508    /// A named graph whose container exceeds [`NAMED_GRAPH_RESIDENT_MAX`]
8509    /// opens through [`open_index_container_lazy`] — the default graph's own
8510    /// machinery, at the container's offset. Drive that helper directly over a
8511    /// named graph's container range (the threshold itself is too large to
8512    /// cross cheaply in a unit test) and require identical matches.
8513    #[test]
8514    fn named_graph_container_opens_tile_lazily_like_the_root() {
8515        let mut db = DictionaryBuilder::new();
8516        let node = |n: u32| format!("<http://ex/n{n}>");
8517        let p = "<http://ex/p>".to_string();
8518        db.observe(&node(0), &p, &node(1));
8519        for i in 0..500u32 {
8520            db.observe(&node(1000 + i), &p, &node(2000 + i));
8521        }
8522        let dict = db.build();
8523        let mut def = GraphIndexBuilder::new();
8524        def.push(dict.encode(&node(0), &p, &node(1)).unwrap());
8525        let mut g = GraphIndexBuilder::new().with_tile_budget(256); // many tiles
8526        for i in 0..500u32 {
8527            g.push(dict.encode(&node(1000 + i), &p, &node(2000 + i)).unwrap());
8528        }
8529        let named = vec![("<http://ex/g>".to_string(), g.build())];
8530        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8531        let rete = Rete::open(&bytes).unwrap();
8532        let header = rete.header();
8533
8534        // The single entry's container range: count varint, iri_len varint,
8535        // iri, container_len varint, then the container.
8536        let soff = header.named_graphs_offset as usize;
8537        let send = soff + header.named_graphs_len as usize;
8538        let sec = &bytes[soff..send];
8539        let (n, mut pos) = read_uvarint(sec).unwrap();
8540        assert_eq!(n, 1);
8541        let (ilen, u1) = read_uvarint(&sec[pos..]).unwrap();
8542        pos += u1 + ilen as usize;
8543        let (clen, u2) = read_uvarint(&sec[pos..]).unwrap();
8544        pos += u2;
8545        let container = ByteRange {
8546            offset: (soff + pos) as u64,
8547            len: clen,
8548        };
8549
8550        use crate::reader::SliceReader;
8551        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8552        let reader: std::sync::Arc<dyn RangeReader + Send + Sync> =
8553            std::sync::Arc::new(SliceReader::new(leaked));
8554        let (lazy_idx, _, _) = open_index_container_lazy(
8555            &reader,
8556            container,
8557            header.block_codec,
8558            header.has_tile_synopsis(),
8559            1,
8560            header.perms,
8561            header.perms.len(),
8562        )
8563        .unwrap();
8564        let mut want = rete
8565            .graph_index("<http://ex/g>")
8566            .unwrap()
8567            .match_pattern((None, None, None));
8568        let mut got = lazy_idx.match_pattern((None, None, None));
8569        want.sort_unstable();
8570        got.sort_unstable();
8571        assert_eq!(got.len(), 500);
8572        assert_eq!(got, want);
8573        assert!(!lazy_idx.load_incomplete());
8574    }
8575
8576    /// `dump_iter` must agree with `dump` exactly — and must be *lazy*: taking
8577    /// the first triple of a lazily range-read file may not drag the whole
8578    /// index across the reader, or the JS client's streaming quad cursor (which
8579    /// is this iterator, suspended between wasm calls) would be a materializing
8580    /// dump wearing a cursor's clothes.
8581    #[test]
8582    fn dump_iter_matches_dump_and_stops_early() {
8583        use crate::reader::{CountingReader, SliceReader};
8584
8585        // Enough triples over tiny tiles to force many tiles per permutation.
8586        let triples: Vec<(String, String, String)> = (0..2000u32)
8587            .map(|i| {
8588                (
8589                    format!("<http://ex/s/{i:04}>"),
8590                    "<http://ex/p>".to_string(),
8591                    format!("<http://ex/o/{i:04}>"),
8592                )
8593            })
8594            .collect();
8595        let mut db = DictionaryBuilder::new();
8596        for (s, p, o) in &triples {
8597            db.observe(s, p, o);
8598        }
8599        let dict = db.build();
8600        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
8601        for (s, p, o) in &triples {
8602            ib.push(dict.encode(s, p, o).unwrap());
8603        }
8604        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8605
8606        // Same triples, same order, as the eager dump.
8607        let rete = Rete::open(&bytes).unwrap();
8608        assert_eq!(rete.dump_iter(None).collect::<Vec<_>>(), rete.dump(None));
8609        // A missing named graph yields nothing rather than the default graph.
8610        assert_eq!(rete.dump_iter(Some("http://ex/nope")).count(), 0);
8611
8612        // Lazily: taking one triple must cost far fewer bytes than draining.
8613        let read_bytes = |take: usize| -> u64 {
8614            let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8615            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8616            let rete = Rete::open_ranged_lazy(reader.clone()).unwrap();
8617            let before = reader.bytes_read();
8618            let n = rete.dump_iter(None).take(take).count();
8619            assert_eq!(n, take.min(triples.len()));
8620            assert!(!rete.index_incomplete());
8621            reader.bytes_read() - before
8622        };
8623        let first_only = read_bytes(1);
8624        let everything = read_bytes(triples.len());
8625        // Always true: stopping after one triple cannot cost a full scan.
8626        assert!(
8627            first_only < everything,
8628            "dump_iter is not lazy: one triple read {first_only} B, a full scan {everything} B"
8629        );
8630        // The STRONG claim — under half — only holds once the payload dwarfs the
8631        // fixed open cost. This fixture is ~32 KB, and with `compression` off the
8632        // dictionary is essentially the whole file, so opening it at all already
8633        // pays most of `everything` (21 KB of 33 KB) and the ratio says nothing
8634        // about laziness. Asserting it unconditionally is how this test failed on
8635        // `--no-default-features` while passing everywhere else.
8636        #[cfg(feature = "compression")]
8637        assert!(
8638            first_only * 2 < everything,
8639            "dump_iter is not lazy: one triple read {first_only} B of the {everything} B a full scan reads"
8640        );
8641
8642        // A FRESH open: these assertions fault tiles and the dictionary in,
8643        // and the laziness measurement above counts bytes on a COLD reader —
8644        // sharing one `rete` makes first_only and everything both ~0, so the
8645        // comparison silently stops testing anything.
8646        let fresh = Rete::open(&bytes).unwrap();
8647        // `dump_batch` is the resumable form the language clients pull through.
8648        // Driven to exhaustion it must reproduce the eager dump EXACTLY — same
8649        // triples, same order — for any batch size, including one so small that
8650        // every call ends on a subject boundary before reaching it.
8651        let drain = |max_quads: usize| {
8652            let (mut out, mut cursor, mut calls) = (Vec::new(), 0u32, 0);
8653            loop {
8654                let (batch, next, done) = fresh.dump_batch(None, cursor, max_quads);
8655                out.extend(batch);
8656                cursor = next;
8657                calls += 1;
8658                assert!(calls < 10_000, "cursor failed to advance");
8659                if done {
8660                    break out;
8661                }
8662            }
8663        };
8664        let eager = fresh.dump(None);
8665        for max in [1usize, 7, 128, 100_000] {
8666            assert_eq!(drain(max), eager, "batch size {max} changed the dump");
8667        }
8668        // An unknown graph is an empty, already-finished dump — not an error and
8669        // not the default graph leaking through.
8670        let (t, _, done) = fresh.dump_batch(Some("http://ex/nope"), 0, 16);
8671        assert!(t.is_empty() && done);
8672    }
8673
8674    /// `query_batch` is `dump_batch` generalized to a PATTERN — the primitive the
8675    /// Java client's RDF4J cursor pulls through, one bounded batch per wasm call.
8676    /// Three things have to hold or the cursor is a materializing scan wearing a
8677    /// cursor's clothes: driven to exhaustion it must reproduce the eager
8678    /// `query_in_graph` for every pattern shape and every batch size; taking one
8679    /// row must cost far fewer bytes than draining; and every call must either
8680    /// yield a row or say `done`, so the caller can never spin.
8681    #[test]
8682    fn query_batch_matches_query_in_graph_and_stops_early() {
8683        use crate::reader::{CountingReader, SliceReader};
8684
8685        // Enough triples over tiny tiles that a scan spans many tiles, with two
8686        // predicates and a repeated subject so bound patterns have real fanout.
8687        let triples: Vec<(String, String, String)> = (0..2000u32)
8688            .map(|i| {
8689                (
8690                    format!("<http://ex/s/{:04}>", i / 2),
8691                    format!("<http://ex/p/{}>", i % 2),
8692                    format!("<http://ex/o/{i:04}>"),
8693                )
8694            })
8695            .collect();
8696        let mut db = DictionaryBuilder::new();
8697        for (s, p, o) in &triples {
8698            db.observe(s, p, o);
8699        }
8700        let dict = db.build();
8701        let mut ib = GraphIndexBuilder::new().with_tile_budget(256);
8702        for (s, p, o) in &triples {
8703            ib.push(dict.encode(s, p, o).unwrap());
8704        }
8705        let bytes = write_file(&dict, &ib.build(), false, &[], 0);
8706        let rete = Rete::open(&bytes).unwrap();
8707
8708        let drain = |s: Option<&str>, p: Option<&str>, o: Option<&str>, max: usize| {
8709            let (mut out, mut cursor, mut calls) = (Vec::new(), 0u64, 0);
8710            loop {
8711                let (batch, next, done) = rete.query_batch(None, s, p, o, cursor, max);
8712                // The contract the client relies on: rows, or done. Never
8713                // "nothing yet, call again" — that is an unbounded spin.
8714                assert!(!batch.is_empty() || done, "empty non-final batch");
8715                out.extend(batch);
8716                cursor = next;
8717                calls += 1;
8718                assert!(calls < 20_000, "cursor failed to advance");
8719                if done {
8720                    break out;
8721                }
8722            }
8723        };
8724        let shapes: [(Option<&str>, Option<&str>, Option<&str>); 6] = [
8725            (None, None, None),
8726            (Some("<http://ex/s/0007>"), None, None),
8727            (None, Some("<http://ex/p/1>"), None),
8728            (None, None, Some("<http://ex/o/0042>")),
8729            (Some("<http://ex/s/0007>"), Some("<http://ex/p/0>"), None),
8730            (
8731                Some("<http://ex/s/0007>"),
8732                Some("<http://ex/p/0>"),
8733                Some("<http://ex/o/0014>"),
8734            ),
8735        ];
8736        for (s, p, o) in shapes {
8737            let eager = rete.query_in_graph(None, s, p, o);
8738            assert!(
8739                !eager.is_empty(),
8740                "fixture shape {s:?} {p:?} {o:?} matches nothing"
8741            );
8742            // The lazy pull form agrees exactly, order included.
8743            assert_eq!(
8744                rete.query_iter(None, s, p, o).collect::<Vec<_>>(),
8745                eager,
8746                "query_iter disagrees for {s:?} {p:?} {o:?}"
8747            );
8748            for max in [1usize, 3, 64, 100_000] {
8749                let mut got = drain(s, p, o, max);
8750                let mut want = eager.clone();
8751                // A batch is re-sorted canonically per batch, not globally, so
8752                // a bound pattern's batches can arrive in permutation order.
8753                got.sort();
8754                want.sort();
8755                assert_eq!(got, want, "batch size {max} changed {s:?} {p:?} {o:?}");
8756            }
8757            // The fully unbound scan routes to SPO, so batching is order-exact.
8758            if s.is_none() && p.is_none() && o.is_none() {
8759                assert_eq!(drain(s, p, o, 7), eager, "unbound batching reordered rows");
8760            }
8761        }
8762
8763        // A bound term the dictionary has never seen, and an unknown graph, are
8764        // both finished-and-empty rather than errors or a leak of everything.
8765        let (t, _, done) = rete.query_batch(None, Some("<http://ex/nope>"), None, None, 0, 16);
8766        assert!(t.is_empty() && done);
8767        let (t, _, done) = rete.query_batch(Some("http://ex/nope"), None, None, None, 0, 16);
8768        assert!(t.is_empty() && done);
8769        assert_eq!(
8770            rete.query_iter(None, Some("<http://ex/nope>"), None, None)
8771                .count(),
8772            0
8773        );
8774
8775        // Lazily: one row must not drag the whole scan across the reader. This
8776        // is the property that makes `LIMIT 1` bounded on a 48 GiB file.
8777        let read_bytes = |take: usize| -> u64 {
8778            let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8779            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8780            let rete = Rete::open_ranged_lazy(reader.clone()).unwrap();
8781            let before = reader.bytes_read();
8782            let (mut n, mut cursor) = (0usize, 0u64);
8783            loop {
8784                let (batch, next, done) = rete.query_batch(None, None, None, None, cursor, 1);
8785                n += batch.len();
8786                cursor = next;
8787                if done || n >= take {
8788                    break;
8789                }
8790            }
8791            assert!(n >= take.min(triples.len()));
8792            assert!(!rete.index_incomplete());
8793            reader.bytes_read() - before
8794        };
8795        let first_only = read_bytes(1);
8796        let everything = read_bytes(triples.len());
8797        assert!(
8798            first_only < everything,
8799            "query_batch is not lazy: one row read {first_only} B, a full scan {everything} B"
8800        );
8801        #[cfg(feature = "compression")]
8802        assert!(
8803            first_only * 2 < everything,
8804            "query_batch is not lazy: one row read {first_only} B of the {everything} B a full scan reads"
8805        );
8806    }
8807
8808    /// A batched cursor over a QUADS file must stay inside the graph it was
8809    /// given: the default graph and each named graph resume independently.
8810    #[test]
8811    fn query_batch_is_graph_scoped() {
8812        let mut db = DictionaryBuilder::new();
8813        for (s, p, o) in [
8814            ("Alice", "knows", "Bob"),
8815            ("Alice", "knows", "Carol"),
8816            ("Alice", "knows", "Dave"),
8817            ("Erin", "knows", "Frank"),
8818            ("Alice", "knows", "Zoe"),
8819        ] {
8820            db.observe(s, p, o);
8821        }
8822        let dict = db.build();
8823        let mut def = GraphIndexBuilder::new();
8824        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
8825        def.push(dict.encode("Alice", "knows", "Carol").unwrap());
8826        let mut g1 = GraphIndexBuilder::new();
8827        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
8828        g1.push(dict.encode("Erin", "knows", "Frank").unwrap());
8829        let mut g2 = GraphIndexBuilder::new();
8830        g2.push(dict.encode("Alice", "knows", "Zoe").unwrap());
8831        let named = vec![
8832            ("http://ex/g1".to_string(), g1.build()),
8833            ("http://ex/g2".to_string(), g2.build()),
8834        ];
8835        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8836        let rete = Rete::open(&bytes).unwrap();
8837        for graph in [None, Some("http://ex/g1"), Some("http://ex/g2")] {
8838            let eager = rete.query_in_graph(graph, None, None, None);
8839            let (mut out, mut cursor) = (Vec::new(), 0u64);
8840            loop {
8841                let (batch, next, done) = rete.query_batch(graph, None, None, None, cursor, 1);
8842                out.extend(batch);
8843                cursor = next;
8844                if done {
8845                    break;
8846                }
8847            }
8848            assert_eq!(out, eager, "graph {graph:?} batched differently");
8849        }
8850    }
8851
8852    #[test]
8853    fn query_in_graph_is_graph_scoped() {
8854        // Default graph: Alice knows Bob, Alice knows Carol.
8855        // Named g1: Alice knows Dave (same predicate, different graph).
8856        let mut db = DictionaryBuilder::new();
8857        for (s, p, o) in [
8858            ("Alice", "knows", "Bob"),
8859            ("Alice", "knows", "Carol"),
8860            ("Alice", "knows", "Dave"),
8861        ] {
8862            db.observe(s, p, o);
8863        }
8864        let dict = db.build();
8865
8866        let mut def = GraphIndexBuilder::new();
8867        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
8868        def.push(dict.encode("Alice", "knows", "Carol").unwrap());
8869        let mut g1 = GraphIndexBuilder::new();
8870        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
8871
8872        let named = vec![("http://ex/g1".to_string(), g1.build())];
8873        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8874        let rete = Rete::open(&bytes).unwrap();
8875
8876        // Default graph only: the two default-graph objects, not Dave.
8877        let mut def_objs: Vec<String> = rete
8878            .query_in_graph(None, Some("Alice"), Some("knows"), None)
8879            .into_iter()
8880            .map(|(_, _, o)| o)
8881            .collect();
8882        def_objs.sort();
8883        assert_eq!(def_objs, vec!["Bob".to_string(), "Carol".to_string()]);
8884
8885        // Named graph only: just Dave.
8886        assert_eq!(
8887            rete.query_in_graph(Some("http://ex/g1"), Some("Alice"), None, None),
8888            vec![("Alice".into(), "knows".into(), "Dave".into())]
8889        );
8890
8891        // A wildcard-everything scan is scoped to its graph.
8892        assert_eq!(rete.query_in_graph(None, None, None, None).len(), 2);
8893        assert_eq!(
8894            rete.query_in_graph(Some("http://ex/g1"), None, None, None)
8895                .len(),
8896            1
8897        );
8898
8899        // An unknown graph IRI is empty, not an error.
8900        assert!(rete
8901            .query_in_graph(Some("http://ex/missing"), None, None, None)
8902            .is_empty());
8903    }
8904
8905    /// A **bounded** `query_in_graph` — and a dump of one named graph — must not
8906    /// fault the whole dictionary on a lazily range-read file.
8907    ///
8908    /// Both used to call `Dictionary::prefetch_all`, so the graph-scoped pattern
8909    /// primitive an RDF4J `Sail` calls once per named graph paid for every term
8910    /// in the file to answer a handful of rows. Measured on `cordis.rete`
8911    /// (801 MB, a 417 MB dictionary) before the fix: a 15-row answer read
8912    /// 416 MB and peaked at 2.5 GB RSS. The fixture below is the same shape in
8913    /// miniature — long literals so the dictionary dominates the file — and the
8914    /// assertion is the invariant that regressed: bytes read must stay well
8915    /// under the dictionary section. Lazy answers are compared against eager
8916    /// throughout: cheaper is only interesting if it is also correct.
8917    #[test]
8918    fn bounded_query_in_graph_does_not_fault_the_whole_dictionary() {
8919        use crate::reader::{CountingReader, SliceReader};
8920
8921        // Long, distinct, poorly-compressible object literals: the dictionary is
8922        // the payload here, exactly as on a graph that stores abstracts or
8923        // embedded media. Repeating one filler string would not do — front
8924        // coding plus zstd shrink 2000 near-identical literals to ~6 KB, which
8925        // is smaller than the index and makes the measurement meaningless.
8926        let payload = |i: u32| -> String {
8927            let mut s = String::with_capacity(800);
8928            let mut x = i as u64 ^ 0x9e37_79b9_7f4a_7c15;
8929            while s.len() < 800 {
8930                x = x
8931                    .wrapping_mul(6364136223846793005)
8932                    .wrapping_add(1442695040888963407);
8933                s.push_str(&format!("{:016x}", x ^ (x >> 29)));
8934            }
8935            s
8936        };
8937        // Half the quads go to the named graph, and the two halves are kept
8938        // lexically apart (`a…` / `b…`) so each lands in its own dictionary
8939        // chunks — the real layout on a quads file whose graphs use different
8940        // IRI namespaces, e.g. `cordis.rete`. Interleaving them instead would
8941        // put a term of both graphs in every chunk, and then no per-graph
8942        // access pattern could avoid faulting the whole dictionary.
8943        let quads: Vec<(String, String, String, bool)> = (0..2000u32)
8944            .map(|i| {
8945                let named = i % 2 == 0;
8946                let ns = if named { 'a' } else { 'b' };
8947                (
8948                    format!("<http://ex/{ns}/s/{i:04}>"),
8949                    "<http://ex/abstract>".to_string(),
8950                    format!("\"{ns}{} number {i}\"", payload(i)),
8951                    named,
8952                )
8953            })
8954            .collect();
8955        let mut db = DictionaryBuilder::new();
8956        for (s, p, o, _) in &quads {
8957            db.observe(s, p, o);
8958        }
8959        let dict = db.build();
8960        let mut def = GraphIndexBuilder::new().with_tile_budget(256);
8961        let mut g1 = GraphIndexBuilder::new().with_tile_budget(256);
8962        for (s, p, o, named) in &quads {
8963            let t = dict.encode(s, p, o).unwrap();
8964            if *named {
8965                g1.push(t);
8966            } else {
8967                def.push(t);
8968            }
8969        }
8970        let named = vec![("http://ex/g1".to_string(), g1.build())];
8971        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
8972
8973        let eager = Rete::open(&bytes).unwrap();
8974        let dict_len = eager.header().dictionary_len;
8975
8976        // `probe` runs one closure against a COLD lazy open and reports the
8977        // bytes it faulted beyond the open itself.
8978        let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
8979        let probe = |f: &dyn Fn(&Rete) -> Vec<TermTriple>| -> (Vec<TermTriple>, u64) {
8980            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
8981            let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
8982            let before = reader.bytes_read();
8983            let out = f(&lazy);
8984            assert!(!lazy.index_incomplete(), "a chunk fetch failed");
8985            (out, reader.bytes_read() - before)
8986        };
8987
8988        // 1. One bound subject in the named graph.
8989        let want = eager.query_in_graph(
8990            Some("http://ex/g1"),
8991            Some("<http://ex/a/s/0100>"),
8992            None,
8993            None,
8994        );
8995        assert_eq!(want.len(), 1, "fixture: expected exactly one match");
8996        let (got, pulled) = probe(&|r| {
8997            r.query_in_graph(
8998                Some("http://ex/g1"),
8999                Some("<http://ex/a/s/0100>"),
9000                None,
9001                None,
9002            )
9003        });
9004        assert_eq!(got, want, "lazy query_in_graph disagrees with eager");
9005        assert!(
9006            pulled * 4 < dict_len,
9007            "a 1-row query_in_graph faulted {pulled} B of a {dict_len} B dictionary — \
9008             it is prefetching the whole thing again"
9009        );
9010
9011        // 2. A graph with NOTHING to resolve must not fault the dictionary.
9012        //    Both shapes below used to pull every chunk before returning an
9013        //    empty answer, and `rete export` hits the second one first on a
9014        //    quads file whose default graph is empty — on `cordis.rete` that
9015        //    was 415 MB read and 2.5 GB of peak RSS to emit zero triples. What
9016        //    is left is the named-graph directory walk it takes to learn the
9017        //    graph is absent at all; that is index, not dictionary.
9018        let (empty, q_pulled) =
9019            probe(&|r| r.query_in_graph(Some("http://ex/nope"), None, None, None));
9020        assert!(empty.is_empty());
9021        let (_, d_pulled) = probe(&|r| {
9022            let mut n = 0usize;
9023            r.dump_each(Some("http://ex/nope"), |_, _, _| n += 1);
9024            assert_eq!(n, 0, "an absent graph yielded triples");
9025            Vec::new()
9026        });
9027        assert_eq!(
9028            q_pulled, d_pulled,
9029            "query_in_graph and dump_each disagree on what an absent graph costs"
9030        );
9031        assert!(
9032            q_pulled * 4 < dict_len,
9033            "an absent graph faulted {q_pulled} B of a {dict_len} B dictionary"
9034        );
9035
9036        // 3. Dumping ONE named graph costs that graph, not the whole file: the
9037        //    default graph's half of the dictionary must stay unfaulted.
9038        let want: Vec<TermTriple> = eager.dump(Some("http://ex/g1"));
9039        assert_eq!(want.len(), 1000);
9040        let (got, pulled) = probe(&|r| {
9041            let mut out = Vec::new();
9042            r.dump_each(Some("http://ex/g1"), |s, p, o| {
9043                out.push((s.to_string(), p.to_string(), o.to_string()))
9044            });
9045            out
9046        });
9047        assert_eq!(got, want, "lazy dump_each disagrees with eager dump");
9048        assert!(
9049            pulled * 3 < dict_len * 2,
9050            "dumping half the graph faulted {pulled} B of a {dict_len} B dictionary — \
9051             the other graph's chunks are being pulled in too"
9052        );
9053    }
9054
9055    /// Build a multi-chunk single-graph ranged file image for the eviction
9056    /// tests: 4000 distinct ~70-byte object literals overflow the 64 KiB
9057    /// dictionary chunk budget into several chunks, and one multi-KiB literal
9058    /// forces a large run/chunk boundary — the shape a tiny budget must survive.
9059    #[cfg(test)]
9060    fn build_multichunk_image() -> Vec<u8> {
9061        use crate::index::PermSet;
9062        let mut db = DictionaryBuilder::new();
9063        let mut triples: Vec<(String, String, String)> = (0..4000u32)
9064            .map(|i| {
9065                (
9066                    format!("<http://ex/s/{i:07}>"),
9067                    format!("<http://ex/p/{}>", i % 8),
9068                    format!("\"object literal number {i:07} with padding text to widen the term\""),
9069                )
9070            })
9071            .collect();
9072        triples.push((
9073            "<http://ex/s/big>".to_string(),
9074            "<http://ex/p/big>".to_string(),
9075            format!("\"{}\"", "Z".repeat(8000)),
9076        ));
9077        for (s, p, o) in &triples {
9078            db.observe(s, p, o);
9079        }
9080        let dict = db.build();
9081        let mut idx = GraphIndexBuilder::new()
9082            .with_tile_budget(64)
9083            .with_perms(PermSet::ALL);
9084        for (s, p, o) in &triples {
9085            idx.push(dict.encode(s, p, o).unwrap());
9086        }
9087        write_dataset(&dict, &idx.build(), &[], true, &[], 0)
9088    }
9089
9090    /// **Bounded-export phase 2: eviction is byte-transparent, and progress is
9091    /// guaranteed at any cap.** A cap far below the decompressed dictionary
9092    /// forces the chunk cache to evict during a full dump, yet the dump is
9093    /// byte-for-byte the unlimited-cap dump — the `Arc` handout keeps every
9094    /// chunk alive across its one decode, so eviction never drops bytes in use.
9095    /// A 1-byte cap (smaller than any single chunk, so every insert evicts
9096    /// itself immediately) still completes with identical output: it is the
9097    /// handout invariant, not residency, that makes a window make progress.
9098    #[test]
9099    fn tiny_dict_cache_evicts_yet_dump_is_byte_identical() {
9100        use crate::reader::SliceReader;
9101        let leaked: &'static [u8] = Box::leak(build_multichunk_image().into_boxed_slice());
9102        let dump = |cap: u64| -> (
9103            Vec<(String, String, String)>,
9104            crate::chunk_cache::CacheStats,
9105        ) {
9106            let rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
9107            rete.dict.set_cache_cap(cap);
9108            let mut out = Vec::new();
9109            rete.dump_filtered_each(None, None, None, None, |s, p, o| {
9110                out.push((s.to_string(), p.to_string(), o.to_string()))
9111            });
9112            assert!(
9113                !rete.index_incomplete(),
9114                "no lazy fault should fail at cap {cap}"
9115            );
9116            let stats = rete.dict_cache_stats();
9117            (out, stats)
9118        };
9119
9120        let (unlimited, us) = dump(u64::MAX);
9121        assert_eq!(unlimited.len(), 4001, "every triple dumped");
9122        assert_eq!(us.evictions, 0, "unlimited cap evicts nothing");
9123
9124        // A cap far below the decompressed dictionary: eviction happens, output
9125        // unchanged.
9126        let (tiny, ts) = dump(128 * 1024);
9127        assert_eq!(tiny, unlimited, "128 KiB-cap dump differs from unlimited");
9128        assert!(ts.evictions > 0, "a tiny cap must evict (stats {ts:?})");
9129
9130        // Progress guarantee: a 1-byte cap still completes, byte-identical.
9131        let (starved, ss) = dump(1);
9132        assert_eq!(starved, unlimited, "1-byte-cap dump differs from unlimited");
9133        assert!(
9134            ss.evictions > 0,
9135            "1-byte cap evicts every insert (stats {ss:?})"
9136        );
9137    }
9138
9139    /// **The budget pays for the open's fixed directories.** A ranged open holds
9140    /// one descriptor per dictionary chunk and per index tile for its whole
9141    /// life, outside every cache — 88 MB on the 14.75 GiB openaire-relation,
9142    /// 224 MB on epfl-infoscience. `set_memory_budget` used to give the caches
9143    /// the whole budget on top of that; it now measures the directories
9144    /// ([`Rete::fixed_overhead`]) and takes them out of the dictionary share, so
9145    /// caps + directories stay within the total. This checks the arithmetic on
9146    /// a many-chunk file, the 64 MiB floor, the unlimited pass-through, and that
9147    /// a dump under the capped budget keeps the dictionary cache within its
9148    /// (net) share.
9149    #[test]
9150    fn memory_budget_charges_the_fixed_directories() {
9151        use crate::reader::SliceReader;
9152        let leaked: &'static [u8] = Box::leak(build_multichunk_image().into_boxed_slice());
9153        let rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
9154
9155        let fixed = rete.fixed_overhead();
9156        // Every chunk and every tile has a descriptor; a multi-chunk dictionary
9157        // and a 64-triple tile budget make both directories non-trivial.
9158        assert!(
9159            fixed.dict_directory > 0 && fixed.index_directory > 0,
9160            "{fixed:?}"
9161        );
9162        assert_eq!(fixed.named_graphs, 0, "no named graphs in this file");
9163        assert_eq!(
9164            fixed.total(),
9165            fixed.dict_directory + fixed.index_directory + fixed.named_graphs
9166        );
9167
9168        // A budget well above the floor: the dictionary share is the plain
9169        // split less the directories, exactly.
9170        let total: u64 = 512 << 20;
9171        let applied = rete.set_memory_budget(Some(total));
9172        let plain = split_memory_budget(total);
9173        assert_eq!(applied.dict_cache, plain.dict_cache - fixed.total());
9174        assert_eq!(rete.dict_cache_cap(), applied.dict_cache);
9175        assert_eq!(applied.tile_cache, plain.tile_cache);
9176        assert_eq!(applied.block_cache, plain.block_cache);
9177        assert!(
9178            applied.dict_cache + applied.tile_cache + applied.block_cache + fixed.total() <= total,
9179            "caps + fixed directories exceed the budget: {applied:?} + {fixed:?} > {total}"
9180        );
9181
9182        // A budget whose share would fall under the floor keeps the floor.
9183        let applied = rete.set_memory_budget(Some(1 << 20));
9184        assert_eq!(applied.dict_cache, MIN_DICT_CACHE_CAP);
9185
9186        // Unlimited stays unlimited: nothing is subtracted from u64::MAX.
9187        let applied = rete.set_memory_budget(None);
9188        assert_eq!(applied.dict_cache, u64::MAX);
9189        assert_eq!(rete.dict_cache_cap(), u64::MAX);
9190
9191        // Under a cap smaller than the decompressed dictionary, a full dump
9192        // keeps the cache within it.
9193        let rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
9194        rete.dict.set_cache_cap(128 * 1024);
9195        let mut rows = 0usize;
9196        rete.dump_filtered_each(None, None, None, None, |_, _, _| rows += 1);
9197        assert_eq!(rows, 4001);
9198        let s = rete.dict_cache_stats();
9199        assert!(s.evictions > 0, "the cap must bind: {s:?}");
9200        // One oversized chunk may sit alone above the cap (the handout
9201        // invariant); otherwise residency is within it.
9202        assert!(
9203            s.entries <= 1 || s.resident_bytes <= 128 * 1024,
9204            "dictionary cache over its cap after the dump: {s:?}"
9205        );
9206    }
9207
9208    /// **Bounded-export phase 2: per-graph index release.** After a named
9209    /// graph's slot is exported, [`Rete::release_named_graph`] drops its decoded
9210    /// index (and its tiles) so a many-graph dump holds one graph's index at a
9211    /// time, not all of them. The graph re-opens lazily if touched again.
9212    #[test]
9213    fn releasing_a_named_graph_drops_and_reopens_its_index() {
9214        use crate::index::PermSet;
9215        use crate::reader::SliceReader;
9216        let mut db = DictionaryBuilder::new();
9217        let all: Vec<(String, String, String)> = (0..40u32)
9218            .map(|i| {
9219                (
9220                    format!("<http://ex/s{}>", i % 7),
9221                    format!("<http://ex/p{}>", i % 3),
9222                    format!("<http://ex/o{}>", i % 5),
9223                )
9224            })
9225            .collect();
9226        for (s, p, o) in &all {
9227            db.observe(s, p, o);
9228        }
9229        let dict = db.build();
9230        let mk = |take: fn(u32) -> bool| {
9231            let mut g = GraphIndexBuilder::new()
9232                .with_tile_budget(16)
9233                .with_perms(PermSet::ALL);
9234            for (i, (s, p, o)) in all.iter().enumerate() {
9235                if take(i as u32) {
9236                    g.push(dict.encode(s, p, o).unwrap());
9237                }
9238            }
9239            g.build()
9240        };
9241        let def = mk(|_| true);
9242        let g1 = mk(|i| i % 2 == 0);
9243        let g2 = mk(|i| i % 3 == 0);
9244        let image = write_dataset(
9245            &dict,
9246            &def,
9247            &[
9248                ("<http://ex/g1>".to_string(), g1),
9249                ("<http://ex/g2>".to_string(), g2),
9250            ],
9251            true,
9252            &[],
9253            0,
9254        );
9255        let leaked: &'static [u8] = Box::leak(image.into_boxed_slice());
9256        let mut rete = Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap();
9257
9258        let opened = |r: &Rete| -> usize {
9259            match &r.named_graphs {
9260                NamedGraphsSlot::Lazy(l) => {
9261                    let mut n = 0;
9262                    l.for_each_opened(|_| n += 1);
9263                    n
9264                }
9265                _ => 0,
9266            }
9267        };
9268
9269        assert_eq!(opened(&rete), 0, "nothing opened before first touch");
9270        // Touching g1 opens its index.
9271        assert!(rete.graph_index("<http://ex/g1>").is_some());
9272        assert_eq!(opened(&rete), 1, "g1 opened");
9273        // Release it: the index is dropped.
9274        rete.release_named_graph("<http://ex/g1>");
9275        assert_eq!(opened(&rete), 0, "g1 released");
9276        // Releasing an unknown / already-released graph is a no-op.
9277        rete.release_named_graph("<http://ex/absent>");
9278        rete.release_named_graph("<http://ex/g1>");
9279        assert_eq!(opened(&rete), 0);
9280        // The graph re-opens lazily on the next access.
9281        assert!(rete.graph_index("<http://ex/g1>").is_some());
9282        assert_eq!(opened(&rete), 1, "g1 re-opened after release");
9283    }
9284
9285    /// **The filtered-dump correctness invariant** (#117 point 1): for every
9286    /// graph and every one of the eight bound/unbound shapes,
9287    /// `dump_filtered_each` must return *exactly* the quads `dump_each` returns,
9288    /// filtered — no more, and above all no fewer.
9289    ///
9290    /// "No fewer" is the whole risk. The speedup comes from *not fetching* tiles
9291    /// a synopsis says cannot match, and a pruning bug does not crash, it
9292    /// silently returns a shorter answer. So the oracle here is the unfiltered
9293    /// dump with a plain Rust `retain` over it — the thing the filter is meant
9294    /// to be a cheaper spelling of — and it is checked on every open path, since
9295    /// the eager path prunes with a different code path than the lazy one.
9296    ///
9297    /// It runs on a `PermSet::CORE` (`--permutations 3`) file as well as a
9298    /// six-permutation one. Every shape routes inside CORE — `perm_routing_never_leaves_core`
9299    /// proves the routing table does — so the two must agree row for row, and if
9300    /// a future shape ever routed outside CORE this test fails on the three-perm
9301    /// build rather than silently returning nothing from an absent section.
9302    #[test]
9303    fn filtered_dump_is_exactly_the_unfiltered_dump_filtered() {
9304        use crate::index::PermSet;
9305        use crate::reader::SliceReader;
9306
9307        let terms = |i: u32| {
9308            (
9309                format!("<http://ex/s{}>", i % 11),
9310                format!("<http://ex/p{}>", i % 3),
9311                // Mix IRIs and literals in the object column: a literal lives in
9312                // a different dictionary section than a node, and the object
9313                // filter has to resolve through the right one.
9314                if i.is_multiple_of(4) {
9315                    format!("\"lit {}\"@en", i % 5)
9316                } else {
9317                    format!("<http://ex/o{}>", i % 7)
9318                },
9319            )
9320        };
9321        let build = |perms: PermSet| {
9322            let mut db = DictionaryBuilder::new();
9323            let all: Vec<_> = (0..90u32).map(terms).collect();
9324            for (s, p, o) in &all {
9325                db.observe(s, p, o);
9326            }
9327            let dict = db.build();
9328            let mut def = GraphIndexBuilder::new()
9329                .with_tile_budget(16)
9330                .with_perms(perms);
9331            let mut g1 = GraphIndexBuilder::new()
9332                .with_tile_budget(16)
9333                .with_perms(perms);
9334            for (i, (s, p, o)) in all.iter().enumerate() {
9335                let t = dict.encode(s, p, o).unwrap();
9336                // Deliberately overlapping, not disjoint: every second triple is
9337                // in both graphs, so a filter that leaked across graphs would
9338                // still look plausible on counts alone.
9339                if i % 2 == 0 {
9340                    g1.push(t);
9341                }
9342                def.push(t);
9343            }
9344            write_dataset(
9345                &dict,
9346                &def.build(),
9347                &[("<http://ex/g1>".to_string(), g1.build())],
9348                true,
9349                &[],
9350                0,
9351            )
9352        };
9353
9354        let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>)> = vec![
9355            (None, None, None),
9356            (Some("<http://ex/s3>"), None, None),
9357            (None, Some("<http://ex/p1>"), None),
9358            (None, None, Some("<http://ex/o5>")),
9359            (None, None, Some("\"lit 0\"@en")),
9360            (Some("<http://ex/s3>"), Some("<http://ex/p0>"), None),
9361            (Some("<http://ex/s4>"), None, Some("<http://ex/o4>")),
9362            (None, Some("<http://ex/p1>"), Some("<http://ex/o5>")),
9363            (
9364                Some("<http://ex/s3>"),
9365                Some("<http://ex/p0>"),
9366                Some("<http://ex/o3>"),
9367            ),
9368            // Terms the dictionary has never seen, in each position: these must
9369            // return nothing, not everything (an unresolvable bound term must
9370            // not silently degrade to "unbound").
9371            (Some("<http://ex/nope>"), None, None),
9372            (None, Some("<http://ex/nope>"), None),
9373            (None, None, Some("<http://ex/nope>")),
9374        ];
9375
9376        let mut any_pruned = false;
9377        for perms in [PermSet::ALL, PermSet::CORE] {
9378            let image = build(perms);
9379            let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
9380            let opens: Vec<(&str, Rete)> = vec![
9381                ("resident", Rete::open(&image).unwrap()),
9382                (
9383                    "ranged",
9384                    Rete::open_ranged(&SliceReader::new(leaked)).unwrap(),
9385                ),
9386                (
9387                    "lazy",
9388                    Rete::open_ranged_lazy(SliceReader::new(leaked)).unwrap(),
9389                ),
9390            ];
9391            for graph in [None, Some("<http://ex/g1>"), Some("<http://ex/absent>")] {
9392                // The oracle: the unfiltered dump, filtered in Rust.
9393                let full: Vec<TermTriple> = {
9394                    let mut v = Vec::new();
9395                    Rete::open(&image).unwrap().dump_each(graph, |s, p, o| {
9396                        v.push((s.to_string(), p.to_string(), o.to_string()))
9397                    });
9398                    v.sort();
9399                    v
9400                };
9401                for &(s, p, o) in &shapes {
9402                    let mut want = full.clone();
9403                    want.retain(|(ts, tp, to)| {
9404                        s.is_none_or(|x| x == ts)
9405                            && p.is_none_or(|x| x == tp)
9406                            && o.is_none_or(|x| x == to)
9407                    });
9408                    for (path, rete) in &opens {
9409                        let mut got = Vec::new();
9410                        rete.dump_filtered_each(graph, s, p, o, |s, p, o| {
9411                            got.push((s.to_string(), p.to_string(), o.to_string()))
9412                        });
9413                        got.sort();
9414                        assert_eq!(
9415                            got,
9416                            want,
9417                            "{path}/{perms:?} graph={graph:?} pattern={:?} — filtered dump \
9418                             is not the unfiltered dump filtered",
9419                            (s, p, o)
9420                        );
9421                        assert!(!rete.index_incomplete(), "{path}: a fetch failed");
9422                    }
9423                    // …and the plan must agree with what the dump actually did:
9424                    // an empty answer means either no scan at all, or a scan
9425                    // whose admitted tiles simply held nothing.
9426                    let plan = opens[2].1.dump_plan(graph, s, p, o);
9427                    if graph == Some("<http://ex/absent>")
9428                        || [s, p, o].contains(&Some("<http://ex/nope>"))
9429                    {
9430                        assert!(
9431                            plan.scan.is_none(),
9432                            "an unmatchable dump still planned a scan: {:?}",
9433                            (graph, s, p, o)
9434                        );
9435                    } else {
9436                        let scan = plan.scan.expect("a matchable dump plans a scan");
9437                        assert!(
9438                            PermSet::CORE.contains(scan.permutation),
9439                            "a dump routed to {:?}, outside PermSet::CORE — a \
9440                             --permutations 3 file has no such section",
9441                            scan.permutation
9442                        );
9443                        assert!(scan.tiles_admitted <= scan.tiles_routed);
9444                        assert!(scan.tiles_routed <= scan.tiles_total);
9445                        assert!(scan.tile_bytes <= scan.section_bytes);
9446                        if scan.tiles_admitted < scan.tiles_total {
9447                            any_pruned = true;
9448                        }
9449                    }
9450                }
9451            }
9452        }
9453        assert!(
9454            any_pruned,
9455            "no shape pruned a single tile — the fixture is too small to be \
9456             measuring anything"
9457        );
9458    }
9459
9460    /// The point of the filter: a predicate-scoped dump of a lazily range-read
9461    /// file must **fetch less** than the same graph's full dump, not merely
9462    /// return less.
9463    ///
9464    /// The fixture is the shape the win shows on — many tiles, and a predicate
9465    /// whose triples live in a minority of them — so the assertion can be about
9466    /// bytes rather than about the code path being taken on faith. Measured on
9467    /// the real thing (`cordis.rete`, 801 MB): one predicate of one named graph
9468    /// read 16.0 MB where the graph's full dump read 375.8 MB.
9469    #[test]
9470    fn a_predicate_scoped_dump_fetches_less_than_the_graph() {
9471        use crate::reader::{CountingReader, SliceReader};
9472
9473        // 200 subjects × 20 predicates, tiled small so there are many tiles, and
9474        // one distinct object per cell so that a predicate slice needs a
9475        // twentieth of the object dictionary rather than all of it — otherwise
9476        // the dictionary, which this change does not prune, swamps the index
9477        // saving the test is about.
9478        let mut db = DictionaryBuilder::new();
9479        let mut all = Vec::new();
9480        for s in 0..200u32 {
9481            for p in 0..20u32 {
9482                let t = (
9483                    format!("<http://ex/s/{s:04}>"),
9484                    format!("<http://ex/p/{p:02}>"),
9485                    format!("\"object {s:04}/{p:02}\""),
9486                );
9487                db.observe(&t.0, &t.1, &t.2);
9488                all.push(t);
9489            }
9490        }
9491        let dict = db.build();
9492        let mut ib = GraphIndexBuilder::new().with_tile_budget(64);
9493        for (s, p, o) in &all {
9494            ib.push(dict.encode(s, p, o).unwrap());
9495        }
9496        let image = write_dataset(&dict, &ib.build(), &[], false, &[], 0);
9497        let leaked: &'static [u8] = Box::leak(image.clone().into_boxed_slice());
9498
9499        // Bytes faulted past the open, for one dump.
9500        let probe = |s: Option<&str>, p: Option<&str>, o: Option<&str>| -> (usize, u64) {
9501            let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9502            let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
9503            let before = reader.bytes_read();
9504            let mut n = 0usize;
9505            lazy.dump_filtered_each(None, s, p, o, |_, _, _| n += 1);
9506            assert!(!lazy.index_incomplete());
9507            (n, reader.bytes_read() - before)
9508        };
9509
9510        let (full_rows, full_bytes) = probe(None, None, None);
9511        let (pred_rows, pred_bytes) = probe(None, Some("<http://ex/p/07>"), None);
9512        let (subj_rows, subj_bytes) = probe(Some("<http://ex/s/0021>"), None, None);
9513        assert_eq!(full_rows, 4000);
9514        assert_eq!(pred_rows, 200, "one predicate covers every subject once");
9515        assert_eq!(subj_rows, 20);
9516        // True in any build: a slice fetches strictly less than the graph.
9517        assert!(
9518            pred_bytes < full_bytes && subj_bytes < full_bytes,
9519            "a slice fetched {pred_bytes} / {subj_bytes} B where the whole graph fetched \
9520             {full_bytes} B — the scan is not being pruned at all"
9521        );
9522        // The SIZE of the win is a property of the FILE, not of this change: it is
9523        // the index share of what a dump reads, and the dictionary — which no
9524        // filter prunes — is the rest. With the default `compression` feature the
9525        // dictionary chunk bodies are zstd-compressed and the slice costs about a
9526        // tenth of the graph (measured here: 1,565 B for the predicate and 864 B
9527        // for the subject, against 15,714 B). Built `--no-default-features` those
9528        // same chunks are stored RAW, so resolving 200 rows' terms dominates and
9529        // the identical, identically-pruned scan costs 22,225 B of 40,165 — 55%.
9530        // Asserting the strong ratio unconditionally would be asserting that zstd
9531        // is enabled; the index-side prune is checked below and holds either way.
9532        #[cfg(feature = "compression")]
9533        assert!(
9534            pred_bytes * 4 < full_bytes && subj_bytes * 4 < full_bytes,
9535            "a 1-in-20 predicate slice fetched {pred_bytes} B and one subject \
9536             {subj_bytes} B where the whole graph fetched {full_bytes} B"
9537        );
9538
9539        // And the plan predicted the index side of it without fetching a tile.
9540        let reader = std::sync::Arc::new(CountingReader::new(SliceReader::new(leaked)));
9541        let lazy = Rete::open_ranged_lazy(reader.clone()).unwrap();
9542        let before = reader.bytes_read();
9543        let plan = lazy
9544            .dump_plan(None, None, Some("<http://ex/p/07>"), None)
9545            .scan
9546            .expect("a known predicate plans a scan");
9547        assert!(
9548            reader.bytes_read() - before < 64 * 1024,
9549            "planning fetched {} B — it is supposed to read directories, not tiles",
9550            reader.bytes_read() - before
9551        );
9552        assert!(plan.tiles_admitted < plan.tiles_total);
9553        // The index side on its own, with the dictionary's share taken out: one
9554        // predicate of twenty routes to a handful of POS tiles. (Measured here:
9555        // the whole dump faults 15,714 B, the predicate slice 1,565 B and one
9556        // subject 864 B — a 10x and an 18x, most of what is left being the
9557        // dictionary this change does not prune.)
9558        assert!(
9559            plan.tile_bytes * 5 < plan.section_bytes,
9560            "one predicate of twenty planned {} B of a {} B section",
9561            plan.tile_bytes,
9562            plan.section_bytes
9563        );
9564    }
9565
9566    #[test]
9567    fn query_quads_tags_every_graph() {
9568        let mut db = DictionaryBuilder::new();
9569        for (s, p, o) in [("Alice", "knows", "Bob"), ("Alice", "knows", "Dave")] {
9570            db.observe(s, p, o);
9571        }
9572        let dict = db.build();
9573        let mut def = GraphIndexBuilder::new();
9574        def.push(dict.encode("Alice", "knows", "Bob").unwrap());
9575        let mut g1 = GraphIndexBuilder::new();
9576        g1.push(dict.encode("Alice", "knows", "Dave").unwrap());
9577        let named = vec![("http://ex/g1".to_string(), g1.build())];
9578        let bytes = write_dataset(&dict, &def.build(), &named, true, &[], 0);
9579        let rete = Rete::open(&bytes).unwrap();
9580
9581        // `Alice knows ?` spans both graphs; each match carries its graph tag.
9582        let quads = rete.query_quads(Some("Alice"), Some("knows"), None);
9583        assert_eq!(quads.len(), 2);
9584        assert_eq!(
9585            quads[0],
9586            (("Alice".into(), "knows".into(), "Bob".into()), None)
9587        );
9588        assert_eq!(
9589            quads[1],
9590            (
9591                ("Alice".into(), "knows".into(), "Dave".into()),
9592                Some("http://ex/g1".to_string())
9593            )
9594        );
9595
9596        // A bound term absent from the dictionary yields nothing, in any graph.
9597        assert!(rete.query_quads(Some("Nobody"), None, None).is_empty());
9598    }
9599
9600    #[test]
9601    fn pyramid_meta_round_trips_in_file() {
9602        let rete = Rete::open(&build_image()).unwrap();
9603        let pyr = rete.pyramid().expect("file has a pyramid");
9604        // Summary covers all 3 triples by count; tiles are not stored in v0.
9605        let total: u32 = pyr.summary.iter().map(|e| e.count).sum();
9606        assert_eq!(total, 3);
9607        assert!(!pyr.summary.is_empty());
9608        assert!(pyr.tiles.is_empty());
9609    }
9610
9611    #[test]
9612    fn schema_pyramid_round_trips_through_file_index_free() {
9613        use crate::reader::{CountingReader, SliceReader};
9614        let sub = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
9615        let q = |s: &str, p: &str, o: &str| {
9616            (s.to_string(), p.to_string(), o.to_string(), None::<String>)
9617        };
9618        // Astronomer ⊑ Scientist ⊑ Person ⊑ Agent, instances at the leaves.
9619        let quads = vec![
9620            q("<a>", RDF_TYPE, "<Astronomer>"),
9621            q("<b>", RDF_TYPE, "<Astronomer>"),
9622            q("<c>", RDF_TYPE, "<Person>"),
9623            q("<Astronomer>", sub, "<Scientist>"),
9624            q("<Scientist>", sub, "<Person>"),
9625            q("<Person>", sub, "<Agent>"),
9626            q("<a>", "<knows>", "<b>"),
9627            q("<b>", "<knows>", "<c>"),
9628        ];
9629        let (bytes, _) =
9630            crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
9631
9632        // The v2 schema pyramid round-trips through the built file.
9633        let rete = Rete::open(&bytes).unwrap();
9634        let pyr = rete.pyramid().expect("pyramid present");
9635        assert!(!pyr.level_rollups.is_empty(), "schema pyramid shipped");
9636        assert!(pyr
9637            .class_hierarchy
9638            .iter()
9639            .any(|n| n.class == "<Agent>" && n.depth == 0));
9640
9641        // It reads index-free: a SummaryView open never touches the index section.
9642        let r = CountingReader::new(SliceReader::new(&bytes));
9643        let view = SummaryView::open_ranged(&r).unwrap().unwrap();
9644        assert!(view.level_count() >= 2, "multi-level pyramid");
9645        let coarse = view.level_rollup(0).unwrap();
9646        assert!(
9647            coarse.classes.iter().any(|(c, _)| c == "<Agent>"),
9648            "coarsest level rolls up to the root Agent"
9649        );
9650        let h = Header::from_bytes(&bytes).unwrap();
9651        assert!(
9652            r.bytes_read() <= bytes.len() as u64 - h.root_dir_len,
9653            "summary read {} bytes; the {}-byte index section must be skipped",
9654            r.bytes_read(),
9655            h.root_dir_len
9656        );
9657    }
9658
9659    #[test]
9660    fn predicate_totals_from_summary_only() {
9661        use crate::reader::SliceReader;
9662        // build_image: 2 `knows` triples + 1 `age` triple.
9663        let bytes = build_image();
9664        let reader = SliceReader::new(&bytes);
9665        let view = SummaryView::open_ranged(&reader).unwrap().unwrap();
9666        assert_eq!(view.predicate_total("knows"), 2);
9667        assert_eq!(view.predicate_total("age"), 1);
9668        assert_eq!(view.predicate_total("missing"), 0);
9669        let totals = view.predicate_totals();
9670        assert_eq!(totals[0], ("knows".to_string(), 2)); // sorted by count desc
9671    }
9672
9673    #[test]
9674    fn query_patterns_resolve_to_terms() {
9675        let rete = Rete::open(&build_image()).unwrap();
9676
9677        // All triples.
9678        assert_eq!(rete.query(None, None, None).len(), 3);
9679
9680        // Subject bound.
9681        let mut alice = rete.query(Some("Alice"), None, None);
9682        alice.sort();
9683        assert_eq!(
9684            alice,
9685            vec![
9686                ("Alice".into(), "age".into(), "30".into()),
9687                ("Alice".into(), "knows".into(), "Bob".into()),
9688            ]
9689        );
9690
9691        // Predicate bound.
9692        assert_eq!(rete.query(None, Some("knows"), None).len(), 2);
9693
9694        // Full triple, present and absent.
9695        assert_eq!(
9696            rete.query(Some("Bob"), Some("knows"), Some("Carol")),
9697            vec![("Bob".into(), "knows".into(), "Carol".into())]
9698        );
9699        assert!(rete.query(Some("Nobody"), None, None).is_empty());
9700        assert!(rete.query(None, Some("likes"), None).is_empty());
9701    }
9702
9703    #[test]
9704    fn query_provenance_reports_terms_ids_sections_and_index_choice() {
9705        let bytes = build_image();
9706        let rete = Rete::open(&bytes).unwrap();
9707
9708        let mut matches = rete.query_with_provenance(None, Some("knows"), None);
9709        matches.sort_by(|a, b| a.terms.cmp(&b.terms));
9710
9711        assert_eq!(matches.len(), 2);
9712        assert_eq!(
9713            matches[0].terms,
9714            ("Alice".into(), "knows".into(), "Bob".into())
9715        );
9716        assert_eq!(
9717            matches[0].ids,
9718            rete.dictionary().encode("Alice", "knows", "Bob").unwrap()
9719        );
9720        assert_eq!(matches[0].graph.as_deref(), None);
9721        assert_eq!(
9722            matches[0].matched_pattern,
9723            (None, Some(matches[0].ids.1), None)
9724        );
9725        assert_eq!(
9726            matches[0].index_permutation,
9727            crate::index::IndexPermutation::Pos
9728        );
9729
9730        let h = rete.header();
9731        assert_eq!(matches[0].dictionary_range.offset, h.dictionary_offset);
9732        assert_eq!(matches[0].dictionary_range.len, h.dictionary_len);
9733        assert_eq!(matches[0].index_range.offset, h.root_dir_offset);
9734        assert_eq!(matches[0].index_range.len, h.root_dir_len);
9735        assert!(
9736            matches[0].index_section_range.offset > h.root_dir_offset,
9737            "POS is section 1, so its payload starts after the container header and SPO payload"
9738        );
9739        assert!(matches[0].index_section_range.len > 0);
9740        assert!(matches[0].index_section_range.end() <= matches[0].index_range.end());
9741        assert!(matches[0].index_section_range.len < matches[0].index_range.len);
9742        assert_eq!(
9743            matches[0].pyramid_range.as_ref().map(|r| (r.offset, r.len)),
9744            Some((h.pyramid_meta_offset, h.pyramid_meta_len))
9745        );
9746        // Tiled (v0.2) files report the physical tile holding the match; its
9747        // compressed byte range nests inside the selected section payload.
9748        let tile_range = matches[0].tile_range.expect("tiled file reports a tile");
9749        assert!(matches[0]
9750            .tile
9751            .as_deref()
9752            .unwrap()
9753            .starts_with(matches[0].index_permutation.name()));
9754        assert!(matches[0].index_section_range.offset <= tile_range.offset);
9755        assert!(tile_range.end() <= matches[0].index_section_range.end());
9756    }
9757
9758    /// Build an in-memory `.rete` with `n` labeled subjects: each carries an
9759    /// `rdfs:label` literal drawn from `WORDS` (a word prefix selects ~1/|WORDS|
9760    /// of them) plus one extra edge so the subject has a degree to rank by.
9761    fn build_labeled(n: usize) -> Vec<u8> {
9762        const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
9763        const WORDS: &[&str] = &[
9764            "alanine",
9765            "benzene",
9766            "glucose",
9767            "dextrose",
9768            "ethanol",
9769            "formate",
9770            "heptane",
9771            "isoleucine",
9772        ];
9773        let triples: Vec<(String, String, String)> = (0..n)
9774            .flat_map(|i| {
9775                let s = format!("<http://ex/e{i}>");
9776                let w = WORDS[i % WORDS.len()];
9777                [
9778                    (s.clone(), LABEL.to_string(), format!("\"{w}-{i:06}\"")),
9779                    (
9780                        s,
9781                        "<http://ex/p>".to_string(),
9782                        format!("<http://ex/c{}>", i % 64),
9783                    ),
9784                ]
9785            })
9786            .collect();
9787        let mut db = DictionaryBuilder::new();
9788        for (s, p, o) in &triples {
9789            db.observe(s, p, o);
9790        }
9791        let dict = db.build();
9792        let ids: Vec<(u32, u32, u32)> = triples
9793            .iter()
9794            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
9795            .collect();
9796        let mut ib = GraphIndexBuilder::new();
9797        for &t in &ids {
9798            ib.push(t);
9799        }
9800        let (meta, levels) = build_pyramid_meta(&dict, &ids, DEFAULT_TILE_BUDGET);
9801        write_file(&dict, &ib.build(), false, &meta, levels)
9802    }
9803
9804    #[test]
9805    fn prefix_search_matches_a_filter_scan() {
9806        // 800 < the 8192 label-index cap, so the index is COMPLETE — every label
9807        // is present and the two paths must return the exact same subject set.
9808        let bytes = build_labeled(800);
9809        let rete = Rete::open(&bytes).unwrap();
9810        let idx_subjects: std::collections::BTreeSet<String> = rete
9811            .prefix_search("glucose", 10_000)
9812            .into_iter()
9813            .map(|(_label, subject)| subject)
9814            .collect();
9815        // The same selection via a SPARQL FILTER scan over every label literal.
9816        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
9817                 FILTER(STRSTARTS(LCASE(?l), \"glucose\")) }";
9818        let crate::QueryOutput::Select(_, rows) = crate::eval_query(&rete, q).unwrap() else {
9819            panic!("expected SELECT");
9820        };
9821        let scan_subjects: std::collections::BTreeSet<String> =
9822            rows.iter().map(|r| r.get("s").cloned().unwrap()).collect();
9823        assert_eq!(idx_subjects, scan_subjects, "index agrees with the scan");
9824        assert_eq!(
9825            idx_subjects.len(),
9826            100,
9827            "800/8 words = 100 glucose-* labels"
9828        );
9829    }
9830
9831    /// Latency: the binary-search label index vs the FILTER scan it replaces.
9832    /// Ignored by default (timing-sensitive); run with
9833    /// `cargo test -p rete-core -- --ignored --nocapture bench_prefix_search`.
9834    #[test]
9835    #[ignore]
9836    fn bench_prefix_search_vs_filter_scan() {
9837        use std::time::Instant;
9838        let n = 6000; // < the 8192 cap, so both paths return identical sets
9839        let bytes = build_labeled(n);
9840        let rete = Rete::open(&bytes).unwrap();
9841        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
9842                 FILTER(STRSTARTS(LCASE(?l), \"glucose\")) }";
9843        let reps = 200;
9844        let idx_n = rete.prefix_search("glucose", 100_000).len();
9845        let t = Instant::now();
9846        for _ in 0..reps {
9847            std::hint::black_box(rete.prefix_search("glucose", 100_000));
9848        }
9849        let idx_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
9850        let t = Instant::now();
9851        for _ in 0..reps {
9852            let _ = std::hint::black_box(crate::eval_query(&rete, q).unwrap());
9853        }
9854        let scan_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
9855        println!(
9856            "label prefix search over {n} labeled subjects ({idx_n} matches): \
9857             index {idx_ms:.4} ms vs FILTER scan {scan_ms:.3} ms ({:.0}× faster)",
9858            scan_ms / idx_ms
9859        );
9860    }
9861
9862    /// Latency: the TEXT_INDEX word search vs the `FILTER(CONTAINS(?l, …))` scan
9863    /// it replaces. Ignored by default (timing-sensitive); run with
9864    /// `cargo test -p rete-core -- --ignored --nocapture bench_text_search`.
9865    #[test]
9866    #[ignore]
9867    fn bench_text_search_vs_contains_scan() {
9868        use std::time::Instant;
9869        const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
9870        const WORDS: &[&str] = &[
9871            "alanine",
9872            "benzene",
9873            "glucose",
9874            "dextrose",
9875            "ethanol",
9876            "formate",
9877            "heptane",
9878            "isoleucine",
9879        ];
9880        let n = 6000;
9881        let triples: Vec<(String, String, String)> = (0..n)
9882            .map(|i| {
9883                (
9884                    format!("<http://ex/e{i}>"),
9885                    LABEL.to_string(),
9886                    format!("\"{} sample number {i:06}\"", WORDS[i % WORDS.len()]),
9887                )
9888            })
9889            .collect();
9890        let mut db = DictionaryBuilder::new();
9891        for (s, p, o) in &triples {
9892            db.observe(s, p, o);
9893        }
9894        let dict = db.build();
9895        let ids: Vec<(u32, u32, u32)> = triples
9896            .iter()
9897            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
9898            .collect();
9899        let mut ib = GraphIndexBuilder::new();
9900        for &t in &ids {
9901            ib.push(t);
9902        }
9903        let ti = compute_text_index(&dict, &ids);
9904        let bytes = write_dataset_with_metadata(&dict, &ib.build(), &[], false, &[], 0, &[], &ti);
9905        let rete = Rete::open(&bytes).unwrap();
9906
9907        let q = "SELECT ?s WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?l \
9908                 FILTER(CONTAINS(LCASE(?l), \"glucose\")) }";
9909        let reps = 200;
9910        let idx_n = rete.text_search(&["glucose"], None, 100_000).len();
9911        let t = Instant::now();
9912        for _ in 0..reps {
9913            std::hint::black_box(rete.text_search(&["glucose"], None, 100_000));
9914        }
9915        let idx_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
9916        let t = Instant::now();
9917        for _ in 0..reps {
9918            let _ = std::hint::black_box(crate::eval_query(&rete, q).unwrap());
9919        }
9920        let scan_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
9921        println!(
9922            "text search over {n} literals ({idx_n} matches): \
9923             index {idx_ms:.4} ms vs FILTER(CONTAINS) scan {scan_ms:.3} ms ({:.0}× faster)",
9924            scan_ms / idx_ms
9925        );
9926    }
9927
9928    /// Operational debugging harness for a REAL on-disk file (ignored; driven
9929    /// by env vars): step-by-step dump of a bound (p, o) POS routing.
9930    ///   RETE_DEBUG_FILE=<path.rete> RETE_DEBUG_P=<iri> RETE_DEBUG_O=<iri>
9931    #[test]
9932    #[ignore = "operational tool, driven by RETE_DEBUG_* env vars"]
9933    fn debug_bound_po_routing() {
9934        struct FR(std::fs::File);
9935        impl crate::RangeReader for FR {
9936            fn len(&self) -> u64 {
9937                self.0.metadata().map(|m| m.len()).unwrap_or(0)
9938            }
9939            fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
9940                use std::os::unix::fs::FileExt;
9941                let mut buf = vec![0u8; len as usize];
9942                self.0.read_exact_at(&mut buf, offset)?;
9943                Ok(buf)
9944            }
9945        }
9946        let path = std::env::var("RETE_DEBUG_FILE").expect("RETE_DEBUG_FILE");
9947        let p_iri = std::env::var("RETE_DEBUG_P").expect("RETE_DEBUG_P");
9948        let o_iri = std::env::var("RETE_DEBUG_O").expect("RETE_DEBUG_O");
9949        let rete =
9950            Rete::open_ranged_lazy(std::sync::Arc::new(FR(std::fs::File::open(&path).unwrap())))
9951                .unwrap();
9952        let pid = rete.dict.predicate_id(&p_iri).expect("p resolves");
9953        let oid = rete.dict.object_id(&o_iri).expect("o resolves");
9954        eprintln!("pid={pid} oid={oid}");
9955        let pattern = (None, Some(pid), Some(oid));
9956        let perm = GraphIndex::best_permutation(pattern);
9957        eprintln!("best_permutation = {}", perm.name());
9958        let si = perm.section_index();
9959        let tiles = &rete.index.sections[si];
9960        eprintln!("section {} tiles = {}", perm.name(), tiles.len());
9961        let [pa, pb, pc] = perm.order_pattern(pattern);
9962        eprintln!("permuted pattern pa={pa:?} pb={pb:?} pc={pc:?}");
9963        let (start, end) = rete.index.tile_span(si, pa);
9964        eprintln!("tile_span = [{start}, {end}) -> {} tiles", end - start);
9965        let mut admitted = 0usize;
9966        for (ti, t) in tiles.iter().enumerate().take(end).skip(start) {
9967            if t.syn_admits(pb, pc) {
9968                admitted += 1;
9969                if admitted <= 10 {
9970                    let (lo, hi) = t.leading_range();
9971                    eprintln!("  admit tile {ti}: a=[{lo},{hi}] syn={:?}", t.syn);
9972                }
9973            }
9974        }
9975        eprintln!("admitted {admitted} tile(s) by synopsis");
9976        let n = rete.index.scan_iter(pattern).count();
9977        eprintln!("scan_iter matches = {n}");
9978        let hi_res = rete.query(None, Some(&p_iri), Some(&o_iri));
9979        eprintln!("high-level query matches = {}", hi_res.len());
9980    }
9981
9982    /// The header segment's LABEL and the span it describes must agree. They
9983    /// did not from v0.3 (which grew the header to 1 KiB) until this test: the
9984    /// label still said "fixed 128 bytes", the pre-v0.3 size, while the segment
9985    /// covered `HEADER_LEN` = 1024 — and that label is exactly what the file
9986    /// explorer shows someone inspecting a real file. Reading the size back out
9987    /// of the string is the point: it fails if anyone retypes the number
9988    /// instead of deriving it from `HEADER_LEN`.
9989    #[test]
9990    fn layout_header_label_matches_its_span() {
9991        let mut db = DictionaryBuilder::new();
9992        let mut triples = Vec::new();
9993        for i in 0..24u32 {
9994            let (s, p, o) = (
9995                format!("<http://ex/s{}>", i % 5),
9996                format!("<http://ex/p{}>", i % 3),
9997                format!("<http://ex/o{i}>"),
9998            );
9999            db.observe(&s, &p, &o);
10000            triples.push((s, p, o));
10001        }
10002        let dict = db.build();
10003        let ids: Vec<(u32, u32, u32)> = triples
10004            .iter()
10005            .map(|(s, p, o)| dict.encode(s, p, o).unwrap())
10006            .collect();
10007        let index = GraphIndexBuilder::from_triples(ids).build();
10008        let image = write_dataset_with_metadata(
10009            &dict,
10010            &index,
10011            &[],
10012            false,
10013            &[],
10014            0,
10015            br#"{"name":"layout-label-fixture"}"#,
10016            &[],
10017        );
10018        let rete = Rete::open(&image).unwrap();
10019
10020        let layout = rete.file_layout();
10021        let header = layout
10022            .iter()
10023            .find(|s| s.kind == "header")
10024            .expect("every layout starts with a header segment");
10025
10026        assert_eq!(
10027            header.offset, 0,
10028            "the header is the first thing in the file"
10029        );
10030        assert_eq!(
10031            header.len, HEADER_LEN as u64,
10032            "the header segment must span exactly HEADER_LEN"
10033        );
10034
10035        // Pull the number back out of the human-readable label and hold it to
10036        // the span. A hard-coded literal that drifts from HEADER_LEN dies here.
10037        let digits: String = header
10038            .label
10039            .chars()
10040            .skip_while(|c| !c.is_ascii_digit())
10041            .take_while(|c| c.is_ascii_digit())
10042            .collect();
10043        assert!(
10044            !digits.is_empty(),
10045            "header label {:?} states no size at all",
10046            header.label
10047        );
10048        assert_eq!(
10049            digits.parse::<u64>().unwrap(),
10050            header.len,
10051            "header label {:?} disagrees with the {}-byte span it describes",
10052            header.label,
10053            header.len
10054        );
10055
10056        // The layout must also stay sorted and start at byte zero, which is
10057        // what makes "the first segment is the header" meaningful.
10058        assert!(
10059            layout.windows(2).all(|w| w[0].offset <= w[1].offset),
10060            "file_layout must return segments sorted by offset"
10061        );
10062    }
10063}